Compare commits

...

136 Commits

Author SHA1 Message Date
zarzet 6ac4f555f6 fix(ios): remove stale built-in Spotify bridge handlers 2026-03-11 17:08:46 +07:00
zarzet 098544393e docs: add centered Trendshift badge below README banner 2026-03-11 17:08:45 +07:00
zarzet 29165da5ac Merge branch 'dev' 2026-03-11 16:42:09 +07:00
zarzet 9343583c69 fix: make README banner more compact for better mobile visibility 2026-03-11 16:27:13 +07:00
zarzet d82d255bae feat: add dark/light theme-aware README banner and reorganize badges 2026-03-11 15:56:42 +07:00
zarzet 93a7042a84 chore: add design/ directory to .gitignore 2026-03-11 15:03:14 +07:00
zarzet 5be5c869da chore: add 'a fan' as donor for this month 2026-03-11 01:23:20 +07:00
zarzet 8d45e023b2 feat: add totalTracks to Track model and refine EP/single classification (#202) 2026-03-11 01:14:12 +07:00
zarzet f2ae1398db feat: add 'By Playlist' folder organization option (#111) 2026-03-11 01:07:14 +07:00
zarzet c2736a61fb refactor: remove built-in Spotify API provider, use Deezer as sole default
- Remove all Spotify credential management (client ID/secret, secure storage)
- Remove Spotify platform channel handlers from MainActivity
- Remove exported Go functions: GetSpotifyMetadata, SearchSpotify,
  SearchSpotifyAll, GetSpotifyRelatedArtists, SetSpotifyAPICredentials
- Simplify GetSpotifyMetadataWithDeezerFallback to SpotFetch-only path
- Remove Spotify built-in fallback in ReEnrichFile search pipeline
- Always return false from HasSpotifyCredentials; getCredentials always errors
- Default metadataProviderPriority is now ['deezer'] only
- Sanitize provider priority list to strip 'spotify' entries on load/save
- Add migration v5 to clear saved Spotify credentials from existing installs
- Remove Spotify source chip and credentials UI from options settings page
- Remove metadataSource param from search() — always uses Deezer
- spotify-web extension remains supported via the extension provider system
2026-03-11 00:58:07 +07:00
zarzet 76fe8dbc69 feat: add CUE sheet support for local library scanning and splitting (#201)
Parse .cue files in library scanner (Go + SAF) to display individual
tracks instead of one large audio file. Add FFmpeg-based CUE splitting
to extract tracks into separate FLAC files with embedded metadata and
cover art.

- Go: CUE parser, two-pass scan (CUE first, skip referenced audio),
  virtual paths (cue#trackNN) for DB UNIQUE constraint, audioDir
  override for SAF temp-file scenarios
- Android: SAF scanner recognizes .cue in both full and incremental
  scan, copies .cue+audio to temp for Go parsing, unchanged-CUE audio
  sibling dedup, parseCueSheet handler resolves SAF audio siblings
- Dart: FFmpegService.splitCueToTracks, CUE split UI in track metadata
  screen, persistent output dir for SAF splits with write-back
- CUE virtual path normalization across fileExists/fileStat/deleteFile/
  openFile; play/share/open blocked for virtual tracks with guidance to
  split first; delete only removes DB entry, not shared .cue file
- iOS: parseCueSheet handler
- Localization: 12 new CUE-related strings

Requested by @Seerafimm
Closes #201
2026-03-11 00:31:20 +07:00
zarzet 64408c8d8b feat: add Single and EP badge on artist page and fix EP bucket filtering (#203) 2026-03-10 23:25:45 +07:00
zarzet db55bb4693 fix(deezer): update quality label to FLAC Best Quality since API may deliver above CD quality 2026-03-10 23:12:44 +07:00
zarzet 9c6856b584 refactor: migrate to git-cliff for changelog and deduplicate library items
- Replace manual CHANGELOG.md parsing with git-cliff action in release workflow
- Add cliff.toml config for conventional commit grouping and GitHub integration
- Extract buildPathMatchKeys into shared utility with Android storage alias support
- Deduplicate local library items overlapping with downloaded items in queue tab
2026-03-10 15:26:19 +07:00
zarzet a4899144c5 fix: show error feedback for unrecognized URLs and fix l10n warnings 2026-03-08 23:12:48 +07:00
zarzet 808083c938 chore(l10n): merge Crowdin translation updates (#153)
Merge branch 'l10n_dev' into dev.

- Update translations for 10 languages (de, fr, hi, id, ja, ko, nl, ru, zh_CN, zh_TW)
- Add new language support: Spanish (es-ES), Portuguese (pt-PT), Turkish (tr-TR)
- Translate previously untranslated English strings to their respective languages
- Add new localization keys: collection/playlist management, batch convert, share, advanced filename tags
- Consolidate and remove deprecated translation keys

Conflicts resolved:
- app_en.arb: kept dev version (reflects Amazon Music moved to extension)
- All other ARB files: kept l10n_dev version (proper Crowdin translations)
2026-03-08 22:52:24 +07:00
zarzet 7e41ab4460 fix: YouTube Music share intent not recognizing album/browse URLs 2026-03-08 22:13:01 +07:00
zarzet 75a2bec8d5 chore: accessibility improvements, Semantics wrappers, and tooltip additions across screens 2026-03-08 15:08:13 +07:00
zarzet c35857bb61 fix(ios): local library scan fails on iOS due to missing security-scoped bookmark access 2026-03-08 14:57:13 +07:00
zarzet 2c897992c5 feat: resolve audio metadata from file, backfill placeholder quality labels with actual bit depth and sample rate 2026-03-08 04:28:16 +07:00
zarzet 7d5cb574c6 feat: move Amazon Music to extension, fix Deezer download timeout 2026-03-08 04:15:28 +07:00
Zarz Eleutherius c582f96cf6 New translations app_en.arb (Turkish) 2026-03-06 23:32:51 +07:00
Zarz Eleutherius 8fab3f60a7 New translations app_en.arb (Hindi) 2026-03-06 23:32:49 +07:00
Zarz Eleutherius c6e981b3a1 New translations app_en.arb (Indonesian) 2026-03-06 23:32:48 +07:00
Zarz Eleutherius f0c5c5660a New translations app_en.arb (Chinese Traditional) 2026-03-06 23:32:47 +07:00
Zarz Eleutherius 9c647bb31b New translations app_en.arb (Chinese Simplified) 2026-03-06 23:32:46 +07:00
Zarz Eleutherius e1e82ac586 New translations app_en.arb (Russian) 2026-03-06 23:32:45 +07:00
Zarz Eleutherius 585d6da98d New translations app_en.arb (Portuguese) 2026-03-06 23:32:43 +07:00
Zarz Eleutherius bc279dd7fd New translations app_en.arb (Dutch) 2026-03-06 23:32:42 +07:00
Zarz Eleutherius f2fdead6d3 New translations app_en.arb (Korean) 2026-03-06 23:32:41 +07:00
Zarz Eleutherius f66ccb4741 New translations app_en.arb (Japanese) 2026-03-06 23:32:40 +07:00
Zarz Eleutherius 32c10c2b23 New translations app_en.arb (German) 2026-03-06 23:32:38 +07:00
Zarz Eleutherius 05674d9586 New translations app_en.arb (Spanish) 2026-03-06 23:32:37 +07:00
Zarz Eleutherius 11bda9aae5 New translations app_en.arb (French) 2026-03-06 23:32:36 +07:00
Zarz Eleutherius 02c803385c Update source file app_en.arb 2026-03-06 23:32:33 +07:00
zarzet 8fe7a1e756 Merge branch 'dev'
# Conflicts:
#	README.md
#	lib/constants/app_info.dart
#	lib/l10n/app_localizations_ru.dart
#	pubspec.yaml
2026-03-06 22:02:12 +07:00
zarzet 4a61ffea8d chore: update VirusTotal hash 2026-03-06 22:00:56 +07:00
zarzet 91548691ad feat(site): add Ruubiiiii as Qobuz & Deezer API provider on partners page 2026-03-06 21:57:39 +07:00
Zarz Eleutherius ab2d671760 New translations app_en.arb (Turkish) 2026-03-04 23:19:46 +07:00
Zarz Eleutherius 5532d0a7d9 New translations app_en.arb (Hindi) 2026-03-04 23:19:44 +07:00
Zarz Eleutherius 277e7719d3 New translations app_en.arb (Indonesian) 2026-03-04 23:19:43 +07:00
Zarz Eleutherius d6cb9fc261 New translations app_en.arb (Chinese Traditional) 2026-03-04 23:19:42 +07:00
Zarz Eleutherius e6a857335f New translations app_en.arb (Chinese Simplified) 2026-03-04 23:19:40 +07:00
Zarz Eleutherius e82e3a8343 New translations app_en.arb (Russian) 2026-03-04 23:19:39 +07:00
Zarz Eleutherius 6d812c76c2 New translations app_en.arb (Portuguese) 2026-03-04 23:19:38 +07:00
Zarz Eleutherius 5af4bb7ade New translations app_en.arb (Dutch) 2026-03-04 23:19:36 +07:00
Zarz Eleutherius 030d66fd65 New translations app_en.arb (Korean) 2026-03-04 23:19:35 +07:00
Zarz Eleutherius c929f8d0a6 New translations app_en.arb (Japanese) 2026-03-04 23:19:33 +07:00
Zarz Eleutherius 6fb50cfc67 New translations app_en.arb (German) 2026-03-04 23:19:32 +07:00
Zarz Eleutherius ebcdcf40dc New translations app_en.arb (Spanish) 2026-03-04 23:19:31 +07:00
Zarz Eleutherius 76a05e717b New translations app_en.arb (French) 2026-03-04 23:19:30 +07:00
Zarz Eleutherius 062ce31cf7 Update source file app_en.arb 2026-03-04 23:19:27 +07:00
Zarz Eleutherius 8675ab3215 New translations app_en.arb (Russian) 2026-03-02 03:16:44 +07:00
Zarz Eleutherius ad6ef2884a New translations app_en.arb (German) 2026-02-28 21:18:46 +07:00
Zarz Eleutherius 3ebb8a5e79 New translations app_en.arb (Indonesian) 2026-02-27 21:04:14 +07:00
Zarz Eleutherius 652b1b0821 New translations app_en.arb (German) 2026-02-27 21:04:13 +07:00
Zarz Eleutherius a202ca4865 New translations app_en.arb (German) 2026-02-25 20:31:40 +07:00
Zarz Eleutherius a2db5bef25 New translations app_en.arb (Russian) 2026-02-23 19:26:36 +07:00
Zarz Eleutherius a81fa1ead7 New translations app_en.arb (German) 2026-02-23 19:26:35 +07:00
Zarz Eleutherius e7315cbc7e New translations app_en.arb (Turkish) 2026-02-22 18:55:55 +07:00
Zarz Eleutherius cd757f177f New translations app_en.arb (Hindi) 2026-02-22 18:55:54 +07:00
Zarz Eleutherius 103c55c072 New translations app_en.arb (Indonesian) 2026-02-22 18:55:53 +07:00
Zarz Eleutherius 765caab6df New translations app_en.arb (Chinese Traditional) 2026-02-22 18:55:52 +07:00
Zarz Eleutherius 72f4663dd5 New translations app_en.arb (Chinese Simplified) 2026-02-22 18:55:51 +07:00
Zarz Eleutherius deb6d92b55 New translations app_en.arb (Russian) 2026-02-22 18:55:50 +07:00
Zarz Eleutherius 0222ea6ccb New translations app_en.arb (Portuguese) 2026-02-22 18:55:49 +07:00
Zarz Eleutherius 8c047600a0 New translations app_en.arb (Dutch) 2026-02-22 18:55:48 +07:00
Zarz Eleutherius 57b5877fdc New translations app_en.arb (Korean) 2026-02-22 18:55:46 +07:00
Zarz Eleutherius 7ddf67a977 New translations app_en.arb (Japanese) 2026-02-22 18:55:45 +07:00
Zarz Eleutherius 7af2212d11 New translations app_en.arb (German) 2026-02-22 18:55:44 +07:00
Zarz Eleutherius 5e13651ed9 New translations app_en.arb (Spanish) 2026-02-22 18:55:43 +07:00
Zarz Eleutherius 08e9c8d463 New translations app_en.arb (French) 2026-02-22 18:55:42 +07:00
Zarz Eleutherius b3d93880b5 Update source file app_en.arb 2026-02-22 18:55:40 +07:00
Zarz Eleutherius 05e100a492 New translations app_en.arb (Russian) 2026-02-21 18:43:19 +07:00
Zarz Eleutherius a4e22de455 New translations app_en.arb (Korean) 2026-02-20 18:25:11 +07:00
Zarz Eleutherius d76d020cfe New translations app_en.arb (German) 2026-02-19 18:17:08 +07:00
Zarz Eleutherius 85bf3cfa84 New translations app_en.arb (Turkish) 2026-02-18 17:44:35 +07:00
Zarz Eleutherius 8eec73d88c New translations app_en.arb (Hindi) 2026-02-18 17:44:34 +07:00
Zarz Eleutherius b63dbbbfd5 New translations app_en.arb (Indonesian) 2026-02-18 17:44:33 +07:00
Zarz Eleutherius 8b16157047 New translations app_en.arb (Chinese Traditional) 2026-02-18 17:44:32 +07:00
Zarz Eleutherius 6628682f97 New translations app_en.arb (Chinese Simplified) 2026-02-18 17:44:31 +07:00
Zarz Eleutherius 5971ffc470 New translations app_en.arb (Russian) 2026-02-18 17:44:30 +07:00
Zarz Eleutherius baf95ec328 New translations app_en.arb (Portuguese) 2026-02-18 17:44:28 +07:00
Zarz Eleutherius 0a6590fafd New translations app_en.arb (Dutch) 2026-02-18 17:44:27 +07:00
Zarz Eleutherius 22dd0ee0f6 New translations app_en.arb (Korean) 2026-02-18 17:44:26 +07:00
Zarz Eleutherius f9ad6046e8 New translations app_en.arb (Japanese) 2026-02-18 17:44:25 +07:00
Zarz Eleutherius 8a21902fa1 New translations app_en.arb (German) 2026-02-18 17:44:24 +07:00
Zarz Eleutherius 016564eda7 New translations app_en.arb (Spanish) 2026-02-18 17:44:23 +07:00
Zarz Eleutherius 5a8ff7db37 New translations app_en.arb (French) 2026-02-18 17:44:22 +07:00
Zarz Eleutherius cc08596adf Update source file app_en.arb 2026-02-18 17:44:19 +07:00
Zarz Eleutherius e83fd66023 New translations app_en.arb (Russian) 2026-02-17 17:43:02 +07:00
Zarz Eleutherius d49bab403d New translations app_en.arb (German) 2026-02-17 17:43:01 +07:00
Zarz Eleutherius a6bef63aa7 New translations app_en.arb (Indonesian) 2026-02-16 17:38:32 +07:00
Zarz Eleutherius 898e28c40c New translations app_en.arb (German) 2026-02-15 17:40:51 +07:00
zarzet 9fda7ef596 Merge branch 'dev' 2026-02-14 17:56:33 +07:00
Zarz Eleutherius 17ba1713ad New translations app_en.arb (Turkish) 2026-02-14 17:37:29 +07:00
Zarz Eleutherius f4110204b1 New translations app_en.arb (Hindi) 2026-02-14 17:37:28 +07:00
Zarz Eleutherius d2a183b52d New translations app_en.arb (Indonesian) 2026-02-14 17:37:27 +07:00
Zarz Eleutherius a8dcf3113c New translations app_en.arb (Chinese Traditional) 2026-02-14 17:37:26 +07:00
Zarz Eleutherius 1f52a6c9e0 New translations app_en.arb (Chinese Simplified) 2026-02-14 17:37:25 +07:00
Zarz Eleutherius adbed63196 New translations app_en.arb (Russian) 2026-02-14 17:37:24 +07:00
Zarz Eleutherius 33e20845f1 New translations app_en.arb (Portuguese) 2026-02-14 17:37:22 +07:00
Zarz Eleutherius 9a7096c301 New translations app_en.arb (Dutch) 2026-02-14 17:37:21 +07:00
Zarz Eleutherius 4c365032ff New translations app_en.arb (Korean) 2026-02-14 17:37:20 +07:00
Zarz Eleutherius bbd32d40a6 New translations app_en.arb (Japanese) 2026-02-14 17:37:19 +07:00
Zarz Eleutherius 73f4a91fa1 New translations app_en.arb (German) 2026-02-14 17:37:18 +07:00
Zarz Eleutherius 1e2e383794 New translations app_en.arb (Spanish) 2026-02-14 17:37:17 +07:00
Zarz Eleutherius 3b70b071e3 New translations app_en.arb (French) 2026-02-14 17:37:16 +07:00
Zarz Eleutherius 838c0ea421 Update source file app_en.arb 2026-02-14 17:37:13 +07:00
zarzet b39ec41255 Merge dev into main: v3.6.7 release 2026-02-13 21:42:02 +07:00
Zarz Eleutherius d4d661d6d4 New translations app_en.arb (German) 2026-02-13 17:08:10 +07:00
Zarz Eleutherius 2092f078ec Update badge URL 2026-02-13 04:06:33 +07:00
Zarz Eleutherius 924569aefb New translations app_en.arb (Russian) 2026-02-12 16:53:01 +07:00
Zarz Eleutherius a5864e15f8 New translations app_en.arb (German) 2026-02-12 16:53:00 +07:00
zarzet 564dd8bf95 refactor: migrate queue_tab cover resolver to shared service, add supporter 2026-02-11 12:43:51 +07:00
zarzet b317f7cd76 fix: show library filter buttons while downloads are active
Previously filter/sort headers in All, Albums, and Singles tabs
were hidden when queue items existed, preventing users from
filtering their library (e.g. find MP3 tracks to re-download
as FLAC) during active downloads.
2026-02-11 12:43:51 +07:00
zarzet a3b49d2642 docs: add batch 3 performance entries to changelog, fix device ID entry 2026-02-11 12:43:51 +07:00
zarzet 6f20620c97 perf: parallel I/O, caching, and chunked DB operations (batch 3)
- Orphan cleanup: parallel file existence checks (chunk 16)
- LocalLibraryState: O(1) findByTrackAndArtist via _byTrackKey map
- Local library load: parallel DB + SharedPreferences fetch
- Legacy mod-time backfill: chunked parallel File.stat (chunk 24)
- Downloaded album screen: cache disc groups, quality, cover path
- Local album screen: cache common quality, map-based batch delete
- Cache management: parallel async init, chunked directory cleanup
- Cover resolver: throttled preview exists check (2.2s interval)
- History/Library DB: chunked SQL DELETE (500 per batch)
- Batch delete screens: O(1) item lookup via tracksById map
2026-02-11 12:43:50 +07:00
zarzet b6a055a01a docs: add performance, security, and UI sections to v3.6.5 changelog 2026-02-11 12:43:50 +07:00
zarzet 44ac593ddc perf+security: polling guards, sensitive data redaction, SAF path sanitization
Go backend:
- Add sensitive data redaction in log buffer (tokens, keys, passwords)
- Validate extension auth URLs (HTTPS only, no private IPs, no embedded creds)
- Block embedded credentials in extension HTTP requests
- Tighten extension storage file permissions (0644 -> 0600)
- Sanitize extension ID in store download path
- Summarize auth URLs in logs to prevent token leakage

Android (Kotlin):
- Add sanitizeRelativeDir to prevent path traversal in SAF operations
- Apply sanitizeFilename to all user-provided file names in SAF

Flutter:
- Add sensitive data redaction in Dart logger (mirrors Go patterns)
- Mask device ID in log exports
- Add in-flight guard to progress polling (download queue + local library)
- Remove redundant _downloadedSpotifyIds Set, use _bySpotifyId map
- Remove redundant _isrcSet, use _byIsrc map
- Expand DownloadQueueLookup with byItemId and itemIds
- Lazy search index building in queue tab
- Bound embedded cover cache in queue tab (max 180)
- Coalesce embedded cover refresh callbacks via postFrameCallback
- Cache album track filtering in downloaded album screen
- Cache thumbnail sizes by extension ID in home tab
- Simplify recent access aggregation (single-pass)
- Remove unused _isTyping state in home tab
- Cap pre-warm track batch size to 80
- Skip setShowingRecentAccess if value unchanged
- Use downloadQueueLookupProvider for granular queue selectors
- Move grouped album filtering before content data computation
2026-02-11 12:43:50 +07:00
zarzet ca4c2a661e perf: memory and rebuild optimizations across app
- Bound Deezer cache with LRU eviction and periodic cleanup
- Configure Flutter image cache limits (240 entries / 60 MiB)
- Add ResizeImage wrapper for precacheImage calls
- Add memCacheWidth/cacheWidth to cover images across screens
- Add DownloadedEmbeddedCoverResolver as centralized cover service
- Throttle download progress notifications with dedup checks
- Normalize progress/speed/bytes values to reduce UI rebuilds
- Optimize queue list with per-item ConsumerWidget and RepaintBoundary
- Preserve derived indexes in LocalLibraryState.copyWith
- Skip non-error logs when detailed logging disabled
- Use async file stat and early-break loops in queue filters
2026-02-11 12:43:50 +07:00
zarzet 8b3b39f390 ui: improve cover preview in edit metadata sheet and user changes
- Cover preview enlarged from 120x120 to 160x160 with shadow and better styling
- Layout changed from Wrap to Row with Expanded for side-by-side covers
- Label moved below image with labelMedium typography
- Cover editor section moved to top of edit form
- Added embedded cover preview cache with LRU eviction in metadata screen
- Added current cover extraction and preview in edit metadata sheet
- Added metadata sync to download history after edits
- Added embedded cover extraction cache in queue tab for downloaded items
- Added SAF mod-time tracking for cover refresh after metadata changes
2026-02-11 12:43:50 +07:00
zarzet 915934e5dd fix: various improvements and fixes 2026-02-11 12:43:50 +07:00
zarzet 42f15018ae v3.6.5: audio format conversion, PC v7.0.8 backend merge, Amazon re-enabled 2026-02-11 12:43:49 +07:00
zarzet 3554a7b5b9 chore: remove Buy Me a Coffee references (account suspended) 2026-02-11 12:43:49 +07:00
zarzet f2941939b7 refactor: preserve extension ID case in DownloadByStrategy, only lowercase built-in providers 2026-02-11 12:43:49 +07:00
zarzet 1a77ded997 refactor: remove deprecated download methods from PlatformBridge and MainActivity 2026-02-11 12:43:49 +07:00
zarzet 05d25d4d7c v3.6.1: fix lyrics_mode, notification v20, SAF duplicate, primary artist setting, unified download strategy 2026-02-11 12:43:49 +07:00
zarzet 7cc1fef989 feat: primary artist only folders, fix notifications v20, fix SAF duplicate dirs
- Add 'Use Primary Artist Only' setting to strip featured artists from folder names
- Fix flutter_local_notifications v20 breaking changes (positional params)
- Fix SAF duplicate folder bug: synchronized ensureDocumentDir to prevent race condition creating empty folders with (1), (2) suffixes during concurrent downloads
2026-02-11 12:43:49 +07:00
Zarz Eleutherius 4a966e5e52 Update README 2026-02-11 02:23:38 +07:00
Zarz Eleutherius d8ba4549aa Merge pull request #144 from Amonoman/main
Update Screenshots
2026-02-11 02:21:09 +07:00
Amonoman 309568becc Readd Screenshots 2026-02-09 20:35:15 +01:00
Amonoman dd9b6dbfe3 Delete assets/images/4.jpg 2026-02-09 20:29:39 +01:00
Amonoman 4692b48174 Delete assets/images/3.jpg 2026-02-09 20:29:29 +01:00
Amonoman db82fa3ae1 Delete assets/images/2.jpg 2026-02-09 20:29:21 +01:00
Amonoman 5c42507b12 Delete assets/images/1.jpg 2026-02-09 20:29:12 +01:00
137 changed files with 12897 additions and 7131 deletions
+42 -64
View File
@@ -309,32 +309,22 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0 # Full history needed for git-cliff
- name: Extract changelog for version
- name: Generate changelog with git-cliff
id: changelog
uses: orhun/git-cliff-action@v4
with:
config: cliff.toml
args: --latest --strip header
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OUTPUT: /tmp/changelog.txt
- name: Show generated changelog
run: |
VERSION=${{ needs.get-version.outputs.version }}
VERSION_NUM=${VERSION#v} # Remove 'v' prefix
echo "Looking for version: $VERSION_NUM"
# Extract changelog section for this version using sed
# Find the line with version, then print until next version header or end
CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md)
# If no changelog found, use default message
if [ -z "$CHANGELOG" ]; then
echo "No changelog found for version $VERSION_NUM"
CHANGELOG="See CHANGELOG.md for details."
else
echo "Found changelog content"
# Remove trailing --- separator if present (CHANGELOG uses --- between versions)
CHANGELOG=$(echo "$CHANGELOG" | sed '/^---$/d')
fi
# Save to file for multiline support
echo "$CHANGELOG" > /tmp/changelog.txt
echo "Extracted changelog:"
echo "Generated changelog:"
cat /tmp/changelog.txt
- name: Download Android APK
@@ -352,15 +342,13 @@ jobs:
- name: Prepare release body
run: |
VERSION=${{ needs.get-version.outputs.version }}
cat > /tmp/release_body.txt << 'HEADER'
### What's New
HEADER
cat /tmp/changelog.txt >> /tmp/release_body.txt
REPO_OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}"
# Start with git-cliff changelog
cp /tmp/changelog.txt /tmp/release_body.txt
# Append download section
cat >> /tmp/release_body.txt << FOOTER
---
@@ -404,6 +392,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Download Android APK
uses: actions/download-artifact@v7
@@ -417,52 +407,40 @@ jobs:
name: ios-ipa
path: ./release
- name: Extract changelog for version
- name: Generate changelog with git-cliff for Telegram
uses: orhun/git-cliff-action@v4
with:
config: cliff.toml
args: --latest --strip all
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OUTPUT: /tmp/cliff_tg.txt
- name: Convert changelog for Telegram
id: changelog
run: |
VERSION=${{ needs.get-version.outputs.version }}
VERSION_NUM=${VERSION#v}
# Extract changelog, limit to ~2500 chars for Telegram (4096 limit minus message overhead)
# Use tr -d '\r' to handle CRLF line endings from Windows
FULL_CHANGELOG=$(cat CHANGELOG.md | tr -d '\r' | sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" | sed '/^---$/d')
echo "DEBUG: Extracted changelog length: ${#FULL_CHANGELOG}"
echo "DEBUG: First 200 chars: ${FULL_CHANGELOG:0:200}"
if [ -z "$FULL_CHANGELOG" ]; then
CHANGELOG="See release notes on GitHub for details."
if [ ! -s /tmp/cliff_tg.txt ]; then
echo "See release notes on GitHub for details." > /tmp/changelog.txt
else
# Convert GitHub Markdown to Telegram HTML:
# - **text** → <b>text</b>
# - `code` → <code>code</code>
# - ### Header → <b>Header</b>
# - Escape HTML special chars first
# - Remove > blockquote prefix
CHANGELOG=$(echo "$FULL_CHANGELOG" | \
sed 's/^> //' | \
# Convert Markdown to Telegram HTML
CHANGELOG=$(cat /tmp/cliff_tg.txt | \
sed '/^\*\*Full Changelog\*\*/d' | \
sed 's/\[#\([0-9]*\)\]([^)]*)/#\1/g' | \
sed 's/\[@\([^]]*\)\]([^)]*)/@\1/g' | \
sed 's/&/\&amp;/g' | \
sed 's/</\&lt;/g' | \
sed 's/>/\&gt;/g' | \
sed 's/`\([^`]*\)`/<code>\1<\/code>/g' | \
sed 's/\*\*\([^*]*\)\*\*/<b>\1<\/b>/g' | \
sed 's/^### \(.*\)$/<b>\1<\/b>/g' | \
sed 's/^## \(.*\)$/<b>\1<\/b>/g' | \
sed 's/^- /• /g' | \
sed 's/^ - / ◦ /g')
# Take first 2500 characters, then cut at last complete line
sed 's/^- /• /g')
# Truncate for Telegram 4096 char limit
CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d')
# Check if truncated
FULL_LEN=${#FULL_CHANGELOG}
if [ $FULL_LEN -gt 2500 ]; then
CHANGELOG="${CHANGELOG}"$'\n\n... (see full changelog on GitHub)'
fi
echo "$CHANGELOG" > /tmp/changelog.txt
fi
echo "$CHANGELOG" > /tmp/changelog.txt
echo "DEBUG: Final changelog:"
echo "Telegram changelog:"
cat /tmp/changelog.txt
- name: Send to Telegram Channel
+3
View File
@@ -12,6 +12,9 @@ Thumbs.db
# Kiro specs (development only)
.kiro/
# Design assets (banners, mockups)
design/
# Reference folder (development only)
referensi/
+49
View File
@@ -1,5 +1,54 @@
# Changelog
## [3.7.2] - 2026-03-07
### Changed
- **Amazon Music is now an Extension**: Amazon Music has been moved from a built-in service to a separate installable extension. Install the "Amazon Music" extension from the Store to continue using it.
### Fixed
- **Deezer Downloads Timing Out**: Deezer downloads were failing with "context deadline exceeded" on larger files. Now uses the proper download timeout, matching Tidal and Qobuz.
- **iOS Local Library Scan Fails**: Local library scanning was failing on iOS because the app lost access to user-picked folders after the FilePicker session ended. Implemented iOS security-scoped bookmark system:
- When a library folder is picked on iOS, a security-scoped bookmark is created and persisted in settings (`localLibraryBookmark`)
- Before each scan, the bookmark is resolved and security-scoped access is started; access is released in `finally` block after scan completes
- `cleanupMissingFiles` also activates the bookmark before checking file existence on iOS
- New `AppDelegate.swift` method channel handlers: `createIosBookmarkFromPath`, `startAccessingIosBookmark`, `stopAccessingIosBookmark`, `resolveIosBookmark`
- New `PlatformBridge` methods: `createIosBookmarkFromPath()`, `startAccessingIosBookmark()`, `stopAccessingIosBookmark()`
- All scan call-sites (Library Settings, Queue tab, Local Album screen) now pass the iOS bookmark to `startScan()`
### Added
- **Amazon Music Extension**: Available in `extension/Amazon-SpotiFLAC/` — same functionality as before, now as an installable extension.
- **Accessibility Tooltips**: Added localized tooltips to all `IconButton` and `PopupMenuButton` widgets across the entire UI for screen reader and long-press discoverability
- Back buttons use `MaterialLocalizations.backButtonTooltip`
- Close buttons use `MaterialLocalizations.closeButtonTooltip`
- Menu buttons use `MaterialLocalizations.showMenuTooltip`
- Search buttons use `MaterialLocalizations.searchFieldLabel`
- Contextual actions use descriptive labels: "Play track", "Dismiss", "Clear search", "Change folder", "Refresh"
- Screens affected: Album, Artist, Playlist, Downloaded Album, Local Album, Home, Search, Queue, Library Playlists, Library Tracks Folder, Setup, Tutorial, Track Metadata, Store, Extension Store Details, and all Settings sub-pages (About, Appearance, Cache Management, Donate, Download, Extensions, Extension Detail, Library, Log, Options, Provider Priority)
- **Semantics Wrappers**: Added `Semantics` widgets to interactive elements that previously had no accessibility information
- Album tiles in Artist screen: announces selection state and album name
- Recently downloaded track tiles in Home tab: announces track name and artist
- Explore items (albums/artists/playlists) in Home tab: announces item type and name
- Color palette picker in Appearance settings: announces selected state and color hex value
- Download button demo in Tutorial screen: added `ExcludeSemantics` on icon to prevent duplicate screen reader announcements
- Queue tab playlist cards: announces playlist name and item count
- Queue tab downloaded album cards: announces album name, artist, and track count
- Queue tab local album cards: announces album name, artist, and track count
- Queue tab play button on completed downloads: announces track name and artist with `ExcludeSemantics` on icon
- Queue tab download status indicators: "Finalizing download", "Download completed", "Downloaded file missing" labels with `ExcludeSemantics` on icons
### Improved
- **Code Formatting**: Reformatted and corrected indentation across multiple files to comply with Dart style guidelines
- `extension_detail_page.dart`: Fixed `SliverAppBar` and all subsequent slivers indentation (was 2 spaces short)
- `log_screen.dart`: Fixed `SliverAppBar` indentation alignment
- `donate_page.dart`: Reformatted ternary expressions and `_cr` function body
- `library_tracks_folder_screen.dart`: Minor line-break formatting
---
## [3.7.1] - 2026-03-06
### Added
+23 -35
View File
@@ -1,20 +1,19 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge&refresh=1)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/40f8f1914287dea317122a837f98b0ddf7af3205adc2f84a350d767e0a6a345c)
[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
<div align="center">
<img src="icon.png" width="128" />
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/images/banner-readme-dark.png">
<source media="(prefers-color-scheme: light)" srcset="assets/images/banner-readme-light.png">
<img alt="SpotiFLAC Mobile" src="assets/images/banner-readme-light.png" width="650" height="auto">
</picture>
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no account required.
![Android](https://img.shields.io/badge/Android-7.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white)
![iOS](https://img.shields.io/badge/iOS-14.0%2B-000000?style=for-the-badge&logo=apple&logoColor=white)
<p align="center">
<a href="https://trendshift.io/repositories/17247">
<img src="https://trendshift.io/api/badge/repositories/17247" alt="zarzet%2FSpotiFLAC-Mobile | Trendshift" width="250" height="55">
</a>
</p>
</div>
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
## Screenshots
<p align="center">
@@ -24,6 +23,17 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no acc
<img src="assets/images/4.jpg?v=2" width="200" />
</p>
<div align="center">
[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge&refresh=1)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/0a2bd2a033551983fc9fcd83f82fd912c83914fd1094cd8d1c7c6a68eb23233f)
[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
[![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac)
[![Telegram Community](https://img.shields.io/badge/COMMUNITY-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac_chat)
</div>
## 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.
@@ -43,18 +53,13 @@ Want to create your own extension? Check out the [Extension Development Guide](h
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
## Telegram
[![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac)
[![Telegram Community](https://img.shields.io/badge/COMMUNITY-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac_chat)
## FAQ
**Q: Why is my download failing with "Song not found"?**
A: The track may not be available on Tidal, Qobuz, or Amazon Music. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions from the Store.
A: The track may not be available on the streaming services. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions like Amazon Music from the Store.
**Q: Why are some tracks downloading in lower quality?**
A: Quality depends on what's available from the streaming service. Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Amazon up to 24-bit/48kHz. The app automatically selects the best available quality.
A: Quality depends on what's available from the streaming service and extensions. Built-in providers: Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Deezer up to 16-bit/44.1kHz.
**Q: Can I download playlists?**
A: Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
@@ -75,23 +80,6 @@ _If this software is useful and brings you value, consider supporting the projec
[![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/zarzet)
## Disclaimer
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Tidal, Qobuz, Amazon Music, Deezer, or any other streaming service.
The application is purely a user interface that facilitates communication between your device and existing third-party services.
You are solely responsible for:
1. Ensuring your use of this software complies with your local laws.
2. Reading and adhering to the Terms of Service of the respective platforms.
3. Any legal consequences resulting from the misuse of this tool.
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
[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) · [dabmusic.xyz](https://dabmusic.xyz) · [AfkarXYZ](https://github.com/afkarxyz) · [LRCLib](https://lrclib.net) · [Paxsenix](https://lyrics.paxsenix.org) · [Cobalt](https://cobalt.tools) · [qwkuns.me](https://qwkuns.me) · [SpotubeDL](https://spotubedl.com) · [Song.link](https://song.link) · [IDHS](https://github.com/sjdonado/idonthavespotify)
@@ -766,6 +766,27 @@ class MainActivity: FlutterFragmentActivity() {
val response = downloader(req.toString())
val respObj = JSONObject(response)
if (respObj.optBoolean("success", false)) {
// Extension providers write to a local temp path instead of the SAF FD.
// Copy the local file into the SAF document so it is not empty.
val goFilePath = respObj.optString("file_path", "")
if (goFilePath.isNotEmpty() &&
!goFilePath.startsWith("content://") &&
!goFilePath.startsWith("/proc/self/fd/")
) {
try {
val srcFile = java.io.File(goFilePath)
if (srcFile.exists() && srcFile.length() > 0) {
contentResolver.openOutputStream(document.uri, "wt")?.use { output ->
srcFile.inputStream().use { input ->
input.copyTo(output)
}
}
srcFile.delete()
}
} catch (e: Exception) {
android.util.Log.w("SpotiFLAC", "Failed to copy extension output to SAF: ${e.message}")
}
}
respObj.put("file_path", document.uri.toString())
respObj.put("file_name", document.name ?: fileName)
} else {
@@ -786,6 +807,72 @@ class MainActivity: FlutterFragmentActivity() {
}
}
/**
* Get the parent DocumentFile directory for a SAF document URI.
* The child URI must be a tree-based document URI (e.g. from SAF tree scan).
* Returns a DocumentFile that supports findFile() for sibling lookup.
*/
private fun safParentDir(childUri: Uri): DocumentFile? {
try {
val docId = android.provider.DocumentsContract.getDocumentId(childUri)
if (docId.isNullOrEmpty()) return null
// Document IDs typically look like "primary:Music/Album/file.cue"
// Parent would be "primary:Music/Album"
val lastSlash = docId.lastIndexOf('/')
if (lastSlash <= 0) return null
val parentDocId = docId.substring(0, lastSlash)
// Build a tree document URI for the parent so it supports listing/findFile
val treeDocId = android.provider.DocumentsContract.getTreeDocumentId(childUri)
if (treeDocId.isNullOrEmpty()) return null
val parentUri = android.provider.DocumentsContract.buildDocumentUriUsingTree(
childUri, parentDocId
)
return DocumentFile.fromTreeUri(this, parentUri)
?: DocumentFile.fromSingleUri(this, parentUri)
} catch (e: Exception) {
android.util.Log.w("SpotiFLAC", "Failed to get SAF parent dir: ${e.message}")
return null
}
}
/**
* Extract the audio filename referenced by a CUE sheet file.
* Reads the FILE "name" TYPE line from the .cue text.
* Returns just the filename (no path), or null if not found.
*/
private fun extractCueAudioFileName(cueTempPath: String): String? {
try {
val lines = File(cueTempPath).readLines()
for (line in lines) {
val trimmed = line.trim().let { l ->
// Strip BOM
if (l.startsWith("\uFEFF")) l.removePrefix("\uFEFF").trim() else l
}
if (trimmed.uppercase(Locale.ROOT).startsWith("FILE ")) {
val rest = trimmed.substring(5).trim()
// Parse: "filename" TYPE or filename TYPE
val filename = if (rest.startsWith("\"")) {
val endQuote = rest.indexOf('"', 1)
if (endQuote > 0) rest.substring(1, endQuote) else rest
} else {
// Last word is the type, everything else is the filename
val parts = rest.split("\\s+".toRegex())
if (parts.size >= 2) parts.dropLast(1).joinToString(" ") else rest
}
// Return just the filename (strip any path separators)
return filename.substringAfterLast("/").substringAfterLast("\\")
}
}
} catch (e: Exception) {
android.util.Log.w("SpotiFLAC", "Failed to extract audio filename from CUE: ${e.message}")
}
return null
}
private fun scanSafTree(treeUriStr: String): String {
if (treeUriStr.isBlank()) return "[]"
@@ -799,8 +886,10 @@ class MainActivity: FlutterFragmentActivity() {
it.currentFile = "Scanning folders..."
}
val supportedExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
val audioFiles = mutableListOf<Pair<DocumentFile, String>>()
// CUE files: (cueDoc, parentDir) — we need the parent to find sibling audio
val cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
val visitedDirUris = mutableSetOf<String>()
var traversalErrors = 0
@@ -849,7 +938,9 @@ class MainActivity: FlutterFragmentActivity() {
} else if (child.isFile) {
val name = child.name ?: continue
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
if (ext.isNotBlank() && supportedExt.contains(".$ext")) {
if (ext == "cue") {
cueFiles.add(child to dir)
} else if (ext.isNotBlank() && supportedAudioExt.contains(".$ext")) {
audioFiles.add(child to path)
}
}
@@ -864,11 +955,12 @@ class MainActivity: FlutterFragmentActivity() {
}
}
val totalItems = audioFiles.size + cueFiles.size
updateSafScanProgress {
it.totalFiles = audioFiles.size
it.totalFiles = totalItems
}
if (audioFiles.isEmpty()) {
if (audioFiles.isEmpty() && cueFiles.isEmpty()) {
updateSafScanProgress {
it.isComplete = true
it.progressPct = 100.0
@@ -880,12 +972,138 @@ class MainActivity: FlutterFragmentActivity() {
var scanned = 0
var errors = traversalErrors
// --- CUE first pass: parse CUE sheets, expand to tracks, track referenced audio ---
val cueReferencedAudioUris = mutableSetOf<String>()
for ((cueDoc, parentDir) in cueFiles) {
if (safScanCancel) {
updateSafScanProgress { it.isComplete = true }
return "[]"
}
val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" }
updateSafScanProgress { it.currentFile = cueName }
var tempCuePath: String? = null
var tempAudioPath: String? = null
try {
// Copy CUE to temp
tempCuePath = copyUriToTemp(cueDoc.uri, ".cue")
if (tempCuePath == null) {
errors++
android.util.Log.w("SpotiFLAC", "SAF scan: failed to copy CUE ${cueDoc.uri}")
scanned++
continue
}
// Extract the audio filename from the CUE sheet text
val audioFileName = extractCueAudioFileName(tempCuePath)
// Find the referenced audio file as a sibling in the same SAF directory
var audioDoc: DocumentFile? = null
if (!audioFileName.isNullOrBlank()) {
audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null }
}
// Fallback: try common audio extensions with the CUE base name
if (audioDoc == null) {
val cueBaseName = cueName.substringBeforeLast('.')
val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a")
for (ext in commonExts) {
audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null }
if (audioDoc != null) break
// Try uppercase
audioDoc = try { parentDir.findFile(cueBaseName + ext.uppercase(Locale.ROOT)) } catch (_: Exception) { null }
if (audioDoc != null) break
}
}
if (audioDoc == null) {
android.util.Log.w("SpotiFLAC", "SAF scan: no audio file found for CUE $cueName")
errors++
scanned++
continue
}
// Mark this audio file so we skip it in the regular audio pass
cueReferencedAudioUris.add(audioDoc.uri.toString())
// Copy audio to same temp dir so Go can resolve it
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
val fallbackAudioExt = if (audioExt.isNotBlank()) ".$audioExt" else null
tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt)
if (tempAudioPath == null) {
android.util.Log.w("SpotiFLAC", "SAF scan: failed to copy audio for CUE $cueName")
errors++
scanned++
continue
}
// Rename temp audio to its original name so Go can find it by name
val renamedAudio = File(tempDir, audioName)
val tempAudioFile = File(tempAudioPath)
if (renamedAudio.absolutePath != tempAudioFile.absolutePath) {
tempAudioFile.renameTo(renamedAudio)
tempAudioPath = renamedAudio.absolutePath
}
val cueLastModified = try { cueDoc.lastModified() } catch (_: Exception) { 0L }
// Call Go to produce library scan entries for each CUE track
val cueResultsJson = Gobackend.scanCueSheetForLibrary(
tempCuePath,
tempDir,
cueDoc.uri.toString(),
cueLastModified
)
val cueArray = JSONArray(cueResultsJson)
for (j in 0 until cueArray.length()) {
results.put(cueArray.getJSONObject(j))
}
android.util.Log.d(
"SpotiFLAC",
"SAF scan: CUE $cueName -> ${cueArray.length()} tracks"
)
} catch (e: Exception) {
errors++
android.util.Log.w("SpotiFLAC", "SAF scan: error processing CUE $cueName: ${e.message}")
} finally {
try { tempCuePath?.let { File(it).delete() } } catch (_: Exception) {}
try { tempAudioPath?.let { File(it).delete() } } catch (_: Exception) {}
}
scanned++
val pct = scanned.toDouble() / totalItems.toDouble() * 100.0
updateSafScanProgress {
it.scannedFiles = scanned
it.errorCount = errors
it.progressPct = pct
}
}
// --- Regular audio file pass: skip files referenced by CUE sheets ---
for ((doc, _) in audioFiles) {
if (safScanCancel) {
updateSafScanProgress { it.isComplete = true }
return "[]"
}
// Skip audio files that are represented by CUE track entries
if (cueReferencedAudioUris.contains(doc.uri.toString())) {
scanned++
val pct = scanned.toDouble() / totalItems.toDouble() * 100.0
updateSafScanProgress {
it.scannedFiles = scanned
it.progressPct = pct
}
continue
}
val name = try { doc.name ?: "" } catch (_: Exception) { "" }
updateSafScanProgress {
it.currentFile = name
@@ -926,7 +1144,7 @@ class MainActivity: FlutterFragmentActivity() {
}
scanned++
val pct = scanned.toDouble() / audioFiles.size.toDouble() * 100.0
val pct = scanned.toDouble() / totalItems.toDouble() * 100.0
updateSafScanProgress {
it.scannedFiles = scanned
it.errorCount = errors
@@ -944,6 +1162,8 @@ class MainActivity: FlutterFragmentActivity() {
/**
* Incremental SAF tree scan - only scans new or modified files.
* Supports .cue sheets: expands them into virtual track entries and
* deduplicates audio files referenced by CUE sheets.
* @param treeUriStr The SAF tree URI to scan
* @param existingFilesJson JSON object mapping file URI -> lastModified timestamp
* @return JSON object with new/changed files and removed URIs
@@ -986,13 +1206,29 @@ class MainActivity: FlutterFragmentActivity() {
it.currentFile = "Scanning folders..."
}
val supportedExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>() // doc, path, lastModified
// CUE files to scan: (cueDoc, parentDir, lastModified)
val cueFilesToScan = mutableListOf<Triple<DocumentFile, DocumentFile, Long>>()
// Unchanged CUE files: (cueDoc, parentDir) — need to discover audio siblings for skip set
val unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
val currentUris = mutableSetOf<String>()
val visitedDirUris = mutableSetOf<String>()
var traversalErrors = 0
// Collect all audio files with lastModified
// Build a map of CUE base URIs -> existing virtual track URIs from the database.
// Virtual paths look like "content://...album.cue#track01".
// We need this to preserve virtual paths for unchanged CUE files.
val existingCueVirtualPaths = mutableMapOf<String, MutableList<String>>() // cueUri -> [virtualPaths]
for (key in existingFiles.keys) {
val hashIdx = key.indexOf("#track")
if (hashIdx > 0) {
val baseCueUri = key.substring(0, hashIdx)
existingCueVirtualPaths.getOrPut(baseCueUri) { mutableListOf() }.add(key)
}
}
// Collect all files with lastModified
val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque()
queue.add(root to "")
@@ -1055,7 +1291,27 @@ class MainActivity: FlutterFragmentActivity() {
val name = child.name ?: continue
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
if (ext.isNotBlank() && supportedExt.contains(".$ext")) {
if (ext == "cue") {
val lastModified = try {
child.lastModified()
} catch (_: Exception) { 0L }
// Check if any virtual track from this CUE exists with matching modTime
val virtualPaths = existingCueVirtualPaths[uriStr]
val existingModified = virtualPaths?.firstOrNull()?.let { existingFiles[it] }
if (existingModified != null && existingModified == lastModified) {
// CUE is unchanged — mark virtual paths as current so they aren't removed
unchangedCueFiles.add(child to dir)
for (vp in virtualPaths) {
currentUris.add(vp)
}
} else {
// CUE is new or modified — needs scanning
cueFilesToScan.add(Triple(child, dir, lastModified))
}
} else if (ext.isNotBlank() && supportedAudioExt.contains(".$ext")) {
val existingModified = existingFiles[uriStr]
val lastModified = try {
child.lastModified()
@@ -1083,13 +1339,14 @@ class MainActivity: FlutterFragmentActivity() {
// Find removed files (in existing but not in current)
val removedUris = existingFiles.keys.filter { !currentUris.contains(it) }
val totalFiles = currentUris.size
val skippedCount = (totalFiles - audioFiles.size).coerceAtLeast(0)
val filesToProcess = audioFiles.size + cueFilesToScan.size
val skippedCount = (totalFiles - filesToProcess).coerceAtLeast(0)
updateSafScanProgress {
it.totalFiles = totalFiles
}
if (audioFiles.isEmpty()) {
if (audioFiles.isEmpty() && cueFilesToScan.isEmpty()) {
updateSafScanProgress {
it.isComplete = true
it.scannedFiles = totalFiles
@@ -1107,6 +1364,173 @@ class MainActivity: FlutterFragmentActivity() {
var scanned = 0
var errors = traversalErrors
// --- CUE first pass: parse new/modified CUE sheets ---
val cueReferencedAudioUris = mutableSetOf<String>()
for ((cueDoc, parentDir, cueLastModified) in cueFilesToScan) {
if (safScanCancel) {
updateSafScanProgress { it.isComplete = true }
val result = JSONObject()
result.put("files", JSONArray())
result.put("removedUris", JSONArray())
result.put("skippedCount", skippedCount)
result.put("totalFiles", totalFiles)
result.put("cancelled", true)
return result.toString()
}
val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" }
updateSafScanProgress { it.currentFile = cueName }
var tempCuePath: String? = null
var tempAudioPath: String? = null
try {
// Copy CUE to temp
tempCuePath = copyUriToTemp(cueDoc.uri, ".cue")
if (tempCuePath == null) {
errors++
android.util.Log.w("SpotiFLAC", "SAF incremental scan: failed to copy CUE ${cueDoc.uri}")
scanned++
continue
}
// Extract the audio filename from the CUE sheet text
val audioFileName = extractCueAudioFileName(tempCuePath)
// Find the referenced audio file as a sibling in the same SAF directory
var audioDoc: DocumentFile? = null
if (!audioFileName.isNullOrBlank()) {
audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null }
}
// Fallback: try common audio extensions with the CUE base name
if (audioDoc == null) {
val cueBaseName = cueName.substringBeforeLast('.')
val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a")
for (ext in commonExts) {
audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null }
if (audioDoc != null) break
audioDoc = try { parentDir.findFile(cueBaseName + ext.uppercase(Locale.ROOT)) } catch (_: Exception) { null }
if (audioDoc != null) break
}
}
if (audioDoc == null) {
android.util.Log.w("SpotiFLAC", "SAF incremental scan: no audio file found for CUE $cueName")
errors++
scanned++
continue
}
// Mark this audio file so we skip it in the regular audio pass
cueReferencedAudioUris.add(audioDoc.uri.toString())
// Copy audio to same temp dir so Go can resolve it
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
val fallbackAudioExt = if (audioExt.isNotBlank()) ".$audioExt" else null
tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt)
if (tempAudioPath == null) {
android.util.Log.w("SpotiFLAC", "SAF incremental scan: failed to copy audio for CUE $cueName")
errors++
scanned++
continue
}
// Rename temp audio to its original name so Go can find it by name
val renamedAudio = File(tempDir, audioName)
val tempAudioFile = File(tempAudioPath)
if (renamedAudio.absolutePath != tempAudioFile.absolutePath) {
tempAudioFile.renameTo(renamedAudio)
tempAudioPath = renamedAudio.absolutePath
}
// Call Go to produce library scan entries for each CUE track
val cueResultsJson = Gobackend.scanCueSheetForLibrary(
tempCuePath,
tempDir,
cueDoc.uri.toString(),
cueLastModified
)
val cueArray = JSONArray(cueResultsJson)
for (j in 0 until cueArray.length()) {
val trackObj = cueArray.getJSONObject(j)
results.put(trackObj)
// Register each virtual path as current so deletion detection works
val virtualPath = trackObj.optString("filePath", "")
if (virtualPath.isNotBlank()) {
currentUris.add(virtualPath)
}
}
android.util.Log.d(
"SpotiFLAC",
"SAF incremental scan: CUE $cueName -> ${cueArray.length()} tracks"
)
} catch (e: Exception) {
errors++
android.util.Log.w("SpotiFLAC", "SAF incremental scan: error processing CUE $cueName: ${e.message}")
} finally {
try { tempCuePath?.let { File(it).delete() } } catch (_: Exception) {}
try { tempAudioPath?.let { File(it).delete() } } catch (_: Exception) {}
}
scanned++
val processed = skippedCount + scanned
val pct = if (totalFiles > 0) {
processed.toDouble() / totalFiles.toDouble() * 100.0
} else {
100.0
}
updateSafScanProgress {
it.scannedFiles = processed
it.errorCount = errors
it.progressPct = pct
}
}
// Discover audio siblings for unchanged CUE files so we skip them
// in the regular audio pass. Copy the .cue to temp (tiny file) to extract
// the audio filename, then find the sibling by name.
for ((cueDoc, parentDir) in unchangedCueFiles) {
var tempCue: String? = null
try {
tempCue = copyUriToTemp(cueDoc.uri, ".cue")
if (tempCue != null) {
val audioFileName = extractCueAudioFileName(tempCue)
var audioDoc: DocumentFile? = null
if (!audioFileName.isNullOrBlank()) {
audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null }
}
// Fallback: try common extensions with CUE base name
if (audioDoc == null) {
val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" }
val cueBaseName = cueName.substringBeforeLast('.')
if (cueBaseName.isNotBlank()) {
val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a")
for (ext in commonExts) {
audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null }
if (audioDoc != null) break
audioDoc = try { parentDir.findFile(cueBaseName + ext.uppercase(Locale.ROOT)) } catch (_: Exception) { null }
if (audioDoc != null) break
}
}
}
if (audioDoc != null) {
cueReferencedAudioUris.add(audioDoc.uri.toString())
}
}
} catch (e: Exception) {
android.util.Log.w("SpotiFLAC", "SAF incremental scan: failed to resolve audio for unchanged CUE: ${e.message}")
} finally {
try { tempCue?.let { File(it).delete() } } catch (_: Exception) {}
}
}
// --- Regular audio file pass: skip files referenced by CUE sheets ---
for ((doc, _, lastModified) in audioFiles) {
if (safScanCancel) {
updateSafScanProgress { it.isComplete = true }
@@ -1119,6 +1543,22 @@ class MainActivity: FlutterFragmentActivity() {
return result.toString()
}
// Skip audio files that are represented by CUE track entries
if (cueReferencedAudioUris.contains(doc.uri.toString())) {
scanned++
val processed = skippedCount + scanned
val pct = if (totalFiles > 0) {
processed.toDouble() / totalFiles.toDouble() * 100.0
} else {
100.0
}
updateSafScanProgress {
it.scannedFiles = processed
it.progressPct = pct
}
continue
}
val name = try { doc.name ?: "" } catch (_: Exception) { "" }
updateSafScanProgress {
it.currentFile = name
@@ -1173,6 +1613,9 @@ class MainActivity: FlutterFragmentActivity() {
}
}
// Recalculate removedUris now that CUE virtual paths have been registered
val finalRemovedUris = existingFiles.keys.filter { !currentUris.contains(it) }
updateSafScanProgress {
it.isComplete = true
it.progressPct = 100.0
@@ -1180,7 +1623,7 @@ class MainActivity: FlutterFragmentActivity() {
val result = JSONObject()
result.put("files", results)
result.put("removedUris", JSONArray(removedUris))
result.put("removedUris", JSONArray(finalRemovedUris))
result.put("skippedCount", skippedCount)
result.put("totalFiles", totalFiles)
return result.toString()
@@ -1434,38 +1877,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"getSpotifyMetadata" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getSpotifyMetadata(url)
}
result.success(response)
}
"searchSpotify" -> {
val query = call.argument<String>("query") ?: ""
val limit = call.argument<Int>("limit") ?: 10
val response = withContext(Dispatchers.IO) {
Gobackend.searchSpotify(query, limit.toLong())
}
result.success(response)
}
"searchSpotifyAll" -> {
val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15
val artistLimit = call.argument<Int>("artist_limit") ?: 3
val response = withContext(Dispatchers.IO) {
Gobackend.searchSpotifyAll(query, trackLimit.toLong(), artistLimit.toLong())
}
result.success(response)
}
"getSpotifyRelatedArtists" -> {
val artistId = call.argument<String>("artist_id") ?: ""
val limit = call.argument<Int>("limit") ?: 12
val response = withContext(Dispatchers.IO) {
Gobackend.getSpotifyRelatedArtists(artistId, limit.toLong())
}
result.success(response)
}
"checkAvailability" -> {
val spotifyId = call.argument<String>("spotify_id") ?: ""
val isrc = call.argument<String>("isrc") ?: ""
@@ -2099,20 +2510,6 @@ class MainActivity: FlutterFragmentActivity() {
"isDownloadServiceRunning" -> {
result.success(DownloadService.isServiceRunning())
}
"setSpotifyCredentials" -> {
val clientId = call.argument<String>("client_id") ?: ""
val clientSecret = call.argument<String>("client_secret") ?: ""
withContext(Dispatchers.IO) {
Gobackend.setSpotifyAPICredentials(clientId, clientSecret)
}
result.success(null)
}
"hasSpotifyCredentials" -> {
val hasCredentials = withContext(Dispatchers.IO) {
Gobackend.checkSpotifyCredentials()
}
result.success(hasCredentials)
}
"preWarmTrackCache" -> {
val tracksJson = call.argument<String>("tracks") ?: "[]"
withContext(Dispatchers.IO) {
@@ -2239,13 +2636,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"getAmazonURLFromDeezerTrack" -> {
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getAmazonURLFromDeezerTrack(deezerTrackId)
}
result.success(response)
}
// Log methods
"getLogs" -> {
val response = withContext(Dispatchers.IO) {
@@ -2742,6 +3132,89 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
// CUE Sheet Parsing
"parseCueSheet" -> {
val cuePath = call.argument<String>("cue_path") ?: ""
val audioDir = call.argument<String>("audio_dir") ?: ""
val response = withContext(Dispatchers.IO) {
try {
if (cuePath.startsWith("content://")) {
val uri = Uri.parse(cuePath)
val tempCuePath = copyUriToTemp(uri, ".cue")
?: return@withContext """{"error":"Failed to copy CUE file to temp"}"""
var tempAudioPath: String? = null
try {
// Extract audio filename from CUE text
val audioFileName = extractCueAudioFileName(tempCuePath)
// Try to find the audio sibling in SAF
var audioDoc: DocumentFile? = null
val parentDir = safParentDir(uri)
if (parentDir != null && !audioFileName.isNullOrBlank()) {
audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null }
}
// Fallback: try common extensions with the CUE base name
if (audioDoc == null && parentDir != null) {
val cueName = try {
DocumentFile.fromSingleUri(this@MainActivity, uri)?.name ?: ""
} catch (_: Exception) { "" }
val cueBaseName = cueName.substringBeforeLast('.')
if (cueBaseName.isNotBlank()) {
val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a")
for (ext in commonExts) {
audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null }
if (audioDoc != null) break
audioDoc = try { parentDir.findFile(cueBaseName + ext.uppercase(Locale.ROOT)) } catch (_: Exception) { null }
if (audioDoc != null) break
}
}
}
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
if (audioDoc != null) {
// Copy audio to same temp dir with original name
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
val fallbackExt = if (audioExt.isNotBlank()) ".$audioExt" else null
val copiedAudio = copyUriToTemp(audioDoc.uri, fallbackExt)
if (copiedAudio != null) {
val renamedAudio = File(tempDir, audioName)
val copiedFile = File(copiedAudio)
if (renamedAudio.absolutePath != copiedFile.absolutePath) {
copiedFile.renameTo(renamedAudio)
}
tempAudioPath = renamedAudio.absolutePath
}
}
// Parse with audio in temp dir; Go will resolve there
val resultJson = Gobackend.parseCueSheet(tempCuePath, tempDir)
// Replace the temp audio_path with the SAF content:// URI
// so Dart knows it's a SAF file and handles it accordingly
if (audioDoc != null) {
val resultObj = JSONObject(resultJson)
resultObj.put("audio_path", audioDoc.uri.toString())
// Also pass the original CUE URI for reference
resultObj.put("cue_path", cuePath)
resultObj.toString()
} else {
resultJson
}
} finally {
try { File(tempCuePath).delete() } catch (_: Exception) {}
try { tempAudioPath?.let { File(it).delete() } } catch (_: Exception) {}
}
} else {
Gobackend.parseCueSheet(cuePath, audioDir)
}
} catch (e: Exception) {
"""{"error":"${e.message?.replace("\"", "'")}"}"""
}
}
result.success(response)
}
else -> result.notImplemented()
}
} catch (e: Exception) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+105
View File
@@ -0,0 +1,105 @@
# git-cliff configuration for SpotiFLAC Mobile
# https://git-cliff.org/docs/configuration
[changelog]
# Template for the changelog body
body = """
{%- macro remote_url() -%}
https://github.com/zarzet/SpotiFLAC-Mobile
{%- endmacro -%}
{% if version %}\
## {{ version | trim_start_matches(pat="v") }}
{% else %}\
## Unreleased
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}\
{{ commit.message | upper_first }}\
{% if commit.github.pr_number %} \
([#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}))\
{% endif %}\
{%- if commit.github.username %} by [@{{ commit.github.username }}](https://github.com/{{ commit.github.username }}){%- endif %}
{%- endfor %}
{% endfor %}
{%- if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %}
### New Contributors
{%- for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}
* @{{ contributor.username }} made their first contribution
{%- if contributor.pr_number %} in \
[#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \
{%- endif %}
{%- endfor %}
{%- endif -%}
{% if version %}
{% if previous.version %}
**Full Changelog**: [{{ previous.version }}...{{ version }}]({{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }})
{% endif %}
{% else -%}
{% raw %}\n{% endraw %}
{% endif %}
"""
# Remove leading and trailing whitespace
trim = true
[git]
# Parse conventional commits
conventional_commits = true
filter_unconventional = true
# Process each line of a commit as an individual commit
split_commits = false
# Regex for preprocessing the commit messages
commit_preprocessors = [
# Remove PR number from message (we add it back via GitHub integration)
{ pattern = '\(#(\d+)\)', replace = '' },
# Strip conventional commit prefix for cleaner messages
# (group header already shows the type)
]
# Regex for parsing and grouping commits
commit_parsers = [
# Skip noise: translation commits from Crowdin
{ message = "^New translations", skip = true },
{ message = "^Update source file", skip = true },
# Skip merge commits
{ message = "^Merge", skip = true },
# Skip version bump commits
{ message = "^v\\d+", skip = true },
{ message = "^chore: update VirusTotal", skip = true },
# Group by conventional commit type
{ message = "^feat", group = "<!-- 0 -->New Features" },
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
{ message = "^perf", group = "<!-- 2 -->Performance" },
{ message = "^refactor", group = "<!-- 3 -->Refactoring" },
{ message = "^doc", group = "<!-- 4 -->Documentation" },
{ message = "^style", group = "<!-- 5 -->Styling" },
{ message = "^test", group = "<!-- 6 -->Testing" },
{ message = "^chore\\(deps\\)", group = "<!-- 7 -->Dependencies" },
{ message = "^chore\\(l10n\\)", skip = true },
{ message = "^chore|^ci", group = "<!-- 8 -->Chores" },
]
# Protect breaking changes from being skipped
protect_breaking_commits = true
# Filter out commits by matching patterns
filter_commits = false
# Tag pattern for version detection
tag_pattern = "v[0-9].*"
# Sort commits by newest first
sort_commits = "newest"
[remote.github]
owner = "zarzet"
repo = "SpotiFLAC-Mobile"
-692
View File
@@ -1,692 +0,0 @@
package gobackend
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
)
// Amazon API timeout and retry configuration for mobile networks
const (
amazonAPITimeoutMobile = 30 * time.Second // Longer timeout for unstable mobile networks
amazonMaxRetries = 2 // Number of retry attempts
amazonRetryDelay = 500 * time.Millisecond
)
type AmazonDownloader struct {
client *http.Client
}
var (
globalAmazonDownloader *AmazonDownloader
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
type AfkarXYZResponse struct {
Success bool `json:"success"`
Data struct {
DirectLink string `json:"direct_link"`
FileName string `json:"file_name"`
FileSize int64 `json:"file_size"`
} `json:"data"`
}
// AmazonStreamResponse is the new response format from amzn.afkarxyz.fun/api/track/{asin}
type AmazonStreamResponse struct {
StreamURL string `json:"streamUrl"`
DecryptionKey string `json:"decryptionKey"`
}
func NewAmazonDownloader() *AmazonDownloader {
amazonDownloaderOnce.Do(func() {
globalAmazonDownloader = &AmazonDownloader{
client: NewHTTPClientWithTimeout(120 * time.Second),
}
})
return globalAmazonDownloader
}
// fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks.
// Returns downloadURL, suggested fileName, optional decryptionKey.
func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, string, error) {
var lastErr error
for attempt := 0; attempt <= amazonMaxRetries; attempt++ {
if attempt > 0 {
delay := amazonRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff
GoLog("[Amazon] Retry %d/%d after %v...\n", attempt, amazonMaxRetries, delay)
time.Sleep(delay)
}
downloadURL, fileName, decryptionKey, err := a.doAfkarXYZRequest(amazonURL)
if err == nil {
return downloadURL, fileName, decryptionKey, nil
}
lastErr = err
errStr := strings.ToLower(err.Error())
// Check if error is retryable
isRetryable := strings.Contains(errStr, "timeout") ||
strings.Contains(errStr, "connection reset") ||
strings.Contains(errStr, "connection refused") ||
strings.Contains(errStr, "eof") ||
strings.Contains(errStr, "status 5") ||
strings.Contains(errStr, "status 429") ||
strings.Contains(errStr, "http 429")
if !isRetryable {
return "", "", "", err
}
GoLog("[Amazon] Attempt %d failed (retryable): %v\n", attempt+1, err)
}
return "", "", "", fmt.Errorf("all %d attempts failed: %w", amazonMaxRetries+1, lastErr)
}
func normalizeAmazonASIN(candidate string) string {
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)
defer cancel()
apiURL := fmt.Sprintf("https://amzn.afkarxyz.fun/api/track/%s", asin)
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != nil {
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()
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return "", "", "", fmt.Errorf("failed to read response: %w", readErr)
}
if resp.StatusCode != 200 {
return "", "", "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode)
}
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://amzn.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())
resp, err := a.client.Do(req)
if err != nil {
return "", "", "", fmt.Errorf("failed to call legacy AfkarXYZ API: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", "", "", fmt.Errorf("legacy AfkarXYZ API returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", "", fmt.Errorf("failed to read legacy response: %w", err)
}
var apiResp AfkarXYZResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return "", "", "", fmt.Errorf("failed to decode legacy response: %w", err)
}
if !apiResp.Success || strings.TrimSpace(apiResp.Data.DirectLink) == "" {
return "", "", "", fmt.Errorf("legacy AfkarXYZ API failed or no download link found")
}
fileName := apiResp.Data.FileName
if fileName == "" {
fileName = "track.flac"
}
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
fileName = reg.ReplaceAllString(fileName, "")
return apiResp.Data.DirectLink, fileName, "", nil
}
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, string, error) {
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
downloadURL, fileName, decryptionKey, err := a.fetchAmazonURLWithRetry(amazonURL)
if err != nil {
return "", "", "", err
}
if decryptionKey != "" {
GoLog("[Amazon] AfkarXYZ returned encrypted stream (decryption key available)\n")
}
GoLog("[Amazon] AfkarXYZ returned: %s\n", fileName)
return downloadURL, fileName, decryptionKey, nil
}
func (a *AmazonDownloader) 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)
}
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := a.client.Do(req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return 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 err
}
bufWriter := bufio.NewWriterSize(out, 256*1024)
var written int64
if itemID != "" {
pw := NewItemProgressWriter(bufWriter, itemID)
written, err = io.Copy(pw, 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("[Amazon] Downloaded: %.2f MB (Complete)\n", float64(written)/(1024*1024))
return nil
}
// AmazonDownloadResult contains download result with quality info
type AmazonDownloadResult struct {
FilePath string
BitDepth int
SampleRate int
Title string
Artist string
Album string
ReleaseDate string
TrackNumber int
DiscNumber int
ISRC string
LyricsLRC string
DecryptionKey string
}
func resolveAmazonURLForRequest(req DownloadRequest, logPrefix string) (string, error) {
if strings.TrimSpace(logPrefix) == "" {
logPrefix = "Amazon"
}
amazonURL := ""
if req.ISRC != "" {
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.AmazonURL != "" {
amazonURL = cached.AmazonURL
GoLog("[%s] Cache hit! Using cached Amazon URL for ISRC %s\n", logPrefix, req.ISRC)
}
}
if amazonURL != "" {
return amazonURL, nil
}
songlink := NewSongLinkClient()
var availability *TrackAvailability
var err error
deezerID := strings.TrimSpace(req.DeezerID)
if prefixedDeezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found && strings.TrimSpace(prefixedDeezerID) != "" {
deezerID = strings.TrimSpace(prefixedDeezerID)
}
if deezerID != "" {
GoLog("[%s] Using Deezer ID for SongLink lookup: %s\n", logPrefix, deezerID)
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
} else if req.SpotifyID != "" {
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
} else {
return "", fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
}
if err != nil {
return "", fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
}
if availability == nil || !availability.Amazon || availability.AmazonURL == "" {
return "", fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
}
amazonURL = availability.AmazonURL
if req.ISRC != "" {
GetTrackIDCache().SetAmazonURL(req.ISRC, amazonURL)
}
return amazonURL, nil
}
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
downloader := NewAmazonDownloader()
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
if !isSafOutput {
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
}
amazonURL, err := resolveAmazonURLForRequest(req, "Amazon")
if err != nil {
return AmazonDownloadResult{}, err
}
if !isSafOutput && req.OutputDir != "." {
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
}
}
// Download using AfkarXYZ API
downloadURL, afkarFileName, decryptionKey, err := downloader.downloadFromAfkarXYZ(amazonURL)
if err != nil {
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err)
}
GoLog("[Amazon] Match found: '%s' by '%s'\n", req.TrackName, req.ArtistName)
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,
})
var outputPath string
if isSafOutput {
outputPath = strings.TrimSpace(req.OutputPath)
if outputPath == "" && isFDOutput(req.OutputFD) {
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
}
} else {
outputExt := strings.ToLower(filepath.Ext(afkarFileName))
if outputExt == "" {
outputExt = ".flac"
}
filename = sanitizeFilename(filename) + outputExt
outputPath = filepath.Join(req.OutputDir, filename)
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
}
// START PARALLEL: Fetch cover and lyrics while downloading audio
var parallelResult *ParallelDownloadResult
parallelDone := make(chan struct{})
go func() {
defer close(parallelDone)
coverURL := req.CoverURL
embedLyrics := req.EmbedLyrics
if !req.EmbedMetadata {
coverURL = ""
embedLyrics = false
}
parallelResult = FetchCoverAndLyricsParallel(
coverURL,
req.EmbedMaxQualityCover,
req.SpotifyID,
req.TrackName,
req.ArtistName,
embedLyrics,
int64(req.DurationMS),
)
}()
// Download audio file with item ID for progress tracking
if err := downloader.DownloadFile(downloadURL, outputPath, req.OutputFD, req.ItemID); err != nil {
if errors.Is(err, ErrDownloadCancelled) {
return AmazonDownloadResult{}, ErrDownloadCancelled
}
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
<-parallelDone
if req.ItemID != "" {
SetItemProgress(req.ItemID, 1.0, 0, 0)
SetItemFinalizing(req.ItemID)
}
actualTrackNum := req.TrackNumber
actualDiscNum := req.DiscNumber
actualDate := req.ReleaseDate
actualAlbum := req.AlbumName
actualTitle := req.TrackName
actualArtist := req.ArtistName
if !needsDecryption {
existingMeta, metaErr := ReadMetadata(actualOutputPath)
if metaErr == nil && existingMeta != nil {
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)
}
}
metadata := Metadata{
Title: actualTitle,
Artist: actualArtist,
Album: actualAlbum,
AlbumArtist: req.AlbumArtist,
Date: actualDate,
TrackNumber: actualTrackNum,
TotalTracks: req.TotalTracks,
DiscNumber: actualDiscNum,
ISRC: req.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
}
var coverData []byte
if parallelResult != nil && parallelResult.CoverData != nil && len(parallelResult.CoverData) > 0 {
coverData = parallelResult.CoverData
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
} else {
existingCover, coverErr := ExtractCoverArt(actualOutputPath)
if coverErr == nil && len(existingCover) > 0 {
coverData = existingCover
GoLog("[Amazon] Using existing cover from Amazon file (%d bytes)\n", len(coverData))
} else {
GoLog("[Amazon] No cover available (parallel fetch failed and no existing cover)\n")
}
}
if isSafOutput || needsDecryption || !req.EmbedMetadata {
if !req.EmbedMetadata {
GoLog("[Amazon] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n")
} else {
GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
}
} else {
isFlacOutput := strings.HasSuffix(strings.ToLower(actualOutputPath), ".flac")
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 != "" {
lyricsMode := req.LyricsMode
if lyricsMode == "" {
lyricsMode = "embed"
}
if lyricsMode == "external" || lyricsMode == "both" {
GoLog("[Amazon] Saving external LRC file...\n")
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
}
}
if (lyricsMode == "embed" || lyricsMode == "both") && isFlacOutput {
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
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 {
GoLog("[Amazon] No lyrics available from parallel fetch\n")
}
}
GoLog("[Amazon] Downloaded successfully from Amazon Music\n")
quality := AudioQuality{}
if isSafOutput || needsDecryption {
GoLog("[Amazon] SAF output detected - skipping post-write file inspection in backend\n")
} else {
quality, err = GetAudioQuality(actualOutputPath)
if err != nil {
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
} else {
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
}
finalMeta, metaReadErr := ReadMetadata(actualOutputPath)
if metaReadErr == nil && finalMeta != nil {
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
actualTrackNum = finalMeta.TrackNumber
actualDiscNum = finalMeta.DiscNumber
if finalMeta.Date != "" {
req.ReleaseDate = finalMeta.Date
}
}
}
// Add to ISRC index for fast duplicate checking.
// When decryption is pending in Flutter, postpone indexing until final file is settled.
if !isSafOutput && !needsDecryption {
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
}
bitDepth := 0
sampleRate := 0
if err == nil {
bitDepth = quality.BitDepth
sampleRate = quality.SampleRate
}
lyricsLRC := ""
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsLRC = parallelResult.LyricsLRC
}
return AmazonDownloadResult{
FilePath: outputPath,
BitDepth: bitDepth,
SampleRate: sampleRate,
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
ReleaseDate: req.ReleaseDate,
TrackNumber: actualTrackNum,
DiscNumber: actualDiscNum,
ISRC: req.ISRC,
LyricsLRC: lyricsLRC,
DecryptionKey: decryptionKey,
}, nil
}
-46
View File
@@ -1,46 +0,0 @@
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)
}
})
}
}
-15
View File
@@ -12,7 +12,6 @@ import (
"strings"
)
// AudioMetadata represents common audio file metadata
type AudioMetadata struct {
Title string
Artist string
@@ -31,7 +30,6 @@ type AudioMetadata struct {
Comment string
}
// MP3Quality represents MP3 specific quality info
type MP3Quality struct {
SampleRate int
BitDepth int
@@ -39,7 +37,6 @@ type MP3Quality struct {
Bitrate int
}
// OggQuality represents Ogg/Opus specific quality info
type OggQuality struct {
SampleRate int
BitDepth int
@@ -47,10 +44,6 @@ type OggQuality struct {
Bitrate int // estimated bitrate in bps
}
// =============================================================================
// ID3 Tag Reading (MP3)
// =============================================================================
func ReadID3Tags(filePath string) (*AudioMetadata, error) {
file, err := os.Open(filePath)
if err != nil {
@@ -1210,10 +1203,6 @@ func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
return 0
}
// =============================================================================
// ID3v1 Genre List
// =============================================================================
var id3v1Genres = []string{
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge",
"Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B",
@@ -1244,10 +1233,6 @@ var id3v1Genres = []string{
"Thrash Metal", "Anime", "J-Pop", "Synthpop",
}
// =============================================================================
// Cover Art Extraction
// =============================================================================
func extractMP3CoverArt(filePath string) ([]byte, string, error) {
file, err := os.Open(filePath)
if err != nil {
+577
View File
@@ -0,0 +1,577 @@
package gobackend
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
)
// CueSheet represents a parsed .cue file
type CueSheet struct {
// Album-level metadata
Performer string `json:"performer"`
Title string `json:"title"`
FileName string `json:"file_name"`
FileType string `json:"file_type"` // WAVE, FLAC, MP3, AIFF, etc.
Genre string `json:"genre,omitempty"`
Date string `json:"date,omitempty"`
Comment string `json:"comment,omitempty"`
Composer string `json:"composer,omitempty"`
Tracks []CueTrack `json:"tracks"`
}
// CueTrack represents a single track in a cue sheet
type CueTrack struct {
Number int `json:"number"`
Title string `json:"title"`
Performer string `json:"performer"`
ISRC string `json:"isrc,omitempty"`
Composer string `json:"composer,omitempty"`
// Index positions in seconds (fractional)
StartTime float64 `json:"start_time"` // INDEX 01 in seconds
PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present)
}
// CueSplitInfo represents the information needed to split a CUE+audio file
type CueSplitInfo struct {
CuePath string `json:"cue_path"`
AudioPath string `json:"audio_path"`
Album string `json:"album"`
Artist string `json:"artist"`
Genre string `json:"genre,omitempty"`
Date string `json:"date,omitempty"`
Tracks []CueSplitTrack `json:"tracks"`
}
// CueSplitTrack has the FFmpeg split parameters for a single track
type CueSplitTrack struct {
Number int `json:"number"`
Title string `json:"title"`
Artist string `json:"artist"`
ISRC string `json:"isrc,omitempty"`
Composer string `json:"composer,omitempty"`
StartSec float64 `json:"start_sec"`
EndSec float64 `json:"end_sec"` // -1 means until end of file
}
var (
reRemCommand = regexp.MustCompile(`^REM\s+(\S+)\s+(.+)$`)
reQuoted = regexp.MustCompile(`"([^"]*)"`)
)
// ParseCueFile parses a .cue file and returns a CueSheet
func ParseCueFile(cuePath string) (*CueSheet, error) {
f, err := os.Open(cuePath)
if err != nil {
return nil, fmt.Errorf("failed to open cue file: %w", err)
}
defer f.Close()
sheet := &CueSheet{}
var currentTrack *CueTrack
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
// Handle BOM at start of file
if strings.HasPrefix(line, "\xef\xbb\xbf") {
line = strings.TrimPrefix(line, "\xef\xbb\xbf")
line = strings.TrimSpace(line)
}
upper := strings.ToUpper(line)
// REM commands (album-level metadata)
if strings.HasPrefix(upper, "REM ") {
matches := reRemCommand.FindStringSubmatch(line)
if len(matches) == 3 {
key := strings.ToUpper(matches[1])
value := unquoteCue(matches[2])
switch key {
case "GENRE":
sheet.Genre = value
case "DATE":
sheet.Date = value
case "COMMENT":
sheet.Comment = value
case "COMPOSER":
if currentTrack != nil {
currentTrack.Composer = value
} else {
sheet.Composer = value
}
}
}
continue
}
// PERFORMER
if strings.HasPrefix(upper, "PERFORMER ") {
value := unquoteCue(line[len("PERFORMER "):])
if currentTrack != nil {
currentTrack.Performer = value
} else {
sheet.Performer = value
}
continue
}
// TITLE
if strings.HasPrefix(upper, "TITLE ") {
value := unquoteCue(line[len("TITLE "):])
if currentTrack != nil {
currentTrack.Title = value
} else {
sheet.Title = value
}
continue
}
// FILE
if strings.HasPrefix(upper, "FILE ") {
rest := line[len("FILE "):]
// Extract filename and type
// Format: FILE "filename.flac" WAVE
// or: FILE filename.flac WAVE
fname, ftype := parseCueFileLine(rest)
sheet.FileName = fname
sheet.FileType = ftype
continue
}
// TRACK
if strings.HasPrefix(upper, "TRACK ") {
// Save previous track
if currentTrack != nil {
sheet.Tracks = append(sheet.Tracks, *currentTrack)
}
parts := strings.Fields(line)
trackNum := 0
if len(parts) >= 2 {
trackNum, _ = strconv.Atoi(parts[1])
}
currentTrack = &CueTrack{
Number: trackNum,
PreGap: -1,
}
continue
}
// INDEX
if strings.HasPrefix(upper, "INDEX ") && currentTrack != nil {
parts := strings.Fields(line)
if len(parts) >= 3 {
indexNum, _ := strconv.Atoi(parts[1])
timeSec := parseCueTimestamp(parts[2])
switch indexNum {
case 0:
currentTrack.PreGap = timeSec
case 1:
currentTrack.StartTime = timeSec
}
}
continue
}
// ISRC
if strings.HasPrefix(upper, "ISRC ") && currentTrack != nil {
currentTrack.ISRC = strings.TrimSpace(line[len("ISRC "):])
continue
}
// SONGWRITER (used as composer sometimes)
if strings.HasPrefix(upper, "SONGWRITER ") {
value := unquoteCue(line[len("SONGWRITER "):])
if currentTrack != nil {
currentTrack.Composer = value
} else {
sheet.Composer = value
}
continue
}
}
// Don't forget the last track
if currentTrack != nil {
sheet.Tracks = append(sheet.Tracks, *currentTrack)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading cue file: %w", err)
}
if len(sheet.Tracks) == 0 {
return nil, fmt.Errorf("no tracks found in cue file")
}
return sheet, nil
}
// parseCueTimestamp converts MM:SS:FF (frames at 75fps) to seconds
func parseCueTimestamp(ts string) float64 {
parts := strings.Split(ts, ":")
if len(parts) != 3 {
return 0
}
minutes, _ := strconv.Atoi(parts[0])
seconds, _ := strconv.Atoi(parts[1])
frames, _ := strconv.Atoi(parts[2])
return float64(minutes)*60 + float64(seconds) + float64(frames)/75.0
}
// formatCueTimestamp converts seconds to HH:MM:SS.mmm format for FFmpeg
func formatCueTimestamp(seconds float64) string {
if seconds < 0 {
return "0"
}
hours := int(seconds) / 3600
mins := (int(seconds) % 3600) / 60
secs := seconds - float64(hours*3600) - float64(mins*60)
return fmt.Sprintf("%02d:%02d:%06.3f", hours, mins, secs)
}
// unquoteCue removes surrounding quotes from a CUE value
func unquoteCue(s string) string {
s = strings.TrimSpace(s)
if matches := reQuoted.FindStringSubmatch(s); len(matches) == 2 {
return matches[1]
}
return s
}
// parseCueFileLine parses the FILE command's filename and type
func parseCueFileLine(rest string) (string, string) {
rest = strings.TrimSpace(rest)
var filename, ftype string
if strings.HasPrefix(rest, "\"") {
// Quoted filename
endQuote := strings.Index(rest[1:], "\"")
if endQuote >= 0 {
filename = rest[1 : endQuote+1]
remaining := strings.TrimSpace(rest[endQuote+2:])
ftype = remaining
} else {
filename = rest
}
} else {
// Unquoted filename - last word is the type
parts := strings.Fields(rest)
if len(parts) >= 2 {
ftype = parts[len(parts)-1]
filename = strings.Join(parts[:len(parts)-1], " ")
} else if len(parts) == 1 {
filename = parts[0]
}
}
return filename, strings.TrimSpace(ftype)
}
// ResolveCueAudioPath finds the actual audio file referenced by a .cue sheet.
// It checks relative to the cue file's directory.
func ResolveCueAudioPath(cuePath string, cueFileName string) string {
cueDir := filepath.Dir(cuePath)
// 1. Try the exact filename from the .cue
candidate := filepath.Join(cueDir, cueFileName)
if _, err := os.Stat(candidate); err == nil {
return candidate
}
// 2. Try common case variations
baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName))
commonExts := []string{".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
for _, ext := range commonExts {
candidate = filepath.Join(cueDir, baseName+ext)
if _, err := os.Stat(candidate); err == nil {
return candidate
}
// Try uppercase ext
candidate = filepath.Join(cueDir, baseName+strings.ToUpper(ext))
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
// 3. Try to find any audio file with the same base name as the .cue file
cueBase := strings.TrimSuffix(filepath.Base(cuePath), filepath.Ext(cuePath))
for _, ext := range commonExts {
candidate = filepath.Join(cueDir, cueBase+ext)
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
// 4. If there's only one audio file in the directory, use that
entries, err := os.ReadDir(cueDir)
if err == nil {
audioExts := map[string]bool{
".flac": true, ".wav": true, ".ape": true, ".mp3": true,
".ogg": true, ".wv": true, ".m4a": true, ".aiff": true,
}
var audioFiles []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(entry.Name()))
if audioExts[ext] {
audioFiles = append(audioFiles, filepath.Join(cueDir, entry.Name()))
}
}
if len(audioFiles) == 1 {
return audioFiles[0]
}
}
return ""
}
// BuildCueSplitInfo creates the split information from a parsed CUE sheet.
// This is returned to the Dart side so FFmpeg can perform the splitting.
// audioDir, if non-empty, overrides the directory for audio file resolution.
func BuildCueSplitInfo(cuePath string, sheet *CueSheet, audioDir string) (*CueSplitInfo, error) {
resolveDir := cuePath
if audioDir != "" {
// Create a virtual path in audioDir so ResolveCueAudioPath looks there
resolveDir = filepath.Join(audioDir, filepath.Base(cuePath))
}
audioPath := ResolveCueAudioPath(resolveDir, sheet.FileName)
if audioPath == "" {
return nil, fmt.Errorf("audio file not found for cue sheet: %s (referenced: %s)", cuePath, sheet.FileName)
}
info := &CueSplitInfo{
CuePath: cuePath,
AudioPath: audioPath,
Album: sheet.Title,
Artist: sheet.Performer,
Genre: sheet.Genre,
Date: sheet.Date,
}
for i, track := range sheet.Tracks {
performer := track.Performer
if performer == "" {
performer = sheet.Performer
}
composer := track.Composer
if composer == "" {
composer = sheet.Composer
}
// End time is the start of the next track, or -1 for the last track
endSec := float64(-1)
if i+1 < len(sheet.Tracks) {
nextTrack := sheet.Tracks[i+1]
// Use pre-gap of next track if available, otherwise its start time
if nextTrack.PreGap >= 0 {
endSec = nextTrack.PreGap
} else {
endSec = nextTrack.StartTime
}
}
info.Tracks = append(info.Tracks, CueSplitTrack{
Number: track.Number,
Title: track.Title,
Artist: performer,
ISRC: track.ISRC,
Composer: composer,
StartSec: track.StartTime,
EndSec: endSec,
})
}
return info, nil
}
// ParseCueFileJSON parses a .cue file and returns JSON with split info.
// This is the main entry point called from Dart via the platform bridge.
// audioDir, if non-empty, overrides the directory used for resolving the
// referenced audio file (useful when the .cue was copied to a temp dir
// but the audio still lives in the original location, e.g. SAF).
func ParseCueFileJSON(cuePath string, audioDir string) (string, error) {
sheet, err := ParseCueFile(cuePath)
if err != nil {
return "", fmt.Errorf("failed to parse cue file: %w", err)
}
info, err := BuildCueSplitInfo(cuePath, sheet, audioDir)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(info)
if err != nil {
return "", fmt.Errorf("failed to marshal cue split info: %w", err)
}
return string(jsonBytes), nil
}
// ScanCueFileForLibrary parses a .cue file and returns multiple LibraryScanResult
// entries, one per track. This is used by the library scanner to populate the
// library with individual track entries from a single CUE+FLAC album.
func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult, error) {
return scanCueFileForLibraryInternal(cuePath, "", "", 0, scanTime)
}
// ScanCueFileForLibraryExt is like ScanCueFileForLibrary but with extra parameters
// for SAF (Storage Access Framework) scenarios:
// - audioDir: if non-empty, overrides the directory used to find the audio file
// - virtualPathPrefix: if non-empty, used instead of cuePath as the base for
// virtual file paths (e.g. a content:// URI). IDs are also based on this.
// - fileModTime: if > 0, used as the FileModTime for all results instead of
// stat-ing the cuePath on disk (useful when the real file lives behind SAF)
func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) {
return scanCueFileForLibraryInternal(cuePath, audioDir, virtualPathPrefix, fileModTime, scanTime)
}
func scanCueFileForLibraryInternal(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) {
sheet, err := ParseCueFile(cuePath)
if err != nil {
return nil, err
}
// Resolve audio file — optionally in an overridden directory
resolveBase := cuePath
if audioDir != "" {
resolveBase = filepath.Join(audioDir, filepath.Base(cuePath))
}
audioPath := ResolveCueAudioPath(resolveBase, sheet.FileName)
if audioPath == "" {
return nil, fmt.Errorf("audio file not found for cue: %s (referenced: %s)", cuePath, sheet.FileName)
}
// Try to get quality info from the audio file
var bitDepth, sampleRate int
var totalDurationSec float64
audioExt := strings.ToLower(filepath.Ext(audioPath))
switch audioExt {
case ".flac":
quality, qErr := GetAudioQuality(audioPath)
if qErr == nil {
bitDepth = quality.BitDepth
sampleRate = quality.SampleRate
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
totalDurationSec = float64(quality.TotalSamples) / float64(quality.SampleRate)
}
}
case ".mp3":
quality, qErr := GetMP3Quality(audioPath)
if qErr == nil {
sampleRate = quality.SampleRate
totalDurationSec = float64(quality.Duration)
}
}
// Extract cover from audio file for all tracks
var coverPath string
libraryCoverCacheMu.RLock()
coverCacheDir := libraryCoverCacheDir
libraryCoverCacheMu.RUnlock()
if coverCacheDir != "" {
cp, err := SaveCoverToCache(audioPath, coverCacheDir)
if err == nil && cp != "" {
coverPath = cp
}
}
// Determine the base path for virtual paths and IDs
pathBase := cuePath
if virtualPathPrefix != "" {
pathBase = virtualPathPrefix
}
// Determine fileModTime
modTime := fileModTime
if modTime <= 0 {
if info, err := os.Stat(cuePath); err == nil {
modTime = info.ModTime().UnixMilli()
}
}
var results []LibraryScanResult
for i, track := range sheet.Tracks {
performer := track.Performer
if performer == "" {
performer = sheet.Performer
}
if performer == "" {
performer = "Unknown Artist"
}
title := track.Title
if title == "" {
title = fmt.Sprintf("Track %02d", track.Number)
}
album := sheet.Title
if album == "" {
album = "Unknown Album"
}
// Calculate duration for this track
var duration int
if i+1 < len(sheet.Tracks) {
nextStart := sheet.Tracks[i+1].StartTime
if sheet.Tracks[i+1].PreGap >= 0 {
nextStart = sheet.Tracks[i+1].PreGap
}
duration = int(nextStart - track.StartTime)
} else if totalDurationSec > 0 {
duration = int(totalDurationSec - track.StartTime)
}
// Use a unique ID based on pathBase + track number
id := generateLibraryID(fmt.Sprintf("%s#track%d", pathBase, track.Number))
// Use a virtual file path that includes the track number to ensure
// uniqueness in the database (file_path has a UNIQUE constraint).
// Format: /path/to/album.cue#track01 or content://...album.cue#track01
virtualFilePath := fmt.Sprintf("%s#track%02d", pathBase, track.Number)
result := LibraryScanResult{
ID: id,
TrackName: title,
ArtistName: performer,
AlbumName: album,
AlbumArtist: sheet.Performer,
FilePath: virtualFilePath,
CoverPath: coverPath,
ScannedAt: scanTime,
ISRC: track.ISRC,
TrackNumber: track.Number,
DiscNumber: 1,
Duration: duration,
ReleaseDate: sheet.Date,
BitDepth: bitDepth,
SampleRate: sampleRate,
Genre: sheet.Genre,
Format: "cue+" + strings.TrimPrefix(audioExt, "."),
}
result.FileModTime = modTime
results = append(results, result)
}
return results, nil
}
+2 -2
View File
@@ -120,7 +120,7 @@ func (c *DeezerClient) DownloadFromYoinkify(spotifyURL, outputPath string, outpu
req.Header.Set("Accept", "*/*")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
resp, err := GetDownloadClient().Do(req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
@@ -324,7 +324,7 @@ func (c *DeezerClient) DownloadFromMusicDL(deezerTrackURL, outputPath string, ou
}
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
resp, err := GetDownloadClient().Do(req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
+37 -241
View File
@@ -32,126 +32,6 @@ func ParseSpotifyURL(url string) (string, error) {
return string(jsonBytes), nil
}
func SetSpotifyAPICredentials(clientID, clientSecret string) {
SetSpotifyCredentials(clientID, clientSecret)
}
func CheckSpotifyCredentials() bool {
return HasSpotifyCredentials()
}
func GetSpotifyMetadata(spotifyURL string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
client, err := NewSpotifyMetadataClient()
if err != nil {
if shouldTrySpotFetchFallback(err) {
data, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
if apiErr == nil {
jsonBytes, marshalErr := json.Marshal(data)
if marshalErr != nil {
return "", marshalErr
}
return string(jsonBytes), nil
}
}
return "", err
}
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
if err != nil {
if shouldTrySpotFetchFallback(err) {
fallbackData, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
if apiErr == nil {
jsonBytes, marshalErr := json.Marshal(fallbackData)
if marshalErr != nil {
return "", marshalErr
}
return string(jsonBytes), nil
}
}
return "", err
}
jsonBytes, err := json.Marshal(data)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func SearchSpotify(query string, limit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
client, err := NewSpotifyMetadataClient()
if err != nil {
return "", err
}
results, err := client.SearchTracks(ctx, query, limit)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(results)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
client, err := NewSpotifyMetadataClient()
if err != nil {
return "", err
}
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(results)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func GetSpotifyRelatedArtists(artistID string, limit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
client, err := NewSpotifyMetadataClient()
if err != nil {
return "", err
}
normalizedArtistID := strings.TrimSpace(strings.TrimPrefix(artistID, "spotify:"))
if normalizedArtistID == "" {
return "", fmt.Errorf("invalid Spotify artist ID")
}
artists, err := client.GetRelatedArtists(ctx, normalizedArtistID, limit)
if err != nil {
return "", err
}
resp := map[string]interface{}{
"artists": artists,
}
jsonBytes, err := json.Marshal(resp)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func CheckAvailability(spotifyID, isrc string) (string, error) {
client := NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
@@ -478,25 +358,6 @@ func DownloadTrack(requestJSON string) (string, error) {
}
}
err = qobuzErr
case "amazon":
amazonResult, amazonErr := downloadFromAmazon(req)
if amazonErr == nil {
result = DownloadResult{
FilePath: amazonResult.FilePath,
BitDepth: amazonResult.BitDepth,
SampleRate: amazonResult.SampleRate,
Title: amazonResult.Title,
Artist: amazonResult.Artist,
Album: amazonResult.Album,
ReleaseDate: amazonResult.ReleaseDate,
TrackNumber: amazonResult.TrackNumber,
DiscNumber: amazonResult.DiscNumber,
ISRC: amazonResult.ISRC,
LyricsLRC: amazonResult.LyricsLRC,
DecryptionKey: amazonResult.DecryptionKey,
}
}
err = amazonErr
case "deezer":
deezerResult, deezerErr := downloadFromDeezer(req)
if deezerErr == nil {
@@ -640,7 +501,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
enrichRequestExtendedMetadata(&req)
allServices := []string{"tidal", "qobuz", "amazon", "deezer"}
allServices := []string{"tidal", "qobuz", "deezer"}
preferredService := req.Service
if preferredService == "" {
preferredService = "tidal"
@@ -707,27 +568,6 @@ func DownloadWithFallback(requestJSON string) (string, error) {
GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
}
err = qobuzErr
case "amazon":
amazonResult, amazonErr := downloadFromAmazon(req)
if amazonErr == nil {
result = DownloadResult{
FilePath: amazonResult.FilePath,
BitDepth: amazonResult.BitDepth,
SampleRate: amazonResult.SampleRate,
Title: amazonResult.Title,
Artist: amazonResult.Artist,
Album: amazonResult.Album,
ReleaseDate: amazonResult.ReleaseDate,
TrackNumber: amazonResult.TrackNumber,
DiscNumber: amazonResult.DiscNumber,
ISRC: amazonResult.ISRC,
LyricsLRC: amazonResult.LyricsLRC,
DecryptionKey: amazonResult.DecryptionKey,
}
} else if !errors.Is(amazonErr, ErrDownloadCancelled) {
GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
}
err = amazonErr
case "deezer":
deezerResult, deezerErr := downloadFromDeezer(req)
if deezerErr == nil {
@@ -824,6 +664,7 @@ func CleanupConnections() {
func ReadFileMetadata(filePath string) (string, error) {
lower := strings.ToLower(filePath)
isFlac := strings.HasSuffix(lower, ".flac")
isM4A := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac")
isMp3 := strings.HasSuffix(lower, ".mp3")
isOgg := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg")
@@ -873,6 +714,12 @@ func ReadFileMetadata(filePath string) (string, error) {
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
}
}
} else if isM4A {
quality, qualityErr := GetM4AQuality(filePath)
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
}
} else if isMp3 {
meta, err := ReadID3Tags(filePath)
if err == nil && meta != nil {
@@ -934,6 +781,32 @@ func ReadFileMetadata(filePath string) (string, error) {
return string(jsonBytes), nil
}
// ParseCueSheet parses a .cue file and returns JSON with split information.
// This is called from Dart to get track listing and timing data for CUE splitting.
// audioDir, if non-empty, overrides the directory used for resolving the
// referenced audio file (useful for SAF temp file scenarios).
func ParseCueSheet(cuePath string, audioDir string) (string, error) {
return ParseCueFileJSON(cuePath, audioDir)
}
// ScanCueSheetForLibrary parses a .cue file and returns a JSON array of
// LibraryScanResult entries (one per track). This is the SAF-friendly variant:
// - audioDir overrides where the referenced audio file is resolved
// - virtualPathPrefix replaces cuePath in filePath / id fields (e.g. a content:// URI)
// - fileModTime is stamped on every result (pass 0 to stat cuePath instead)
func ScanCueSheetForLibrary(cuePath, audioDir, virtualPathPrefix string, fileModTime int64) (string, error) {
scanTime := time.Now().UTC().Format(time.RFC3339)
results, err := ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix, fileModTime, scanTime)
if err != nil {
return "[]", err
}
jsonBytes, err := json.Marshal(results)
if err != nil {
return "[]", fmt.Errorf("failed to marshal cue scan results: %w", err)
}
return string(jsonBytes), nil
}
// EditFileMetadata writes metadata to an audio file.
// For FLAC files, uses native Go FLAC library.
// For MP3/Opus, returns the metadata map so Dart can use FFmpeg.
@@ -1446,28 +1319,6 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var spotifyErr error
client, err := NewSpotifyMetadataClient()
if err != nil {
LogWarn("Spotify", "Credentials not configured, falling back to Deezer")
spotifyErr = err
} else {
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
if err == nil {
jsonBytes, err := json.Marshal(data)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
spotifyErr = err
if !shouldTrySpotFetchFallback(err) {
return "", err
}
}
spotFetchData, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
if apiErr == nil {
GoLog("[Fallback] Spotify metadata fetched via SpotFetch API\n")
@@ -1481,9 +1332,6 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
parsed, parseErr := parseSpotifyURI(spotifyURL)
if parseErr != nil {
if spotifyErr != nil {
return "", fmt.Errorf("spotify failed (%v), SpotFetch fallback failed (%v), and URL parsing failed: %w", spotifyErr, apiErr, parseErr)
}
return "", fmt.Errorf("SpotFetch fallback failed (%v) and URL parsing failed: %w", apiErr, parseErr)
}
@@ -1494,15 +1342,9 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
}
if parsed.Type == "artist" {
if spotifyErr != nil {
return "", fmt.Errorf("spotify metadata unavailable (%v) and SpotFetch fallback failed (%v). Artist pages require Spotify/SpotFetch API", spotifyErr, apiErr)
}
return "", fmt.Errorf("SpotFetch fallback failed (%v). Artist pages require Spotify/SpotFetch API", apiErr)
return "", fmt.Errorf("SpotFetch fallback failed (%v). Artist pages now require SpotFetch or a metadata extension such as spotify-web", apiErr)
}
if spotifyErr != nil {
return "", fmt.Errorf("spotify metadata unavailable (%v), SpotFetch fallback failed (%v), and Deezer conversion is unavailable for playlists", spotifyErr, apiErr)
}
return "", fmt.Errorf("SpotFetch fallback failed (%v), and Deezer conversion is unavailable for playlists", apiErr)
}
@@ -1579,11 +1421,6 @@ func GetTidalURLFromDeezerTrack(deezerTrackID string) (string, error) {
return client.GetTidalURLFromDeezer(deezerTrackID)
}
func GetAmazonURLFromDeezerTrack(deezerTrackID string) (string, error) {
client := NewSongLinkClient()
return client.GetAmazonURLFromDeezer(deezerTrackID)
}
func errorResponse(msg string) (string, error) {
errorType := "unknown"
lowerMsg := strings.ToLower(msg)
@@ -1838,8 +1675,8 @@ func ReEnrichFile(requestJSON string) (string, error) {
GoLog("[ReEnrich] Starting re-enrichment for: %s\n", req.FilePath)
// When search_online is true, search for metadata from internet
// Priority: 1) Deezer (reliable, no credentials) 2) Extension providers (spotify-web etc) 3) Spotify built-in API (last resort, deprecated)
// When search_online is true, search for metadata from internet.
// Priority: 1) Deezer (reliable, no credentials) 2) Extension providers (spotify-web etc)
if req.SearchOnline && req.TrackName != "" && req.ArtistName != "" {
GoLog("[ReEnrich] Searching online metadata for: %s - %s\n", req.TrackName, req.ArtistName)
searchQuery := req.TrackName + " " + req.ArtistName
@@ -1913,37 +1750,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
}
}
// 3) Try Spotify built-in API as last resort (will be deprecated)
if !found {
GoLog("[ReEnrich] Trying Spotify API (fallback)...\n")
spotifyClient, spotifyErr := NewSpotifyMetadataClient()
if spotifyErr == nil {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
results, err := spotifyClient.SearchTracks(ctx, searchQuery, 5)
cancel()
if err == nil && len(results.Tracks) > 0 {
track := results.Tracks[0]
GoLog("[ReEnrich] Spotify match: %s - %s (album: %s)\n", track.Name, track.Artists, track.AlbumName)
req.SpotifyID = track.SpotifyID
req.AlbumName = track.AlbumName
req.AlbumArtist = track.AlbumArtist
req.TrackNumber = track.TrackNumber
req.DiscNumber = track.DiscNumber
req.ReleaseDate = track.ReleaseDate
req.ISRC = track.ISRC
if track.Images != "" {
req.CoverURL = track.Images
}
req.DurationMs = int64(track.DurationMS)
found = true
} else if err != nil {
GoLog("[ReEnrich] Spotify search failed: %v\n", err)
}
} else {
GoLog("[ReEnrich] Spotify client unavailable: %v\n", spotifyErr)
}
}
// Try to get extended metadata (genre, label) from Deezer if not already set
if found && req.ISRC != "" && (req.Genre == "" || req.Label == "") {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -2146,8 +1952,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
return string(jsonBytes), nil
}
// ==================== EXTENSION SYSTEM ====================
func InitExtensionSystem(extensionsDir, dataDir string) error {
manager := GetExtensionManager()
if err := manager.SetDirectories(extensionsDir, dataDir); err != nil {
@@ -2519,8 +2323,6 @@ func GetAllPendingFFmpegCommandsJSON() (string, error) {
return string(jsonBytes), nil
}
// ==================== EXTENSION CUSTOM SEARCH ====================
func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) {
manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID)
@@ -3273,9 +3075,6 @@ func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) {
return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second)
}
// ==================== LOCAL LIBRARY SCANNING ====================
// SetLibraryCoverCacheDirJSON sets the directory for caching extracted cover art
func SetLibraryCoverCacheDirJSON(cacheDir string) {
SetLibraryCoverCacheDir(cacheDir)
}
@@ -3284,9 +3083,6 @@ func ScanLibraryFolderJSON(folderPath string) (string, error) {
return ScanLibraryFolder(folderPath)
}
// ScanLibraryFolderIncrementalJSON performs an incremental library scan
// existingFilesJSON: JSON object mapping filePath -> modTime (unix millis)
// Returns IncrementalScanResult as JSON
func ScanLibraryFolderIncrementalJSON(folderPath, existingFilesJSON string) (string, error) {
return ScanLibraryFolderIncremental(folderPath, existingFilesJSON)
}
-14
View File
@@ -401,7 +401,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
return nil, fmt.Errorf("failed to read manifest.json: %w", err)
}
// Parse and validate manifest
manifest, err := ParseManifest(manifestData)
if err != nil {
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
@@ -467,17 +466,11 @@ func (m *ExtensionManager) RemoveExtension(extensionID string) error {
}
}
// Optionally remove data directory (keep for now to preserve settings)
// if ext.DataDir != "" {
// os.RemoveAll(ext.DataDir)
// }
return nil
}
// Only allows upgrades (new version > current version), not downgrades
func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) {
// Validate file extension
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
}
@@ -529,7 +522,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
return nil, fmt.Errorf("Extension '%s' is not installed. Use install instead of upgrade.", newManifest.DisplayName)
}
// Compare versions - only allow upgrade, not downgrade
versionCompare := compareVersions(newManifest.Version, existing.Manifest.Version)
if versionCompare < 0 {
return nil, fmt.Errorf("Cannot downgrade extension. Current version: %s, New version: %s", existing.Manifest.Version, newManifest.Version)
@@ -540,7 +532,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
GoLog("[Extension] Upgrading %s from v%s to v%s\n", newManifest.DisplayName, existing.Manifest.Version, newManifest.Version)
// Save data directory path and enabled state (we want to preserve them)
extDataDir := existing.DataDir
extDir := existing.SourceDir
wasEnabled := existing.Enabled
@@ -601,7 +592,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
SourceDir: extDir,
}
// Initialize Goja VM
if err := m.initializeVM(ext); err != nil {
ext.Error = err.Error()
ext.Enabled = false
@@ -626,7 +616,6 @@ type ExtensionUpgradeInfo struct {
}
func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
// Validate file extension
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
}
@@ -675,7 +664,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
}
if !exists {
// Not installed - this is a new install, not upgrade
info.CurrentVersion = ""
info.CanUpgrade = false
} else {
@@ -739,7 +727,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
permissions = append(permissions, "storage:enabled")
}
// Determine status
status := "loaded"
if ext.Error != "" {
status = "error"
@@ -940,7 +927,6 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
return nil, fmt.Errorf("extension is disabled")
}
// Call the action function on the extension object
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
-1
View File
@@ -1,4 +1,3 @@
// Package gobackend provides extension manifest parsing and validation
package gobackend
import (
+115 -48
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
@@ -99,15 +100,16 @@ type ExtDownloadResult struct {
ErrorMessage string `json:"error_message,omitempty"`
ErrorType string `json:"error_type,omitempty"`
Title string `json:"title,omitempty"`
Artist string `json:"artist,omitempty"`
Album string `json:"album,omitempty"`
AlbumArtist string `json:"album_artist,omitempty"`
TrackNumber int `json:"track_number,omitempty"`
DiscNumber int `json:"disc_number,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
ISRC string `json:"isrc,omitempty"`
Title string `json:"title,omitempty"`
Artist string `json:"artist,omitempty"`
Album string `json:"album,omitempty"`
AlbumArtist string `json:"album_artist,omitempty"`
TrackNumber int `json:"track_number,omitempty"`
DiscNumber int `json:"disc_number,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
ISRC string `json:"isrc,omitempty"`
DecryptionKey string `json:"decryption_key,omitempty"`
}
type ExtensionProviderWrapper struct {
@@ -388,7 +390,7 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
return &enrichedTrack, nil
}
func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName string) (*ExtAvailabilityResult, error) {
func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName, spotifyID, deezerID string) (*ExtAvailabilityResult, error) {
if !p.extension.Manifest.IsDownloadProvider() {
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
}
@@ -403,11 +405,11 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.checkAvailability === 'function') {
return extension.checkAvailability(%q, %q, %q);
return extension.checkAvailability(%q, %q, %q, {spotify_id: %q, deezer_id: %q});
}
return null;
})()
`, isrc, trackName, artistName)
`, isrc, trackName, artistName, spotifyID, deezerID)
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
if err != nil {
@@ -631,7 +633,7 @@ func GetProviderPriority() []string {
defer providerPriorityMu.RUnlock()
if len(providerPriority) == 0 {
return []string{"tidal", "qobuz", "amazon", "deezer"}
return []string{"tidal", "qobuz", "deezer"}
}
result := make([]string, len(providerPriority))
@@ -642,8 +644,26 @@ func GetProviderPriority() []string {
func SetMetadataProviderPriority(providerIDs []string) {
metadataProviderPriorityMu.Lock()
defer metadataProviderPriorityMu.Unlock()
metadataProviderPriority = providerIDs
GoLog("[Extension] Metadata provider priority set: %v\n", providerIDs)
sanitized := make([]string, 0, len(providerIDs)+1)
seen := map[string]struct{}{}
for _, providerID := range providerIDs {
providerID = strings.TrimSpace(providerID)
if providerID == "" || providerID == "spotify" {
continue
}
if _, exists := seen[providerID]; exists {
continue
}
seen[providerID] = struct{}{}
sanitized = append(sanitized, providerID)
}
if _, exists := seen["deezer"]; !exists {
sanitized = append([]string{"deezer"}, sanitized...)
}
metadataProviderPriority = sanitized
GoLog("[Extension] Metadata provider priority set: %v\n", sanitized)
}
func GetMetadataProviderPriority() []string {
@@ -651,7 +671,7 @@ func GetMetadataProviderPriority() []string {
defer metadataProviderPriorityMu.RUnlock()
if len(metadataProviderPriority) == 0 {
return []string{"deezer", "spotify"}
return []string{"deezer"}
}
result := make([]string, len(metadataProviderPriority))
@@ -661,7 +681,7 @@ func GetMetadataProviderPriority() []string {
func isBuiltInProvider(providerID string) bool {
switch providerID {
case "tidal", "qobuz", "amazon", "deezer":
case "tidal", "qobuz", "deezer":
return true
default:
return false
@@ -694,6 +714,27 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
priority = newPriority
GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority)
} else if !strictMode && req.Service != "" && !isBuiltInProvider(strings.ToLower(req.Service)) {
found := false
for _, p := range priority {
if strings.EqualFold(p, req.Service) {
found = true
break
}
}
newPriority := []string{req.Service}
for _, p := range priority {
if !strings.EqualFold(p, req.Service) {
newPriority = append(newPriority, p)
}
}
priority = newPriority
if !found {
GoLog("[DownloadWithExtensionFallback] Extension service '%s' added to priority front\n", req.Service)
} else {
GoLog("[DownloadWithExtensionFallback] Extension service '%s' moved to priority front\n", req.Service)
}
GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority)
}
var lastErr error
@@ -777,7 +818,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn)
outputPath := buildOutputPath(req)
outputPath := buildOutputPathForExtension(req, ext)
if req.ItemID != "" {
StartItemProgress(req.ItemID)
}
@@ -813,6 +854,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
DecryptionKey: result.DecryptionKey,
}
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
@@ -966,7 +1008,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
provider := NewExtensionProviderWrapper(ext)
availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName)
availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID)
if err != nil || !availability.Available {
GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID)
if err != nil {
@@ -975,7 +1017,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
continue
}
outputPath := buildOutputPath(req)
outputPath := buildOutputPathForExtension(req, ext)
if req.ItemID != "" {
StartItemProgress(req.ItemID)
}
@@ -1011,6 +1053,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
DecryptionKey: result.DecryptionKey,
}
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
@@ -1128,25 +1171,6 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
}
}
err = qobuzErr
case "amazon":
amazonResult, amazonErr := downloadFromAmazon(req)
if amazonErr == nil {
result = DownloadResult{
FilePath: amazonResult.FilePath,
BitDepth: amazonResult.BitDepth,
SampleRate: amazonResult.SampleRate,
Title: amazonResult.Title,
Artist: amazonResult.Artist,
Album: amazonResult.Album,
ReleaseDate: amazonResult.ReleaseDate,
TrackNumber: amazonResult.TrackNumber,
DiscNumber: amazonResult.DiscNumber,
ISRC: amazonResult.ISRC,
LyricsLRC: amazonResult.LyricsLRC,
DecryptionKey: amazonResult.DecryptionKey,
}
}
err = amazonErr
case "deezer":
deezerResult, deezerErr := downloadFromDeezer(req)
if deezerErr == nil {
@@ -1226,7 +1250,58 @@ func buildOutputPath(req DownloadRequest) string {
ext = "." + ext
}
return fmt.Sprintf("%s/%s%s", req.OutputDir, filename, ext)
outputDir := req.OutputDir
if strings.TrimSpace(outputDir) == "" {
outputDir = filepath.Join(os.TempDir(), "spotiflac-downloads")
os.MkdirAll(outputDir, 0755)
AddAllowedDownloadDir(outputDir)
}
return filepath.Join(outputDir, filename+ext)
}
func buildOutputPathForExtension(req DownloadRequest, ext *LoadedExtension) string {
if strings.TrimSpace(req.OutputPath) != "" {
return strings.TrimSpace(req.OutputPath)
}
if strings.TrimSpace(req.OutputDir) != "" {
return buildOutputPath(req)
}
// SAF mode: use extension's data dir as writable temp location
tempDir := filepath.Join(ext.DataDir, "downloads")
os.MkdirAll(tempDir, 0755)
AddAllowedDownloadDir(tempDir)
metadata := map[string]interface{}{
"title": req.TrackName,
"artist": req.ArtistName,
"album": req.AlbumName,
"album_artist": req.AlbumArtist,
"track": req.TrackNumber,
"track_number": req.TrackNumber,
"disc": req.DiscNumber,
"disc_number": req.DiscNumber,
"year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate,
"release_date": req.ReleaseDate,
"isrc": req.ISRC,
}
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
if filename == "" {
filename = sanitizeFilename(fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName))
}
outputExt := strings.TrimSpace(req.OutputExt)
if outputExt == "" {
outputExt = ".flac"
} else if !strings.HasPrefix(outputExt, ".") {
outputExt = "." + outputExt
}
return filepath.Join(tempDir, filename+outputExt)
}
func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) {
@@ -1653,7 +1728,6 @@ func (m *ExtensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResu
}, nil
}
// GetPostProcessingProviders returns all extensions that provide post-processing
func (m *ExtensionManager) GetPostProcessingProviders() []*ExtensionProviderWrapper {
m.mu.RLock()
defer m.mu.RUnlock()
@@ -1667,7 +1741,6 @@ func (m *ExtensionManager) GetPostProcessingProviders() []*ExtensionProviderWrap
return providers
}
// RunPostProcessing runs all enabled post-processing hooks on a file
func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[string]interface{}) (*PostProcessResult, error) {
providers := m.GetPostProcessingProviders()
if len(providers) == 0 {
@@ -1713,7 +1786,6 @@ func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[strin
return &PostProcessResult{Success: true, NewFilePath: currentPath}, nil
}
// RunPostProcessingV2 runs all enabled post-processing hooks on a file input.
func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata map[string]interface{}) (*PostProcessResult, error) {
providers := m.GetPostProcessingProviders()
if len(providers) == 0 {
@@ -1768,9 +1840,6 @@ func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata
return &PostProcessResult{Success: true, NewFilePath: currentInput.Path, NewFileURI: currentInput.URI}, nil
}
// ==================== Lyrics Provider ====================
// ExtLyricsResult represents lyrics data returned from an extension
type ExtLyricsResult struct {
Lines []ExtLyricsLine `json:"lines"`
SyncType string `json:"syncType"`
@@ -1785,7 +1854,6 @@ type ExtLyricsLine struct {
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)
@@ -1885,7 +1953,6 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName
return response, nil
}
// GetLyricsProviders returns all enabled extensions that provide lyrics
func (m *ExtensionManager) GetLyricsProviders() []*ExtensionProviderWrapper {
m.mu.RLock()
defer m.mu.RUnlock()
-9
View File
@@ -1,4 +1,3 @@
// Package gobackend provides Auth API and PKCE support for extension runtime
package gobackend
import (
@@ -16,8 +15,6 @@ import (
"github.com/dop251/goja"
)
// ==================== Auth API (OAuth Support) ====================
func validateExtensionAuthURL(urlStr string) error {
parsed, err := url.Parse(urlStr)
if err != nil {
@@ -204,9 +201,6 @@ func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(result)
}
// ==================== PKCE Support ====================
// generatePKCEVerifier generates a cryptographically random code verifier
// Length should be between 43-128 characters (RFC 7636)
func generatePKCEVerifier(length int) (string, error) {
if length < 43 {
@@ -394,9 +388,7 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
})
}
// authExchangeCodeWithPKCE exchanges auth code for tokens using PKCE
// config: { tokenUrl, clientId, redirectUri, code, extraParams }
// Uses the stored PKCE verifier automatically
func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
@@ -414,7 +406,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
})
}
// Required fields
tokenURL, _ := config["tokenUrl"].(string)
clientID, _ := config["clientId"].(string)
redirectURI, _ := config["redirectUri"].(string)
+1 -5
View File
@@ -1,4 +1,3 @@
// Package gobackend provides FFmpeg API for extension runtime
package gobackend
import (
@@ -10,9 +9,7 @@ import (
"github.com/dop251/goja"
)
// ==================== FFmpeg API (Post-Processing) ====================
// FFmpegCommand holds a pending FFmpeg command for Flutter to execute
// FFmpegCommand holds a pending FFmpeg command for Flutter to execute.
type FFmpegCommand struct {
ExtensionID string
Command string
@@ -24,7 +21,6 @@ type FFmpegCommand struct {
Output string
}
// Global FFmpeg command queue
var (
ffmpegCommands = make(map[string]*FFmpegCommand)
ffmpegCommandsMu sync.RWMutex
-3
View File
@@ -1,4 +1,3 @@
// Package gobackend provides File API for extension runtime
package gobackend
import (
@@ -13,8 +12,6 @@ import (
"github.com/dop251/goja"
)
// ==================== File API (Sandboxed) ====================
var (
allowedDownloadDirs []string
allowedDownloadDirsMu sync.RWMutex
-3
View File
@@ -1,4 +1,3 @@
// Package gobackend provides HTTP API for extension runtime
package gobackend
import (
@@ -12,8 +11,6 @@ import (
"github.com/dop251/goja"
)
// ==================== HTTP API (Sandboxed) ====================
type HTTPResponse struct {
StatusCode int `json:"statusCode"`
Body string `json:"body"`
-3
View File
@@ -1,4 +1,3 @@
// Package gobackend provides Track Matching API for extension runtime
package gobackend
import (
@@ -7,8 +6,6 @@ import (
"github.com/dop251/goja"
)
// ==================== Track Matching API ====================
func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(0.0)
+3 -11
View File
@@ -1,4 +1,3 @@
// Package gobackend provides Browser-like Polyfills for extension runtime
package gobackend
import (
@@ -13,12 +12,10 @@ import (
"github.com/dop251/goja"
)
// ==================== Browser-like Polyfills ====================
// These polyfills make porting browser/Node.js libraries easier
// without compromising sandbox security
// without compromising sandbox security.
// fetchPolyfill implements browser-compatible fetch() API
// Returns a Promise-like object with json(), text() methods
// Returns a Promise-like object with json(), text() methods.
func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.createFetchError("URL is required")
@@ -141,7 +138,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
return responseObj
}
// createFetchError creates a fetch error response
func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
errorObj := r.vm.NewObject()
errorObj.Set("ok", false)
@@ -157,7 +153,6 @@ func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
return errorObj
}
// atobPolyfill implements browser atob() - decode base64 to string
func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
@@ -174,7 +169,6 @@ func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(string(decoded))
}
// btoaPolyfill implements browser btoa() - encode string to base64
func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
@@ -183,7 +177,6 @@ func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
}
// registerTextEncoderDecoder registers TextEncoder and TextDecoder classes
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
encoder := call.This
@@ -429,9 +422,8 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
})
}
// registerJSONGlobal ensures JSON global is properly set up
// JSON is already built-in to Goja; this ensures a fallback exists.
func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
// JSON is already built-in to Goja, but we can enhance it
jsonScript := `
if (typeof JSON === 'undefined') {
var JSON = {
-3
View File
@@ -1,4 +1,3 @@
// Package gobackend provides Storage and Credentials API for extension runtime
package gobackend
import (
@@ -17,8 +16,6 @@ import (
"github.com/dop251/goja"
)
// ==================== Storage API ====================
const (
defaultStorageFlushDelay = 400 * time.Millisecond
storageFlushRetryDelay = 2 * time.Second
-3
View File
@@ -1,4 +1,3 @@
// Package gobackend provides Utility functions for extension runtime
package gobackend
import (
@@ -17,8 +16,6 @@ import (
"github.com/dop251/goja"
)
// ==================== Utility Functions ====================
func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue("")
-1
View File
@@ -1,4 +1,3 @@
// Package gobackend provides extension settings storage
package gobackend
import (
-1
View File
@@ -1,4 +1,3 @@
// Package gobackend provides timeout execution for extension JS code
package gobackend
import (
-2
View File
@@ -489,7 +489,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
}
}
// Check error message patterns for common ISP blocking indicators
blockingPatterns := []struct {
pattern string
reason string
@@ -532,7 +531,6 @@ func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
return false
}
// extractDomain extracts the domain from a URL string
func extractDomain(rawURL string) string {
if rawURL == "" {
return "unknown"
-6
View File
@@ -91,7 +91,6 @@ func (t *utlsTransport) getPort(u *url.URL) string {
return "80"
}
// Cloudflare bypass client using uTLS Chrome fingerprint
var cloudflareBypassTransport = newUTLSTransport()
var cloudflareBypassClient = &http.Client{
@@ -111,7 +110,6 @@ func GetCloudflareBypassClient() *http.Client {
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent())
// Try with standard client first
resp, err := sharedClient.Do(req)
if err == nil {
// Check for Cloudflare challenge page (403 with specific markers)
@@ -138,11 +136,9 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
if isCloudflare {
LogDebug("HTTP", "Cloudflare detected, retrying with Chrome TLS fingerprint...")
// Clone request for retry
reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
// Retry with uTLS Chrome fingerprint
return cloudflareBypassClient.Do(reqCopy)
}
}
@@ -168,11 +164,9 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
if tlsRelated {
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
// Clone request for retry
reqCopy := req.Clone(req.Context())
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
// Retry with uTLS Chrome fingerprint
return cloudflareBypassClient.Do(reqCopy)
}
-7
View File
@@ -22,13 +22,11 @@ var (
idhsRateLimiter = NewRateLimiter(8, time.Minute) // 8 req/min (below 10 limit)
)
// IDHSSearchRequest represents the request body for IDHS API
type IDHSSearchRequest struct {
Link string `json:"link"`
Adapters []string `json:"adapters,omitempty"`
}
// IDHSSearchResponse represents the response from IDHS API
type IDHSSearchResponse struct {
ID string `json:"id"`
Type string `json:"type"` // song, album, artist, podcast, show
@@ -41,7 +39,6 @@ type IDHSSearchResponse struct {
Links []IDHSLink `json:"links"`
}
// IDHSLink represents a link to a streaming platform
type IDHSLink struct {
Type string `json:"type"` // spotify, youTube, appleMusic, deezer, soundCloud, tidal
URL string `json:"url"`
@@ -49,7 +46,6 @@ type IDHSLink struct {
NotAvailable bool `json:"notAvailable,omitempty"`
}
// NewIDHSClient creates a new IDHS client
func NewIDHSClient() *IDHSClient {
idhsClientOnce.Do(func() {
globalIDHSClient = &IDHSClient{
@@ -117,7 +113,6 @@ func (c *IDHSClient) Search(link string, adapters []string) (*IDHSSearchResponse
func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
// Request only the platforms we need
adapters := []string{"tidal", "deezer"}
result, err := c.Search(spotifyURL, adapters)
@@ -151,11 +146,9 @@ func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAv
return availability, nil
}
// GetAvailabilityFromDeezer checks track availability using IDHS
func (c *IDHSClient) GetAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
// Request only the platforms we need
adapters := []string{"spotify", "tidal"}
result, err := c.Search(deezerURL, adapters)
+119 -13
View File
@@ -10,7 +10,6 @@ import (
"time"
)
// LibraryScanResult represents metadata from a scanned audio file
type LibraryScanResult struct {
ID string `json:"id"`
TrackName string `json:"trackName"`
@@ -42,7 +41,6 @@ type LibraryScanProgress struct {
IsComplete bool `json:"is_complete"`
}
// IncrementalScanResult contains results of an incremental library scan
type IncrementalScanResult struct {
Scanned []LibraryScanResult `json:"scanned"` // New or updated files
DeletedPaths []string `json:"deletedPaths"` // Files that no longer exist
@@ -65,6 +63,7 @@ var supportedAudioFormats = map[string]bool{
".mp3": true,
".opus": true,
".ogg": true,
".cue": true,
}
type libraryAudioFileInfo struct {
@@ -168,6 +167,23 @@ func ScanLibraryFolder(folderPath string) (string, error) {
scanTime := time.Now().UTC().Format(time.RFC3339)
errorCount := 0
// Track audio files referenced by .cue sheets to avoid duplicates
cueReferencedAudioFiles := make(map[string]bool)
// First pass: scan .cue files to collect referenced audio paths
for _, filePath := range audioFiles {
ext := strings.ToLower(filepath.Ext(filePath))
if ext == ".cue" {
sheet, err := ParseCueFile(filePath)
if err == nil && sheet.FileName != "" {
audioPath := ResolveCueAudioPath(filePath, sheet.FileName)
if audioPath != "" {
cueReferencedAudioFiles[audioPath] = true
}
}
}
}
for i, filePath := range audioFiles {
select {
case <-cancelCh:
@@ -181,6 +197,28 @@ func ScanLibraryFolder(folderPath string) (string, error) {
libraryScanProgress.ProgressPct = float64(i+1) / float64(totalFiles) * 100
libraryScanProgressMu.Unlock()
ext := strings.ToLower(filepath.Ext(filePath))
// Handle .cue files: produce multiple track results
if ext == ".cue" {
cueResults, err := ScanCueFileForLibrary(filePath, scanTime)
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning cue %s: %v\n", filePath, err)
continue
}
results = append(results, cueResults...)
GoLog("[LibraryScan] CUE sheet %s: %d tracks\n", filepath.Base(filePath), len(cueResults))
continue
}
// Skip audio files that are referenced by a .cue sheet
// (they will be represented by the cue sheet's track entries instead)
if cueReferencedAudioFiles[filePath] {
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
continue
}
result, err := scanAudioFile(filePath, scanTime)
if err != nil {
errorCount++
@@ -216,7 +254,6 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
Format: strings.TrimPrefix(ext, "."),
}
// Get file modification time
if info, err := os.Stat(filePath); err == nil {
result.FileModTime = info.ModTime().UnixMilli()
}
@@ -466,7 +503,6 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
return "{}", fmt.Errorf("path is not a folder: %s", folderPath)
}
// Parse existing files map
existingFiles := make(map[string]int64)
if existingFilesJSON != "" && existingFilesJSON != "{}" {
if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil {
@@ -476,12 +512,10 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
GoLog("[LibraryScan] Incremental scan starting, %d existing files in database\n", len(existingFiles))
// Reset progress
libraryScanProgressMu.Lock()
libraryScanProgress = LibraryScanProgress{}
libraryScanProgressMu.Unlock()
// Setup cancellation
libraryScanCancelMu.Lock()
if libraryScanCancel != nil {
close(libraryScanCancel)
@@ -490,7 +524,6 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
cancelCh := libraryScanCancel
libraryScanCancelMu.Unlock()
// Collect all audio files with their mod times
currentFiles, err := collectLibraryAudioFiles(folderPath, cancelCh)
if err != nil {
return "{}", err
@@ -509,24 +542,64 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
var filesToScan []libraryAudioFileInfo
skippedCount := 0
// Build a set of existing CUE virtual path base files for incremental matching.
// CUE tracks are stored with virtual paths like "/path/album.cue#track01".
// We need to match these against the actual .cue file's modTime.
cueBaseModTimes := make(map[string]int64) // base cue path -> modTime from disk
for _, f := range currentFiles {
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
cueBaseModTimes[f.path] = f.modTime
}
}
for _, f := range currentFiles {
existingModTime, exists := existingFiles[f.path]
if !exists {
// New file
// For .cue files, also check if any virtual path entries exist
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
hasCueTracks := false
for existingPath := range existingFiles {
if strings.HasPrefix(existingPath, f.path+"#track") {
hasCueTracks = true
break
}
}
if hasCueTracks {
// CUE file exists in DB via virtual paths; check if modTime changed
// Use modTime from any virtual path (they all share the same .cue modTime)
for existingPath, modTime := range existingFiles {
if strings.HasPrefix(existingPath, f.path+"#track") {
if f.modTime == modTime {
skippedCount++
} else {
filesToScan = append(filesToScan, f)
}
break
}
}
continue
}
}
filesToScan = append(filesToScan, f)
} else if f.modTime != existingModTime {
// Modified file
filesToScan = append(filesToScan, f)
} else {
// Unchanged file - skip
skippedCount++
}
}
// Find deleted files
var deletedPaths []string
for existingPath := range existingFiles {
if !currentPathSet[existingPath] {
// For CUE virtual paths (e.g. "/path/album.cue#track01"),
// check if the base .cue file still exists on disk
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
baseCuePath := existingPath[:idx]
if currentPathSet[baseCuePath] {
continue // Base .cue file still exists, not deleted
}
// Base CUE file is gone, mark virtual path as deleted
deletedPaths = append(deletedPaths, existingPath)
} else if !currentPathSet[existingPath] {
deletedPaths = append(deletedPaths, existingPath)
}
}
@@ -551,11 +624,25 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
return string(jsonBytes), nil
}
// Scan the files that need scanning
results := make([]LibraryScanResult, 0, len(filesToScan))
scanTime := time.Now().UTC().Format(time.RFC3339)
errorCount := 0
// Track audio files referenced by .cue sheets to avoid duplicates (incremental)
cueReferencedAudioFilesInc := make(map[string]bool)
for _, f := range filesToScan {
ext := strings.ToLower(filepath.Ext(f.path))
if ext == ".cue" {
sheet, err := ParseCueFile(f.path)
if err == nil && sheet.FileName != "" {
audioPath := ResolveCueAudioPath(f.path, sheet.FileName)
if audioPath != "" {
cueReferencedAudioFilesInc[audioPath] = true
}
}
}
}
for i, f := range filesToScan {
select {
case <-cancelCh:
@@ -569,6 +656,25 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
libraryScanProgress.ProgressPct = float64(skippedCount+i+1) / float64(totalFiles) * 100
libraryScanProgressMu.Unlock()
ext := strings.ToLower(filepath.Ext(f.path))
// Handle .cue files: produce multiple track results
if ext == ".cue" {
cueResults, err := ScanCueFileForLibrary(f.path, scanTime)
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err)
continue
}
results = append(results, cueResults...)
continue
}
// Skip audio files referenced by .cue sheets
if cueReferencedAudioFilesInc[f.path] {
continue
}
result, err := scanAudioFile(f.path, scanTime)
if err != nil {
errorCount++
-3
View File
@@ -41,7 +41,6 @@ var DefaultLyricsProviders = []string{
LyricsProviderQQMusic,
}
// Global lyrics provider configuration
var (
lyricsProvidersMu sync.RWMutex
lyricsProviders []string // ordered list of enabled providers
@@ -598,7 +597,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
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)
@@ -621,7 +619,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
return &cachedCopy, nil
}
// Get configured provider order
providerOrder := GetLyricsProviderOrder()
simplifiedTrack := simplifyTrackName(trackName)
-4
View File
@@ -97,7 +97,6 @@ func (m *appleTokenManager) clearToken() {
m.token = ""
}
// Apple Music API response models
type appleMusicSearchResponse struct {
Results struct {
Songs *struct {
@@ -239,15 +238,12 @@ func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
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
-5
View File
@@ -16,7 +16,6 @@ type MusixmatchClient struct {
baseURL string
}
// Musixmatch proxy response models
type musixmatchSearchResponse struct {
ID int64 `json:"id"`
SongName string `json:"songName"`
@@ -116,7 +115,6 @@ func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string)
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 {
@@ -129,7 +127,6 @@ func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string)
}
}
// Fall back to unsynced lyrics for selected language
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
@@ -162,7 +159,6 @@ func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec
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 {
@@ -175,7 +171,6 @@ func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec
}
}
// Fall back to unsynced lyrics
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
-2
View File
@@ -15,7 +15,6 @@ type NeteaseClient struct {
httpClient *http.Client
}
// Netease API response models
type neteaseSearchResponse struct {
Result struct {
Songs []struct {
@@ -172,7 +171,6 @@ func (c *NeteaseClient) FetchLyrics(
return nil, err
}
// Parse the LRC text into LyricsResponse
lines := parseSyncedLyrics(lrcText)
if len(lines) == 0 {
// May be plain text lyrics without timestamps
-2
View File
@@ -17,7 +17,6 @@ type QQMusicClient struct {
httpClient *http.Client
}
// QQ Music search response models
type qqMusicSearchResponse struct {
Data struct {
Song struct {
@@ -184,7 +183,6 @@ func (c *QQMusicClient) FetchLyrics(
}, nil
}
// Fall back to plain text
resultLines := plainTextLyricsLines(lrcText)
if len(resultLines) > 0 {
-2
View File
@@ -1,10 +1,8 @@
// mobile_deps.go
// This file ensures gomobile dependencies are not removed by go mod tidy.
// These packages are required by gomobile bind but not directly imported in code.
package gobackend
import (
// Required for gomobile bind to work
_ "golang.org/x/mobile/bind"
)
-33
View File
@@ -10,7 +10,6 @@ import (
type TrackIDCacheEntry struct {
TidalTrackID int64
QobuzTrackID int64
AmazonURL string
ExpiresAt time.Time
}
@@ -107,25 +106,6 @@ func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
}
}
func (c *TrackIDCache) SetAmazonURL(isrc string, amazonURL string) {
c.mu.Lock()
defer c.mu.Unlock()
entry, exists := c.cache[isrc]
if !exists {
entry = &TrackIDCacheEntry{}
c.cache[isrc] = entry
}
entry.AmazonURL = amazonURL
now := time.Now()
entry.ExpiresAt = now.Add(c.ttl)
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
c.pruneExpiredLocked(now)
c.lastCleanup = now
}
}
func (c *TrackIDCache) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
@@ -235,8 +215,6 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName)
case "qobuz":
preWarmQobuzCache(r.ISRC, r.SpotifyID)
case "amazon":
preWarmAmazonCache(r.ISRC, r.SpotifyID)
}
}(req)
}
@@ -256,12 +234,10 @@ func preWarmTidalCache(isrc, _, _ string) {
// 1. From SongLink (fast, no Qobuz API call needed)
// 2. Direct ISRC search on Qobuz API (slower, may fail if ISRC not in Qobuz database)
func preWarmQobuzCache(isrc, spotifyID string) {
// First, try to get QobuzID from SongLink - this is faster and more reliable
if spotifyID != "" {
client := NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
if err == nil && availability != nil && availability.QobuzID != "" {
// Parse QobuzID to int64
var trackID int64
if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from SongLink for ISRC %s\n", trackID, isrc)
@@ -271,7 +247,6 @@ func preWarmQobuzCache(isrc, spotifyID string) {
}
}
// Fallback: Direct ISRC search on Qobuz API
downloader := NewQobuzDownloader()
track, err := downloader.SearchTrackByISRC(isrc)
if err == nil && track != nil {
@@ -280,14 +255,6 @@ func preWarmQobuzCache(isrc, spotifyID string) {
}
}
func preWarmAmazonCache(isrc, spotifyID string) {
client := NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
if err == nil && availability != nil && availability.AmazonURL != "" {
GetTrackIDCache().SetAmazonURL(isrc, availability.AmazonURL)
}
}
func PreWarmCache(tracksJSON string) error {
var tracks []struct {
ISRC string `json:"isrc"`
+5 -12
View File
@@ -923,18 +923,14 @@ type qobuzAPIResult struct {
duration time.Duration
}
// Qobuz API timeout configuration
// Mobile networks are more unstable, so we use longer timeouts
const (
qobuzAPITimeoutMobile = 25 * time.Second
qobuzMaxRetries = 2 // Number of retries per API
qobuzMaxRetries = 2
qobuzRetryDelay = 500 * time.Millisecond
)
// getQobuzAPITimeout returns appropriate timeout based on platform
// For mobile (gomobile builds), we use longer timeouts
func getQobuzAPITimeout() time.Duration {
// Since this runs in gomobile context, we always use mobile timeout
// The Go backend is only used on mobile (Android/iOS)
return qobuzAPITimeoutMobile
}
@@ -944,7 +940,6 @@ func fetchQobuzURLWithRetry(provider qobuzAPIProvider, trackID int64, quality st
return fetchQobuzURLSingleAttempt(provider, trackID, quality, timeout, "")
}
// fetchQobuzURLSingleAttempt fetches download URL with retry logic for a single API+country combination
func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, quality string, timeout time.Duration, country string) (qobuzDownloadInfo, error) {
var lastErr error
retryDelay := qobuzRetryDelay
@@ -967,7 +962,7 @@ func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, qualit
if attempt > 0 {
GoLog("[Qobuz] Retry %d/%d for %s after %v\n", attempt, qobuzMaxRetries, provider.Name, retryDelay)
time.Sleep(retryDelay)
retryDelay *= 2 // Exponential backoff
retryDelay *= 2
}
client := NewHTTPClientWithTimeout(timeout)
@@ -1014,11 +1009,10 @@ func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, qualit
strings.Contains(errStr, "reset") ||
strings.Contains(errStr, "connection refused") ||
strings.Contains(errStr, "eof") {
continue // Retry
continue
}
break // Non-retryable error
break
}
// Server errors are retryable
if resp.StatusCode >= 500 {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
@@ -1031,7 +1025,7 @@ func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, qualit
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
lastErr = fmt.Errorf("rate limited")
retryDelay = 2 * time.Second // Wait longer for rate limit
retryDelay = 2 * time.Second
continue
}
@@ -1308,7 +1302,6 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
track = nil
} else if track != nil {
GoLog("[%s] Successfully found track via SongLink ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
// Cache for future use
if req.ISRC != "" {
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
}
-1
View File
@@ -48,7 +48,6 @@ func (r *RateLimiter) WaitForSlot() {
r.timestamps = append(r.timestamps, time.Now())
}
// cleanOldTimestamps removes timestamps that are outside the current window
func (r *RateLimiter) cleanOldTimestamps(now time.Time) {
cutoff := now.Add(-r.window)
validStart := 0
-5
View File
@@ -170,11 +170,9 @@ func JapaneseToRomaji(text string) string {
}
func BuildSearchQuery(trackName, artistName string) string {
// Convert Japanese to romaji
trackRomaji := JapaneseToRomaji(trackName)
artistRomaji := JapaneseToRomaji(artistName)
// Clean up the query - remove special characters that might interfere with search
trackClean := cleanSearchQuery(trackRomaji)
artistClean := cleanSearchQuery(artistRomaji)
@@ -196,16 +194,13 @@ func cleanSearchQuery(s string) string {
func CleanToASCII(s string) string {
var result strings.Builder
for _, r := range s {
// Keep only ASCII letters, numbers, spaces, and basic punctuation
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' {
result.WriteRune(r)
} else if r == ',' || r == '.' {
// Convert punctuation to space
result.WriteRune(' ')
}
}
// Clean up multiple spaces
cleaned := strings.Join(strings.Fields(result.String()), " ")
return strings.TrimSpace(cleaned)
}
+2 -12
View File
@@ -291,7 +291,7 @@ func extractDeezerIDFromURL(deezerURL string) string {
return ""
}
// extractQobuzIDFromURL extracts Qobuz track ID from URL
// extractQobuzIDFromURL extracts Qobuz track ID from URL.
// URL formats:
// - https://www.qobuz.com/us-en/album/.../12345678 (album page with track highlight)
// - https://open.qobuz.com/track/12345678
@@ -302,29 +302,24 @@ func extractQobuzIDFromURL(qobuzURL string) string {
return ""
}
// Try to find /track/ID pattern first
if strings.Contains(qobuzURL, "/track/") {
parts := strings.Split(qobuzURL, "/track/")
if len(parts) > 1 {
idPart := parts[1]
// Remove query parameters
if idx := strings.Index(idPart, "?"); idx > 0 {
idPart = idPart[:idx]
}
// Remove trailing slash or path
if idx := strings.Index(idPart, "/"); idx > 0 {
idPart = idPart[:idx]
}
idPart = strings.TrimSpace(idPart)
// Validate it's a number
if idPart != "" && isNumeric(idPart) {
return idPart
}
}
}
// Try to extract from album URL with track highlight
// Format: /album/albumname/trackid or ?trackId=12345678
// Try to extract from album URL with track highlight (e.g. ?trackId=12345678)
if strings.Contains(qobuzURL, "trackId=") {
parts := strings.Split(qobuzURL, "trackId=")
if len(parts) > 1 {
@@ -343,7 +338,6 @@ func extractQobuzIDFromURL(qobuzURL string) string {
parts := strings.Split(qobuzURL, "/")
for i := len(parts) - 1; i >= 0; i-- {
part := parts[i]
// Remove query parameters
if idx := strings.Index(part, "?"); idx > 0 {
part = part[:idx]
}
@@ -386,7 +380,6 @@ func extractYouTubeIDFromURL(youtubeURL string) string {
return ""
}
// Handle youtu.be short URLs
if strings.Contains(youtubeURL, "youtu.be/") {
parts := strings.Split(youtubeURL, "youtu.be/")
if len(parts) >= 2 {
@@ -401,7 +394,6 @@ func extractYouTubeIDFromURL(youtubeURL string) string {
}
}
// Handle youtube.com URLs with ?v= parameter
parsed, err := url.Parse(youtubeURL)
if err != nil {
return ""
@@ -411,7 +403,6 @@ func extractYouTubeIDFromURL(youtubeURL string) string {
return v
}
// Handle /embed/ format
if strings.Contains(parsed.Path, "/embed/") {
parts := strings.Split(parsed.Path, "/embed/")
if len(parts) >= 2 {
@@ -540,7 +531,6 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
return availability, nil
}
// checkAvailabilityFromDeezerSongLink is the original SongLink implementation for Deezer
func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID string) (*TrackAvailability, error) {
songLinkRateLimiter.WaitForSlot()
+3 -29
View File
@@ -9,7 +9,6 @@ import (
"math/rand"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
@@ -64,45 +63,20 @@ var (
credentialsMu sync.RWMutex
)
var ErrNoSpotifyCredentials = errors.New("Spotify credentials not configured. Please set your own Client ID and Secret in Settings, or use Deezer as metadata source (free, no credentials required)")
var ErrNoSpotifyCredentials = errors.New("built-in Spotify API metadata provider has been removed; use Deezer or the spotify-web extension instead")
func SetSpotifyCredentials(clientID, clientSecret string) {
credentialsMu.Lock()
defer credentialsMu.Unlock()
customClientID = clientID
customClientSecret = clientSecret
customClientID = ""
customClientSecret = ""
}
func HasSpotifyCredentials() bool {
credentialsMu.RLock()
defer credentialsMu.RUnlock()
if customClientID != "" && customClientSecret != "" {
return true
}
if os.Getenv("SPOTIFY_CLIENT_ID") != "" && os.Getenv("SPOTIFY_CLIENT_SECRET") != "" {
return true
}
return false
}
func getCredentials() (string, string, error) {
credentialsMu.RLock()
defer credentialsMu.RUnlock()
if customClientID != "" && customClientSecret != "" {
return customClientID, customClientSecret, nil
}
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
if clientID != "" && clientSecret != "" {
return clientID, clientSecret, nil
}
return "", "", ErrNoSpotifyCredentials
}
+7 -13
View File
@@ -103,7 +103,7 @@ type MPD struct {
func NewTidalDownloader() *TidalDownloader {
tidalDownloaderOnce.Do(func() {
globalTidalDownloader = &TidalDownloader{
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
client: NewHTTPClientWithTimeout(DefaultTimeout),
}
apis := globalTidalDownloader.GetAvailableAPIs()
@@ -116,7 +116,7 @@ func NewTidalDownloader() *TidalDownloader {
func (t *TidalDownloader) GetAvailableAPIs() []string {
return []string{
"https://tidal-api.binimum.org", // priority
"https://tidal-api.binimum.org",
"https://tidal.kinoplus.online",
"https://triton.squid.wtf",
"https://vogel.qqdl.site",
@@ -195,7 +195,6 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
return nil, fmt.Errorf("tidal ISRC search API disabled: no client credentials mode")
}
// Now includes romaji conversion for Japanese text (4 search strategies like PC)
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, albumName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode")
}
@@ -204,7 +203,6 @@ func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*
return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode")
}
// TidalDownloadInfo contains download URL and quality info
type TidalDownloadInfo struct {
URL string
BitDepth int
@@ -218,15 +216,13 @@ type tidalAPIResult struct {
duration time.Duration
}
// Tidal API timeout configuration
// Mobile networks are more unstable, so we use longer timeouts
const (
tidalAPITimeoutMobile = 25 * time.Second
tidalMaxRetries = 2 // Number of retries per API
tidalMaxRetries = 2
tidalRetryDelay = 500 * time.Millisecond
)
// fetchTidalURLWithRetry fetches download URL from a single Tidal API with retry logic
func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout time.Duration) (TidalDownloadInfo, error) {
var lastErr error
retryDelay := tidalRetryDelay
@@ -235,7 +231,7 @@ func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout t
if attempt > 0 {
GoLog("[Tidal] Retry %d/%d for %s after %v\n", attempt, tidalMaxRetries, api, retryDelay)
time.Sleep(retryDelay)
retryDelay *= 2 // Exponential backoff
retryDelay *= 2
}
client := NewHTTPClientWithTimeout(timeout)
@@ -250,17 +246,15 @@ func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout t
resp, err := client.Do(req)
if err != nil {
lastErr = err
// Check for retryable errors (timeout, connection reset)
errStr := strings.ToLower(err.Error())
if strings.Contains(errStr, "timeout") ||
strings.Contains(errStr, "reset") ||
strings.Contains(errStr, "connection refused") ||
strings.Contains(errStr, "eof") {
continue // Retry
continue
}
break // Non-retryable error
break
}
// Server errors are retryable
if resp.StatusCode >= 500 {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
@@ -273,7 +267,7 @@ func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout t
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
lastErr = fmt.Errorf("rate limited")
retryDelay = 2 * time.Second // Wait longer for rate limit
retryDelay = 2 * time.Second
continue
}
-5
View File
@@ -1,4 +1,3 @@
// Package gobackend - YouTube download via Cobalt API (lossy-only provider)
package gobackend
import (
@@ -161,7 +160,6 @@ func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalize
}
}
// 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)
@@ -213,7 +211,6 @@ func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQua
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,
@@ -470,7 +467,6 @@ 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
@@ -707,7 +703,6 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
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")
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

+138 -50
View File
@@ -15,6 +15,9 @@ import Gobackend // Import Go framework
private var libraryScanProgressEventSink: FlutterEventSink?
private var lastLibraryScanProgressPayload: String?
/// Currently accessed security-scoped URL for library folder
private var activeSecurityScopedURL: URL?
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
@@ -157,38 +160,6 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "getSpotifyMetadata":
let args = call.arguments as! [String: Any]
let url = args["url"] as! String
let response = GobackendGetSpotifyMetadata(url, &error)
if let error = error { throw error }
return response
case "searchSpotify":
let args = call.arguments as! [String: Any]
let query = args["query"] as! String
let limit = args["limit"] as? Int ?? 10
let response = GobackendSearchSpotify(query, Int(limit), &error)
if let error = error { throw error }
return response
case "searchSpotifyAll":
let args = call.arguments as! [String: Any]
let query = args["query"] as! String
let trackLimit = args["track_limit"] as? Int ?? 15
let artistLimit = args["artist_limit"] as? Int ?? 3
let response = GobackendSearchSpotifyAll(query, Int(trackLimit), Int(artistLimit), &error)
if let error = error { throw error }
return response
case "getSpotifyRelatedArtists":
let args = call.arguments as! [String: Any]
let artistId = args["artist_id"] as! String
let limit = args["limit"] as? Int ?? 12
let response = GobackendGetSpotifyRelatedArtists(artistId, Int(limit), &error)
if let error = error { throw error }
return response
case "checkAvailability":
let args = call.arguments as! [String: Any]
let spotifyId = args["spotify_id"] as! String
@@ -492,13 +463,6 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "getAmazonURLFromDeezerTrack":
let args = call.arguments as! [String: Any]
let deezerTrackId = args["deezer_track_id"] as! String
let response = GobackendGetAmazonURLFromDeezerTrack(deezerTrackId, &error)
if let error = error { throw error }
return response
case "preWarmTrackCache":
let args = call.arguments as! [String: Any]
let tracksJson = args["tracks"] as! String
@@ -514,17 +478,6 @@ import Gobackend // Import Go framework
GobackendClearTrackCache()
return nil
case "setSpotifyCredentials":
let args = call.arguments as! [String: Any]
let clientId = args["client_id"] as! String
let clientSecret = args["client_secret"] as! String
GobackendSetSpotifyAPICredentials(clientId, clientSecret)
return nil
case "hasSpotifyCredentials":
let hasCredentials = GobackendCheckSpotifyCredentials()
return hasCredentials
// Log methods
case "getLogs":
let response = GobackendGetLogs()
@@ -922,6 +875,26 @@ import Gobackend // Import Go framework
let response = GobackendReadAudioMetadataJSON(filePath, &error)
if let error = error { throw error }
return response
// iOS Security-Scoped Bookmark for Local Library
case "resolveIosBookmark":
let args = call.arguments as! [String: Any]
let bookmarkBase64 = args["bookmark"] as! String
return try resolveIosBookmark(bookmarkBase64)
case "startAccessingIosBookmark":
let args = call.arguments as! [String: Any]
let bookmarkBase64 = args["bookmark"] as! String
return try startAccessingIosBookmark(bookmarkBase64)
case "stopAccessingIosBookmark":
stopAccessingIosBookmark()
return nil
case "createIosBookmarkFromPath":
let args = call.arguments as! [String: Any]
let path = args["path"] as! String
return try createIosBookmarkFromPath(path)
// Lyrics Provider Settings
case "setLyricsProviders":
@@ -953,6 +926,15 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
// CUE Sheet Parsing
case "parseCueSheet":
let args = call.arguments as! [String: Any]
let cuePath = args["cue_path"] as! String
let audioDir = args["audio_dir"] as? String ?? ""
let response = GobackendParseCueSheet(cuePath, audioDir, &error)
if let error = error { throw error }
return response
default:
throw NSError(
domain: "SpotiFLAC",
@@ -961,6 +943,112 @@ import Gobackend // Import Go framework
)
}
}
// MARK: - iOS Security-Scoped Bookmark Helpers
/// Create a security-scoped bookmark from a filesystem path (e.g. from FilePicker).
/// The path must currently be accessible (within the same picker session).
/// Returns base64-encoded bookmark data.
private func createIosBookmarkFromPath(_ path: String) throws -> String {
let url = URL(fileURLWithPath: path)
do {
let bookmarkData = try url.bookmarkData(
options: .minimalBookmark,
includingResourceValuesForKeys: nil,
relativeTo: nil
)
return bookmarkData.base64EncodedString()
} catch {
throw NSError(
domain: "SpotiFLAC",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Failed to create bookmark for path \(path): \(error.localizedDescription)"]
)
}
}
/// Resolve a base64-encoded security-scoped bookmark and return the resolved path.
/// Does NOT start accessing the resource.
private func resolveIosBookmark(_ bookmarkBase64: String) throws -> String {
guard let bookmarkData = Data(base64Encoded: bookmarkBase64) else {
throw NSError(
domain: "SpotiFLAC",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Invalid base64 bookmark data"]
)
}
var isStale = false
let url: URL
do {
url = try URL(
resolvingBookmarkData: bookmarkData,
options: [],
relativeTo: nil,
bookmarkDataIsStale: &isStale
)
} catch {
throw NSError(
domain: "SpotiFLAC",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Failed to resolve bookmark: \(error.localizedDescription)"]
)
}
return url.path
}
/// Resolve a base64-encoded bookmark, start accessing the security-scoped resource,
/// and return the resolved filesystem path. The resource stays accessed until
/// `stopAccessingIosBookmark()` is called.
private func startAccessingIosBookmark(_ bookmarkBase64: String) throws -> String {
// Stop any previously accessed resource first
stopAccessingIosBookmark()
guard let bookmarkData = Data(base64Encoded: bookmarkBase64) else {
throw NSError(
domain: "SpotiFLAC",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Invalid base64 bookmark data"]
)
}
var isStale = false
let url: URL
do {
url = try URL(
resolvingBookmarkData: bookmarkData,
options: [],
relativeTo: nil,
bookmarkDataIsStale: &isStale
)
} catch {
throw NSError(
domain: "SpotiFLAC",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Failed to resolve bookmark: \(error.localizedDescription)"]
)
}
guard url.startAccessingSecurityScopedResource() else {
throw NSError(
domain: "SpotiFLAC",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Failed to start accessing security-scoped resource at \(url.path)"]
)
}
activeSecurityScopedURL = url
return url.path
}
/// Stop accessing the currently active security-scoped resource, if any.
private func stopAccessingIosBookmark() {
if let url = activeSecurityScopedURL {
url.stopAccessingSecurityScopedResource()
activeSecurityScopedURL = nil
}
}
}
private final class ClosureStreamHandler: NSObject, FlutterStreamHandler {
-1
View File
@@ -17,7 +17,6 @@ final _routerProvider = Provider<GoRouter>((ref) {
settingsProvider.select((s) => s.hasCompletedTutorial),
);
// Determine initial location based on app state
String initialLocation;
if (isFirstLaunch) {
initialLocation = '/setup';
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '3.7.1';
static const String buildNumber = '104';
static const String version = '3.7.2';
static const String buildNumber = '105';
static const String fullVersion = '$version+$buildNumber';
+105 -3
View File
@@ -763,7 +763,7 @@ abstract class AppLocalizations {
/// App description in header card
///
/// In en, this message translates to:
/// **'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'**
/// **'Download Spotify tracks in lossless quality from Tidal and Qobuz.'**
String get aboutAppDescription;
/// Section header for artist albums
@@ -1306,6 +1306,24 @@ abstract class AppLocalizations {
/// **'No tracks found'**
String get errorNoTracksFound;
/// Error title - URL not handled by any extension or service
///
/// In en, this message translates to:
/// **'Link not recognized'**
String get errorUrlNotRecognized;
/// Error message - URL not recognized explanation
///
/// In en, this message translates to:
/// **'This link is not supported. Make sure the URL is correct and a compatible extension is installed.'**
String get errorUrlNotRecognizedMessage;
/// Error message - generic URL fetch failure
///
/// In en, this message translates to:
/// **'Failed to load content from this link. Please try again.'**
String get errorUrlFetchFailed;
/// Error - extension source not available
///
/// In en, this message translates to:
@@ -1438,6 +1456,18 @@ abstract class AppLocalizations {
/// **'No organization'**
String get folderOrganizationNone;
/// Folder option - playlist folders
///
/// In en, this message translates to:
/// **'By Playlist'**
String get folderOrganizationByPlaylist;
/// Subtitle for playlist folder option
///
/// In en, this message translates to:
/// **'Separate folder for each playlist'**
String get folderOrganizationByPlaylistSubtitle;
/// Folder option - artist folders
///
/// In en, this message translates to:
@@ -1576,7 +1606,7 @@ abstract class AppLocalizations {
/// **'If a track is not available on the first provider, the app will automatically try the next one.'**
String get providerPriorityInfo;
/// Label for built-in providers (Tidal/Qobuz/Amazon)
/// Label for built-in providers (Tidal/Qobuz)
///
/// In en, this message translates to:
/// **'Built-in'**
@@ -3271,7 +3301,7 @@ abstract class AppLocalizations {
/// Tutorial welcome tip 2
///
/// In en, this message translates to:
/// **'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'**
/// **'Get FLAC quality audio from Tidal, Qobuz, or Deezer'**
String get tutorialWelcomeTip2;
/// Tutorial welcome tip 3
@@ -3794,6 +3824,78 @@ abstract class AppLocalizations {
/// **'Conversion failed'**
String get trackConvertFailed;
/// Title for CUE split bottom sheet
///
/// In en, this message translates to:
/// **'Split CUE Sheet'**
String get cueSplitTitle;
/// Subtitle for CUE split menu item
///
/// In en, this message translates to:
/// **'Split CUE+FLAC into individual tracks'**
String get cueSplitSubtitle;
/// Album name in CUE split sheet
///
/// In en, this message translates to:
/// **'Album: {album}'**
String cueSplitAlbum(String album);
/// Artist name in CUE split sheet
///
/// In en, this message translates to:
/// **'Artist: {artist}'**
String cueSplitArtist(String artist);
/// Number of tracks in CUE sheet
///
/// In en, this message translates to:
/// **'{count} tracks'**
String cueSplitTrackCount(int count);
/// CUE split confirmation dialog title
///
/// In en, this message translates to:
/// **'Split CUE Album'**
String get cueSplitConfirmTitle;
/// CUE split confirmation dialog message
///
/// In en, this message translates to:
/// **'Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.'**
String cueSplitConfirmMessage(String album, int count);
/// Snackbar while splitting CUE
///
/// In en, this message translates to:
/// **'Splitting CUE sheet... ({current}/{total})'**
String cueSplitSplitting(int current, int total);
/// Snackbar after successful CUE split
///
/// In en, this message translates to:
/// **'Split into {count} tracks successfully'**
String cueSplitSuccess(int count);
/// Snackbar when CUE split fails
///
/// In en, this message translates to:
/// **'CUE split failed'**
String get cueSplitFailed;
/// Error when CUE audio file is missing
///
/// In en, this message translates to:
/// **'Audio file not found for this CUE sheet'**
String get cueSplitNoAudioFile;
/// Button text to start CUE splitting
///
/// In en, this message translates to:
/// **'Split into Tracks'**
String get cueSplitButton;
/// Generic action button - create
///
/// In en, this message translates to:
File diff suppressed because it is too large Load Diff
+68 -2
View File
@@ -356,7 +356,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
@override
String get artistAlbums => 'Albums';
@@ -688,6 +688,17 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@override
String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
@override
String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.';
@override
String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source';
@@ -761,6 +772,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get folderOrganizationNone => 'No organization';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
@override
String get folderOrganizationByArtist => 'By Artist';
@@ -1809,7 +1827,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
@override
String get tutorialWelcomeTip3 =>
@@ -2126,6 +2144,54 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get cueSplitTitle => 'Split CUE Sheet';
@override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
String cueSplitAlbum(String album) {
return 'Album: $album';
}
@override
String cueSplitArtist(String artist) {
return 'Artist: $artist';
}
@override
String cueSplitTrackCount(int count) {
return '$count tracks';
}
@override
String get cueSplitConfirmTitle => 'Split CUE Album';
@override
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
}
@override
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
}
@override
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
}
@override
String get cueSplitFailed => 'CUE split failed';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
@override
String get cueSplitButton => 'Split into Tracks';
@override
String get actionCreate => 'Create';
+70 -4
View File
@@ -356,7 +356,7 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
@override
String get artistAlbums => 'Albums';
@@ -688,6 +688,17 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@override
String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
@override
String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.';
@override
String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source';
@@ -761,6 +772,13 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get folderOrganizationNone => 'No organization';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
@override
String get folderOrganizationByArtist => 'By Artist';
@@ -1809,7 +1827,7 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
@override
String get tutorialWelcomeTip3 =>
@@ -2126,6 +2144,54 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get cueSplitTitle => 'Split CUE Sheet';
@override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
String cueSplitAlbum(String album) {
return 'Album: $album';
}
@override
String cueSplitArtist(String artist) {
return 'Artist: $artist';
}
@override
String cueSplitTrackCount(int count) {
return '$count tracks';
}
@override
String get cueSplitConfirmTitle => 'Split CUE Album';
@override
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
}
@override
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
}
@override
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
}
@override
String get cueSplitFailed => 'CUE split failed';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
@override
String get cueSplitButton => 'Split into Tracks';
@override
String get actionCreate => 'Create';
@@ -2705,7 +2771,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
@override
String get aboutAppDescription =>
'Descarga pistas de Spotify con calidad sin pérdida de Tidal, Qobuz y Amazon Music.';
'Descarga pistas de Spotify con calidad sin pérdida de Tidal y Qobuz.';
@override
String get artistAlbums => 'Álbumes';
@@ -4150,7 +4216,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
'Obtén audio en calidad FLAC de Tidal, Qobuz o Deezer';
@override
String get tutorialWelcomeTip3 =>
+66
View File
@@ -690,6 +690,17 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@override
String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
@override
String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.';
@override
String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source';
@@ -763,6 +774,13 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get folderOrganizationNone => 'No organization';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
@override
String get folderOrganizationByArtist => 'By Artist';
@@ -2128,6 +2146,54 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get cueSplitTitle => 'Split CUE Sheet';
@override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
String cueSplitAlbum(String album) {
return 'Album: $album';
}
@override
String cueSplitArtist(String artist) {
return 'Artist: $artist';
}
@override
String cueSplitTrackCount(int count) {
return '$count tracks';
}
@override
String get cueSplitConfirmTitle => 'Split CUE Album';
@override
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
}
@override
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
}
@override
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
}
@override
String get cueSplitFailed => 'CUE split failed';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
@override
String get cueSplitButton => 'Split into Tracks';
@override
String get actionCreate => 'Create';
+66
View File
@@ -688,6 +688,17 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@override
String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
@override
String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.';
@override
String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source';
@@ -761,6 +772,13 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get folderOrganizationNone => 'No organization';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
@override
String get folderOrganizationByArtist => 'By Artist';
@@ -2126,6 +2144,54 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get cueSplitTitle => 'Split CUE Sheet';
@override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
String cueSplitAlbum(String album) {
return 'Album: $album';
}
@override
String cueSplitArtist(String artist) {
return 'Artist: $artist';
}
@override
String cueSplitTrackCount(int count) {
return '$count tracks';
}
@override
String get cueSplitConfirmTitle => 'Split CUE Album';
@override
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
}
@override
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
}
@override
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
}
@override
String get cueSplitFailed => 'CUE split failed';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
@override
String get cueSplitButton => 'Split into Tracks';
@override
String get actionCreate => 'Create';
+141 -76
View File
@@ -15,7 +15,7 @@ class AppLocalizationsId extends AppLocalizations {
String get navHome => 'Beranda';
@override
String get navLibrary => 'Library';
String get navLibrary => 'Pustaka';
@override
String get navSettings => 'Pengaturan';
@@ -45,7 +45,7 @@ class AppLocalizationsId extends AppLocalizations {
String get historyFilterSingles => 'Single';
@override
String get historySearchHint => 'Search history...';
String get historySearchHint => 'Cari riwayat...';
@override
String get settingsTitle => 'Pengaturan';
@@ -104,7 +104,7 @@ class AppLocalizationsId extends AppLocalizations {
String get appearanceHistoryViewList => 'Daftar';
@override
String get appearanceHistoryViewGrid => 'Grid';
String get appearanceHistoryViewGrid => 'Kisi';
@override
String get optionsTitle => 'Opsi';
@@ -126,7 +126,7 @@ class AppLocalizationsId extends AppLocalizations {
'Ketuk Deezer atau Spotify untuk beralih dari ekstensi';
@override
String get optionsAutoFallback => 'Auto Fallback';
String get optionsAutoFallback => 'Cadangan Otomatis';
@override
String get optionsAutoFallbackSubtitle =>
@@ -217,7 +217,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String optionsSpotifyCredentialsConfigured(String clientId) {
return 'Client ID: $clientId...';
return 'ID Klien: $clientId...';
}
@override
@@ -230,7 +230,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
'Pencarian Spotify akan dihentikan pada 3 Maret 2026 karena perubahan API Spotify. Silakan beralih ke Deezer.';
@override
String get extensionsTitle => 'Ekstensi';
@@ -283,7 +283,7 @@ class AppLocalizationsId extends AppLocalizations {
'Seniman berbakat yang membuat logo aplikasi kita yang indah!';
@override
String get aboutTranslators => 'Translators';
String get aboutTranslators => 'Penerjemah';
@override
String get aboutSpecialThanks => 'Terima Kasih Khusus';
@@ -311,19 +311,19 @@ class AppLocalizationsId extends AppLocalizations {
'Sarankan fitur baru untuk aplikasi';
@override
String get aboutTelegramChannel => 'Telegram Channel';
String get aboutTelegramChannel => 'Saluran Telegram';
@override
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
String get aboutTelegramChannelSubtitle => 'Pengumuman dan pembaruan';
@override
String get aboutTelegramChat => 'Telegram Community';
String get aboutTelegramChat => 'Komunitas Telegram';
@override
String get aboutTelegramChatSubtitle => 'Chat with other users';
String get aboutTelegramChatSubtitle => 'Berbincang dengan pengguna lain';
@override
String get aboutSocial => 'Social';
String get aboutSocial => 'Sosial';
@override
String get aboutApp => 'Aplikasi';
@@ -341,7 +341,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get aboutSjdonadoDesc =>
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
'Pencipta I Don\'t Have Spotify (IDHS). Penyelesai tautan cadangan yang menyelamatkan keadaan!';
@override
String get aboutDabMusic => 'DAB Music';
@@ -355,7 +355,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
'Tidal perangkat streaming FLAC resolusi tinggi. Bagian penting dari teka-teki tanpa kehilangan kualitas!';
@override
String get aboutAppDescription =>
@@ -456,7 +456,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get setupIcloudNotSupported =>
'iCloud Drive is not supported. Please use the app Documents folder.';
'iCloud Drive tidak didukung. Silakan gunakan folder Dokumen di aplikasi.';
@override
String get setupDownloadInFlac => 'Unduh lagu Spotify dalam format FLAC';
@@ -593,7 +593,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
return '$count trek dari CSV';
}
@override
@@ -613,7 +613,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String snackbarAlreadyInLibrary(String trackName) {
return '\"$trackName\" already exists in your library';
return '\"$trackName\" sudah ada di perpustakaan Anda';
}
@override
@@ -691,6 +691,17 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get errorNoTracksFound => 'Tidak ada lagu ditemukan';
@override
String get errorUrlNotRecognized => 'Link tidak dikenali';
@override
String get errorUrlNotRecognizedMessage =>
'Link ini tidak didukung. Pastikan URL benar dan ekstensi yang kompatibel sudah terpasang.';
@override
String get errorUrlFetchFailed =>
'Gagal memuat konten dari link ini. Silakan coba lagi.';
@override
String errorMissingExtensionSource(String item) {
return 'Tidak dapat memuat $item: sumber ekstensi tidak ada';
@@ -755,15 +766,22 @@ class AppLocalizationsId extends AppLocalizations {
String get filenameFormat => 'Format Nama File';
@override
String get filenameShowAdvancedTags => 'Tampilkan tag lanjutan';
String get filenameShowAdvancedTags => 'Show advanced tags';
@override
String get filenameShowAdvancedTagsDescription =>
'Aktifkan tag format untuk padding nomor lagu dan pola tanggal';
'Enable formatted tags for track padding and date patterns';
@override
String get folderOrganizationNone => 'Tidak ada';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
@override
String get folderOrganizationByArtist => 'Berdasarkan Artis';
@@ -1343,10 +1361,10 @@ class AppLocalizationsId extends AppLocalizations {
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'Bitrate Opus YouTube';
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'Bitrate MP3 YouTube';
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
@@ -1684,8 +1702,8 @@ class AppLocalizationsId extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'trek',
one: 'trek',
other: 'tracks',
one: 'track',
);
return '$_temp0';
}
@@ -2134,10 +2152,58 @@ class AppLocalizationsId extends AppLocalizations {
String get trackConvertFailed => 'Conversion failed';
@override
String get actionCreate => 'Buat';
String get cueSplitTitle => 'Split CUE Sheet';
@override
String get collectionFoldersTitle => 'Folder saya';
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
String cueSplitAlbum(String album) {
return 'Album: $album';
}
@override
String cueSplitArtist(String artist) {
return 'Artist: $artist';
}
@override
String cueSplitTrackCount(int count) {
return '$count tracks';
}
@override
String get cueSplitConfirmTitle => 'Split CUE Album';
@override
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
}
@override
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
}
@override
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
}
@override
String get cueSplitFailed => 'CUE split failed';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
@override
String get cueSplitButton => 'Split into Tracks';
@override
String get actionCreate => 'Create';
@override
String get collectionFoldersTitle => 'My folders';
@override
String get collectionWishlist => 'Wishlist';
@@ -2146,172 +2212,171 @@ class AppLocalizationsId extends AppLocalizations {
String get collectionLoved => 'Loved';
@override
String get collectionPlaylists => 'Playlist';
String get collectionPlaylists => 'Playlists';
@override
String get collectionPlaylist => 'Playlist';
@override
String get collectionAddToPlaylist => 'Tambahkan ke playlist';
String get collectionAddToPlaylist => 'Add to playlist';
@override
String get collectionCreatePlaylist => 'Buat playlist';
String get collectionCreatePlaylist => 'Create playlist';
@override
String get collectionNoPlaylistsYet => 'Belum ada playlist';
String get collectionNoPlaylistsYet => 'No playlists yet';
@override
String get collectionNoPlaylistsSubtitle =>
'Buat playlist untuk mulai mengategorikan lagu';
'Create a playlist to start categorizing tracks';
@override
String collectionPlaylistTracks(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count lagu',
one: '1 lagu',
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String collectionAddedToPlaylist(String playlistName) {
return 'Ditambahkan ke \"$playlistName\"';
return 'Added to \"$playlistName\"';
}
@override
String collectionAlreadyInPlaylist(String playlistName) {
return 'Sudah ada di \"$playlistName\"';
return 'Already in \"$playlistName\"';
}
@override
String get collectionPlaylistCreated => 'Playlist berhasil dibuat';
String get collectionPlaylistCreated => 'Playlist created';
@override
String get collectionPlaylistNameHint => 'Nama playlist';
String get collectionPlaylistNameHint => 'Playlist name';
@override
String get collectionPlaylistNameRequired => 'Nama playlist wajib diisi';
String get collectionPlaylistNameRequired => 'Playlist name is required';
@override
String get collectionRenamePlaylist => 'Ubah nama playlist';
String get collectionRenamePlaylist => 'Rename playlist';
@override
String get collectionDeletePlaylist => 'Hapus playlist';
String get collectionDeletePlaylist => 'Delete playlist';
@override
String collectionDeletePlaylistMessage(String playlistName) {
return 'Hapus \"$playlistName\" beserta semua lagunya?';
return 'Delete \"$playlistName\" and all tracks inside it?';
}
@override
String get collectionPlaylistDeleted => 'Playlist dihapus';
String get collectionPlaylistDeleted => 'Playlist deleted';
@override
String get collectionPlaylistRenamed => 'Nama playlist diperbarui';
String get collectionPlaylistRenamed => 'Playlist renamed';
@override
String get collectionWishlistEmptyTitle => 'Wishlist masih kosong';
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
@override
String get collectionWishlistEmptySubtitle =>
'Tap + di lagu untuk menyimpan yang ingin diunduh nanti';
'Tap + on tracks to save what you want to download later';
@override
String get collectionLovedEmptyTitle => 'Folder Loved masih kosong';
String get collectionLovedEmptyTitle => 'Loved folder is empty';
@override
String get collectionLovedEmptySubtitle =>
'Tap love di lagu untuk menyimpan favoritmu';
'Tap love on tracks to keep your favorites';
@override
String get collectionPlaylistEmptyTitle => 'Playlist masih kosong';
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
@override
String get collectionPlaylistEmptySubtitle =>
'Tekan lama tombol + pada lagu untuk menambahkannya ke sini';
'Long-press + on any track to add it here';
@override
String get collectionRemoveFromPlaylist => 'Hapus dari playlist';
String get collectionRemoveFromPlaylist => 'Remove from playlist';
@override
String get collectionRemoveFromFolder => 'Hapus dari folder';
String get collectionRemoveFromFolder => 'Remove from folder';
@override
String collectionRemoved(String trackName) {
return '\"$trackName\" dihapus';
return '\"$trackName\" removed';
}
@override
String collectionAddedToLoved(String trackName) {
return '\"$trackName\" ditambahkan ke Loved';
return '\"$trackName\" added to Loved';
}
@override
String collectionRemovedFromLoved(String trackName) {
return '\"$trackName\" dihapus dari Loved';
return '\"$trackName\" removed from Loved';
}
@override
String collectionAddedToWishlist(String trackName) {
return '\"$trackName\" ditambahkan ke Wishlist';
return '\"$trackName\" added to Wishlist';
}
@override
String collectionRemovedFromWishlist(String trackName) {
return '\"$trackName\" dihapus dari Wishlist';
return '\"$trackName\" removed from Wishlist';
}
@override
String get trackOptionAddToLoved => 'Tambahkan ke Loved';
String get trackOptionAddToLoved => 'Add to Loved';
@override
String get trackOptionRemoveFromLoved => 'Hapus dari Loved';
String get trackOptionRemoveFromLoved => 'Remove from Loved';
@override
String get trackOptionAddToWishlist => 'Tambahkan ke Wishlist';
String get trackOptionAddToWishlist => 'Add to Wishlist';
@override
String get trackOptionRemoveFromWishlist => 'Hapus dari Wishlist';
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
@override
String get collectionPlaylistChangeCover => 'Ubah gambar sampul';
String get collectionPlaylistChangeCover => 'Change cover image';
@override
String get collectionPlaylistRemoveCover => 'Hapus gambar sampul';
String get collectionPlaylistRemoveCover => 'Remove cover image';
@override
String selectionShareCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'trek',
one: 'trek',
other: 'tracks',
one: 'track',
);
return 'Bagikan $count $_temp0';
return 'Share $count $_temp0';
}
@override
String get selectionShareNoFiles => 'Tidak ada file yang dapat dibagikan';
String get selectionShareNoFiles => 'No shareable files found';
@override
String selectionConvertCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'trek',
one: 'trek',
other: 'tracks',
one: 'track',
);
return 'Konversi $count $_temp0';
return 'Convert $count $_temp0';
}
@override
String get selectionConvertNoConvertible =>
'Tidak ada trek yang dapat dikonversi dipilih';
String get selectionConvertNoConvertible => 'No convertible tracks selected';
@override
String get selectionBatchConvertConfirmTitle => 'Konversi Massal';
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
@override
String selectionBatchConvertConfirmMessage(
@@ -2322,20 +2387,20 @@ class AppLocalizationsId extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'trek',
one: 'trek',
other: 'tracks',
one: 'track',
);
return 'Konversi $count $_temp0 ke $format pada $bitrate?\n\nFile asli akan dihapus setelah konversi.';
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Mengonversi $current dari $total...';
return 'Converting $current of $total...';
}
@override
String selectionBatchConvertSuccess(int success, int total, String format) {
return 'Berhasil mengonversi $success dari $total trek ke $format';
return 'Converted $success of $total tracks to $format';
}
@override
+145 -79
View File
@@ -15,7 +15,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get navHome => 'ホーム';
@override
String get navLibrary => 'Library';
String get navLibrary => 'ライブラリ';
@override
String get navSettings => '設定';
@@ -160,7 +160,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String optionsConcurrentParallel(int count) {
return '$count parallel downloads';
return '$count 件の分割ダウンロード';
}
@override
@@ -683,6 +683,17 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get errorNoTracksFound => 'トラックがありません';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@override
String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
@override
String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.';
@override
String errorMissingExtensionSource(String item) {
return '$item を読み込めません: 拡張ソースがありません';
@@ -756,6 +767,13 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get folderOrganizationNone => '構成がありません';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
@override
String get folderOrganizationByArtist => 'アーティスト別';
@@ -1111,7 +1129,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get trackLyricsLoadFailed => '歌詞の読み込みに失敗しました';
@override
String get trackEmbedLyrics => 'Embed Lyrics';
String get trackEmbedLyrics => '歌詞を埋め込む';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@@ -1325,10 +1343,10 @@ class AppLocalizationsJa extends AppLocalizations {
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
String get youtubeOpusBitrateTitle => 'YouTube Opus のビットレート';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
String get youtubeMp3BitrateTitle => 'YouTube MP3 のビットレート';
@override
String get downloadAskBeforeDownload => 'ダウンロード前に確認する';
@@ -1375,20 +1393,20 @@ class AppLocalizationsJa extends AppLocalizations {
String get queueClearAllMessage => 'すべてのダウンロードを消去してもよろしいですか?';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
String get settingsAutoExportFailed => 'ダウンロードの自動エクスポートに失敗しました';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get settingsDownloadNetwork => 'Download Network';
String get settingsDownloadNetwork => 'ダウンロードネットワーク';
@override
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
String get settingsDownloadNetworkAny => 'Wi-Fi + モバイルデータ';
@override
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
String get settingsDownloadNetworkWifiOnly => 'Wi-Fi のみ';
@override
String get settingsDownloadNetworkSubtitle =>
@@ -1419,7 +1437,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get albumFolderYearAlbumSubtitle => 'アルバム/[2005] アルバム名/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
String get albumFolderArtistAlbumSingles => 'アーティスト / アルバム + シングル';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
@@ -1485,7 +1503,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
String get recentShowAllDownloads => 'すべてのダウンロードを表示';
@override
String recentPlaylistInfo(String name) {
@@ -1559,10 +1577,10 @@ class AppLocalizationsJa extends AppLocalizations {
String get discographyFailedToFetch => '一部のアルバムの取得に失敗しました';
@override
String get sectionStorageAccess => 'Storage Access';
String get sectionStorageAccess => 'ストレージアクセス';
@override
String get allFilesAccess => 'All Files Access';
String get allFilesAccess => 'すべてのファイルへのアクセス';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@@ -1583,35 +1601,35 @@ class AppLocalizationsJa extends AppLocalizations {
'All Files Access disabled. The app will use limited storage access.';
@override
String get settingsLocalLibrary => 'Local Library';
String get settingsLocalLibrary => 'ローカルライブラリ';
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
String get settingsCache => 'ストレージとキャッシュ';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
String get libraryTitle => 'ローカルライブラリ';
@override
String get libraryScanSettings => 'Scan Settings';
String get libraryScanSettings => 'スキャン設定';
@override
String get libraryEnableLocalLibrary => 'Enable Local Library';
String get libraryEnableLocalLibrary => 'ローカルライブラリを有効';
@override
String get libraryEnableLocalLibrarySubtitle =>
'Scan and track your existing music';
@override
String get libraryFolder => 'Library Folder';
String get libraryFolder => 'ライブラリのフォルダ';
@override
String get libraryFolderHint => 'Tap to select folder';
String get libraryFolderHint => 'タップでフォルダを選択';
@override
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
@@ -1621,13 +1639,13 @@ class AppLocalizationsJa extends AppLocalizations {
'Show when searching for existing tracks';
@override
String get libraryActions => 'Actions';
String get libraryActions => 'アクション';
@override
String get libraryScan => 'Scan Library';
String get libraryScan => 'ライブラリをスキャン';
@override
String get libraryScanSubtitle => 'Scan for audio files';
String get libraryScanSubtitle => 'オーディオファイルをスキャン';
@override
String get libraryScanSelectFolderFirst => 'Select a folder first';
@@ -1640,20 +1658,20 @@ class AppLocalizationsJa extends AppLocalizations {
'Remove entries for files that no longer exist';
@override
String get libraryClear => 'Clear Library';
String get libraryClear => 'ライブラリを消去';
@override
String get libraryClearSubtitle => 'Remove all scanned tracks';
@override
String get libraryClearConfirmTitle => 'Clear Library';
String get libraryClearConfirmTitle => 'ライブラリを消去';
@override
String get libraryClearConfirmMessage =>
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
@override
String get libraryAbout => 'About Local Library';
String get libraryAbout => 'ローカルライブラリについて';
@override
String get libraryAboutDescription =>
@@ -1672,14 +1690,14 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
return '最終スキャン: $time';
}
@override
String get libraryLastScannedNever => 'Never';
@override
String get libraryScanning => 'Scanning...';
String get libraryScanning => 'スキャン中...';
@override
String libraryScanProgress(String progress, int total) {
@@ -1687,7 +1705,7 @@ class AppLocalizationsJa extends AppLocalizations {
}
@override
String get libraryInLibrary => 'In Library';
String get libraryInLibrary => 'ライブラリ内';
@override
String libraryRemovedMissingFiles(int count) {
@@ -1698,7 +1716,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get libraryCleared => 'Library cleared';
@override
String get libraryStorageAccessRequired => 'Storage Access Required';
String get libraryStorageAccessRequired => 'ストレージアクセスが必要です';
@override
String get libraryStorageAccessMessage =>
@@ -1708,37 +1726,37 @@ class AppLocalizationsJa extends AppLocalizations {
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
String get librarySourceDownloaded => 'Downloaded';
String get librarySourceDownloaded => 'ダウンロード済み';
@override
String get librarySourceLocal => 'Local';
String get librarySourceLocal => 'ローカル';
@override
String get libraryFilterAll => 'All';
String get libraryFilterAll => 'すべて';
@override
String get libraryFilterDownloaded => 'Downloaded';
String get libraryFilterDownloaded => 'ダウンロード済み';
@override
String get libraryFilterLocal => 'Local';
String get libraryFilterLocal => 'ローカル';
@override
String get libraryFilterTitle => 'Filters';
String get libraryFilterTitle => 'フィルター';
@override
String get libraryFilterReset => 'Reset';
String get libraryFilterReset => 'リセット';
@override
String get libraryFilterApply => 'Apply';
String get libraryFilterApply => '適用';
@override
String get libraryFilterSource => 'Source';
String get libraryFilterSource => 'ソース';
@override
String get libraryFilterQuality => 'Quality';
String get libraryFilterQuality => '品質';
@override
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
String get libraryFilterQualityHiRes => 'ハイレゾ (24bit)';
@override
String get libraryFilterQualityCD => 'CD (16bit)';
@@ -1747,7 +1765,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get libraryFilterQualityLossy => 'Lossy';
@override
String get libraryFilterFormat => 'Format';
String get libraryFilterFormat => '形式';
@override
String get libraryFilterSort => 'Sort';
@@ -1766,8 +1784,8 @@ class AppLocalizationsJa extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes ago',
one: '1 minute ago',
other: '$count 分前',
one: '1 分前',
);
return '$_temp0';
}
@@ -1777,14 +1795,14 @@ class AppLocalizationsJa extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours ago',
one: '1 hour ago',
other: '$count 時間前',
one: '1 時間前',
);
return '$_temp0';
}
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
String get tutorialWelcomeTitle => 'SpotiFLAC へようこそ!';
@override
String get tutorialWelcomeDesc =>
@@ -1810,14 +1828,14 @@ class AppLocalizationsJa extends AppLocalizations {
'There are two easy ways to find music you want to download.';
@override
String get tutorialDownloadTitle => 'Downloading Music';
String get tutorialDownloadTitle => '音楽をダウンロード中';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialLibraryTitle => 'Your Library';
String get tutorialLibraryTitle => 'あなたのライブラリ';
@override
String get tutorialLibraryDesc =>
@@ -1836,7 +1854,7 @@ class AppLocalizationsJa extends AppLocalizations {
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
String get tutorialExtensionsTitle => '拡張';
@override
String get tutorialExtensionsDesc =>
@@ -1877,7 +1895,7 @@ class AppLocalizationsJa extends AppLocalizations {
'You\'re all set! Start downloading your favorite music now.';
@override
String get libraryForceFullScan => 'Force Full Scan';
String get libraryForceFullScan => '強制フルスキャン';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@@ -1898,10 +1916,10 @@ class AppLocalizationsJa extends AppLocalizations {
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
String get cacheTitle => 'ストレージとキャッシュ';
@override
String get cacheSummaryTitle => 'Cache overview';
String get cacheSummaryTitle => 'キャッシュの概要';
@override
String get cacheSummarySubtitle =>
@@ -1913,34 +1931,34 @@ class AppLocalizationsJa extends AppLocalizations {
}
@override
String get cacheSectionStorage => 'Cached Data';
String get cacheSectionStorage => 'キャッシュ済みデータ';
@override
String get cacheSectionMaintenance => 'Maintenance';
String get cacheSectionMaintenance => 'メンテナンス';
@override
String get cacheAppDirectory => 'App cache directory';
String get cacheAppDirectory => 'アプリキャッシュのディレクトリ';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
String get cacheTempDirectory => '一時ディレクトリ';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
String get cacheCoverImage => 'カバー画像のキャッシュ';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
String get cacheLibraryCover => 'ライブラリのカバーキャッシュ';
@override
String get cacheLibraryCoverDesc =>
@@ -1965,7 +1983,7 @@ class AppLocalizationsJa extends AppLocalizations {
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
String get cacheNoData => 'キャッシュデータはありません';
@override
String cacheSizeWithFiles(String size, int count) {
@@ -1979,16 +1997,16 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String cacheEntries(int count) {
return '$count entries';
return '$count 個のエントリ';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
return '消去済み: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
String get cacheClearConfirmTitle => 'キャッシュを消去しますか?';
@override
String cacheClearConfirmMessage(String target) {
@@ -1996,17 +2014,17 @@ class AppLocalizationsJa extends AppLocalizations {
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
String get cacheClearAllConfirmTitle => 'すべてのキャッシュを消去しますか?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
String get cacheClearAll => 'すべてのキャッシュを消去';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
String get cacheCleanupUnused => '未使用のデータを削除';
@override
String get cacheCleanupUnusedSubtitle =>
@@ -2018,16 +2036,16 @@ class AppLocalizationsJa extends AppLocalizations {
}
@override
String get cacheRefreshStats => 'Refresh stats';
String get cacheRefreshStats => '状態を更新';
@override
String get trackSaveCoverArt => 'Save Cover Art';
String get trackSaveCoverArt => 'カバー画像を保存';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
String get trackSaveLyrics => '歌詞を保存 (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@@ -2043,7 +2061,7 @@ class AppLocalizationsJa extends AppLocalizations {
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
String get trackEditMetadata => 'メタデータを編集';
@override
String trackCoverSaved(String fileName) {
@@ -2072,26 +2090,26 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
return '失敗: $error';
}
@override
String get trackConvertFormat => 'Convert Format';
String get trackConvertFormat => '変換の形式';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
String get trackConvertFormatSubtitle => 'MP3 または Opus に変換';
@override
String get trackConvertTitle => 'Convert Audio';
String get trackConvertTitle => 'オーディオを変換';
@override
String get trackConvertTargetFormat => 'Target Format';
String get trackConvertTargetFormat => 'ターゲットの形式';
@override
String get trackConvertBitrate => 'Bitrate';
String get trackConvertBitrate => 'ビットレート';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
String get trackConvertConfirmTitle => '変換を確認';
@override
String trackConvertConfirmMessage(
@@ -2103,7 +2121,7 @@ class AppLocalizationsJa extends AppLocalizations {
}
@override
String get trackConvertConverting => 'Converting audio...';
String get trackConvertConverting => 'オーディオを変換中...';
@override
String trackConvertSuccess(String format) {
@@ -2111,7 +2129,55 @@ class AppLocalizationsJa extends AppLocalizations {
}
@override
String get trackConvertFailed => 'Conversion failed';
String get trackConvertFailed => '変換に失敗しました';
@override
String get cueSplitTitle => 'Split CUE Sheet';
@override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
String cueSplitAlbum(String album) {
return 'Album: $album';
}
@override
String cueSplitArtist(String artist) {
return 'Artist: $artist';
}
@override
String cueSplitTrackCount(int count) {
return '$count tracks';
}
@override
String get cueSplitConfirmTitle => 'Split CUE Album';
@override
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
}
@override
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
}
@override
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
}
@override
String get cueSplitFailed => 'CUE split failed';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
@override
String get cueSplitButton => 'Split into Tracks';
@override
String get actionCreate => 'Create';
File diff suppressed because it is too large Load Diff
+66
View File
@@ -688,6 +688,17 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@override
String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
@override
String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.';
@override
String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source';
@@ -761,6 +772,13 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get folderOrganizationNone => 'No organization';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
@override
String get folderOrganizationByArtist => 'By Artist';
@@ -2126,6 +2144,54 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get cueSplitTitle => 'Split CUE Sheet';
@override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
String cueSplitAlbum(String album) {
return 'Album: $album';
}
@override
String cueSplitArtist(String artist) {
return 'Artist: $artist';
}
@override
String cueSplitTrackCount(int count) {
return '$count tracks';
}
@override
String get cueSplitConfirmTitle => 'Split CUE Album';
@override
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
}
@override
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
}
@override
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
}
@override
String get cueSplitFailed => 'CUE split failed';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
@override
String get cueSplitButton => 'Split into Tracks';
@override
String get actionCreate => 'Create';
+70 -4
View File
@@ -356,7 +356,7 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
@override
String get artistAlbums => 'Albums';
@@ -688,6 +688,17 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@override
String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
@override
String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.';
@override
String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source';
@@ -761,6 +772,13 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get folderOrganizationNone => 'No organization';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
@override
String get folderOrganizationByArtist => 'By Artist';
@@ -1809,7 +1827,7 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
@override
String get tutorialWelcomeTip3 =>
@@ -2126,6 +2144,54 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get cueSplitTitle => 'Split CUE Sheet';
@override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
String cueSplitAlbum(String album) {
return 'Album: $album';
}
@override
String cueSplitArtist(String artist) {
return 'Artist: $artist';
}
@override
String cueSplitTrackCount(int count) {
return '$count tracks';
}
@override
String get cueSplitConfirmTitle => 'Split CUE Album';
@override
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
}
@override
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
}
@override
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
}
@override
String get cueSplitFailed => 'CUE split failed';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
@override
String get cueSplitButton => 'Split into Tracks';
@override
String get actionCreate => 'Create';
@@ -2705,7 +2771,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
@override
String get aboutAppDescription =>
'Baixe faixas do Spotify em qualidade sem perdas do Tidal, Qobuz e Amazon Music.';
'Baixe faixas do Spotify em qualidade sem perdas do Tidal e Qobuz.';
@override
String get artistAlbums => 'Álbuns';
@@ -4147,7 +4213,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
'Obtenha áudio em qualidade FLAC do Tidal, Qobuz ou Deezer';
@override
String get tutorialWelcomeTip3 =>
+182 -107
View File
@@ -67,7 +67,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get settingsAbout => 'О программе';
@override
String get downloadTitle => 'Скачивание';
String get downloadTitle => 'Скачать';
@override
String get downloadAskQualitySubtitle =>
@@ -146,11 +146,11 @@ class AppLocalizationsRu extends AppLocalizations {
'Использование только встроенных провайдеров';
@override
String get optionsEmbedLyrics => 'Вставить текст песни';
String get optionsEmbedLyrics => 'Вписать текст песни';
@override
String get optionsEmbedLyricsSubtitle =>
'Вставить синхронизированные тексты в FLAC файлы';
'Вписать синхронизированные тексты во FLAC файлы';
@override
String get optionsMaxQualityCover => 'Максимальное качество обложки';
@@ -337,7 +337,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get aboutBinimumDesc =>
'Создатель QQDL & HiFi API. Без этого API загрузки Tidal не существовали бы!';
'Создатель QQDL & HiFi API. Без него API загрузки Tidal не существовали бы!';
@override
String get aboutSachinsenalDesc =>
@@ -601,7 +601,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String csvImportTracks(int count) {
return '$count треков из CSV';
return '$count трек(-ов) из CSV';
}
@override
@@ -702,6 +702,17 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get errorNoTracksFound => 'Треки не найдены';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@override
String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
@override
String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.';
@override
String errorMissingExtensionSource(String item) {
return 'Невозможно загрузить $item: отсутствует источник расширения';
@@ -766,15 +777,22 @@ class AppLocalizationsRu extends AppLocalizations {
String get filenameFormat => 'Формат имени файла';
@override
String get filenameShowAdvancedTags => 'Show advanced tags';
String get filenameShowAdvancedTags => 'Показать расширенные теги';
@override
String get filenameShowAdvancedTagsDescription =>
'Enable formatted tags for track padding and date patterns';
'Включить форматированные теги для отслеживания заполнения и шаблонов дат';
@override
String get folderOrganizationNone => 'Без организации';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
@override
String get folderOrganizationByArtist => 'По исполнителю';
@@ -983,7 +1001,7 @@ class AppLocalizationsRu extends AppLocalizations {
'Выберите как сохранить тексты песен при скачивании';
@override
String get lyricsModeEmbed => 'Вставить в файл';
String get lyricsModeEmbed => 'Вписать в файл';
@override
String get lyricsModeEmbedSubtitle => 'Встроить текст в метаданные FLAC';
@@ -999,7 +1017,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get lyricsModeBoth => 'Оба варианта';
@override
String get lyricsModeBothSubtitle => 'Вставить и сохранить файл .lrc';
String get lyricsModeBothSubtitle => 'Вписать и сохранить .lrc файл';
@override
String get sectionColor => 'Цвет';
@@ -1138,7 +1156,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get trackLyricsLoadFailed => 'Не удалось загрузить текст песни';
@override
String get trackEmbedLyrics => 'Вставить текст песни';
String get trackEmbedLyrics => 'Вписать текст песни';
@override
String get trackLyricsEmbedded => 'Текст успешно добавлен';
@@ -1361,10 +1379,10 @@ class AppLocalizationsRu extends AppLocalizations {
'YouTube обеспечивает только звук с потерями(Lossy).';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
String get youtubeOpusBitrateTitle => 'Битрейт YouTube Opus';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
String get youtubeMp3BitrateTitle => 'Битрейт YouTube MP3';
@override
String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием';
@@ -1383,7 +1401,8 @@ class AppLocalizationsRu extends AppLocalizations {
'Использовать исполнителя альбома для папок';
@override
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
String get downloadUsePrimaryArtistOnly =>
'Основной исполнитель только для папок';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
@@ -1391,7 +1410,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
'Полная строка исполнителя, используемая для имени папки';
@override
String get downloadSelectQuality => 'Выбор качества';
@@ -1423,7 +1442,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get settingsDownloadNetwork => 'Сеть для скачивания';
@override
String get settingsDownloadNetworkAny => 'WiFi и мобильная сеть';
String get settingsDownloadNetworkAny => 'WiFi и Мобильная сеть';
@override
String get settingsDownloadNetworkWifiOnly => 'Только WiFi';
@@ -1712,8 +1731,10 @@ class AppLocalizationsRu extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
other: 'треков',
many: 'треков',
few: 'трека',
one: 'трек',
);
return '$_temp0';
}
@@ -1800,7 +1821,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get libraryFilterQualityCD => 'CD (16 бит)';
@override
String get libraryFilterQualityLossy => 'С потерями';
String get libraryFilterQualityLossy => 'Lossy';
@override
String get libraryFilterFormat => 'Формат';
@@ -1904,7 +1925,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
'Просмотрите вкладку Магазина, чтобы найти полезные расширения';
@override
String get tutorialExtensionsTip2 =>
@@ -1912,14 +1933,14 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
'Получайте тексты песен, улучшенные метаданные и другие возможности';
@override
String get tutorialSettingsTitle => 'Настройте приложение под себя';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
'Персонализируйте приложение в Настройках, чтобы оно соответствовало вашим предпочтениям.';
@override
String get tutorialSettingsTip1 =>
@@ -1944,11 +1965,11 @@ class AppLocalizationsRu extends AppLocalizations {
'Пересканировать все файлы, игнорировать кэш';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
String get cleanupOrphanedDownloads => 'Очистка отложенных скачиваний';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
'Удалить историю записи для файлов, которых больше не существует';
@override
String cleanupOrphanedDownloadsResult(int count) {
@@ -1956,7 +1977,7 @@ class AppLocalizationsRu extends AppLocalizations {
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
String get cleanupOrphanedDownloadsNone => 'Записей без описания не найдено';
@override
String get cacheTitle => 'Хранилище и кэш';
@@ -1966,11 +1987,11 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
'Очистка кэша не приведет к удалению загруженных музыкальных файлов.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
return 'Приблизительное использование кэша: $size';
}
@override
@@ -1984,42 +2005,42 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
'HTTP-ответы, данные WebView и другие временные данные приложения.';
@override
String get cacheTempDirectory => 'Temporary directory';
String get cacheTempDirectory => 'Временная директория';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
'Временные файлы из загрузок и аудио конвертации.';
@override
String get cacheCoverImage => 'Cover image cache';
String get cacheCoverImage => 'Кэш обложек';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
'Скачанный альбом и трек обложки. Будет заново скачан после просмотра.';
@override
String get cacheLibraryCover => 'Library cover cache';
String get cacheLibraryCover => 'Кэш обложек библиотеки';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
'Обложка извлечена из локальных музыкальных файлов. Будет повторно извлечено при следующем сканировании.';
@override
String get cacheExploreFeed => 'Explore feed cache';
String get cacheExploreFeed => 'Просмотреть кэш ленты';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
'Изучите содержимое вкладки (новые релизы, тренды). Они обновятся при следующем посещении.';
@override
String get cacheTrackLookup => 'Track lookup cache';
String get cacheTrackLookup => 'Отслеживать кэш поиска';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
'Поиск ID трека в Spotify/Deezer. Очистка может замедлить следующие несколько поисков.';
@override
String get cacheCleanupUnusedDesc =>
@@ -2040,7 +2061,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String cacheEntries(int count) {
return '$count entries';
return '$count записей';
}
@override
@@ -2053,7 +2074,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
return 'Это очистит кэш для $target. Загруженные музыкальные файлы не будут удалены.';
}
@override
@@ -2075,7 +2096,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
return 'Очистка завершена: $downloadCount потерянных загрузок, $libraryCount отсутствующих записей в библиотеке';
}
@override
@@ -2095,14 +2116,14 @@ class AppLocalizationsRu extends AppLocalizations {
'Получить и сохранить текст песни в формате .lrc';
@override
String get trackSaveLyricsProgress => 'Saving lyrics...';
String get trackSaveLyricsProgress => 'Сохранение текста...';
@override
String get trackReEnrich => 'Re-enrich';
String get trackReEnrich => 'Обновить';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
'Поиск в сети метаданных и встраивание в файл';
@override
String get trackEditMetadata => 'Редактировать метаданные';
@@ -2121,13 +2142,13 @@ class AppLocalizationsRu extends AppLocalizations {
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
String get trackReEnrichProgress => 'Обновление метаданных...';
@override
String get trackReEnrichSearching => 'Поиск метаданных в сети...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
String get trackReEnrichSuccess => 'Метаданные успешно обновлены';
@override
String get trackReEnrichFfmpegFailed =>
@@ -2139,22 +2160,22 @@ class AppLocalizationsRu extends AppLocalizations {
}
@override
String get trackConvertFormat => 'Convert Format';
String get trackConvertFormat => 'Переконвертировать формат';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
String get trackConvertFormatSubtitle => 'Конвертировать в MP3 или Opus';
@override
String get trackConvertTitle => 'Convert Audio';
String get trackConvertTitle => 'Конвертировать аудио';
@override
String get trackConvertTargetFormat => 'Target Format';
String get trackConvertTargetFormat => 'Целевой формат';
@override
String get trackConvertBitrate => 'Bitrate';
String get trackConvertBitrate => 'Битрейт';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
String get trackConvertConfirmTitle => 'Подтвердить конвертацию';
@override
String trackConvertConfirmMessage(
@@ -2162,177 +2183,229 @@ class AppLocalizationsRu extends AppLocalizations {
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
return 'Конвертировать из $sourceFormat в $targetFormat $bitrate?\n\nОригинальный файл будет удален после конвертации.';
}
@override
String get trackConvertConverting => 'Converting audio...';
String get trackConvertConverting => 'Конвертация аудио...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
return 'Успешно конвертировано в $format';
}
@override
String get trackConvertFailed => 'Conversion failed';
String get trackConvertFailed => 'Ошибка конвертации';
@override
String get actionCreate => 'Create';
String get cueSplitTitle => 'Split CUE Sheet';
@override
String get collectionFoldersTitle => 'My folders';
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
String get collectionWishlist => 'Wishlist';
String cueSplitAlbum(String album) {
return 'Album: $album';
}
@override
String get collectionLoved => 'Loved';
String cueSplitArtist(String artist) {
return 'Artist: $artist';
}
@override
String get collectionPlaylists => 'Playlists';
String cueSplitTrackCount(int count) {
return '$count tracks';
}
@override
String get collectionPlaylist => 'Playlist';
String get cueSplitConfirmTitle => 'Split CUE Album';
@override
String get collectionAddToPlaylist => 'Add to playlist';
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
}
@override
String get collectionCreatePlaylist => 'Create playlist';
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
}
@override
String get collectionNoPlaylistsYet => 'No playlists yet';
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
}
@override
String get cueSplitFailed => 'CUE split failed';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
@override
String get cueSplitButton => 'Split into Tracks';
@override
String get actionCreate => 'Создать';
@override
String get collectionFoldersTitle => 'Мои папки';
@override
String get collectionWishlist => 'Список желаемого';
@override
String get collectionLoved => 'Любимые';
@override
String get collectionPlaylists => 'Плейлисты';
@override
String get collectionPlaylist => 'Плейлист';
@override
String get collectionAddToPlaylist => 'Добавить в плейлист';
@override
String get collectionCreatePlaylist => 'Создать плейлист';
@override
String get collectionNoPlaylistsYet => 'Плейлисты отсутствуют';
@override
String get collectionNoPlaylistsSubtitle =>
'Create a playlist to start categorizing tracks';
'Создайте плейлист, чтобы начать классифицировать треки';
@override
String collectionPlaylistTracks(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
other: '$count треков',
many: '$count треков',
few: '$count трека',
one: '$count трек',
);
return '$_temp0';
}
@override
String collectionAddedToPlaylist(String playlistName) {
return 'Added to \"$playlistName\"';
return 'Добавлено в \"$playlistName\"';
}
@override
String collectionAlreadyInPlaylist(String playlistName) {
return 'Already in \"$playlistName\"';
return 'Уже в \"$playlistName\"';
}
@override
String get collectionPlaylistCreated => 'Playlist created';
String get collectionPlaylistCreated => 'Плейлист создан';
@override
String get collectionPlaylistNameHint => 'Playlist name';
String get collectionPlaylistNameHint => 'Название плейлиста';
@override
String get collectionPlaylistNameRequired => 'Playlist name is required';
String get collectionPlaylistNameRequired => 'Имя плейлиста обязательно';
@override
String get collectionRenamePlaylist => 'Rename playlist';
String get collectionRenamePlaylist => 'Переименовать плейлист';
@override
String get collectionDeletePlaylist => 'Delete playlist';
String get collectionDeletePlaylist => 'Удалить плейлист';
@override
String collectionDeletePlaylistMessage(String playlistName) {
return 'Delete \"$playlistName\" and all tracks inside it?';
return 'Удалить \"$playlistName\" и все треки внутри него?';
}
@override
String get collectionPlaylistDeleted => 'Playlist deleted';
String get collectionPlaylistDeleted => 'Плейлист удалён';
@override
String get collectionPlaylistRenamed => 'Playlist renamed';
String get collectionPlaylistRenamed => 'Плейлист переименован';
@override
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
String get collectionWishlistEmptyTitle => 'Список желаний пуст';
@override
String get collectionWishlistEmptySubtitle =>
'Tap + on tracks to save what you want to download later';
'Нажмите + на треках, чтобы сохранить то, что вы хотите скачать позже';
@override
String get collectionLovedEmptyTitle => 'Loved folder is empty';
String get collectionLovedEmptyTitle => 'Папка Любимые пуста';
@override
String get collectionLovedEmptySubtitle =>
'Tap love on tracks to keep your favorites';
'Нажмите \"любовь\" на треках, чтобы сохранить ваши избранные';
@override
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
String get collectionPlaylistEmptyTitle => 'Плейлист пуст';
@override
String get collectionPlaylistEmptySubtitle =>
'Long-press + on any track to add it here';
'Удерживайте + на любом треке, чтобы добавить его сюда';
@override
String get collectionRemoveFromPlaylist => 'Remove from playlist';
String get collectionRemoveFromPlaylist => 'Удалить из плейлиста';
@override
String get collectionRemoveFromFolder => 'Remove from folder';
String get collectionRemoveFromFolder => 'Убрать из папки';
@override
String collectionRemoved(String trackName) {
return '\"$trackName\" removed';
return '\"$trackName\" удалён';
}
@override
String collectionAddedToLoved(String trackName) {
return '\"$trackName\" added to Loved';
return '\"$trackName\" добавлен в Любимые';
}
@override
String collectionRemovedFromLoved(String trackName) {
return '\"$trackName\" removed from Loved';
return '\"$trackName\" удалено из Любимых';
}
@override
String collectionAddedToWishlist(String trackName) {
return '\"$trackName\" added to Wishlist';
return '\"$trackName\" добавлен в список желаний';
}
@override
String collectionRemovedFromWishlist(String trackName) {
return '\"$trackName\" removed from Wishlist';
return '\"$trackName\" удалён из списка желаний';
}
@override
String get trackOptionAddToLoved => 'Add to Loved';
String get trackOptionAddToLoved => 'Добавить в Любимое';
@override
String get trackOptionRemoveFromLoved => 'Remove from Loved';
String get trackOptionRemoveFromLoved => 'Исключить из Любимых';
@override
String get trackOptionAddToWishlist => 'Add to Wishlist';
String get trackOptionAddToWishlist => 'Добавить в список желаний';
@override
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
String get trackOptionRemoveFromWishlist => 'Удалить из списка желаний';
@override
String get collectionPlaylistChangeCover => 'Change cover image';
String get collectionPlaylistChangeCover => 'Изменить обложку';
@override
String get collectionPlaylistRemoveCover => 'Remove cover image';
String get collectionPlaylistRemoveCover => 'Удалить обложку';
@override
String selectionShareCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
other: 'треков',
many: 'треков',
few: 'трека',
one: 'трек',
);
return 'Share $count $_temp0';
return 'Отправить $count $_temp0';
}
@override
@@ -2343,17 +2416,19 @@ class AppLocalizationsRu extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
other: 'треков',
many: 'треков',
few: 'трека',
one: 'трек',
);
return 'Convert $count $_temp0';
return 'Конвертировать $count $_temp0';
}
@override
String get selectionConvertNoConvertible => 'No convertible tracks selected';
String get selectionConvertNoConvertible => 'Не выбраны конвертируемые треки';
@override
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
String get selectionBatchConvertConfirmTitle => 'Пакетная конвертация';
@override
String selectionBatchConvertConfirmMessage(
@@ -2372,12 +2447,12 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
return 'Конвертация $current из $total...';
}
@override
String selectionBatchConvertSuccess(int success, int total, String format) {
return 'Converted $success of $total tracks to $format';
return 'Конвертировано $success треков $total в $format';
}
@override
@@ -2387,7 +2462,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
'Для папок исполнителей используется исполнитель альбома, если он указан';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
+68 -2
View File
@@ -361,7 +361,7 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get aboutAppDescription =>
'Spotify şarkılarını Tidal, Qobuz ve Amazon Music\'den yüksek kalitede indir.';
'Spotify şarkılarını Tidal ve Qobuz\'den yüksek kalitede indir.';
@override
String get artistAlbums => 'Albümler';
@@ -693,6 +693,17 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get errorNoTracksFound => 'Parça bulunamadı';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@override
String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
@override
String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.';
@override
String errorMissingExtensionSource(String item) {
return '$item yüklenemedi: Eksik eklenti kaynağı';
@@ -766,6 +777,13 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get folderOrganizationNone => 'Organizasyon yok';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
@override
String get folderOrganizationByArtist => 'Sanatçıya Göre';
@@ -1821,7 +1839,7 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
'Tidal, Qobuz veya Deezer\'den FLAC kalitesinde ses alın';
@override
String get tutorialWelcomeTip3 =>
@@ -2138,6 +2156,54 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get cueSplitTitle => 'Split CUE Sheet';
@override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
String cueSplitAlbum(String album) {
return 'Album: $album';
}
@override
String cueSplitArtist(String artist) {
return 'Artist: $artist';
}
@override
String cueSplitTrackCount(int count) {
return '$count tracks';
}
@override
String get cueSplitConfirmTitle => 'Split CUE Album';
@override
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
}
@override
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
}
@override
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
}
@override
String get cueSplitFailed => 'CUE split failed';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
@override
String get cueSplitButton => 'Split into Tracks';
@override
String get actionCreate => 'Create';
File diff suppressed because it is too large Load Diff
+583 -281
View File
File diff suppressed because it is too large Load Diff
+107 -3
View File
@@ -450,7 +450,7 @@
"@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 and Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -897,6 +897,18 @@
"@errorNoTracksFound": {
"description": "Error - search returned no results"
},
"errorUrlNotRecognized": "Link not recognized",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
},
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
"@errorUrlNotRecognizedMessage": {
"description": "Error message - URL not recognized explanation"
},
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
"@errorUrlFetchFailed": {
"description": "Error message - generic URL fetch failure"
},
"errorMissingExtensionSource": "Cannot load {item}: missing extension source",
"@errorMissingExtensionSource": {
"description": "Error - extension source not available",
@@ -1003,6 +1015,14 @@
"@folderOrganizationNone": {
"description": "Folder option - flat structure"
},
"folderOrganizationByPlaylist": "By Playlist",
"@folderOrganizationByPlaylist": {
"description": "Folder option - playlist folders"
},
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
"@folderOrganizationByPlaylistSubtitle": {
"description": "Subtitle for playlist folder option"
},
"folderOrganizationByArtist": "By Artist",
"@folderOrganizationByArtist": {
"description": "Folder option - artist folders"
@@ -1097,7 +1117,7 @@
},
"providerBuiltIn": "Built-in",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Extension",
"@providerExtension": {
@@ -2383,7 +2403,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},
@@ -2808,6 +2828,90 @@
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
},
"cueSplitTitle": "Split CUE Sheet",
"@cueSplitTitle": {
"description": "Title for CUE split bottom sheet"
},
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
"@cueSplitSubtitle": {
"description": "Subtitle for CUE split menu item"
},
"cueSplitAlbum": "Album: {album}",
"@cueSplitAlbum": {
"description": "Album name in CUE split sheet",
"placeholders": {
"album": {
"type": "String"
}
}
},
"cueSplitArtist": "Artist: {artist}",
"@cueSplitArtist": {
"description": "Artist name in CUE split sheet",
"placeholders": {
"artist": {
"type": "String"
}
}
},
"cueSplitTrackCount": "{count} tracks",
"@cueSplitTrackCount": {
"description": "Number of tracks in CUE sheet",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitConfirmTitle": "Split CUE Album",
"@cueSplitConfirmTitle": {
"description": "CUE split confirmation dialog title"
},
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
"@cueSplitConfirmMessage": {
"description": "CUE split confirmation dialog message",
"placeholders": {
"album": {
"type": "String"
},
"count": {
"type": "int"
}
}
},
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
"@cueSplitSplitting": {
"description": "Snackbar while splitting CUE",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"cueSplitSuccess": "Split into {count} tracks successfully",
"@cueSplitSuccess": {
"description": "Snackbar after successful CUE split",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitFailed": "CUE split failed",
"@cueSplitFailed": {
"description": "Snackbar when CUE split fails"
},
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
"@cueSplitNoAudioFile": {
"description": "Error when CUE audio file is missing"
},
"cueSplitButton": "Split into Tracks",
"@cueSplitButton": {
"description": "Button text to start CUE splitting"
},
"actionCreate": "Create",
"@actionCreate": {
"description": "Generic action button - create"
+2 -2
View File
@@ -402,7 +402,7 @@
"@aboutDabMusicDesc": {
"description": "Credit for DAB Music API"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -1005,7 +1005,7 @@
},
"providerBuiltIn": "Built-in",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Extension",
"@providerExtension": {
+3 -3
View File
@@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Descarga pistas de Spotify con calidad sin pérdida de Tidal, Qobuz y Amazon Music.",
"aboutAppDescription": "Descarga pistas de Spotify con calidad sin pérdida de Tidal y Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -1089,7 +1089,7 @@
},
"providerBuiltIn": "Integrado",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Extensión",
"@providerExtension": {
@@ -2358,7 +2358,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
"tutorialWelcomeTip2": "Obtén audio en calidad FLAC de Tidal, Qobuz o Deezer",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},
+303 -1
View File
@@ -991,6 +991,14 @@
"@filenameFormat": {
"description": "Setting title - filename pattern"
},
"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"
},
"folderOrganizationNone": "No organization",
"@folderOrganizationNone": {
"description": "Folder option - flat structure"
@@ -1749,6 +1757,14 @@
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
@@ -2198,6 +2214,15 @@
"@libraryAboutDescription": {
"description": "Description of local library feature"
},
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
"@libraryTracksUnit": {
"description": "Unit label for tracks count (without the number itself)",
"placeholders": {
"count": {
"type": "int"
}
}
},
"libraryLastScanned": "Last scanned: {time}",
"@libraryLastScanned": {
"description": "Last scan time display",
@@ -2783,6 +2808,283 @@
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
},
"actionCreate": "Create",
"@actionCreate": {
"description": "Generic action button - create"
},
"collectionFoldersTitle": "My folders",
"@collectionFoldersTitle": {
"description": "Library section title for custom folders"
},
"collectionWishlist": "Wishlist",
"@collectionWishlist": {
"description": "Custom folder for saved tracks to download later"
},
"collectionLoved": "Loved",
"@collectionLoved": {
"description": "Custom folder for favorite tracks"
},
"collectionPlaylists": "Playlists",
"@collectionPlaylists": {
"description": "Custom user playlists folder"
},
"collectionPlaylist": "Playlist",
"@collectionPlaylist": {
"description": "Single playlist label"
},
"collectionAddToPlaylist": "Add to playlist",
"@collectionAddToPlaylist": {
"description": "Action to add a track to user playlist"
},
"collectionCreatePlaylist": "Create playlist",
"@collectionCreatePlaylist": {
"description": "Action to create a new playlist"
},
"collectionNoPlaylistsYet": "No playlists yet",
"@collectionNoPlaylistsYet": {
"description": "Empty state title when user has no playlists"
},
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
"@collectionNoPlaylistsSubtitle": {
"description": "Empty state subtitle when user has no playlists"
},
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
"@collectionPlaylistTracks": {
"description": "Track count label for custom playlists",
"placeholders": {
"count": {
"type": "int"
}
}
},
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
"@collectionAddedToPlaylist": {
"description": "Snackbar after adding track to playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
"@collectionAlreadyInPlaylist": {
"description": "Snackbar when track already exists in playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistCreated": "Playlist created",
"@collectionPlaylistCreated": {
"description": "Snackbar after creating playlist"
},
"collectionPlaylistNameHint": "Playlist name",
"@collectionPlaylistNameHint": {
"description": "Hint text for playlist name input"
},
"collectionPlaylistNameRequired": "Playlist name is required",
"@collectionPlaylistNameRequired": {
"description": "Validation error for empty playlist name"
},
"collectionRenamePlaylist": "Rename playlist",
"@collectionRenamePlaylist": {
"description": "Action to rename playlist"
},
"collectionDeletePlaylist": "Delete playlist",
"@collectionDeletePlaylist": {
"description": "Action to delete playlist"
},
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
"@collectionDeletePlaylistMessage": {
"description": "Confirmation message for deleting playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistDeleted": "Playlist deleted",
"@collectionPlaylistDeleted": {
"description": "Snackbar after deleting playlist"
},
"collectionPlaylistRenamed": "Playlist renamed",
"@collectionPlaylistRenamed": {
"description": "Snackbar after renaming playlist"
},
"collectionWishlistEmptyTitle": "Wishlist is empty",
"@collectionWishlistEmptyTitle": {
"description": "Wishlist empty state title"
},
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
"@collectionWishlistEmptySubtitle": {
"description": "Wishlist empty state subtitle"
},
"collectionLovedEmptyTitle": "Loved folder is empty",
"@collectionLovedEmptyTitle": {
"description": "Loved empty state title"
},
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
"@collectionLovedEmptySubtitle": {
"description": "Loved empty state subtitle"
},
"collectionPlaylistEmptyTitle": "Playlist is empty",
"@collectionPlaylistEmptyTitle": {
"description": "Playlist empty state title"
},
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
"@collectionPlaylistEmptySubtitle": {
"description": "Playlist empty state subtitle"
},
"collectionRemoveFromPlaylist": "Remove from playlist",
"@collectionRemoveFromPlaylist": {
"description": "Tooltip for removing track from playlist"
},
"collectionRemoveFromFolder": "Remove from folder",
"@collectionRemoveFromFolder": {
"description": "Tooltip for removing track from wishlist/loved folder"
},
"collectionRemoved": "\"{trackName}\" removed",
"@collectionRemoved": {
"description": "Snackbar after removing a track from a collection",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
"@collectionAddedToLoved": {
"description": "Snackbar after adding track to loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
"@collectionRemovedFromLoved": {
"description": "Snackbar after removing track from loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
"@collectionAddedToWishlist": {
"description": "Snackbar after adding track to wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
"@collectionRemovedFromWishlist": {
"description": "Snackbar after removing track from wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"trackOptionAddToLoved": "Add to Loved",
"@trackOptionAddToLoved": {
"description": "Bottom sheet action label - add track to loved folder"
},
"trackOptionRemoveFromLoved": "Remove from Loved",
"@trackOptionRemoveFromLoved": {
"description": "Bottom sheet action label - remove track from loved folder"
},
"trackOptionAddToWishlist": "Add to Wishlist",
"@trackOptionAddToWishlist": {
"description": "Bottom sheet action label - add track to wishlist"
},
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
"@trackOptionRemoveFromWishlist": {
"description": "Bottom sheet action label - remove track from wishlist"
},
"collectionPlaylistChangeCover": "Change cover image",
"@collectionPlaylistChangeCover": {
"description": "Bottom sheet action to pick a custom cover image for a playlist"
},
"collectionPlaylistRemoveCover": "Remove cover image",
"@collectionPlaylistRemoveCover": {
"description": "Bottom sheet action to remove custom cover image from a playlist"
},
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
"@selectionShareCount": {
"description": "Share button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionShareNoFiles": "No shareable files found",
"@selectionShareNoFiles": {
"description": "Snackbar when no selected files exist on disk"
},
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
"@selectionConvertCount": {
"description": "Convert button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionConvertNoConvertible": "No convertible tracks selected",
"@selectionConvertNoConvertible": {
"description": "Snackbar when no selected tracks support conversion"
},
"selectionBatchConvertConfirmTitle": "Batch Convert",
"@selectionBatchConvertConfirmTitle": {
"description": "Confirmation dialog title for batch conversion"
},
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
"@selectionBatchConvertConfirmMessage": {
"description": "Confirmation dialog message for batch conversion",
"placeholders": {
"count": {
"type": "int"
},
"format": {
"type": "String"
},
"bitrate": {
"type": "String"
}
}
},
"selectionBatchConvertProgress": "Converting {current} of {total}...",
"@selectionBatchConvertProgress": {
"description": "Snackbar during batch conversion progress",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
"@selectionBatchConvertSuccess": {
"description": "Snackbar after batch conversion completes",
"placeholders": {
"success": {
"type": "int"
},
"total": {
"type": "int"
},
"format": {
"type": "String"
}
}
},
"downloadedAlbumDownloadedCount": "{count} downloaded",
"@downloadedAlbumDownloadedCount": {
"description": "Downloaded tracks count badge",
@@ -2800,4 +3102,4 @@
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming"
}
}
}
+303 -1
View File
@@ -991,6 +991,14 @@
"@filenameFormat": {
"description": "Setting title - filename pattern"
},
"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"
},
"folderOrganizationNone": "No organization",
"@folderOrganizationNone": {
"description": "Folder option - flat structure"
@@ -1749,6 +1757,14 @@
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
@@ -2198,6 +2214,15 @@
"@libraryAboutDescription": {
"description": "Description of local library feature"
},
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
"@libraryTracksUnit": {
"description": "Unit label for tracks count (without the number itself)",
"placeholders": {
"count": {
"type": "int"
}
}
},
"libraryLastScanned": "Last scanned: {time}",
"@libraryLastScanned": {
"description": "Last scan time display",
@@ -2783,6 +2808,283 @@
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
},
"actionCreate": "Create",
"@actionCreate": {
"description": "Generic action button - create"
},
"collectionFoldersTitle": "My folders",
"@collectionFoldersTitle": {
"description": "Library section title for custom folders"
},
"collectionWishlist": "Wishlist",
"@collectionWishlist": {
"description": "Custom folder for saved tracks to download later"
},
"collectionLoved": "Loved",
"@collectionLoved": {
"description": "Custom folder for favorite tracks"
},
"collectionPlaylists": "Playlists",
"@collectionPlaylists": {
"description": "Custom user playlists folder"
},
"collectionPlaylist": "Playlist",
"@collectionPlaylist": {
"description": "Single playlist label"
},
"collectionAddToPlaylist": "Add to playlist",
"@collectionAddToPlaylist": {
"description": "Action to add a track to user playlist"
},
"collectionCreatePlaylist": "Create playlist",
"@collectionCreatePlaylist": {
"description": "Action to create a new playlist"
},
"collectionNoPlaylistsYet": "No playlists yet",
"@collectionNoPlaylistsYet": {
"description": "Empty state title when user has no playlists"
},
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
"@collectionNoPlaylistsSubtitle": {
"description": "Empty state subtitle when user has no playlists"
},
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
"@collectionPlaylistTracks": {
"description": "Track count label for custom playlists",
"placeholders": {
"count": {
"type": "int"
}
}
},
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
"@collectionAddedToPlaylist": {
"description": "Snackbar after adding track to playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
"@collectionAlreadyInPlaylist": {
"description": "Snackbar when track already exists in playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistCreated": "Playlist created",
"@collectionPlaylistCreated": {
"description": "Snackbar after creating playlist"
},
"collectionPlaylistNameHint": "Playlist name",
"@collectionPlaylistNameHint": {
"description": "Hint text for playlist name input"
},
"collectionPlaylistNameRequired": "Playlist name is required",
"@collectionPlaylistNameRequired": {
"description": "Validation error for empty playlist name"
},
"collectionRenamePlaylist": "Rename playlist",
"@collectionRenamePlaylist": {
"description": "Action to rename playlist"
},
"collectionDeletePlaylist": "Delete playlist",
"@collectionDeletePlaylist": {
"description": "Action to delete playlist"
},
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
"@collectionDeletePlaylistMessage": {
"description": "Confirmation message for deleting playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistDeleted": "Playlist deleted",
"@collectionPlaylistDeleted": {
"description": "Snackbar after deleting playlist"
},
"collectionPlaylistRenamed": "Playlist renamed",
"@collectionPlaylistRenamed": {
"description": "Snackbar after renaming playlist"
},
"collectionWishlistEmptyTitle": "Wishlist is empty",
"@collectionWishlistEmptyTitle": {
"description": "Wishlist empty state title"
},
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
"@collectionWishlistEmptySubtitle": {
"description": "Wishlist empty state subtitle"
},
"collectionLovedEmptyTitle": "Loved folder is empty",
"@collectionLovedEmptyTitle": {
"description": "Loved empty state title"
},
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
"@collectionLovedEmptySubtitle": {
"description": "Loved empty state subtitle"
},
"collectionPlaylistEmptyTitle": "Playlist is empty",
"@collectionPlaylistEmptyTitle": {
"description": "Playlist empty state title"
},
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
"@collectionPlaylistEmptySubtitle": {
"description": "Playlist empty state subtitle"
},
"collectionRemoveFromPlaylist": "Remove from playlist",
"@collectionRemoveFromPlaylist": {
"description": "Tooltip for removing track from playlist"
},
"collectionRemoveFromFolder": "Remove from folder",
"@collectionRemoveFromFolder": {
"description": "Tooltip for removing track from wishlist/loved folder"
},
"collectionRemoved": "\"{trackName}\" removed",
"@collectionRemoved": {
"description": "Snackbar after removing a track from a collection",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
"@collectionAddedToLoved": {
"description": "Snackbar after adding track to loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
"@collectionRemovedFromLoved": {
"description": "Snackbar after removing track from loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
"@collectionAddedToWishlist": {
"description": "Snackbar after adding track to wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
"@collectionRemovedFromWishlist": {
"description": "Snackbar after removing track from wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"trackOptionAddToLoved": "Add to Loved",
"@trackOptionAddToLoved": {
"description": "Bottom sheet action label - add track to loved folder"
},
"trackOptionRemoveFromLoved": "Remove from Loved",
"@trackOptionRemoveFromLoved": {
"description": "Bottom sheet action label - remove track from loved folder"
},
"trackOptionAddToWishlist": "Add to Wishlist",
"@trackOptionAddToWishlist": {
"description": "Bottom sheet action label - add track to wishlist"
},
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
"@trackOptionRemoveFromWishlist": {
"description": "Bottom sheet action label - remove track from wishlist"
},
"collectionPlaylistChangeCover": "Change cover image",
"@collectionPlaylistChangeCover": {
"description": "Bottom sheet action to pick a custom cover image for a playlist"
},
"collectionPlaylistRemoveCover": "Remove cover image",
"@collectionPlaylistRemoveCover": {
"description": "Bottom sheet action to remove custom cover image from a playlist"
},
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
"@selectionShareCount": {
"description": "Share button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionShareNoFiles": "No shareable files found",
"@selectionShareNoFiles": {
"description": "Snackbar when no selected files exist on disk"
},
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
"@selectionConvertCount": {
"description": "Convert button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionConvertNoConvertible": "No convertible tracks selected",
"@selectionConvertNoConvertible": {
"description": "Snackbar when no selected tracks support conversion"
},
"selectionBatchConvertConfirmTitle": "Batch Convert",
"@selectionBatchConvertConfirmTitle": {
"description": "Confirmation dialog title for batch conversion"
},
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
"@selectionBatchConvertConfirmMessage": {
"description": "Confirmation dialog message for batch conversion",
"placeholders": {
"count": {
"type": "int"
},
"format": {
"type": "String"
},
"bitrate": {
"type": "String"
}
}
},
"selectionBatchConvertProgress": "Converting {current} of {total}...",
"@selectionBatchConvertProgress": {
"description": "Snackbar during batch conversion progress",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
"@selectionBatchConvertSuccess": {
"description": "Snackbar after batch conversion completes",
"placeholders": {
"success": {
"type": "int"
},
"total": {
"type": "int"
},
"format": {
"type": "String"
}
}
},
"downloadedAlbumDownloadedCount": "{count} downloaded",
"@downloadedAlbumDownloadedCount": {
"description": "Downloaded tracks count badge",
@@ -2800,4 +3102,4 @@
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming"
}
}
}
+80 -68
View File
@@ -9,7 +9,7 @@
"@navHome": {
"description": "Bottom navigation - Home tab"
},
"navLibrary": "Library",
"navLibrary": "Pustaka",
"@navLibrary": {
"description": "Bottom navigation - Library tab"
},
@@ -49,7 +49,7 @@
"@historyFilterSingles": {
"description": "Filter chip - show singles only"
},
"historySearchHint": "Search history...",
"historySearchHint": "Cari riwayat...",
"@historySearchHint": {
"description": "Search bar placeholder in history"
},
@@ -125,7 +125,7 @@
"@appearanceHistoryViewList": {
"description": "List layout option"
},
"appearanceHistoryViewGrid": "Grid",
"appearanceHistoryViewGrid": "Kisi",
"@appearanceHistoryViewGrid": {
"description": "Grid layout option"
},
@@ -154,7 +154,7 @@
"@optionsSwitchBack": {
"description": "Hint to switch back to built-in providers"
},
"optionsAutoFallback": "Auto Fallback",
"optionsAutoFallback": "Cadangan Otomatis",
"@optionsAutoFallback": {
"description": "Auto-retry with other services"
},
@@ -267,7 +267,7 @@
"@optionsSpotifyCredentials": {
"description": "Spotify API credentials setting"
},
"optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...",
"optionsSpotifyCredentialsConfigured": "ID Klien: {clientId}...",
"@optionsSpotifyCredentialsConfigured": {
"description": "Shows configured client ID preview",
"placeholders": {
@@ -284,7 +284,7 @@
"@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": "Pencarian Spotify akan dihentikan pada 3 Maret 2026 karena perubahan API Spotify. Silakan beralih ke Deezer.",
"@optionsSpotifyDeprecationWarning": {
"description": "Warning about Spotify API deprecation"
},
@@ -358,7 +358,7 @@
"@aboutLogoArtist": {
"description": "Role description for logo artist"
},
"aboutTranslators": "Translators",
"aboutTranslators": "Penerjemah",
"@aboutTranslators": {
"description": "Section for translators"
},
@@ -394,23 +394,23 @@
"@aboutFeatureRequestSubtitle": {
"description": "Subtitle for feature request"
},
"aboutTelegramChannel": "Telegram Channel",
"aboutTelegramChannel": "Saluran Telegram",
"@aboutTelegramChannel": {
"description": "Link to Telegram channel"
},
"aboutTelegramChannelSubtitle": "Announcements and updates",
"aboutTelegramChannelSubtitle": "Pengumuman dan pembaruan",
"@aboutTelegramChannelSubtitle": {
"description": "Subtitle for Telegram channel"
},
"aboutTelegramChat": "Telegram Community",
"aboutTelegramChat": "Komunitas Telegram",
"@aboutTelegramChat": {
"description": "Link to Telegram chat group"
},
"aboutTelegramChatSubtitle": "Chat with other users",
"aboutTelegramChatSubtitle": "Berbincang dengan pengguna lain",
"@aboutTelegramChatSubtitle": {
"description": "Subtitle for Telegram chat"
},
"aboutSocial": "Social",
"aboutSocial": "Sosial",
"@aboutSocial": {
"description": "Section for social links"
},
@@ -430,7 +430,7 @@
"@aboutSachinsenalDesc": {
"description": "Credit description for sachinsenal0x64"
},
"aboutSjdonadoDesc": "Creator of I Don't Have Spotify (IDHS). The fallback link resolver that saves the day!",
"aboutSjdonadoDesc": "Pencipta I Don't Have Spotify (IDHS). Penyelesai tautan cadangan yang menyelamatkan keadaan!",
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
@@ -446,7 +446,7 @@
"@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": "Tidal perangkat streaming FLAC resolusi tinggi. Bagian penting dari teka-teki tanpa kehilangan kualitas!",
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
@@ -579,7 +579,7 @@
"@setupIosEmptyFolderWarning": {
"description": "iOS folder selection warning"
},
"setupIcloudNotSupported": "iCloud Drive is not supported. Please use the app Documents folder.",
"setupIcloudNotSupported": "iCloud Drive tidak didukung. Silakan gunakan folder Dokumen di aplikasi.",
"@setupIcloudNotSupported": {
"description": "Error when user selects iCloud Drive on iOS"
},
@@ -742,7 +742,7 @@
"description": "Dialog title - import CSV playlist"
},
"dialogImportPlaylistMessage": "Ditemukan {count} lagu di CSV. Tambahkan ke antrian unduhan?",
"csvImportTracks": "{count} tracks from CSV",
"csvImportTracks": "{count} trek dari CSV",
"@csvImportTracks": {
"description": "Label shown in quality picker for CSV import",
"placeholders": {
@@ -786,7 +786,7 @@
}
}
},
"snackbarAlreadyInLibrary": "\"{trackName}\" already exists in your library",
"snackbarAlreadyInLibrary": "\"{trackName}\" sudah ada di perpustakaan Anda",
"@snackbarAlreadyInLibrary": {
"description": "Snackbar - track already exists in local library",
"placeholders": {
@@ -897,6 +897,18 @@
"@errorNoTracksFound": {
"description": "Error - search returned no results"
},
"errorUrlNotRecognized": "Link tidak dikenali",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
},
"errorUrlNotRecognizedMessage": "Link ini tidak didukung. Pastikan URL benar dan ekstensi yang kompatibel sudah terpasang.",
"@errorUrlNotRecognizedMessage": {
"description": "Error message - URL not recognized explanation"
},
"errorUrlFetchFailed": "Gagal memuat konten dari link ini. Silakan coba lagi.",
"@errorUrlFetchFailed": {
"description": "Error message - generic URL fetch failure"
},
"errorMissingExtensionSource": "Tidak dapat memuat {item}: sumber ekstensi tidak ada",
"@errorMissingExtensionSource": {
"description": "Error - extension source not available",
@@ -991,11 +1003,11 @@
"@filenameFormat": {
"description": "Setting title - filename pattern"
},
"filenameShowAdvancedTags": "Tampilkan tag lanjutan",
"filenameShowAdvancedTags": "Show advanced tags",
"@filenameShowAdvancedTags": {
"description": "Toggle label for showing advanced filename tags"
},
"filenameShowAdvancedTagsDescription": "Aktifkan tag format untuk padding nomor lagu dan pola tanggal",
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
"@filenameShowAdvancedTagsDescription": {
"description": "Description for advanced filename tag toggle"
},
@@ -1757,11 +1769,11 @@
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "Bitrate Opus YouTube",
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "Bitrate MP3 YouTube",
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
@@ -2214,7 +2226,7 @@
"@libraryAboutDescription": {
"description": "Description of local library feature"
},
"libraryTracksUnit": "{count, plural, =1{trek} other{trek}}",
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
"@libraryTracksUnit": {
"description": "Unit label for tracks count (without the number itself)",
"placeholders": {
@@ -2808,11 +2820,11 @@
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
},
"actionCreate": "Buat",
"actionCreate": "Create",
"@actionCreate": {
"description": "Generic action button - create"
},
"collectionFoldersTitle": "Folder saya",
"collectionFoldersTitle": "My folders",
"@collectionFoldersTitle": {
"description": "Library section title for custom folders"
},
@@ -2824,7 +2836,7 @@
"@collectionLoved": {
"description": "Custom folder for favorite tracks"
},
"collectionPlaylists": "Playlist",
"collectionPlaylists": "Playlists",
"@collectionPlaylists": {
"description": "Custom user playlists folder"
},
@@ -2832,23 +2844,23 @@
"@collectionPlaylist": {
"description": "Single playlist label"
},
"collectionAddToPlaylist": "Tambahkan ke playlist",
"collectionAddToPlaylist": "Add to playlist",
"@collectionAddToPlaylist": {
"description": "Action to add a track to user playlist"
},
"collectionCreatePlaylist": "Buat playlist",
"collectionCreatePlaylist": "Create playlist",
"@collectionCreatePlaylist": {
"description": "Action to create a new playlist"
},
"collectionNoPlaylistsYet": "Belum ada playlist",
"collectionNoPlaylistsYet": "No playlists yet",
"@collectionNoPlaylistsYet": {
"description": "Empty state title when user has no playlists"
},
"collectionNoPlaylistsSubtitle": "Buat playlist untuk mulai mengategorikan lagu",
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
"@collectionNoPlaylistsSubtitle": {
"description": "Empty state subtitle when user has no playlists"
},
"collectionPlaylistTracks": "{count, plural, =1{1 lagu} other{{count} lagu}}",
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
"@collectionPlaylistTracks": {
"description": "Track count label for custom playlists",
"placeholders": {
@@ -2857,7 +2869,7 @@
}
}
},
"collectionAddedToPlaylist": "Ditambahkan ke \"{playlistName}\"",
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
"@collectionAddedToPlaylist": {
"description": "Snackbar after adding track to playlist",
"placeholders": {
@@ -2866,7 +2878,7 @@
}
}
},
"collectionAlreadyInPlaylist": "Sudah ada di \"{playlistName}\"",
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
"@collectionAlreadyInPlaylist": {
"description": "Snackbar when track already exists in playlist",
"placeholders": {
@@ -2875,27 +2887,27 @@
}
}
},
"collectionPlaylistCreated": "Playlist berhasil dibuat",
"collectionPlaylistCreated": "Playlist created",
"@collectionPlaylistCreated": {
"description": "Snackbar after creating playlist"
},
"collectionPlaylistNameHint": "Nama playlist",
"collectionPlaylistNameHint": "Playlist name",
"@collectionPlaylistNameHint": {
"description": "Hint text for playlist name input"
},
"collectionPlaylistNameRequired": "Nama playlist wajib diisi",
"collectionPlaylistNameRequired": "Playlist name is required",
"@collectionPlaylistNameRequired": {
"description": "Validation error for empty playlist name"
},
"collectionRenamePlaylist": "Ubah nama playlist",
"collectionRenamePlaylist": "Rename playlist",
"@collectionRenamePlaylist": {
"description": "Action to rename playlist"
},
"collectionDeletePlaylist": "Hapus playlist",
"collectionDeletePlaylist": "Delete playlist",
"@collectionDeletePlaylist": {
"description": "Action to delete playlist"
},
"collectionDeletePlaylistMessage": "Hapus \"{playlistName}\" beserta semua lagunya?",
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
"@collectionDeletePlaylistMessage": {
"description": "Confirmation message for deleting playlist",
"placeholders": {
@@ -2904,47 +2916,47 @@
}
}
},
"collectionPlaylistDeleted": "Playlist dihapus",
"collectionPlaylistDeleted": "Playlist deleted",
"@collectionPlaylistDeleted": {
"description": "Snackbar after deleting playlist"
},
"collectionPlaylistRenamed": "Nama playlist diperbarui",
"collectionPlaylistRenamed": "Playlist renamed",
"@collectionPlaylistRenamed": {
"description": "Snackbar after renaming playlist"
},
"collectionWishlistEmptyTitle": "Wishlist masih kosong",
"collectionWishlistEmptyTitle": "Wishlist is empty",
"@collectionWishlistEmptyTitle": {
"description": "Wishlist empty state title"
},
"collectionWishlistEmptySubtitle": "Tap + di lagu untuk menyimpan yang ingin diunduh nanti",
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
"@collectionWishlistEmptySubtitle": {
"description": "Wishlist empty state subtitle"
},
"collectionLovedEmptyTitle": "Folder Loved masih kosong",
"collectionLovedEmptyTitle": "Loved folder is empty",
"@collectionLovedEmptyTitle": {
"description": "Loved empty state title"
},
"collectionLovedEmptySubtitle": "Tap love di lagu untuk menyimpan favoritmu",
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
"@collectionLovedEmptySubtitle": {
"description": "Loved empty state subtitle"
},
"collectionPlaylistEmptyTitle": "Playlist masih kosong",
"collectionPlaylistEmptyTitle": "Playlist is empty",
"@collectionPlaylistEmptyTitle": {
"description": "Playlist empty state title"
},
"collectionPlaylistEmptySubtitle": "Tekan lama tombol + pada lagu untuk menambahkannya ke sini",
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
"@collectionPlaylistEmptySubtitle": {
"description": "Playlist empty state subtitle"
},
"collectionRemoveFromPlaylist": "Hapus dari playlist",
"collectionRemoveFromPlaylist": "Remove from playlist",
"@collectionRemoveFromPlaylist": {
"description": "Tooltip for removing track from playlist"
},
"collectionRemoveFromFolder": "Hapus dari folder",
"collectionRemoveFromFolder": "Remove from folder",
"@collectionRemoveFromFolder": {
"description": "Tooltip for removing track from wishlist/loved folder"
},
"collectionRemoved": "\"{trackName}\" dihapus",
"collectionRemoved": "\"{trackName}\" removed",
"@collectionRemoved": {
"description": "Snackbar after removing a track from a collection",
"placeholders": {
@@ -2953,7 +2965,7 @@
}
}
},
"collectionAddedToLoved": "\"{trackName}\" ditambahkan ke Loved",
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
"@collectionAddedToLoved": {
"description": "Snackbar after adding track to loved folder",
"placeholders": {
@@ -2962,7 +2974,7 @@
}
}
},
"collectionRemovedFromLoved": "\"{trackName}\" dihapus dari Loved",
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
"@collectionRemovedFromLoved": {
"description": "Snackbar after removing track from loved folder",
"placeholders": {
@@ -2971,7 +2983,7 @@
}
}
},
"collectionAddedToWishlist": "\"{trackName}\" ditambahkan ke Wishlist",
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
"@collectionAddedToWishlist": {
"description": "Snackbar after adding track to wishlist",
"placeholders": {
@@ -2980,7 +2992,7 @@
}
}
},
"collectionRemovedFromWishlist": "\"{trackName}\" dihapus dari Wishlist",
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
"@collectionRemovedFromWishlist": {
"description": "Snackbar after removing track from wishlist",
"placeholders": {
@@ -2989,31 +3001,31 @@
}
}
},
"trackOptionAddToLoved": "Tambahkan ke Loved",
"trackOptionAddToLoved": "Add to Loved",
"@trackOptionAddToLoved": {
"description": "Bottom sheet action label - add track to loved folder"
},
"trackOptionRemoveFromLoved": "Hapus dari Loved",
"trackOptionRemoveFromLoved": "Remove from Loved",
"@trackOptionRemoveFromLoved": {
"description": "Bottom sheet action label - remove track from loved folder"
},
"trackOptionAddToWishlist": "Tambahkan ke Wishlist",
"trackOptionAddToWishlist": "Add to Wishlist",
"@trackOptionAddToWishlist": {
"description": "Bottom sheet action label - add track to wishlist"
},
"trackOptionRemoveFromWishlist": "Hapus dari Wishlist",
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
"@trackOptionRemoveFromWishlist": {
"description": "Bottom sheet action label - remove track from wishlist"
},
"collectionPlaylistChangeCover": "Ubah gambar sampul",
"collectionPlaylistChangeCover": "Change cover image",
"@collectionPlaylistChangeCover": {
"description": "Bottom sheet action to pick a custom cover image for a playlist"
},
"collectionPlaylistRemoveCover": "Hapus gambar sampul",
"collectionPlaylistRemoveCover": "Remove cover image",
"@collectionPlaylistRemoveCover": {
"description": "Bottom sheet action to remove custom cover image from a playlist"
},
"selectionShareCount": "Bagikan {count} {count, plural, =1{trek} other{trek}}",
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
"@selectionShareCount": {
"description": "Share button text with count in selection mode",
"placeholders": {
@@ -3022,11 +3034,11 @@
}
}
},
"selectionShareNoFiles": "Tidak ada file yang dapat dibagikan",
"selectionShareNoFiles": "No shareable files found",
"@selectionShareNoFiles": {
"description": "Snackbar when no selected files exist on disk"
},
"selectionConvertCount": "Konversi {count} {count, plural, =1{trek} other{trek}}",
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
"@selectionConvertCount": {
"description": "Convert button text with count in selection mode",
"placeholders": {
@@ -3035,15 +3047,15 @@
}
}
},
"selectionConvertNoConvertible": "Tidak ada trek yang dapat dikonversi dipilih",
"selectionConvertNoConvertible": "No convertible tracks selected",
"@selectionConvertNoConvertible": {
"description": "Snackbar when no selected tracks support conversion"
},
"selectionBatchConvertConfirmTitle": "Konversi Massal",
"selectionBatchConvertConfirmTitle": "Batch Convert",
"@selectionBatchConvertConfirmTitle": {
"description": "Confirmation dialog title for batch conversion"
},
"selectionBatchConvertConfirmMessage": "Konversi {count} {count, plural, =1{trek} other{trek}} ke {format} pada {bitrate}?\n\nFile asli akan dihapus setelah konversi.",
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
"@selectionBatchConvertConfirmMessage": {
"description": "Confirmation dialog message for batch conversion",
"placeholders": {
@@ -3058,7 +3070,7 @@
}
}
},
"selectionBatchConvertProgress": "Mengonversi {current} dari {total}...",
"selectionBatchConvertProgress": "Converting {current} of {total}...",
"@selectionBatchConvertProgress": {
"description": "Snackbar during batch conversion progress",
"placeholders": {
@@ -3070,7 +3082,7 @@
}
}
},
"selectionBatchConvertSuccess": "Berhasil mengonversi {success} dari {total} trek ke {format}",
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
"@selectionBatchConvertSuccess": {
"description": "Snackbar after batch conversion completes",
"placeholders": {
@@ -3102,4 +3114,4 @@
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming"
}
}
}
+378 -76
View File
@@ -9,7 +9,7 @@
"@navHome": {
"description": "Bottom navigation - Home tab"
},
"navLibrary": "Library",
"navLibrary": "ライブラリ",
"@navLibrary": {
"description": "Bottom navigation - Library tab"
},
@@ -198,7 +198,7 @@
"@optionsConcurrentSequential": {
"description": "Download one at a time"
},
"optionsConcurrentParallel": "{count} parallel downloads",
"optionsConcurrentParallel": "{count} 件の分割ダウンロード",
"@optionsConcurrentParallel": {
"description": "Multiple parallel downloads",
"placeholders": {
@@ -991,6 +991,14 @@
"@filenameFormat": {
"description": "Setting title - filename pattern"
},
"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"
},
"folderOrganizationNone": "構成がありません",
"@folderOrganizationNone": {
"description": "Folder option - flat structure"
@@ -1455,7 +1463,7 @@
"@trackLyricsLoadFailed": {
"description": "Message when lyrics loading fails"
},
"trackEmbedLyrics": "Embed Lyrics",
"trackEmbedLyrics": "歌詞を埋め込む",
"@trackEmbedLyrics": {
"description": "Action - embed lyrics into audio file"
},
@@ -1749,6 +1757,14 @@
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus のビットレート",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 のビットレート",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "ダウンロード前に確認する",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
@@ -1805,7 +1821,7 @@
"@queueClearAllMessage": {
"description": "Clear queue confirmation"
},
"settingsAutoExportFailed": "Auto-export failed downloads",
"settingsAutoExportFailed": "ダウンロードの自動エクスポートに失敗しました",
"@settingsAutoExportFailed": {
"description": "Setting toggle for auto-export"
},
@@ -1813,15 +1829,15 @@
"@settingsAutoExportFailedSubtitle": {
"description": "Subtitle for auto-export setting"
},
"settingsDownloadNetwork": "Download Network",
"settingsDownloadNetwork": "ダウンロードネットワーク",
"@settingsDownloadNetwork": {
"description": "Setting for network type preference"
},
"settingsDownloadNetworkAny": "WiFi + Mobile Data",
"settingsDownloadNetworkAny": "Wi-Fi + モバイルデータ",
"@settingsDownloadNetworkAny": {
"description": "Network option - use any connection"
},
"settingsDownloadNetworkWifiOnly": "WiFi Only",
"settingsDownloadNetworkWifiOnly": "Wi-Fi のみ",
"@settingsDownloadNetworkWifiOnly": {
"description": "Network option - only use WiFi"
},
@@ -1861,7 +1877,7 @@
"@albumFolderYearAlbumSubtitle": {
"description": "Folder structure example"
},
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
"albumFolderArtistAlbumSingles": "アーティスト / アルバム + シングル",
"@albumFolderArtistAlbumSingles": {
"description": "Album folder option with singles inside artist"
},
@@ -1942,7 +1958,7 @@
"@recentEmpty": {
"description": "Empty state text for recent access list"
},
"recentShowAllDownloads": "Show All Downloads",
"recentShowAllDownloads": "すべてのダウンロードを表示",
"@recentShowAllDownloads": {
"description": "Button label to unhide hidden downloads in recent access"
},
@@ -2074,11 +2090,11 @@
"@discographyFailedToFetch": {
"description": "Error - some albums failed to load"
},
"sectionStorageAccess": "Storage Access",
"sectionStorageAccess": "ストレージアクセス",
"@sectionStorageAccess": {
"description": "Section header for storage access settings"
},
"allFilesAccess": "All Files Access",
"allFilesAccess": "すべてのファイルへのアクセス",
"@allFilesAccess": {
"description": "Toggle for MANAGE_EXTERNAL_STORAGE permission"
},
@@ -2102,7 +2118,7 @@
"@allFilesAccessDisabledMessage": {
"description": "Snackbar message when user disables all files access"
},
"settingsLocalLibrary": "Local Library",
"settingsLocalLibrary": "ローカルライブラリ",
"@settingsLocalLibrary": {
"description": "Settings menu item - local library"
},
@@ -2110,7 +2126,7 @@
"@settingsLocalLibrarySubtitle": {
"description": "Subtitle for local library settings"
},
"settingsCache": "Storage & Cache",
"settingsCache": "ストレージとキャッシュ",
"@settingsCache": {
"description": "Settings menu item - cache management"
},
@@ -2118,15 +2134,15 @@
"@settingsCacheSubtitle": {
"description": "Subtitle for cache management menu"
},
"libraryTitle": "Local Library",
"libraryTitle": "ローカルライブラリ",
"@libraryTitle": {
"description": "Library settings page title"
},
"libraryScanSettings": "Scan Settings",
"libraryScanSettings": "スキャン設定",
"@libraryScanSettings": {
"description": "Section header for scan settings"
},
"libraryEnableLocalLibrary": "Enable Local Library",
"libraryEnableLocalLibrary": "ローカルライブラリを有効",
"@libraryEnableLocalLibrary": {
"description": "Toggle to enable library scanning"
},
@@ -2134,11 +2150,11 @@
"@libraryEnableLocalLibrarySubtitle": {
"description": "Subtitle for enable toggle"
},
"libraryFolder": "Library Folder",
"libraryFolder": "ライブラリのフォルダ",
"@libraryFolder": {
"description": "Folder selection setting"
},
"libraryFolderHint": "Tap to select folder",
"libraryFolderHint": "タップでフォルダを選択",
"@libraryFolderHint": {
"description": "Placeholder when no folder selected"
},
@@ -2150,15 +2166,15 @@
"@libraryShowDuplicateIndicatorSubtitle": {
"description": "Subtitle for duplicate indicator toggle"
},
"libraryActions": "Actions",
"libraryActions": "アクション",
"@libraryActions": {
"description": "Section header for library actions"
},
"libraryScan": "Scan Library",
"libraryScan": "ライブラリをスキャン",
"@libraryScan": {
"description": "Button to start library scan"
},
"libraryScanSubtitle": "Scan for audio files",
"libraryScanSubtitle": "オーディオファイルをスキャン",
"@libraryScanSubtitle": {
"description": "Subtitle for scan button"
},
@@ -2174,7 +2190,7 @@
"@libraryCleanupMissingFilesSubtitle": {
"description": "Subtitle for cleanup button"
},
"libraryClear": "Clear Library",
"libraryClear": "ライブラリを消去",
"@libraryClear": {
"description": "Button to clear all library entries"
},
@@ -2182,7 +2198,7 @@
"@libraryClearSubtitle": {
"description": "Subtitle for clear button"
},
"libraryClearConfirmTitle": "Clear Library",
"libraryClearConfirmTitle": "ライブラリを消去",
"@libraryClearConfirmTitle": {
"description": "Dialog title for clear confirmation"
},
@@ -2190,7 +2206,7 @@
"@libraryClearConfirmMessage": {
"description": "Dialog message for clear confirmation"
},
"libraryAbout": "About Local Library",
"libraryAbout": "ローカルライブラリについて",
"@libraryAbout": {
"description": "Section header for about info"
},
@@ -2198,7 +2214,16 @@
"@libraryAboutDescription": {
"description": "Description of local library feature"
},
"libraryLastScanned": "Last scanned: {time}",
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
"@libraryTracksUnit": {
"description": "Unit label for tracks count (without the number itself)",
"placeholders": {
"count": {
"type": "int"
}
}
},
"libraryLastScanned": "最終スキャン: {time}",
"@libraryLastScanned": {
"description": "Last scan time display",
"placeholders": {
@@ -2211,7 +2236,7 @@
"@libraryLastScannedNever": {
"description": "Shown when library has never been scanned"
},
"libraryScanning": "Scanning...",
"libraryScanning": "スキャン中...",
"@libraryScanning": {
"description": "Status during scan"
},
@@ -2227,7 +2252,7 @@
}
}
},
"libraryInLibrary": "In Library",
"libraryInLibrary": "ライブラリ内",
"@libraryInLibrary": {
"description": "Badge shown on tracks that exist in local library"
},
@@ -2244,7 +2269,7 @@
"@libraryCleared": {
"description": "Snackbar after clearing library"
},
"libraryStorageAccessRequired": "Storage Access Required",
"libraryStorageAccessRequired": "ストレージアクセスが必要です",
"@libraryStorageAccessRequired": {
"description": "Dialog title for storage permission"
},
@@ -2256,47 +2281,47 @@
"@libraryFolderNotExist": {
"description": "Error when folder doesn't exist"
},
"librarySourceDownloaded": "Downloaded",
"librarySourceDownloaded": "ダウンロード済み",
"@librarySourceDownloaded": {
"description": "Badge for tracks downloaded via SpotiFLAC"
},
"librarySourceLocal": "Local",
"librarySourceLocal": "ローカル",
"@librarySourceLocal": {
"description": "Badge for tracks from local library scan"
},
"libraryFilterAll": "All",
"libraryFilterAll": "すべて",
"@libraryFilterAll": {
"description": "Filter chip - show all library items"
},
"libraryFilterDownloaded": "Downloaded",
"libraryFilterDownloaded": "ダウンロード済み",
"@libraryFilterDownloaded": {
"description": "Filter chip - show only downloaded items"
},
"libraryFilterLocal": "Local",
"libraryFilterLocal": "ローカル",
"@libraryFilterLocal": {
"description": "Filter chip - show only local library items"
},
"libraryFilterTitle": "Filters",
"libraryFilterTitle": "フィルター",
"@libraryFilterTitle": {
"description": "Filter bottom sheet title"
},
"libraryFilterReset": "Reset",
"libraryFilterReset": "リセット",
"@libraryFilterReset": {
"description": "Reset all filters button"
},
"libraryFilterApply": "Apply",
"libraryFilterApply": "適用",
"@libraryFilterApply": {
"description": "Apply filters button"
},
"libraryFilterSource": "Source",
"libraryFilterSource": "ソース",
"@libraryFilterSource": {
"description": "Filter section - source type"
},
"libraryFilterQuality": "Quality",
"libraryFilterQuality": "品質",
"@libraryFilterQuality": {
"description": "Filter section - audio quality"
},
"libraryFilterQualityHiRes": "Hi-Res (24bit)",
"libraryFilterQualityHiRes": "ハイレゾ (24bit)",
"@libraryFilterQualityHiRes": {
"description": "Filter option - high resolution audio"
},
@@ -2308,7 +2333,7 @@
"@libraryFilterQualityLossy": {
"description": "Filter option - lossy compressed audio"
},
"libraryFilterFormat": "Format",
"libraryFilterFormat": "形式",
"@libraryFilterFormat": {
"description": "Filter section - file format"
},
@@ -2328,7 +2353,7 @@
"@timeJustNow": {
"description": "Relative time - less than a minute ago"
},
"timeMinutesAgo": "{count, plural, =1{1 minute ago} other{{count} minutes ago}}",
"timeMinutesAgo": "{count, plural, =1{1 分前} other{{count} 分前}}",
"@timeMinutesAgo": {
"description": "Relative time - minutes ago",
"placeholders": {
@@ -2337,7 +2362,7 @@
}
}
},
"timeHoursAgo": "{count, plural, =1{1 hour ago} other{{count} hours ago}}",
"timeHoursAgo": "{count, plural, =1{1 時間前} other{{count} 時間前}}",
"@timeHoursAgo": {
"description": "Relative time - hours ago",
"placeholders": {
@@ -2346,7 +2371,7 @@
}
}
},
"tutorialWelcomeTitle": "Welcome to SpotiFLAC!",
"tutorialWelcomeTitle": "SpotiFLAC へようこそ!",
"@tutorialWelcomeTitle": {
"description": "Tutorial welcome page title"
},
@@ -2374,7 +2399,7 @@
"@tutorialSearchDesc": {
"description": "Tutorial search page description"
},
"tutorialDownloadTitle": "Downloading Music",
"tutorialDownloadTitle": "音楽をダウンロード中",
"@tutorialDownloadTitle": {
"description": "Tutorial download page title"
},
@@ -2382,7 +2407,7 @@
"@tutorialDownloadDesc": {
"description": "Tutorial download page description"
},
"tutorialLibraryTitle": "Your Library",
"tutorialLibraryTitle": "あなたのライブラリ",
"@tutorialLibraryTitle": {
"description": "Tutorial library page title"
},
@@ -2402,7 +2427,7 @@
"@tutorialLibraryTip3": {
"description": "Tutorial library tip 3"
},
"tutorialExtensionsTitle": "Extensions",
"tutorialExtensionsTitle": "拡張",
"@tutorialExtensionsTitle": {
"description": "Tutorial extensions page title"
},
@@ -2446,7 +2471,7 @@
"@tutorialReadyMessage": {
"description": "Tutorial completion message"
},
"libraryForceFullScan": "Force Full Scan",
"libraryForceFullScan": "強制フルスキャン",
"@libraryForceFullScan": {
"description": "Button to force a complete rescan of library"
},
@@ -2475,11 +2500,11 @@
"@cleanupOrphanedDownloadsNone": {
"description": "Snackbar when no orphans found"
},
"cacheTitle": "Storage & Cache",
"cacheTitle": "ストレージとキャッシュ",
"@cacheTitle": {
"description": "Cache management page title"
},
"cacheSummaryTitle": "Cache overview",
"cacheSummaryTitle": "キャッシュの概要",
"@cacheSummaryTitle": {
"description": "Heading for cache summary card"
},
@@ -2496,15 +2521,15 @@
}
}
},
"cacheSectionStorage": "Cached Data",
"cacheSectionStorage": "キャッシュ済みデータ",
"@cacheSectionStorage": {
"description": "Section header for cache entries"
},
"cacheSectionMaintenance": "Maintenance",
"cacheSectionMaintenance": "メンテナンス",
"@cacheSectionMaintenance": {
"description": "Section header for cleanup actions"
},
"cacheAppDirectory": "App cache directory",
"cacheAppDirectory": "アプリキャッシュのディレクトリ",
"@cacheAppDirectory": {
"description": "Cache item title for app cache directory"
},
@@ -2512,7 +2537,7 @@
"@cacheAppDirectoryDesc": {
"description": "Description of what app cache directory contains"
},
"cacheTempDirectory": "Temporary directory",
"cacheTempDirectory": "一時ディレクトリ",
"@cacheTempDirectory": {
"description": "Cache item title for temporary files directory"
},
@@ -2520,7 +2545,7 @@
"@cacheTempDirectoryDesc": {
"description": "Description of what temporary directory contains"
},
"cacheCoverImage": "Cover image cache",
"cacheCoverImage": "カバー画像のキャッシュ",
"@cacheCoverImage": {
"description": "Cache item title for persistent cover images"
},
@@ -2528,7 +2553,7 @@
"@cacheCoverImageDesc": {
"description": "Description of what cover image cache contains"
},
"cacheLibraryCover": "Library cover cache",
"cacheLibraryCover": "ライブラリのカバーキャッシュ",
"@cacheLibraryCover": {
"description": "Cache item title for local library cover art images"
},
@@ -2556,7 +2581,7 @@
"@cacheCleanupUnusedDesc": {
"description": "Description of what cleanup unused data does"
},
"cacheNoData": "No cached data",
"cacheNoData": "キャッシュデータはありません",
"@cacheNoData": {
"description": "Label when cache category has no data"
},
@@ -2581,7 +2606,7 @@
}
}
},
"cacheEntries": "{count} entries",
"cacheEntries": "{count} 個のエントリ",
"@cacheEntries": {
"description": "Track cache entry count",
"placeholders": {
@@ -2590,7 +2615,7 @@
}
}
},
"cacheClearSuccess": "Cleared: {target}",
"cacheClearSuccess": "消去済み: {target}",
"@cacheClearSuccess": {
"description": "Snackbar after clearing selected cache",
"placeholders": {
@@ -2599,7 +2624,7 @@
}
}
},
"cacheClearConfirmTitle": "Clear cache?",
"cacheClearConfirmTitle": "キャッシュを消去しますか?",
"@cacheClearConfirmTitle": {
"description": "Dialog title before clearing one cache category"
},
@@ -2612,7 +2637,7 @@
}
}
},
"cacheClearAllConfirmTitle": "Clear all cache?",
"cacheClearAllConfirmTitle": "すべてのキャッシュを消去しますか?",
"@cacheClearAllConfirmTitle": {
"description": "Dialog title before clearing all caches"
},
@@ -2620,11 +2645,11 @@
"@cacheClearAllConfirmMessage": {
"description": "Dialog message before clearing all caches"
},
"cacheClearAll": "Clear all cache",
"cacheClearAll": "すべてのキャッシュを消去",
"@cacheClearAll": {
"description": "Button label to clear all caches"
},
"cacheCleanupUnused": "Cleanup unused data",
"cacheCleanupUnused": "未使用のデータを削除",
"@cacheCleanupUnused": {
"description": "Action title for cleaning unused entries"
},
@@ -2644,11 +2669,11 @@
}
}
},
"cacheRefreshStats": "Refresh stats",
"cacheRefreshStats": "状態を更新",
"@cacheRefreshStats": {
"description": "Button label to refresh cache statistics"
},
"trackSaveCoverArt": "Save Cover Art",
"trackSaveCoverArt": "カバー画像を保存",
"@trackSaveCoverArt": {
"description": "Menu action - save album cover art as file"
},
@@ -2656,7 +2681,7 @@
"@trackSaveCoverArtSubtitle": {
"description": "Subtitle for save cover art action"
},
"trackSaveLyrics": "Save Lyrics (.lrc)",
"trackSaveLyrics": "歌詞を保存 (.lrc)",
"@trackSaveLyrics": {
"description": "Menu action - save lyrics as .lrc file"
},
@@ -2676,7 +2701,7 @@
"@trackReEnrichOnlineSubtitle": {
"description": "Subtitle for re-enrich metadata action for local items"
},
"trackEditMetadata": "Edit Metadata",
"trackEditMetadata": "メタデータを編集",
"@trackEditMetadata": {
"description": "Menu action - edit embedded metadata"
},
@@ -2718,7 +2743,7 @@
"@trackReEnrichFfmpegFailed": {
"description": "Snackbar when FFmpeg embed fails for MP3/Opus"
},
"trackSaveFailed": "Failed: {error}",
"trackSaveFailed": "失敗: {error}",
"@trackSaveFailed": {
"description": "Snackbar when save operation fails",
"placeholders": {
@@ -2727,27 +2752,27 @@
}
}
},
"trackConvertFormat": "Convert Format",
"trackConvertFormat": "変換の形式",
"@trackConvertFormat": {
"description": "Menu item - convert audio format"
},
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
"trackConvertFormatSubtitle": "MP3 または Opus に変換",
"@trackConvertFormatSubtitle": {
"description": "Subtitle for convert format menu item"
},
"trackConvertTitle": "Convert Audio",
"trackConvertTitle": "オーディオを変換",
"@trackConvertTitle": {
"description": "Title of convert bottom sheet"
},
"trackConvertTargetFormat": "Target Format",
"trackConvertTargetFormat": "ターゲットの形式",
"@trackConvertTargetFormat": {
"description": "Label for format selection"
},
"trackConvertBitrate": "Bitrate",
"trackConvertBitrate": "ビットレート",
"@trackConvertBitrate": {
"description": "Label for bitrate selection"
},
"trackConvertConfirmTitle": "Confirm Conversion",
"trackConvertConfirmTitle": "変換を確認",
"@trackConvertConfirmTitle": {
"description": "Confirmation dialog title"
},
@@ -2766,7 +2791,7 @@
}
}
},
"trackConvertConverting": "Converting audio...",
"trackConvertConverting": "オーディオを変換中...",
"@trackConvertConverting": {
"description": "Snackbar while converting"
},
@@ -2779,10 +2804,287 @@
}
}
},
"trackConvertFailed": "Conversion failed",
"trackConvertFailed": "変換に失敗しました",
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
},
"actionCreate": "Create",
"@actionCreate": {
"description": "Generic action button - create"
},
"collectionFoldersTitle": "My folders",
"@collectionFoldersTitle": {
"description": "Library section title for custom folders"
},
"collectionWishlist": "Wishlist",
"@collectionWishlist": {
"description": "Custom folder for saved tracks to download later"
},
"collectionLoved": "Loved",
"@collectionLoved": {
"description": "Custom folder for favorite tracks"
},
"collectionPlaylists": "Playlists",
"@collectionPlaylists": {
"description": "Custom user playlists folder"
},
"collectionPlaylist": "Playlist",
"@collectionPlaylist": {
"description": "Single playlist label"
},
"collectionAddToPlaylist": "Add to playlist",
"@collectionAddToPlaylist": {
"description": "Action to add a track to user playlist"
},
"collectionCreatePlaylist": "Create playlist",
"@collectionCreatePlaylist": {
"description": "Action to create a new playlist"
},
"collectionNoPlaylistsYet": "No playlists yet",
"@collectionNoPlaylistsYet": {
"description": "Empty state title when user has no playlists"
},
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
"@collectionNoPlaylistsSubtitle": {
"description": "Empty state subtitle when user has no playlists"
},
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
"@collectionPlaylistTracks": {
"description": "Track count label for custom playlists",
"placeholders": {
"count": {
"type": "int"
}
}
},
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
"@collectionAddedToPlaylist": {
"description": "Snackbar after adding track to playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
"@collectionAlreadyInPlaylist": {
"description": "Snackbar when track already exists in playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistCreated": "Playlist created",
"@collectionPlaylistCreated": {
"description": "Snackbar after creating playlist"
},
"collectionPlaylistNameHint": "Playlist name",
"@collectionPlaylistNameHint": {
"description": "Hint text for playlist name input"
},
"collectionPlaylistNameRequired": "Playlist name is required",
"@collectionPlaylistNameRequired": {
"description": "Validation error for empty playlist name"
},
"collectionRenamePlaylist": "Rename playlist",
"@collectionRenamePlaylist": {
"description": "Action to rename playlist"
},
"collectionDeletePlaylist": "Delete playlist",
"@collectionDeletePlaylist": {
"description": "Action to delete playlist"
},
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
"@collectionDeletePlaylistMessage": {
"description": "Confirmation message for deleting playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistDeleted": "Playlist deleted",
"@collectionPlaylistDeleted": {
"description": "Snackbar after deleting playlist"
},
"collectionPlaylistRenamed": "Playlist renamed",
"@collectionPlaylistRenamed": {
"description": "Snackbar after renaming playlist"
},
"collectionWishlistEmptyTitle": "Wishlist is empty",
"@collectionWishlistEmptyTitle": {
"description": "Wishlist empty state title"
},
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
"@collectionWishlistEmptySubtitle": {
"description": "Wishlist empty state subtitle"
},
"collectionLovedEmptyTitle": "Loved folder is empty",
"@collectionLovedEmptyTitle": {
"description": "Loved empty state title"
},
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
"@collectionLovedEmptySubtitle": {
"description": "Loved empty state subtitle"
},
"collectionPlaylistEmptyTitle": "Playlist is empty",
"@collectionPlaylistEmptyTitle": {
"description": "Playlist empty state title"
},
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
"@collectionPlaylistEmptySubtitle": {
"description": "Playlist empty state subtitle"
},
"collectionRemoveFromPlaylist": "Remove from playlist",
"@collectionRemoveFromPlaylist": {
"description": "Tooltip for removing track from playlist"
},
"collectionRemoveFromFolder": "Remove from folder",
"@collectionRemoveFromFolder": {
"description": "Tooltip for removing track from wishlist/loved folder"
},
"collectionRemoved": "\"{trackName}\" removed",
"@collectionRemoved": {
"description": "Snackbar after removing a track from a collection",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
"@collectionAddedToLoved": {
"description": "Snackbar after adding track to loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
"@collectionRemovedFromLoved": {
"description": "Snackbar after removing track from loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
"@collectionAddedToWishlist": {
"description": "Snackbar after adding track to wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
"@collectionRemovedFromWishlist": {
"description": "Snackbar after removing track from wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"trackOptionAddToLoved": "Add to Loved",
"@trackOptionAddToLoved": {
"description": "Bottom sheet action label - add track to loved folder"
},
"trackOptionRemoveFromLoved": "Remove from Loved",
"@trackOptionRemoveFromLoved": {
"description": "Bottom sheet action label - remove track from loved folder"
},
"trackOptionAddToWishlist": "Add to Wishlist",
"@trackOptionAddToWishlist": {
"description": "Bottom sheet action label - add track to wishlist"
},
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
"@trackOptionRemoveFromWishlist": {
"description": "Bottom sheet action label - remove track from wishlist"
},
"collectionPlaylistChangeCover": "Change cover image",
"@collectionPlaylistChangeCover": {
"description": "Bottom sheet action to pick a custom cover image for a playlist"
},
"collectionPlaylistRemoveCover": "Remove cover image",
"@collectionPlaylistRemoveCover": {
"description": "Bottom sheet action to remove custom cover image from a playlist"
},
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
"@selectionShareCount": {
"description": "Share button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionShareNoFiles": "No shareable files found",
"@selectionShareNoFiles": {
"description": "Snackbar when no selected files exist on disk"
},
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
"@selectionConvertCount": {
"description": "Convert button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionConvertNoConvertible": "No convertible tracks selected",
"@selectionConvertNoConvertible": {
"description": "Snackbar when no selected tracks support conversion"
},
"selectionBatchConvertConfirmTitle": "Batch Convert",
"@selectionBatchConvertConfirmTitle": {
"description": "Confirmation dialog title for batch conversion"
},
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
"@selectionBatchConvertConfirmMessage": {
"description": "Confirmation dialog message for batch conversion",
"placeholders": {
"count": {
"type": "int"
},
"format": {
"type": "String"
},
"bitrate": {
"type": "String"
}
}
},
"selectionBatchConvertProgress": "Converting {current} of {total}...",
"@selectionBatchConvertProgress": {
"description": "Snackbar during batch conversion progress",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
"@selectionBatchConvertSuccess": {
"description": "Snackbar after batch conversion completes",
"placeholders": {
"success": {
"type": "int"
},
"total": {
"type": "int"
},
"format": {
"type": "String"
}
}
},
"downloadedAlbumDownloadedCount": "{count} 個をダウンロード済み",
"@downloadedAlbumDownloadedCount": {
"description": "Downloaded tracks count badge",
@@ -2800,4 +3102,4 @@
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming"
}
}
}
+520 -218
View File
File diff suppressed because it is too large Load Diff
+303 -1
View File
@@ -991,6 +991,14 @@
"@filenameFormat": {
"description": "Setting title - filename pattern"
},
"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"
},
"folderOrganizationNone": "No organization",
"@folderOrganizationNone": {
"description": "Folder option - flat structure"
@@ -1749,6 +1757,14 @@
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
@@ -2198,6 +2214,15 @@
"@libraryAboutDescription": {
"description": "Description of local library feature"
},
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
"@libraryTracksUnit": {
"description": "Unit label for tracks count (without the number itself)",
"placeholders": {
"count": {
"type": "int"
}
}
},
"libraryLastScanned": "Last scanned: {time}",
"@libraryLastScanned": {
"description": "Last scan time display",
@@ -2783,6 +2808,283 @@
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
},
"actionCreate": "Create",
"@actionCreate": {
"description": "Generic action button - create"
},
"collectionFoldersTitle": "My folders",
"@collectionFoldersTitle": {
"description": "Library section title for custom folders"
},
"collectionWishlist": "Wishlist",
"@collectionWishlist": {
"description": "Custom folder for saved tracks to download later"
},
"collectionLoved": "Loved",
"@collectionLoved": {
"description": "Custom folder for favorite tracks"
},
"collectionPlaylists": "Playlists",
"@collectionPlaylists": {
"description": "Custom user playlists folder"
},
"collectionPlaylist": "Playlist",
"@collectionPlaylist": {
"description": "Single playlist label"
},
"collectionAddToPlaylist": "Add to playlist",
"@collectionAddToPlaylist": {
"description": "Action to add a track to user playlist"
},
"collectionCreatePlaylist": "Create playlist",
"@collectionCreatePlaylist": {
"description": "Action to create a new playlist"
},
"collectionNoPlaylistsYet": "No playlists yet",
"@collectionNoPlaylistsYet": {
"description": "Empty state title when user has no playlists"
},
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
"@collectionNoPlaylistsSubtitle": {
"description": "Empty state subtitle when user has no playlists"
},
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
"@collectionPlaylistTracks": {
"description": "Track count label for custom playlists",
"placeholders": {
"count": {
"type": "int"
}
}
},
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
"@collectionAddedToPlaylist": {
"description": "Snackbar after adding track to playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
"@collectionAlreadyInPlaylist": {
"description": "Snackbar when track already exists in playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistCreated": "Playlist created",
"@collectionPlaylistCreated": {
"description": "Snackbar after creating playlist"
},
"collectionPlaylistNameHint": "Playlist name",
"@collectionPlaylistNameHint": {
"description": "Hint text for playlist name input"
},
"collectionPlaylistNameRequired": "Playlist name is required",
"@collectionPlaylistNameRequired": {
"description": "Validation error for empty playlist name"
},
"collectionRenamePlaylist": "Rename playlist",
"@collectionRenamePlaylist": {
"description": "Action to rename playlist"
},
"collectionDeletePlaylist": "Delete playlist",
"@collectionDeletePlaylist": {
"description": "Action to delete playlist"
},
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
"@collectionDeletePlaylistMessage": {
"description": "Confirmation message for deleting playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistDeleted": "Playlist deleted",
"@collectionPlaylistDeleted": {
"description": "Snackbar after deleting playlist"
},
"collectionPlaylistRenamed": "Playlist renamed",
"@collectionPlaylistRenamed": {
"description": "Snackbar after renaming playlist"
},
"collectionWishlistEmptyTitle": "Wishlist is empty",
"@collectionWishlistEmptyTitle": {
"description": "Wishlist empty state title"
},
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
"@collectionWishlistEmptySubtitle": {
"description": "Wishlist empty state subtitle"
},
"collectionLovedEmptyTitle": "Loved folder is empty",
"@collectionLovedEmptyTitle": {
"description": "Loved empty state title"
},
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
"@collectionLovedEmptySubtitle": {
"description": "Loved empty state subtitle"
},
"collectionPlaylistEmptyTitle": "Playlist is empty",
"@collectionPlaylistEmptyTitle": {
"description": "Playlist empty state title"
},
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
"@collectionPlaylistEmptySubtitle": {
"description": "Playlist empty state subtitle"
},
"collectionRemoveFromPlaylist": "Remove from playlist",
"@collectionRemoveFromPlaylist": {
"description": "Tooltip for removing track from playlist"
},
"collectionRemoveFromFolder": "Remove from folder",
"@collectionRemoveFromFolder": {
"description": "Tooltip for removing track from wishlist/loved folder"
},
"collectionRemoved": "\"{trackName}\" removed",
"@collectionRemoved": {
"description": "Snackbar after removing a track from a collection",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
"@collectionAddedToLoved": {
"description": "Snackbar after adding track to loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
"@collectionRemovedFromLoved": {
"description": "Snackbar after removing track from loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
"@collectionAddedToWishlist": {
"description": "Snackbar after adding track to wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
"@collectionRemovedFromWishlist": {
"description": "Snackbar after removing track from wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"trackOptionAddToLoved": "Add to Loved",
"@trackOptionAddToLoved": {
"description": "Bottom sheet action label - add track to loved folder"
},
"trackOptionRemoveFromLoved": "Remove from Loved",
"@trackOptionRemoveFromLoved": {
"description": "Bottom sheet action label - remove track from loved folder"
},
"trackOptionAddToWishlist": "Add to Wishlist",
"@trackOptionAddToWishlist": {
"description": "Bottom sheet action label - add track to wishlist"
},
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
"@trackOptionRemoveFromWishlist": {
"description": "Bottom sheet action label - remove track from wishlist"
},
"collectionPlaylistChangeCover": "Change cover image",
"@collectionPlaylistChangeCover": {
"description": "Bottom sheet action to pick a custom cover image for a playlist"
},
"collectionPlaylistRemoveCover": "Remove cover image",
"@collectionPlaylistRemoveCover": {
"description": "Bottom sheet action to remove custom cover image from a playlist"
},
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
"@selectionShareCount": {
"description": "Share button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionShareNoFiles": "No shareable files found",
"@selectionShareNoFiles": {
"description": "Snackbar when no selected files exist on disk"
},
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
"@selectionConvertCount": {
"description": "Convert button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionConvertNoConvertible": "No convertible tracks selected",
"@selectionConvertNoConvertible": {
"description": "Snackbar when no selected tracks support conversion"
},
"selectionBatchConvertConfirmTitle": "Batch Convert",
"@selectionBatchConvertConfirmTitle": {
"description": "Confirmation dialog title for batch conversion"
},
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
"@selectionBatchConvertConfirmMessage": {
"description": "Confirmation dialog message for batch conversion",
"placeholders": {
"count": {
"type": "int"
},
"format": {
"type": "String"
},
"bitrate": {
"type": "String"
}
}
},
"selectionBatchConvertProgress": "Converting {current} of {total}...",
"@selectionBatchConvertProgress": {
"description": "Snackbar during batch conversion progress",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
"@selectionBatchConvertSuccess": {
"description": "Snackbar after batch conversion completes",
"placeholders": {
"success": {
"type": "int"
},
"total": {
"type": "int"
},
"format": {
"type": "String"
}
}
},
"downloadedAlbumDownloadedCount": "{count} downloaded",
"@downloadedAlbumDownloadedCount": {
"description": "Downloaded tracks count badge",
@@ -2800,4 +3102,4 @@
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming"
}
}
}
+2 -2
View File
@@ -402,7 +402,7 @@
"@aboutDabMusicDesc": {
"description": "Credit for DAB Music API"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -1005,7 +1005,7 @@
},
"providerBuiltIn": "Built-in",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Extension",
"@providerExtension": {
+3 -3
View File
@@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Baixe faixas do Spotify em qualidade sem perdas do Tidal, Qobuz e Amazon Music.",
"aboutAppDescription": "Baixe faixas do Spotify em qualidade sem perdas do Tidal e Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -1089,7 +1089,7 @@
},
"providerBuiltIn": "Embutido",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Extensão",
"@providerExtension": {
@@ -2358,7 +2358,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
"tutorialWelcomeTip2": "Obtenha áudio em qualidade FLAC do Tidal, Qobuz ou Deezer",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},
+357 -55
View File
@@ -77,7 +77,7 @@
"@settingsAbout": {
"description": "Settings section - app info"
},
"downloadTitle": "Скачивание",
"downloadTitle": "Скачать",
"@downloadTitle": {
"description": "Download settings page title"
},
@@ -174,11 +174,11 @@
"@optionsUseExtensionProvidersOff": {
"description": "Status when extension providers disabled"
},
"optionsEmbedLyrics": "Вставить текст песни",
"optionsEmbedLyrics": "Вписать текст песни",
"@optionsEmbedLyrics": {
"description": "Embed lyrics in audio files"
},
"optionsEmbedLyricsSubtitle": "Вставить синхронизированные тексты в FLAC файлы",
"optionsEmbedLyricsSubtitle": "Вписать синхронизированные тексты во FLAC файлы",
"@optionsEmbedLyricsSubtitle": {
"description": "Subtitle for embed lyrics"
},
@@ -422,7 +422,7 @@
"@aboutVersion": {
"description": "Version info label"
},
"aboutBinimumDesc": "Создатель QQDL & HiFi API. Без этого API загрузки Tidal не существовали бы!",
"aboutBinimumDesc": "Создатель QQDL & HiFi API. Без него API загрузки Tidal не существовали бы!",
"@aboutBinimumDesc": {
"description": "Credit description for binimum"
},
@@ -728,7 +728,7 @@
"@dialogDeleteSelectedTitle": {
"description": "Dialog title - delete selected items"
},
"dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}} из истории?\n\nЭто также удалит файлы из хранилища.",
"dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}} из истории?\n\nЭто также удалит файлы из хранилища.",
"@dialogDeleteSelectedMessage": {
"description": "Dialog message - delete selected tracks",
"placeholders": {
@@ -742,7 +742,7 @@
"description": "Dialog title - import CSV playlist"
},
"dialogImportPlaylistMessage": "Найдено {count} треков в CSV. Добавить их в очередь загрузки?",
"csvImportTracks": "{count} треков из CSV",
"csvImportTracks": "{count} трек(-ов) из CSV",
"@csvImportTracks": {
"description": "Label shown in quality picker for CSV import",
"placeholders": {
@@ -807,7 +807,7 @@
"@snackbarCredentialsCleared": {
"description": "Snackbar - Spotify credentials removed"
},
"snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} other {треков}}",
"snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
"@snackbarDeletedTracks": {
"description": "Snackbar - tracks deleted",
"placeholders": {
@@ -991,6 +991,14 @@
"@filenameFormat": {
"description": "Setting title - filename pattern"
},
"filenameShowAdvancedTags": "Показать расширенные теги",
"@filenameShowAdvancedTags": {
"description": "Toggle label for showing advanced filename tags"
},
"filenameShowAdvancedTagsDescription": "Включить форматированные теги для отслеживания заполнения и шаблонов дат",
"@filenameShowAdvancedTagsDescription": {
"description": "Description for advanced filename tag toggle"
},
"folderOrganizationNone": "Без организации",
"@folderOrganizationNone": {
"description": "Folder option - flat structure"
@@ -1261,7 +1269,7 @@
"@lyricsModeDescription": {
"description": "Lyrics mode picker description"
},
"lyricsModeEmbed": "Вставить в файл",
"lyricsModeEmbed": "Вписать в файл",
"@lyricsModeEmbed": {
"description": "Lyrics mode option - embed in audio file"
},
@@ -1281,7 +1289,7 @@
"@lyricsModeBoth": {
"description": "Lyrics mode option - embed and external"
},
"lyricsModeBothSubtitle": "Вставить и сохранить файл .lrc",
"lyricsModeBothSubtitle": "Вписать и сохранить .lrc файл",
"@lyricsModeBothSubtitle": {
"description": "Subtitle for both option"
},
@@ -1455,7 +1463,7 @@
"@trackLyricsLoadFailed": {
"description": "Message when lyrics loading fails"
},
"trackEmbedLyrics": "Вставить текст песни",
"trackEmbedLyrics": "Вписать текст песни",
"@trackEmbedLyrics": {
"description": "Action - embed lyrics into audio file"
},
@@ -1749,6 +1757,14 @@
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "Битрейт YouTube Opus",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "Битрейт YouTube MP3",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Спрашивать перед скачиванием",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
@@ -1769,7 +1785,7 @@
"@downloadUseAlbumArtistForFolders": {
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
},
"downloadUsePrimaryArtistOnly": "Primary artist only for folders",
"downloadUsePrimaryArtistOnly": "Основной исполнитель только для папок",
"@downloadUsePrimaryArtistOnly": {
"description": "Setting - strip featured artists from folder name"
},
@@ -1777,7 +1793,7 @@
"@downloadUsePrimaryArtistOnlyEnabled": {
"description": "Subtitle when primary artist only is enabled"
},
"downloadUsePrimaryArtistOnlyDisabled": "Full artist string used for folder name",
"downloadUsePrimaryArtistOnlyDisabled": "Полная строка исполнителя, используемая для имени папки",
"@downloadUsePrimaryArtistOnlyDisabled": {
"description": "Subtitle when primary artist only is disabled"
},
@@ -1817,7 +1833,7 @@
"@settingsDownloadNetwork": {
"description": "Setting for network type preference"
},
"settingsDownloadNetworkAny": "WiFi и мобильная сеть",
"settingsDownloadNetworkAny": "WiFi и Мобильная сеть",
"@settingsDownloadNetworkAny": {
"description": "Network option - use any connection"
},
@@ -1873,7 +1889,7 @@
"@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks"
},
"downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.",
"downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.",
"@downloadedAlbumDeleteMessage": {
"description": "Delete confirmation with count",
"placeholders": {
@@ -1899,7 +1915,7 @@
"@downloadedAlbumTapToSelect": {
"description": "Selection hint"
},
"downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}}",
"downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
"@downloadedAlbumDeleteCount": {
"description": "Delete button text with count",
"placeholders": {
@@ -2198,6 +2214,15 @@
"@libraryAboutDescription": {
"description": "Description of local library feature"
},
"libraryTracksUnit": "{count, plural, one {трек} few {трека} many {треков} other{треков}}",
"@libraryTracksUnit": {
"description": "Unit label for tracks count (without the number itself)",
"placeholders": {
"count": {
"type": "int"
}
}
},
"libraryLastScanned": "Последнее сканирование: {time}",
"@libraryLastScanned": {
"description": "Last scan time display",
@@ -2304,7 +2329,7 @@
"@libraryFilterQualityCD": {
"description": "Filter option - CD quality audio"
},
"libraryFilterQualityLossy": "С потерями",
"libraryFilterQualityLossy": "Lossy",
"@libraryFilterQualityLossy": {
"description": "Filter option - lossy compressed audio"
},
@@ -2410,7 +2435,7 @@
"@tutorialExtensionsDesc": {
"description": "Tutorial extensions page description"
},
"tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions",
"tutorialExtensionsTip1": "Просмотрите вкладку Магазина, чтобы найти полезные расширения",
"@tutorialExtensionsTip1": {
"description": "Tutorial extensions tip 1"
},
@@ -2418,7 +2443,7 @@
"@tutorialExtensionsTip2": {
"description": "Tutorial extensions tip 2"
},
"tutorialExtensionsTip3": "Get lyrics, enhanced metadata, and more features",
"tutorialExtensionsTip3": "Получайте тексты песен, улучшенные метаданные и другие возможности",
"@tutorialExtensionsTip3": {
"description": "Tutorial extensions tip 3"
},
@@ -2426,7 +2451,7 @@
"@tutorialSettingsTitle": {
"description": "Tutorial settings page title"
},
"tutorialSettingsDesc": "Personalize the app in Settings to match your preferences.",
"tutorialSettingsDesc": "Персонализируйте приложение в Настройках, чтобы оно соответствовало вашим предпочтениям.",
"@tutorialSettingsDesc": {
"description": "Tutorial settings page description"
},
@@ -2454,11 +2479,11 @@
"@libraryForceFullScanSubtitle": {
"description": "Subtitle for force full scan button"
},
"cleanupOrphanedDownloads": "Cleanup Orphaned Downloads",
"cleanupOrphanedDownloads": "Очистка отложенных скачиваний",
"@cleanupOrphanedDownloads": {
"description": "Button to remove history entries for deleted files"
},
"cleanupOrphanedDownloadsSubtitle": "Remove history entries for files that no longer exist",
"cleanupOrphanedDownloadsSubtitle": "Удалить историю записи для файлов, которых больше не существует",
"@cleanupOrphanedDownloadsSubtitle": {
"description": "Subtitle for orphaned cleanup button"
},
@@ -2471,7 +2496,7 @@
}
}
},
"cleanupOrphanedDownloadsNone": "No orphaned entries found",
"cleanupOrphanedDownloadsNone": "Записей без описания не найдено",
"@cleanupOrphanedDownloadsNone": {
"description": "Snackbar when no orphans found"
},
@@ -2483,11 +2508,11 @@
"@cacheSummaryTitle": {
"description": "Heading for cache summary card"
},
"cacheSummarySubtitle": "Clearing cache will not remove downloaded music files.",
"cacheSummarySubtitle": "Очистка кэша не приведет к удалению загруженных музыкальных файлов.",
"@cacheSummarySubtitle": {
"description": "Helper text for cache summary card"
},
"cacheEstimatedTotal": "Estimated cache usage: {size}",
"cacheEstimatedTotal": "Приблизительное использование кэша: {size}",
"@cacheEstimatedTotal": {
"description": "Total cache size shown in summary",
"placeholders": {
@@ -2508,47 +2533,47 @@
"@cacheAppDirectory": {
"description": "Cache item title for app cache directory"
},
"cacheAppDirectoryDesc": "HTTP responses, WebView data, and other temporary app data.",
"cacheAppDirectoryDesc": "HTTP-ответы, данные WebView и другие временные данные приложения.",
"@cacheAppDirectoryDesc": {
"description": "Description of what app cache directory contains"
},
"cacheTempDirectory": "Temporary directory",
"cacheTempDirectory": "Временная директория",
"@cacheTempDirectory": {
"description": "Cache item title for temporary files directory"
},
"cacheTempDirectoryDesc": "Temporary files from downloads and audio conversion.",
"cacheTempDirectoryDesc": "Временные файлы из загрузок и аудио конвертации.",
"@cacheTempDirectoryDesc": {
"description": "Description of what temporary directory contains"
},
"cacheCoverImage": "Cover image cache",
"cacheCoverImage": "Кэш обложек",
"@cacheCoverImage": {
"description": "Cache item title for persistent cover images"
},
"cacheCoverImageDesc": "Downloaded album and track cover art. Will re-download when viewed.",
"cacheCoverImageDesc": "Скачанный альбом и трек обложки. Будет заново скачан после просмотра.",
"@cacheCoverImageDesc": {
"description": "Description of what cover image cache contains"
},
"cacheLibraryCover": "Library cover cache",
"cacheLibraryCover": "Кэш обложек библиотеки",
"@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": "Обложка извлечена из локальных музыкальных файлов. Будет повторно извлечено при следующем сканировании.",
"@cacheLibraryCoverDesc": {
"description": "Description of what library cover cache contains"
},
"cacheExploreFeed": "Explore feed cache",
"cacheExploreFeed": "Просмотреть кэш ленты",
"@cacheExploreFeed": {
"description": "Cache item title for explore home feed cache"
},
"cacheExploreFeedDesc": "Explore tab content (new releases, trending). Will refresh on next visit.",
"cacheExploreFeedDesc": "Изучите содержимое вкладки (новые релизы, тренды). Они обновятся при следующем посещении.",
"@cacheExploreFeedDesc": {
"description": "Description of what explore feed cache contains"
},
"cacheTrackLookup": "Track lookup cache",
"cacheTrackLookup": "Отслеживать кэш поиска",
"@cacheTrackLookup": {
"description": "Cache item title for track ID lookup cache"
},
"cacheTrackLookupDesc": "Spotify/Deezer track ID lookups. Clearing may slow next few searches.",
"cacheTrackLookupDesc": "Поиск ID трека в Spotify/Deezer. Очистка может замедлить следующие несколько поисков.",
"@cacheTrackLookupDesc": {
"description": "Description of what track lookup cache contains"
},
@@ -2581,7 +2606,7 @@
}
}
},
"cacheEntries": "{count} entries",
"cacheEntries": "{count} записей",
"@cacheEntries": {
"description": "Track cache entry count",
"placeholders": {
@@ -2603,7 +2628,7 @@
"@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": "Это очистит кэш для {target}. Загруженные музыкальные файлы не будут удалены.",
"@cacheClearConfirmMessage": {
"description": "Dialog message before clearing selected cache",
"placeholders": {
@@ -2632,7 +2657,7 @@
"@cacheCleanupUnusedSubtitle": {
"description": "Subtitle for cleanup unused data action"
},
"cacheCleanupResult": "Cleanup completed: {downloadCount} orphaned downloads, {libraryCount} missing library entries",
"cacheCleanupResult": "Очистка завершена: {downloadCount} потерянных загрузок, {libraryCount} отсутствующих записей в библиотеке",
"@cacheCleanupResult": {
"description": "Snackbar after unused data cleanup",
"placeholders": {
@@ -2664,15 +2689,15 @@
"@trackSaveLyricsSubtitle": {
"description": "Subtitle for save lyrics action"
},
"trackSaveLyricsProgress": "Saving lyrics...",
"trackSaveLyricsProgress": "Сохранение текста...",
"@trackSaveLyricsProgress": {
"description": "Snackbar while saving lyrics to file"
},
"trackReEnrich": "Re-enrich",
"trackReEnrich": "Обновить",
"@trackReEnrich": {
"description": "Menu action - re-embed metadata into audio file"
},
"trackReEnrichOnlineSubtitle": "Search metadata online and embed into file",
"trackReEnrichOnlineSubtitle": "Поиск в сети метаданных и встраивание в файл",
"@trackReEnrichOnlineSubtitle": {
"description": "Subtitle for re-enrich metadata action for local items"
},
@@ -2702,7 +2727,7 @@
}
}
},
"trackReEnrichProgress": "Re-enriching metadata...",
"trackReEnrichProgress": "Обновление метаданных...",
"@trackReEnrichProgress": {
"description": "Snackbar while re-enriching metadata"
},
@@ -2710,7 +2735,7 @@
"@trackReEnrichSearching": {
"description": "Snackbar while searching metadata from internet for local items"
},
"trackReEnrichSuccess": "Metadata re-enriched successfully",
"trackReEnrichSuccess": "Метаданные успешно обновлены",
"@trackReEnrichSuccess": {
"description": "Snackbar after successful re-enrichment"
},
@@ -2727,31 +2752,31 @@
}
}
},
"trackConvertFormat": "Convert Format",
"trackConvertFormat": "Переконвертировать формат",
"@trackConvertFormat": {
"description": "Menu item - convert audio format"
},
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
"trackConvertFormatSubtitle": "Конвертировать в MP3 или Opus",
"@trackConvertFormatSubtitle": {
"description": "Subtitle for convert format menu item"
},
"trackConvertTitle": "Convert Audio",
"trackConvertTitle": "Конвертировать аудио",
"@trackConvertTitle": {
"description": "Title of convert bottom sheet"
},
"trackConvertTargetFormat": "Target Format",
"trackConvertTargetFormat": "Целевой формат",
"@trackConvertTargetFormat": {
"description": "Label for format selection"
},
"trackConvertBitrate": "Bitrate",
"trackConvertBitrate": "Битрейт",
"@trackConvertBitrate": {
"description": "Label for bitrate selection"
},
"trackConvertConfirmTitle": "Confirm Conversion",
"trackConvertConfirmTitle": "Подтвердить конвертацию",
"@trackConvertConfirmTitle": {
"description": "Confirmation dialog title"
},
"trackConvertConfirmMessage": "Convert from {sourceFormat} to {targetFormat} at {bitrate}?\n\nThe original file will be deleted after conversion.",
"trackConvertConfirmMessage": "Конвертировать из {sourceFormat} в {targetFormat} {bitrate}?\n\nОригинальный файл будет удален после конвертации.",
"@trackConvertConfirmMessage": {
"description": "Confirmation dialog message",
"placeholders": {
@@ -2766,11 +2791,11 @@
}
}
},
"trackConvertConverting": "Converting audio...",
"trackConvertConverting": "Конвертация аудио...",
"@trackConvertConverting": {
"description": "Snackbar while converting"
},
"trackConvertSuccess": "Converted to {format} successfully",
"trackConvertSuccess": "Успешно конвертировано в {format}",
"@trackConvertSuccess": {
"description": "Snackbar after successful conversion",
"placeholders": {
@@ -2779,10 +2804,287 @@
}
}
},
"trackConvertFailed": "Conversion failed",
"trackConvertFailed": "Ошибка конвертации",
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
},
"actionCreate": "Создать",
"@actionCreate": {
"description": "Generic action button - create"
},
"collectionFoldersTitle": "Мои папки",
"@collectionFoldersTitle": {
"description": "Library section title for custom folders"
},
"collectionWishlist": "Список желаемого",
"@collectionWishlist": {
"description": "Custom folder for saved tracks to download later"
},
"collectionLoved": "Любимые",
"@collectionLoved": {
"description": "Custom folder for favorite tracks"
},
"collectionPlaylists": "Плейлисты",
"@collectionPlaylists": {
"description": "Custom user playlists folder"
},
"collectionPlaylist": "Плейлист",
"@collectionPlaylist": {
"description": "Single playlist label"
},
"collectionAddToPlaylist": "Добавить в плейлист",
"@collectionAddToPlaylist": {
"description": "Action to add a track to user playlist"
},
"collectionCreatePlaylist": "Создать плейлист",
"@collectionCreatePlaylist": {
"description": "Action to create a new playlist"
},
"collectionNoPlaylistsYet": "Плейлисты отсутствуют",
"@collectionNoPlaylistsYet": {
"description": "Empty state title when user has no playlists"
},
"collectionNoPlaylistsSubtitle": "Создайте плейлист, чтобы начать классифицировать треки",
"@collectionNoPlaylistsSubtitle": {
"description": "Empty state subtitle when user has no playlists"
},
"collectionPlaylistTracks": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}",
"@collectionPlaylistTracks": {
"description": "Track count label for custom playlists",
"placeholders": {
"count": {
"type": "int"
}
}
},
"collectionAddedToPlaylist": "Добавлено в \"{playlistName}\"",
"@collectionAddedToPlaylist": {
"description": "Snackbar after adding track to playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionAlreadyInPlaylist": "Уже в \"{playlistName}\"",
"@collectionAlreadyInPlaylist": {
"description": "Snackbar when track already exists in playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistCreated": "Плейлист создан",
"@collectionPlaylistCreated": {
"description": "Snackbar after creating playlist"
},
"collectionPlaylistNameHint": "Название плейлиста",
"@collectionPlaylistNameHint": {
"description": "Hint text for playlist name input"
},
"collectionPlaylistNameRequired": "Имя плейлиста обязательно",
"@collectionPlaylistNameRequired": {
"description": "Validation error for empty playlist name"
},
"collectionRenamePlaylist": "Переименовать плейлист",
"@collectionRenamePlaylist": {
"description": "Action to rename playlist"
},
"collectionDeletePlaylist": "Удалить плейлист",
"@collectionDeletePlaylist": {
"description": "Action to delete playlist"
},
"collectionDeletePlaylistMessage": "Удалить \"{playlistName}\" и все треки внутри него?",
"@collectionDeletePlaylistMessage": {
"description": "Confirmation message for deleting playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistDeleted": "Плейлист удалён",
"@collectionPlaylistDeleted": {
"description": "Snackbar after deleting playlist"
},
"collectionPlaylistRenamed": "Плейлист переименован",
"@collectionPlaylistRenamed": {
"description": "Snackbar after renaming playlist"
},
"collectionWishlistEmptyTitle": "Список желаний пуст",
"@collectionWishlistEmptyTitle": {
"description": "Wishlist empty state title"
},
"collectionWishlistEmptySubtitle": "Нажмите + на треках, чтобы сохранить то, что вы хотите скачать позже",
"@collectionWishlistEmptySubtitle": {
"description": "Wishlist empty state subtitle"
},
"collectionLovedEmptyTitle": "Папка Любимые пуста",
"@collectionLovedEmptyTitle": {
"description": "Loved empty state title"
},
"collectionLovedEmptySubtitle": "Нажмите \"любовь\" на треках, чтобы сохранить ваши избранные",
"@collectionLovedEmptySubtitle": {
"description": "Loved empty state subtitle"
},
"collectionPlaylistEmptyTitle": "Плейлист пуст",
"@collectionPlaylistEmptyTitle": {
"description": "Playlist empty state title"
},
"collectionPlaylistEmptySubtitle": "Удерживайте + на любом треке, чтобы добавить его сюда",
"@collectionPlaylistEmptySubtitle": {
"description": "Playlist empty state subtitle"
},
"collectionRemoveFromPlaylist": "Удалить из плейлиста",
"@collectionRemoveFromPlaylist": {
"description": "Tooltip for removing track from playlist"
},
"collectionRemoveFromFolder": "Убрать из папки",
"@collectionRemoveFromFolder": {
"description": "Tooltip for removing track from wishlist/loved folder"
},
"collectionRemoved": "\"{trackName}\" удалён",
"@collectionRemoved": {
"description": "Snackbar after removing a track from a collection",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToLoved": "\"{trackName}\" добавлен в Любимые",
"@collectionAddedToLoved": {
"description": "Snackbar after adding track to loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromLoved": "\"{trackName}\" удалено из Любимых",
"@collectionRemovedFromLoved": {
"description": "Snackbar after removing track from loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToWishlist": "\"{trackName}\" добавлен в список желаний",
"@collectionAddedToWishlist": {
"description": "Snackbar after adding track to wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromWishlist": "\"{trackName}\" удалён из списка желаний",
"@collectionRemovedFromWishlist": {
"description": "Snackbar after removing track from wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"trackOptionAddToLoved": "Добавить в Любимое",
"@trackOptionAddToLoved": {
"description": "Bottom sheet action label - add track to loved folder"
},
"trackOptionRemoveFromLoved": "Исключить из Любимых",
"@trackOptionRemoveFromLoved": {
"description": "Bottom sheet action label - remove track from loved folder"
},
"trackOptionAddToWishlist": "Добавить в список желаний",
"@trackOptionAddToWishlist": {
"description": "Bottom sheet action label - add track to wishlist"
},
"trackOptionRemoveFromWishlist": "Удалить из списка желаний",
"@trackOptionRemoveFromWishlist": {
"description": "Bottom sheet action label - remove track from wishlist"
},
"collectionPlaylistChangeCover": "Изменить обложку",
"@collectionPlaylistChangeCover": {
"description": "Bottom sheet action to pick a custom cover image for a playlist"
},
"collectionPlaylistRemoveCover": "Удалить обложку",
"@collectionPlaylistRemoveCover": {
"description": "Bottom sheet action to remove custom cover image from a playlist"
},
"selectionShareCount": "Отправить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
"@selectionShareCount": {
"description": "Share button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionShareNoFiles": "No shareable files found",
"@selectionShareNoFiles": {
"description": "Snackbar when no selected files exist on disk"
},
"selectionConvertCount": "Конвертировать {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
"@selectionConvertCount": {
"description": "Convert button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionConvertNoConvertible": "Не выбраны конвертируемые треки",
"@selectionConvertNoConvertible": {
"description": "Snackbar when no selected tracks support conversion"
},
"selectionBatchConvertConfirmTitle": "Пакетная конвертация",
"@selectionBatchConvertConfirmTitle": {
"description": "Confirmation dialog title for batch conversion"
},
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
"@selectionBatchConvertConfirmMessage": {
"description": "Confirmation dialog message for batch conversion",
"placeholders": {
"count": {
"type": "int"
},
"format": {
"type": "String"
},
"bitrate": {
"type": "String"
}
}
},
"selectionBatchConvertProgress": "Конвертация {current} из {total}...",
"@selectionBatchConvertProgress": {
"description": "Snackbar during batch conversion progress",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"selectionBatchConvertSuccess": "Конвертировано {success} треков {total} в {format}",
"@selectionBatchConvertSuccess": {
"description": "Snackbar after batch conversion completes",
"placeholders": {
"success": {
"type": "int"
},
"total": {
"type": "int"
},
"format": {
"type": "String"
}
}
},
"downloadedAlbumDownloadedCount": "{count} скачано",
"@downloadedAlbumDownloadedCount": {
"description": "Downloaded tracks count badge",
@@ -2792,7 +3094,7 @@
}
}
},
"downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available",
"downloadUseAlbumArtistForFoldersAlbumSubtitle": "Для папок исполнителей используется исполнитель альбома, если он указан",
"@downloadUseAlbumArtistForFoldersAlbumSubtitle": {
"description": "Subtitle when Album Artist is used for folder naming"
},
@@ -2800,4 +3102,4 @@
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming"
}
}
}
+3 -3
View File
@@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Spotify şarkılarını Tidal, Qobuz ve Amazon Music'den yüksek kalitede indir.",
"aboutAppDescription": "Spotify şarkılarını Tidal ve Qobuz'den yüksek kalitede indir.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -1089,7 +1089,7 @@
},
"providerBuiltIn": "Dahili",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Eklenti",
"@providerExtension": {
@@ -2358,7 +2358,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
"tutorialWelcomeTip2": "Tidal, Qobuz veya Deezer'den FLAC kalitesinde ses alın",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},
+2 -2
View File
@@ -402,7 +402,7 @@
"@aboutDabMusicDesc": {
"description": "Credit for DAB Music API"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -1005,7 +1005,7 @@
},
"providerBuiltIn": "Built-in",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Extension",
"@providerExtension": {
+303 -1
View File
@@ -991,6 +991,14 @@
"@filenameFormat": {
"description": "Setting title - filename pattern"
},
"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"
},
"folderOrganizationNone": "No organization",
"@folderOrganizationNone": {
"description": "Folder option - flat structure"
@@ -1749,6 +1757,14 @@
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
@@ -2198,6 +2214,15 @@
"@libraryAboutDescription": {
"description": "Description of local library feature"
},
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
"@libraryTracksUnit": {
"description": "Unit label for tracks count (without the number itself)",
"placeholders": {
"count": {
"type": "int"
}
}
},
"libraryLastScanned": "Last scanned: {time}",
"@libraryLastScanned": {
"description": "Last scan time display",
@@ -2783,6 +2808,283 @@
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
},
"actionCreate": "Create",
"@actionCreate": {
"description": "Generic action button - create"
},
"collectionFoldersTitle": "My folders",
"@collectionFoldersTitle": {
"description": "Library section title for custom folders"
},
"collectionWishlist": "Wishlist",
"@collectionWishlist": {
"description": "Custom folder for saved tracks to download later"
},
"collectionLoved": "Loved",
"@collectionLoved": {
"description": "Custom folder for favorite tracks"
},
"collectionPlaylists": "Playlists",
"@collectionPlaylists": {
"description": "Custom user playlists folder"
},
"collectionPlaylist": "Playlist",
"@collectionPlaylist": {
"description": "Single playlist label"
},
"collectionAddToPlaylist": "Add to playlist",
"@collectionAddToPlaylist": {
"description": "Action to add a track to user playlist"
},
"collectionCreatePlaylist": "Create playlist",
"@collectionCreatePlaylist": {
"description": "Action to create a new playlist"
},
"collectionNoPlaylistsYet": "No playlists yet",
"@collectionNoPlaylistsYet": {
"description": "Empty state title when user has no playlists"
},
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
"@collectionNoPlaylistsSubtitle": {
"description": "Empty state subtitle when user has no playlists"
},
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
"@collectionPlaylistTracks": {
"description": "Track count label for custom playlists",
"placeholders": {
"count": {
"type": "int"
}
}
},
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
"@collectionAddedToPlaylist": {
"description": "Snackbar after adding track to playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
"@collectionAlreadyInPlaylist": {
"description": "Snackbar when track already exists in playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistCreated": "Playlist created",
"@collectionPlaylistCreated": {
"description": "Snackbar after creating playlist"
},
"collectionPlaylistNameHint": "Playlist name",
"@collectionPlaylistNameHint": {
"description": "Hint text for playlist name input"
},
"collectionPlaylistNameRequired": "Playlist name is required",
"@collectionPlaylistNameRequired": {
"description": "Validation error for empty playlist name"
},
"collectionRenamePlaylist": "Rename playlist",
"@collectionRenamePlaylist": {
"description": "Action to rename playlist"
},
"collectionDeletePlaylist": "Delete playlist",
"@collectionDeletePlaylist": {
"description": "Action to delete playlist"
},
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
"@collectionDeletePlaylistMessage": {
"description": "Confirmation message for deleting playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistDeleted": "Playlist deleted",
"@collectionPlaylistDeleted": {
"description": "Snackbar after deleting playlist"
},
"collectionPlaylistRenamed": "Playlist renamed",
"@collectionPlaylistRenamed": {
"description": "Snackbar after renaming playlist"
},
"collectionWishlistEmptyTitle": "Wishlist is empty",
"@collectionWishlistEmptyTitle": {
"description": "Wishlist empty state title"
},
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
"@collectionWishlistEmptySubtitle": {
"description": "Wishlist empty state subtitle"
},
"collectionLovedEmptyTitle": "Loved folder is empty",
"@collectionLovedEmptyTitle": {
"description": "Loved empty state title"
},
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
"@collectionLovedEmptySubtitle": {
"description": "Loved empty state subtitle"
},
"collectionPlaylistEmptyTitle": "Playlist is empty",
"@collectionPlaylistEmptyTitle": {
"description": "Playlist empty state title"
},
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
"@collectionPlaylistEmptySubtitle": {
"description": "Playlist empty state subtitle"
},
"collectionRemoveFromPlaylist": "Remove from playlist",
"@collectionRemoveFromPlaylist": {
"description": "Tooltip for removing track from playlist"
},
"collectionRemoveFromFolder": "Remove from folder",
"@collectionRemoveFromFolder": {
"description": "Tooltip for removing track from wishlist/loved folder"
},
"collectionRemoved": "\"{trackName}\" removed",
"@collectionRemoved": {
"description": "Snackbar after removing a track from a collection",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
"@collectionAddedToLoved": {
"description": "Snackbar after adding track to loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
"@collectionRemovedFromLoved": {
"description": "Snackbar after removing track from loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
"@collectionAddedToWishlist": {
"description": "Snackbar after adding track to wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
"@collectionRemovedFromWishlist": {
"description": "Snackbar after removing track from wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"trackOptionAddToLoved": "Add to Loved",
"@trackOptionAddToLoved": {
"description": "Bottom sheet action label - add track to loved folder"
},
"trackOptionRemoveFromLoved": "Remove from Loved",
"@trackOptionRemoveFromLoved": {
"description": "Bottom sheet action label - remove track from loved folder"
},
"trackOptionAddToWishlist": "Add to Wishlist",
"@trackOptionAddToWishlist": {
"description": "Bottom sheet action label - add track to wishlist"
},
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
"@trackOptionRemoveFromWishlist": {
"description": "Bottom sheet action label - remove track from wishlist"
},
"collectionPlaylistChangeCover": "Change cover image",
"@collectionPlaylistChangeCover": {
"description": "Bottom sheet action to pick a custom cover image for a playlist"
},
"collectionPlaylistRemoveCover": "Remove cover image",
"@collectionPlaylistRemoveCover": {
"description": "Bottom sheet action to remove custom cover image from a playlist"
},
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
"@selectionShareCount": {
"description": "Share button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionShareNoFiles": "No shareable files found",
"@selectionShareNoFiles": {
"description": "Snackbar when no selected files exist on disk"
},
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
"@selectionConvertCount": {
"description": "Convert button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionConvertNoConvertible": "No convertible tracks selected",
"@selectionConvertNoConvertible": {
"description": "Snackbar when no selected tracks support conversion"
},
"selectionBatchConvertConfirmTitle": "Batch Convert",
"@selectionBatchConvertConfirmTitle": {
"description": "Confirmation dialog title for batch conversion"
},
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
"@selectionBatchConvertConfirmMessage": {
"description": "Confirmation dialog message for batch conversion",
"placeholders": {
"count": {
"type": "int"
},
"format": {
"type": "String"
},
"bitrate": {
"type": "String"
}
}
},
"selectionBatchConvertProgress": "Converting {current} of {total}...",
"@selectionBatchConvertProgress": {
"description": "Snackbar during batch conversion progress",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
"@selectionBatchConvertSuccess": {
"description": "Snackbar after batch conversion completes",
"placeholders": {
"success": {
"type": "int"
},
"total": {
"type": "int"
},
"format": {
"type": "String"
}
}
},
"downloadedAlbumDownloadedCount": "{count} downloaded",
"@downloadedAlbumDownloadedCount": {
"description": "Downloaded tracks count badge",
@@ -2800,4 +3102,4 @@
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming"
}
}
}
+303 -1
View File
@@ -991,6 +991,14 @@
"@filenameFormat": {
"description": "Setting title - filename pattern"
},
"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"
},
"folderOrganizationNone": "No organization",
"@folderOrganizationNone": {
"description": "Folder option - flat structure"
@@ -1749,6 +1757,14 @@
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
@@ -2198,6 +2214,15 @@
"@libraryAboutDescription": {
"description": "Description of local library feature"
},
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
"@libraryTracksUnit": {
"description": "Unit label for tracks count (without the number itself)",
"placeholders": {
"count": {
"type": "int"
}
}
},
"libraryLastScanned": "Last scanned: {time}",
"@libraryLastScanned": {
"description": "Last scan time display",
@@ -2783,6 +2808,283 @@
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
},
"actionCreate": "Create",
"@actionCreate": {
"description": "Generic action button - create"
},
"collectionFoldersTitle": "My folders",
"@collectionFoldersTitle": {
"description": "Library section title for custom folders"
},
"collectionWishlist": "Wishlist",
"@collectionWishlist": {
"description": "Custom folder for saved tracks to download later"
},
"collectionLoved": "Loved",
"@collectionLoved": {
"description": "Custom folder for favorite tracks"
},
"collectionPlaylists": "Playlists",
"@collectionPlaylists": {
"description": "Custom user playlists folder"
},
"collectionPlaylist": "Playlist",
"@collectionPlaylist": {
"description": "Single playlist label"
},
"collectionAddToPlaylist": "Add to playlist",
"@collectionAddToPlaylist": {
"description": "Action to add a track to user playlist"
},
"collectionCreatePlaylist": "Create playlist",
"@collectionCreatePlaylist": {
"description": "Action to create a new playlist"
},
"collectionNoPlaylistsYet": "No playlists yet",
"@collectionNoPlaylistsYet": {
"description": "Empty state title when user has no playlists"
},
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
"@collectionNoPlaylistsSubtitle": {
"description": "Empty state subtitle when user has no playlists"
},
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
"@collectionPlaylistTracks": {
"description": "Track count label for custom playlists",
"placeholders": {
"count": {
"type": "int"
}
}
},
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
"@collectionAddedToPlaylist": {
"description": "Snackbar after adding track to playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
"@collectionAlreadyInPlaylist": {
"description": "Snackbar when track already exists in playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistCreated": "Playlist created",
"@collectionPlaylistCreated": {
"description": "Snackbar after creating playlist"
},
"collectionPlaylistNameHint": "Playlist name",
"@collectionPlaylistNameHint": {
"description": "Hint text for playlist name input"
},
"collectionPlaylistNameRequired": "Playlist name is required",
"@collectionPlaylistNameRequired": {
"description": "Validation error for empty playlist name"
},
"collectionRenamePlaylist": "Rename playlist",
"@collectionRenamePlaylist": {
"description": "Action to rename playlist"
},
"collectionDeletePlaylist": "Delete playlist",
"@collectionDeletePlaylist": {
"description": "Action to delete playlist"
},
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
"@collectionDeletePlaylistMessage": {
"description": "Confirmation message for deleting playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistDeleted": "Playlist deleted",
"@collectionPlaylistDeleted": {
"description": "Snackbar after deleting playlist"
},
"collectionPlaylistRenamed": "Playlist renamed",
"@collectionPlaylistRenamed": {
"description": "Snackbar after renaming playlist"
},
"collectionWishlistEmptyTitle": "Wishlist is empty",
"@collectionWishlistEmptyTitle": {
"description": "Wishlist empty state title"
},
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
"@collectionWishlistEmptySubtitle": {
"description": "Wishlist empty state subtitle"
},
"collectionLovedEmptyTitle": "Loved folder is empty",
"@collectionLovedEmptyTitle": {
"description": "Loved empty state title"
},
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
"@collectionLovedEmptySubtitle": {
"description": "Loved empty state subtitle"
},
"collectionPlaylistEmptyTitle": "Playlist is empty",
"@collectionPlaylistEmptyTitle": {
"description": "Playlist empty state title"
},
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
"@collectionPlaylistEmptySubtitle": {
"description": "Playlist empty state subtitle"
},
"collectionRemoveFromPlaylist": "Remove from playlist",
"@collectionRemoveFromPlaylist": {
"description": "Tooltip for removing track from playlist"
},
"collectionRemoveFromFolder": "Remove from folder",
"@collectionRemoveFromFolder": {
"description": "Tooltip for removing track from wishlist/loved folder"
},
"collectionRemoved": "\"{trackName}\" removed",
"@collectionRemoved": {
"description": "Snackbar after removing a track from a collection",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
"@collectionAddedToLoved": {
"description": "Snackbar after adding track to loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
"@collectionRemovedFromLoved": {
"description": "Snackbar after removing track from loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
"@collectionAddedToWishlist": {
"description": "Snackbar after adding track to wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
"@collectionRemovedFromWishlist": {
"description": "Snackbar after removing track from wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"trackOptionAddToLoved": "Add to Loved",
"@trackOptionAddToLoved": {
"description": "Bottom sheet action label - add track to loved folder"
},
"trackOptionRemoveFromLoved": "Remove from Loved",
"@trackOptionRemoveFromLoved": {
"description": "Bottom sheet action label - remove track from loved folder"
},
"trackOptionAddToWishlist": "Add to Wishlist",
"@trackOptionAddToWishlist": {
"description": "Bottom sheet action label - add track to wishlist"
},
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
"@trackOptionRemoveFromWishlist": {
"description": "Bottom sheet action label - remove track from wishlist"
},
"collectionPlaylistChangeCover": "Change cover image",
"@collectionPlaylistChangeCover": {
"description": "Bottom sheet action to pick a custom cover image for a playlist"
},
"collectionPlaylistRemoveCover": "Remove cover image",
"@collectionPlaylistRemoveCover": {
"description": "Bottom sheet action to remove custom cover image from a playlist"
},
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
"@selectionShareCount": {
"description": "Share button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionShareNoFiles": "No shareable files found",
"@selectionShareNoFiles": {
"description": "Snackbar when no selected files exist on disk"
},
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
"@selectionConvertCount": {
"description": "Convert button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionConvertNoConvertible": "No convertible tracks selected",
"@selectionConvertNoConvertible": {
"description": "Snackbar when no selected tracks support conversion"
},
"selectionBatchConvertConfirmTitle": "Batch Convert",
"@selectionBatchConvertConfirmTitle": {
"description": "Confirmation dialog title for batch conversion"
},
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
"@selectionBatchConvertConfirmMessage": {
"description": "Confirmation dialog message for batch conversion",
"placeholders": {
"count": {
"type": "int"
},
"format": {
"type": "String"
},
"bitrate": {
"type": "String"
}
}
},
"selectionBatchConvertProgress": "Converting {current} of {total}...",
"@selectionBatchConvertProgress": {
"description": "Snackbar during batch conversion progress",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
"@selectionBatchConvertSuccess": {
"description": "Snackbar after batch conversion completes",
"placeholders": {
"success": {
"type": "int"
},
"total": {
"type": "int"
},
"format": {
"type": "String"
}
}
},
"downloadedAlbumDownloadedCount": "{count} downloaded",
"@downloadedAlbumDownloadedCount": {
"description": "Downloaded tracks count badge",
@@ -2800,4 +3102,4 @@
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming"
}
}
}
+4
View File
@@ -34,6 +34,7 @@ class DownloadItem {
final DownloadErrorType? errorType;
final DateTime createdAt;
final String? qualityOverride; // Override quality for this specific download
final String? playlistName; // Playlist context for folder organization
const DownloadItem({
required this.id,
@@ -48,6 +49,7 @@ class DownloadItem {
this.errorType,
required this.createdAt,
this.qualityOverride,
this.playlistName,
});
DownloadItem copyWith({
@@ -63,6 +65,7 @@ class DownloadItem {
DownloadErrorType? errorType,
DateTime? createdAt,
String? qualityOverride,
String? playlistName,
}) {
return DownloadItem(
id: id ?? this.id,
@@ -77,6 +80,7 @@ class DownloadItem {
errorType: errorType ?? this.errorType,
createdAt: createdAt ?? this.createdAt,
qualityOverride: qualityOverride ?? this.qualityOverride,
playlistName: playlistName ?? this.playlistName,
);
}
+2
View File
@@ -21,6 +21,7 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']),
createdAt: DateTime.parse(json['createdAt'] as String),
qualityOverride: json['qualityOverride'] as String?,
playlistName: json['playlistName'] as String?,
);
Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
@@ -37,6 +38,7 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
'errorType': _$DownloadErrorTypeEnumMap[instance.errorType],
'createdAt': instance.createdAt.toIso8601String(),
'qualityOverride': instance.qualityOverride,
'playlistName': instance.playlistName,
};
const _$DownloadStatusEnumMap = {
+7 -18
View File
@@ -55,17 +55,16 @@ class AppSettings {
final String
songLinkRegion; // SongLink userCountry region code used for platform lookup
// Local Library Settings
final bool localLibraryEnabled; // Enable local library scanning
final String localLibraryPath; // Path to scan for audio files
final String
localLibraryBookmark; // Base64-encoded iOS security-scoped bookmark
final bool
localLibraryShowDuplicates; // Show indicator when searching for existing tracks
// Tutorial/Onboarding
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
@@ -77,7 +76,6 @@ class AppSettings {
final String
musixmatchLanguage; // Optional ISO language code for Musixmatch localized lyrics
// Version upgrade tracking
final String
lastSeenVersion; // Last app version the user has acknowledged (e.g. '3.7.0')
@@ -106,7 +104,7 @@ class AppSettings {
this.askQualityBeforeDownload = true,
this.spotifyClientId = '',
this.spotifyClientSecret = '',
this.useCustomSpotifyCredentials = true,
this.useCustomSpotifyCredentials = false,
this.metadataSource = 'deezer',
this.enableLogging = false,
this.useExtensionProviders = true,
@@ -124,13 +122,11 @@ class AppSettings {
this.downloadNetworkMode = 'any',
this.networkCompatibilityMode = false,
this.songLinkRegion = 'US',
// Local Library defaults
this.localLibraryEnabled = false,
this.localLibraryPath = '',
this.localLibraryBookmark = '',
this.localLibraryShowDuplicates = true,
// Tutorial default
this.hasCompletedTutorial = false,
// Lyrics providers default order
this.lyricsProviders = const [
'lrclib',
'spotify_api',
@@ -143,7 +139,6 @@ class AppSettings {
this.lyricsIncludeRomanizationNetease = false,
this.lyricsMultiPersonWordByWord = false,
this.musixmatchLanguage = '',
// Version upgrade tracking
this.lastSeenVersion = '',
});
@@ -154,7 +149,7 @@ class AppSettings {
String? downloadDirectory,
String? storageMode,
String? downloadTreeUri,
bool? autoFallback,
bool? autoFallback,
bool? embedMetadata,
bool? embedLyrics,
bool? maxQualityCover,
@@ -191,19 +186,16 @@ class AppSettings {
String? downloadNetworkMode,
bool? networkCompatibilityMode,
String? songLinkRegion,
// Local Library
bool? localLibraryEnabled,
String? localLibraryPath,
String? localLibraryBookmark,
bool? localLibraryShowDuplicates,
// Tutorial
bool? hasCompletedTutorial,
// Lyrics providers
List<String>? lyricsProviders,
bool? lyricsIncludeTranslationNetease,
bool? lyricsIncludeRomanizationNetease,
bool? lyricsMultiPersonWordByWord,
String? musixmatchLanguage,
// Version upgrade tracking
String? lastSeenVersion,
}) {
return AppSettings(
@@ -259,14 +251,12 @@ class AppSettings {
networkCompatibilityMode:
networkCompatibilityMode ?? this.networkCompatibilityMode,
songLinkRegion: songLinkRegion ?? this.songLinkRegion,
// Local Library
localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled,
localLibraryPath: localLibraryPath ?? this.localLibraryPath,
localLibraryBookmark: localLibraryBookmark ?? this.localLibraryBookmark,
localLibraryShowDuplicates:
localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
// Tutorial
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
// Lyrics providers
lyricsProviders: lyricsProviders ?? this.lyricsProviders,
lyricsIncludeTranslationNetease:
lyricsIncludeTranslationNetease ??
@@ -277,7 +267,6 @@ class AppSettings {
lyricsMultiPersonWordByWord:
lyricsMultiPersonWordByWord ?? this.lyricsMultiPersonWordByWord,
musixmatchLanguage: musixmatchLanguage ?? this.musixmatchLanguage,
// Version upgrade tracking
lastSeenVersion: lastSeenVersion ?? this.lastSeenVersion,
);
}
+3 -1
View File
@@ -33,7 +33,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
spotifyClientId: json['spotifyClientId'] as String? ?? '',
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
useCustomSpotifyCredentials:
json['useCustomSpotifyCredentials'] as bool? ?? true,
json['useCustomSpotifyCredentials'] as bool? ?? false,
metadataSource: json['metadataSource'] as String? ?? 'deezer',
enableLogging: json['enableLogging'] as bool? ?? false,
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
@@ -55,6 +55,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
songLinkRegion: json['songLinkRegion'] as String? ?? 'US',
localLibraryEnabled: json['localLibraryEnabled'] as bool? ?? false,
localLibraryPath: json['localLibraryPath'] as String? ?? '',
localLibraryBookmark: json['localLibraryBookmark'] as String? ?? '',
localLibraryShowDuplicates:
json['localLibraryShowDuplicates'] as bool? ?? true,
hasCompletedTutorial: json['hasCompletedTutorial'] as bool? ?? false,
@@ -128,6 +129,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'songLinkRegion': instance.songLinkRegion,
'localLibraryEnabled': instance.localLibraryEnabled,
'localLibraryPath': instance.localLibraryPath,
'localLibraryBookmark': instance.localLibraryBookmark,
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
'hasCompletedTutorial': instance.hasCompletedTutorial,
'lyricsProviders': instance.lyricsProviders,
+13 -1
View File
@@ -21,6 +21,7 @@ class Track {
final ServiceAvailability? availability;
final String? source;
final String? albumType;
final int? totalTracks;
final String? itemType;
const Track({
@@ -41,10 +42,21 @@ class Track {
this.availability,
this.source,
this.albumType,
this.totalTracks,
this.itemType,
});
bool get isSingle => albumType == 'single' || albumType == 'ep';
bool get isSingle {
switch (albumType?.toLowerCase()) {
case 'single':
return true;
case 'ep':
final count = totalTracks;
return count == null || count <= 1;
default:
return false;
}
}
bool get isAlbumItem => itemType == 'album';
+2
View File
@@ -28,6 +28,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
),
source: json['source'] as String?,
albumType: json['albumType'] as String?,
totalTracks: (json['totalTracks'] as num?)?.toInt(),
itemType: json['itemType'] as String?,
);
@@ -49,6 +50,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
'availability': instance.availability,
'source': instance.source,
'albumType': instance.albumType,
'totalTracks': instance.totalTracks,
'itemType': instance.itemType,
};
+277 -31
View File
@@ -251,9 +251,11 @@ class DownloadHistoryState {
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
static const int _safRepairBatchSize = 20;
static const int _safRepairMaxPerLaunch = 60;
static const int _audioMetadataBackfillMaxPerLaunch = 24;
final HistoryDatabase _db = HistoryDatabase.instance;
bool _isLoaded = false;
bool _isSafRepairInProgress = false;
bool _isAudioMetadataBackfillInProgress = false;
@override
DownloadHistoryState build() {
@@ -298,9 +300,19 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
maxItems: _safRepairMaxPerLaunch,
);
await cleanupOrphanedDownloads();
await _backfillAudioMetadata(
state.items,
maxItems: _audioMetadataBackfillMaxPerLaunch,
);
});
} else {
Future.microtask(() => cleanupOrphanedDownloads());
Future.microtask(() async {
await cleanupOrphanedDownloads();
await _backfillAudioMetadata(
state.items,
maxItems: _audioMetadataBackfillMaxPerLaunch,
);
});
}
} catch (e, stack) {
_historyLog.e('Failed to load history from database: $e', e, stack);
@@ -429,6 +441,157 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
}
}
int? _readPositiveInt(dynamic value) {
if (value == null) return null;
if (value is num) {
final asInt = value.toInt();
return asInt > 0 ? asInt : null;
}
final parsed = int.tryParse(value.toString());
if (parsed == null || parsed <= 0) return null;
return parsed;
}
bool _supportsAudioMetadataProbe(String filePath) {
final trimmed = filePath.trim().toLowerCase();
if (trimmed.isEmpty) return false;
if (trimmed.startsWith('content://')) return true;
return trimmed.endsWith('.flac') ||
trimmed.endsWith('.m4a') ||
trimmed.endsWith('.aac') ||
trimmed.endsWith('.mp3') ||
trimmed.endsWith('.opus') ||
trimmed.endsWith('.ogg');
}
bool _shouldBackfillAudioMetadata(DownloadHistoryItem item) {
if (!_supportsAudioMetadataProbe(item.filePath)) {
return false;
}
final trimmedPath = item.filePath.trim().toLowerCase();
final hasResolvedSpecs =
item.bitDepth != null &&
item.bitDepth! > 0 &&
item.sampleRate != null &&
item.sampleRate! > 0;
final needsLosslessSpecProbe =
!hasResolvedSpecs &&
(trimmedPath.endsWith('.flac') ||
trimmedPath.endsWith('.m4a') ||
trimmedPath.endsWith('.aac') ||
trimmedPath.startsWith('content://'));
if (hasResolvedSpecs && !isPlaceholderQualityLabel(item.quality)) {
return false;
}
return needsLosslessSpecProbe ||
isPlaceholderQualityLabel(item.quality) ||
normalizeOptionalString(item.quality) == null;
}
Future<Map<String, dynamic>?> _probeAudioMetadata(
String filePath, {
String? fallbackQuality,
}) async {
if (!_supportsAudioMetadataProbe(filePath)) {
return null;
}
try {
final result = await PlatformBridge.readFileMetadata(filePath);
if (result['error'] != null) {
return null;
}
final bitDepth = _readPositiveInt(result['bit_depth']);
final sampleRate = _readPositiveInt(result['sample_rate']);
final quality = buildDisplayAudioQuality(
bitDepth: bitDepth,
sampleRate: sampleRate,
storedQuality: fallbackQuality,
);
if (quality == null && bitDepth == null && sampleRate == null) {
return null;
}
return {
'quality': quality,
'bitDepth': bitDepth,
'sampleRate': sampleRate,
};
} catch (e) {
_historyLog.d('Audio metadata probe failed for $filePath: $e');
return null;
}
}
Future<void> _backfillAudioMetadata(
List<DownloadHistoryItem> items, {
required int maxItems,
}) async {
if (_isAudioMetadataBackfillInProgress || items.isEmpty) {
return;
}
_isAudioMetadataBackfillInProgress = true;
try {
var refreshedCount = 0;
for (final item in items) {
if (refreshedCount >= maxItems) {
break;
}
if (!_shouldBackfillAudioMetadata(item)) {
continue;
}
final probed = await _probeAudioMetadata(
item.filePath,
fallbackQuality: item.quality,
);
if (probed == null) {
continue;
}
final resolvedQuality = normalizeOptionalString(
probed['quality'] as String?,
);
final resolvedBitDepth = probed['bitDepth'] as int?;
final resolvedSampleRate = probed['sampleRate'] as int?;
final qualityChanged =
resolvedQuality != null && resolvedQuality != item.quality;
final bitDepthChanged =
resolvedBitDepth != null && resolvedBitDepth != item.bitDepth;
final sampleRateChanged =
resolvedSampleRate != null && resolvedSampleRate != item.sampleRate;
if (!qualityChanged && !bitDepthChanged && !sampleRateChanged) {
continue;
}
await updateAudioMetadataForItem(
id: item.id,
quality: resolvedQuality,
bitDepth: resolvedBitDepth,
sampleRate: resolvedSampleRate,
);
refreshedCount++;
}
if (refreshedCount > 0) {
_historyLog.i(
'Audio metadata backfill refreshed $refreshedCount items',
);
}
} finally {
_isAudioMetadataBackfillInProgress = false;
}
}
Future<void> reloadFromStorage() async {
await _loadFromDatabase();
}
@@ -509,6 +672,39 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
return DownloadHistoryItem.fromJson(json);
}
Future<void> updateAudioMetadataForItem({
required String id,
String? quality,
int? bitDepth,
int? sampleRate,
}) async {
final index = state.items.indexWhere((item) => item.id == id);
if (index < 0) return;
final current = state.items[index];
final updated = current.copyWith(
quality: quality,
bitDepth: bitDepth,
sampleRate: sampleRate,
);
if (updated.quality == current.quality &&
updated.bitDepth == current.bitDepth &&
updated.sampleRate == current.sampleRate) {
return;
}
final updatedItems = [...state.items];
updatedItems[index] = updated;
state = state.copyWith(items: updatedItems);
await _db.updateAudioMetadata(
id,
newQuality: quality,
newBitDepth: bitDepth,
newSampleRate: sampleRate,
);
}
Future<void> updateMetadataForItem({
required String id,
required String trackName,
@@ -592,10 +788,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
return 0;
}
// Delete from database
final deletedCount = await _db.deleteByIds(orphanedIds);
// Update in-memory state
final orphanedSet = orphanedIds.toSet();
state = state.copyWith(
items: state.items
@@ -1379,6 +1573,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
bool useAlbumArtistForFolders = true,
bool usePrimaryArtistOnly = false,
bool filterContributingArtistsInAlbumArtist = false,
String? playlistName,
}) async {
String baseDir = state.outputDir;
final normalizedAlbumArtist = normalizeOptionalString(track.albumArtist);
@@ -1453,6 +1648,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String subPath = '';
switch (folderOrganization) {
case 'playlist':
if (playlistName != null && playlistName.isNotEmpty) {
subPath = _sanitizeFolderName(playlistName);
}
break;
case 'artist':
final artistName = _sanitizeFolderName(folderArtist);
subPath = artistName;
@@ -1531,6 +1731,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
bool useAlbumArtistForFolders = true,
bool usePrimaryArtistOnly = false,
bool filterContributingArtistsInAlbumArtist = false,
String? playlistName,
}) async {
final normalizedAlbumArtist = normalizeOptionalString(track.albumArtist);
var folderArtist = useAlbumArtistForFolders
@@ -1582,6 +1783,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
switch (folderOrganization) {
case 'playlist':
if (playlistName != null && playlistName.isNotEmpty) {
return _sanitizeFolderName(playlistName);
}
return '';
case 'artist':
return _sanitizeFolderName(folderArtist);
case 'album':
@@ -1596,18 +1802,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
String _determineOutputExt(String quality, String service) {
// YouTube provider - lossy only (Opus or MP3)
if (service.toLowerCase() == 'youtube') {
if (quality.toLowerCase().contains('mp3')) {
return '.mp3';
}
return '.opus';
}
// Amazon stream is delivered as MP4/M4A container (may contain FLAC audio),
// so SAF should keep .m4a before decrypt/convert pipeline.
if (service.toLowerCase() == 'amazon') {
return '.m4a';
}
if (service.toLowerCase() == 'tidal' && quality == 'HIGH') {
return '.m4a';
}
@@ -1712,7 +1912,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
);
}
String addToQueue(Track track, String service, {String? qualityOverride}) {
String addToQueue(Track track, String service, {String? qualityOverride, String? playlistName}) {
final settings = ref.read(settingsProvider);
updateSettings(settings);
@@ -1724,6 +1924,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
service: service,
createdAt: DateTime.now(),
qualityOverride: qualityOverride,
playlistName: playlistName,
);
state = state.copyWith(items: [...state.items, item]);
@@ -1740,6 +1941,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
List<Track> tracks,
String service, {
String? qualityOverride,
String? playlistName,
}) {
final settings = ref.read(settingsProvider);
updateSettings(settings);
@@ -1754,6 +1956,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
service: service,
createdAt: DateTime.now(),
qualityOverride: qualityOverride,
playlistName: playlistName,
);
}).toList();
@@ -2159,6 +2362,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
deezerId: baseTrack.deezerId,
availability: baseTrack.availability,
albumType: baseTrack.albumType,
totalTracks: baseTrack.totalTracks,
source: baseTrack.source,
);
}
@@ -2903,7 +3107,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
failedCount: _failedInSession,
);
// Auto-export failed downloads if enabled
final settings = ref.read(settingsProvider);
if (settings.autoExportFailedDownloads && _failedInSession > 0) {
final exportPath = await exportFailedDownloads();
@@ -3072,6 +3275,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
albumType:
(data['album_type'] as String?) ??
trackToDownload.albumType,
totalTracks:
data['total_tracks'] as int? ?? trackToDownload.totalTracks,
source: trackToDownload.source,
);
_log.d(
@@ -3130,6 +3335,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
filterContributingArtistsInAlbumArtist:
settings.filterContributingArtistsInAlbumArtist,
playlistName: item.playlistName,
)
: '';
String? appOutputDir;
@@ -3144,6 +3350,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
filterContributingArtistsInAlbumArtist:
settings.filterContributingArtistsInAlbumArtist,
playlistName: item.playlistName,
);
var effectiveOutputDir = initialOutputDir;
var effectiveSafMode = isSafMode;
@@ -3206,7 +3413,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
!trackToDownload.id.startsWith('deezer:') &&
!trackToDownload.id.startsWith('extension:')) {
try {
// Extract clean Spotify ID (remove spotify: prefix if present)
String spotifyId = trackToDownload.id;
if (spotifyId.startsWith('spotify:track:')) {
spotifyId = spotifyId.split(':').last;
@@ -3285,6 +3491,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
deezerId: deezerTrackId,
availability: trackToDownload.availability,
albumType: trackToDownload.albumType,
totalTracks: trackToDownload.totalTracks,
source: trackToDownload.source,
);
_log.d(
@@ -3506,11 +3713,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final decryptionKey =
(result['decryption_key'] as String?)?.trim() ?? '';
if (!wasExisting &&
decryptionKey.isNotEmpty &&
filePath != null &&
actualService == 'amazon') {
_log.i('Amazon encrypted stream detected, decrypting via FFmpeg...');
if (!wasExisting && decryptionKey.isNotEmpty && filePath != null) {
_log.i('Encrypted stream detected, decrypting via FFmpeg...');
updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.9);
if (effectiveSafMode && isContentUri(filePath)) {
@@ -3539,7 +3743,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
updateItemStatus(
item.id,
DownloadStatus.failed,
error: 'Failed to decrypt Amazon stream',
error: 'Failed to decrypt encrypted stream',
errorType: DownloadErrorType.unknown,
);
return;
@@ -3564,7 +3768,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
);
if (newUri == null) {
_log.e('Failed to write decrypted Amazon stream back to SAF');
_log.e('Failed to write decrypted stream back to SAF');
updateItemStatus(
item.id,
DownloadStatus.failed,
@@ -3579,7 +3783,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
filePath = newUri;
finalSafFileName = newFileName;
_log.i('Amazon SAF decryption completed');
_log.i('SAF decryption completed');
} finally {
try {
await File(tempPath).delete();
@@ -3601,7 +3805,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
updateItemStatus(
item.id,
DownloadStatus.failed,
error: 'Failed to decrypt Amazon stream',
error: 'Failed to decrypt encrypted stream',
errorType: DownloadErrorType.unknown,
);
try {
@@ -3610,7 +3814,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return;
}
filePath = decryptedPath;
_log.i('Amazon local decryption completed');
_log.i('Local decryption completed');
}
}
@@ -3832,7 +4036,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
} else {
// Local file path flow (original)
if (quality == 'HIGH') {
final tidalHighFormat = settings.tidalHighFormat;
_log.i(
@@ -4049,10 +4252,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
!effectiveSafMode &&
isFlacFile &&
!wasExisting &&
actualService == 'amazon' &&
decryptionKey.isNotEmpty) {
_log.d(
'Local FLAC after Amazon decrypt detected, embedding metadata and cover...',
'Local FLAC after decrypt detected, embedding metadata and cover...',
);
try {
updateItemStatus(
@@ -4112,7 +4314,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final isContentUriPath = isContentUri(filePath);
if (isContentUriPath && effectiveSafMode) {
// SAF mode: copy to temp, embed, write back
final tempPath = await _copySafToTemp(filePath);
if (tempPath != null) {
try {
@@ -4133,7 +4334,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
copyright: backendCopyright,
);
}
// Write back to SAF
final ext = isMp3File ? '.mp3' : '.opus';
final newFileName = '${safBaseName ?? 'track'}$ext';
final newUri = await _writeTempToSaf(
@@ -4162,7 +4362,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
} else {
// Non-SAF mode: embed directly
try {
if (isMp3File) {
await _embedMetadataToMp3(
@@ -4347,6 +4546,50 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
normalizeOptionalString(copyright) ??
normalizeOptionalString(existingInHistory?.copyright);
int? finalBitDepth = backendBitDepth;
int? finalSampleRate = backendSampleRate;
final lowerFilePath = filePath.toLowerCase();
final canProbeFinalMetadata =
filePath.startsWith('content://') ||
lowerFilePath.endsWith('.flac') ||
lowerFilePath.endsWith('.m4a') ||
lowerFilePath.endsWith('.aac') ||
lowerFilePath.endsWith('.mp3') ||
lowerFilePath.endsWith('.opus') ||
lowerFilePath.endsWith('.ogg');
if (canProbeFinalMetadata) {
try {
final metadata = await PlatformBridge.readFileMetadata(filePath);
if (metadata['error'] == null) {
final probedBitDepth = metadata['bit_depth'] is num
? (metadata['bit_depth'] as num).toInt()
: int.tryParse(metadata['bit_depth']?.toString() ?? '');
final probedSampleRate = metadata['sample_rate'] is num
? (metadata['sample_rate'] as num).toInt()
: int.tryParse(metadata['sample_rate']?.toString() ?? '');
if (probedBitDepth != null && probedBitDepth > 0) {
finalBitDepth = probedBitDepth;
}
if (probedSampleRate != null && probedSampleRate > 0) {
finalSampleRate = probedSampleRate;
}
final resolvedQuality = buildDisplayAudioQuality(
bitDepth: finalBitDepth,
sampleRate: finalSampleRate,
storedQuality: actualQuality,
);
if (resolvedQuality != null) {
actualQuality = resolvedQuality;
}
}
} catch (e) {
_log.d('Final audio metadata probe failed for $filePath: $e');
}
}
_log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}');
final historyAlbumArtist =
@@ -4354,9 +4597,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
? resolvedAlbumArtist
: null;
final isMp3 = filePath.endsWith('.mp3');
final historyBitDepth = isMp3 ? null : backendBitDepth;
final historySampleRate = isMp3 ? null : backendSampleRate;
final isLossyOutput =
lowerFilePath.endsWith('.mp3') ||
lowerFilePath.endsWith('.opus') ||
lowerFilePath.endsWith('.ogg');
final historyBitDepth = isLossyOutput ? null : finalBitDepth;
final historySampleRate = isLossyOutput ? null : finalSampleRate;
ref
.read(downloadHistoryProvider.notifier)
+38 -19
View File
@@ -757,7 +757,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
Future<void> loadProviderPriority() async {
try {
// Load from SharedPreferences first (persisted)
final prefs = await SharedPreferences.getInstance();
final savedJson = prefs.getString(_providerPriorityKey);
@@ -768,10 +767,8 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
priority = _sanitizeDownloadProviderPriority(priority);
_log.d('Loaded provider priority from prefs: $priority');
await prefs.setString(_providerPriorityKey, jsonEncode(priority));
// Sync to Go backend
await PlatformBridge.setProviderPriority(priority);
} else {
// Fallback to Go backend default
priority = await PlatformBridge.getProviderPriority();
priority = _sanitizeDownloadProviderPriority(priority);
await PlatformBridge.setProviderPriority(priority);
@@ -787,11 +784,9 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
Future<void> setProviderPriority(List<String> priority) async {
try {
final sanitized = _sanitizeDownloadProviderPriority(priority);
// Save to SharedPreferences for persistence
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_providerPriorityKey, jsonEncode(sanitized));
// Sync to Go backend
await PlatformBridge.setProviderPriority(sanitized);
state = state.copyWith(providerPriority: sanitized);
_log.d('Saved provider priority: $sanitized');
@@ -811,7 +806,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
for (final provider in const ['tidal', 'qobuz', 'amazon', 'deezer']) {
for (final provider in const ['tidal', 'qobuz', 'deezer']) {
if (!result.contains(provider)) {
result.add(provider);
}
@@ -822,20 +817,25 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
Future<void> loadMetadataProviderPriority() async {
try {
// Load from SharedPreferences first (persisted)
final prefs = await SharedPreferences.getInstance();
final savedJson = prefs.getString(_metadataProviderPriorityKey);
List<String> priority;
if (savedJson != null) {
final saved = jsonDecode(savedJson) as List<dynamic>;
priority = saved.map((e) => e as String).toList();
priority = _sanitizeMetadataProviderPriority(
saved.map((e) => e as String).toList(),
);
_log.d('Loaded metadata provider priority from prefs: $priority');
// Sync to Go backend
await prefs.setString(
_metadataProviderPriorityKey,
jsonEncode(priority),
);
await PlatformBridge.setMetadataProviderPriority(priority);
} else {
// Fallback to Go backend default
priority = await PlatformBridge.getMetadataProviderPriority();
priority = _sanitizeMetadataProviderPriority(
await PlatformBridge.getMetadataProviderPriority(),
);
_log.d('Using default metadata provider priority: $priority');
}
@@ -847,14 +847,16 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
Future<void> setMetadataProviderPriority(List<String> priority) async {
try {
// Save to SharedPreferences for persistence
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_metadataProviderPriorityKey, jsonEncode(priority));
final sanitized = _sanitizeMetadataProviderPriority(priority);
await prefs.setString(
_metadataProviderPriorityKey,
jsonEncode(sanitized),
);
// Sync to Go backend
await PlatformBridge.setMetadataProviderPriority(priority);
state = state.copyWith(metadataProviderPriority: priority);
_log.d('Saved metadata provider priority: $priority');
await PlatformBridge.setMetadataProviderPriority(sanitized);
state = state.copyWith(metadataProviderPriority: sanitized);
_log.d('Saved metadata provider priority: $sanitized');
} catch (e) {
_log.e('Failed to set metadata provider priority: $e');
state = state.copyWith(error: e.toString());
@@ -880,7 +882,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
List<String> getAllDownloadProviders() {
final providers = ['tidal', 'qobuz', 'amazon', 'deezer'];
final providers = ['tidal', 'qobuz', 'deezer'];
for (final ext in state.extensions) {
if (ext.enabled && ext.hasDownloadProvider) {
providers.add(ext.id);
@@ -890,7 +892,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
List<String> getAllMetadataProviders() {
final providers = ['deezer', 'spotify'];
final providers = ['deezer'];
for (final ext in state.extensions) {
if (ext.enabled && ext.hasMetadataProvider) {
providers.add(ext.id);
@@ -899,6 +901,23 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
return providers;
}
List<String> _sanitizeMetadataProviderPriority(List<String> input) {
final allowed = getAllMetadataProviders().toSet();
final result = <String>[];
for (final provider in input) {
if (allowed.contains(provider) && !result.contains(provider)) {
result.add(provider);
}
}
if (!result.contains('deezer')) {
result.insert(0, 'deezer');
}
return result;
}
List<Extension> get searchProviders {
return state.extensions
.where((ext) => ext.enabled && ext.hasCustomSearch)
+53 -75
View File
@@ -9,6 +9,7 @@ import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/path_match_keys.dart';
final _log = AppLogger('LocalLibrary');
@@ -193,74 +194,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
await _loadFromDatabase();
}
Set<String> _buildPathMatchKeys(String? filePath) {
final raw = filePath?.trim() ?? '';
if (raw.isEmpty) return const {};
final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7) : raw;
final keys = <String>{};
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 (_) {}
}
Uri? parsed;
try {
parsed = Uri.parse(trimmed);
} catch (_) {}
if (parsed != null && parsed.hasScheme) {
final noQueryOrFragment = parsed.replace(query: null, fragment: null);
keys.add(noQueryOrFragment.toString());
keys.add(noQueryOrFragment.toString().toLowerCase());
if (parsed.scheme == 'file') {
try {
final fileOnly = parsed.toFilePath();
if (fileOnly.isNotEmpty) {
keys.add(fileOnly);
keys.add(fileOnly.toLowerCase());
if (fileOnly.contains('\\')) {
final slash = fileOnly.replaceAll('\\', '/');
keys.add(slash);
keys.add(slash.toLowerCase());
}
}
} catch (_) {}
}
} else if (trimmed.startsWith('/')) {
try {
final asFileUri = Uri.file(trimmed).toString();
keys.add(asFileUri);
keys.add(asFileUri.toLowerCase());
} catch (_) {}
}
}
addNormalized(cleaned);
return keys;
}
bool _isDownloadedPath(String? filePath, Set<String> downloadedPathKeys) {
if (filePath == null || filePath.isEmpty || downloadedPathKeys.isEmpty) {
return false;
}
final candidateKeys = _buildPathMatchKeys(filePath);
final candidateKeys = buildPathMatchKeys(filePath);
for (final key in candidateKeys) {
if (downloadedPathKeys.contains(key)) {
return true;
@@ -272,6 +210,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
Future<void> startScan(
String folderPath, {
bool forceFullScan = false,
String? iosBookmark,
}) async {
if (state.isScanning) {
_log.w('Scan already in progress');
@@ -316,8 +255,28 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_startProgressPolling();
// On iOS, start accessing the security-scoped bookmark so the Go backend
// can read files outside the app sandbox.
String? resolvedPath;
bool didStartSecurityAccess = false;
if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) {
resolvedPath = await PlatformBridge.startAccessingIosBookmark(
iosBookmark,
);
if (resolvedPath != null) {
didStartSecurityAccess = true;
_log.i('Started iOS security-scoped access: $resolvedPath');
} else {
_log.w(
'Failed to start iOS security-scoped access, '
'falling back to original path',
);
}
}
final effectiveFolderPath = resolvedPath ?? folderPath;
try {
final isSaf = folderPath.startsWith('content://');
final isSaf = effectiveFolderPath.startsWith('content://');
// Get all file paths from download history to exclude them.
// Merge DB + in-memory state to avoid race when a fresh download has not
@@ -334,7 +293,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
};
final downloadedPathKeys = <String>{};
for (final path in allHistoryPaths) {
downloadedPathKeys.addAll(_buildPathMatchKeys(path));
downloadedPathKeys.addAll(buildPathMatchKeys(path));
}
_log.i(
'Excluding ${allHistoryPaths.length} downloaded files from library scan '
@@ -344,8 +303,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
if (forceFullScan) {
// Full scan path - ignores existing data
final results = isSaf
? await PlatformBridge.scanSafTree(folderPath)
: await PlatformBridge.scanLibraryFolder(folderPath);
? await PlatformBridge.scanSafTree(effectiveFolderPath)
: await PlatformBridge.scanLibraryFolder(effectiveFolderPath);
if (_scanCancelRequested) {
state = state.copyWith(isScanning: false, scanWasCancelled: true);
await _showScanCancelledNotification();
@@ -424,12 +383,12 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
final Map<String, dynamic> result;
if (isSaf) {
result = await PlatformBridge.scanSafTreeIncremental(
folderPath,
effectiveFolderPath,
existingFiles,
);
} else {
result = await PlatformBridge.scanLibraryFolderIncremental(
folderPath,
effectiveFolderPath,
existingFiles,
);
}
@@ -553,6 +512,10 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
state = state.copyWith(isScanning: false, scanWasCancelled: false);
await _showScanFailedNotification(e.toString());
} finally {
if (didStartSecurityAccess) {
await PlatformBridge.stopAccessingIosBookmark();
_log.i('Stopped iOS security-scoped access');
}
_stopProgressPolling();
}
}
@@ -807,12 +770,27 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
return decoded;
}
Future<int> cleanupMissingFiles() async {
final removed = await _db.cleanupMissingFiles();
if (removed > 0) {
await reloadFromStorage();
Future<int> cleanupMissingFiles({String? iosBookmark}) async {
bool didStartSecurityAccess = false;
if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) {
final resolved = await PlatformBridge.startAccessingIosBookmark(
iosBookmark,
);
if (resolved != null) {
didStartSecurityAccess = true;
}
}
try {
final removed = await _db.cleanupMissingFiles();
if (removed > 0) {
await reloadFromStorage();
}
return removed;
} finally {
if (didStartSecurityAccess) {
await PlatformBridge.stopAccessingIosBookmark();
}
}
return removed;
}
Future<void> clearLibrary() async {
+12
View File
@@ -24,6 +24,9 @@ class PlaybackController extends Notifier<PlaybackState> {
String coverUrl = '',
Track? track,
}) async {
if (isCueVirtualPath(path)) {
throw Exception(cueVirtualTrackRequiresSplitMessage);
}
_log.d('Opening external player for "$title" by $artist: $path');
await openFile(path);
}
@@ -32,11 +35,16 @@ class PlaybackController extends Notifier<PlaybackState> {
if (tracks.isEmpty) return;
final orderedTracks = _orderedTracksFromStartIndex(tracks, startIndex);
var skippedCueVirtualTrack = false;
for (final track in orderedTracks) {
final resolvedPath = await _resolveTrackPath(track);
if (resolvedPath == null) {
continue;
}
if (isCueVirtualPath(resolvedPath)) {
skippedCueVirtualTrack = true;
continue;
}
_log.d(
'Opening first available external track for list playback: '
@@ -46,6 +54,10 @@ class PlaybackController extends Notifier<PlaybackState> {
return;
}
if (skippedCueVirtualTrack) {
throw Exception(cueVirtualTrackRequiresSplitMessage);
}
throw Exception(
'No local audio file is available to open. Download the track first.',
);
+43 -78
View File
@@ -9,7 +9,7 @@ import 'package:spotiflac_android/utils/logger.dart';
const _settingsKey = 'app_settings';
const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 4;
const _currentMigrationVersion = 5;
const _spotifyClientSecretKey = 'spotify_client_secret';
final _log = AppLogger('SettingsProvider');
@@ -41,9 +41,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
await _normalizeSongLinkRegionIfNeeded();
}
await _loadSpotifyClientSecret(prefs);
_applySpotifyCredentials();
await _retireBuiltInSpotifyProvider();
LogBuffer.loggingEnabled = state.enableLogging;
@@ -105,6 +103,17 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
state = state.copyWith(lyricsProviders: updatedProviders);
}
if (state.metadataSource != 'deezer' ||
state.spotifyClientId.isNotEmpty ||
state.spotifyClientSecret.isNotEmpty ||
state.useCustomSpotifyCredentials) {
state = state.copyWith(
metadataSource: 'deezer',
spotifyClientId: '',
spotifyClientSecret: '',
useCustomSpotifyCredentials: false,
);
}
state = state.copyWith(lastSeenVersion: AppInfo.version);
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
await _saveSettings();
@@ -193,49 +202,28 @@ class SettingsNotifier extends Notifier<AppSettings> {
await _saveSettings();
}
Future<void> _loadSpotifyClientSecret(SharedPreferences prefs) async {
Future<void> _retireBuiltInSpotifyProvider() async {
final storedSecret = await _secureStorage.read(
key: _spotifyClientSecretKey,
);
final prefsSecret = state.spotifyClientSecret;
if ((storedSecret == null || storedSecret.isEmpty) &&
prefsSecret.isNotEmpty) {
await _secureStorage.write(
key: _spotifyClientSecretKey,
value: prefsSecret,
);
}
final effectiveSecret = (storedSecret != null && storedSecret.isNotEmpty)
? storedSecret
: (prefsSecret.isNotEmpty ? prefsSecret : '');
if (effectiveSecret != state.spotifyClientSecret) {
state = state.copyWith(spotifyClientSecret: effectiveSecret);
}
if (prefsSecret.isNotEmpty) {
await _saveSettings();
}
}
Future<void> _storeSpotifyClientSecret(String secret) async {
if (secret.isEmpty) {
if (storedSecret != null && storedSecret.isNotEmpty) {
await _secureStorage.delete(key: _spotifyClientSecretKey);
} else {
await _secureStorage.write(key: _spotifyClientSecretKey, value: secret);
}
}
Future<void> _applySpotifyCredentials() async {
if (state.spotifyClientId.isNotEmpty &&
state.spotifyClientSecret.isNotEmpty) {
await PlatformBridge.setSpotifyCredentials(
state.spotifyClientId,
state.spotifyClientSecret,
);
if (state.metadataSource == 'deezer' &&
state.spotifyClientId.isEmpty &&
state.spotifyClientSecret.isEmpty &&
!state.useCustomSpotifyCredentials) {
return;
}
state = state.copyWith(
metadataSource: 'deezer',
spotifyClientId: '',
spotifyClientSecret: '',
useCustomSpotifyCredentials: false,
);
await _saveSettings();
}
void setDefaultService(String service) {
@@ -396,45 +384,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setSpotifyClientId(String clientId) {
state = state.copyWith(spotifyClientId: clientId);
_saveSettings();
}
Future<void> setSpotifyClientSecret(String clientSecret) async {
state = state.copyWith(spotifyClientSecret: clientSecret);
await _storeSpotifyClientSecret(clientSecret);
_saveSettings();
}
Future<void> setSpotifyCredentials(
String clientId,
String clientSecret,
) async {
state = state.copyWith(
spotifyClientId: clientId,
spotifyClientSecret: clientSecret,
);
await _storeSpotifyClientSecret(clientSecret);
_saveSettings();
_applySpotifyCredentials();
}
Future<void> clearSpotifyCredentials() async {
state = state.copyWith(spotifyClientId: '', spotifyClientSecret: '');
await _storeSpotifyClientSecret('');
_saveSettings();
_applySpotifyCredentials();
}
void setUseCustomSpotifyCredentials(bool enabled) {
state = state.copyWith(useCustomSpotifyCredentials: enabled);
_saveSettings();
_applySpotifyCredentials();
}
void setMetadataSource(String source) {
state = state.copyWith(metadataSource: source);
final normalized = source == 'deezer' ? 'deezer' : 'deezer';
state = state.copyWith(metadataSource: normalized);
_saveSettings();
}
@@ -532,6 +484,19 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setLocalLibraryBookmark(String bookmark) {
state = state.copyWith(localLibraryBookmark: bookmark);
_saveSettings();
}
void setLocalLibraryPathAndBookmark(String path, String bookmark) {
state = state.copyWith(
localLibraryPath: path,
localLibraryBookmark: bookmark,
);
_saveSettings();
}
void setLocalLibraryShowDuplicates(bool show) {
state = state.copyWith(localLibraryShowDuplicates: show);
_saveSettings();
+31 -34
View File
@@ -204,7 +204,6 @@ class TrackNotifier extends Notifier<TrackState> {
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
try {
// Step 1: Check for extension URL handlers first (handles YT Music, etc.)
final extensionHandler = await PlatformBridge.findURLHandler(url);
if (extensionHandler != null) {
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
@@ -215,7 +214,6 @@ class TrackNotifier extends Notifier<TrackState> {
result = await PlatformBridge.handleURLWithExtension(url);
if (!_isRequestValid(requestId)) return;
// Check if we got valid data
if (result != null &&
result['type'] == 'track' &&
result['track'] != null) {
@@ -321,7 +319,6 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
// Step 2: Try Deezer URL parsing
if (url.contains('deezer.com') || url.contains('deezer.page.link')) {
_log.i('Detected Deezer URL, parsing...');
final parsed = await PlatformBridge.parseDeezerUrl(url);
@@ -387,7 +384,6 @@ class TrackNotifier extends Notifier<TrackState> {
return;
}
// Step 3: Try Tidal URL parsing
if (url.contains('tidal.com')) {
_log.i('Detected Tidal URL, parsing...');
final parsed = await PlatformBridge.parseTidalUrl(url);
@@ -461,7 +457,20 @@ class TrackNotifier extends Notifier<TrackState> {
return;
}
// Step 4: Fall back to Spotify parsing
// If URL doesn't match any known service, it's unrecognized
final isSpotifyUrl =
url.contains('open.spotify.com') ||
url.contains('spotify.link') ||
url.startsWith('spotify:');
if (!isSpotifyUrl) {
state = TrackState(
isLoading: false,
error: 'url_not_recognized',
hasSearchText: state.hasSearchText,
);
return;
}
final parsed = await PlatformBridge.parseSpotifyUrl(url);
if (!_isRequestValid(requestId)) return;
@@ -538,11 +547,7 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
Future<void> search(
String query, {
String? metadataSource,
String? filterOverride,
}) async {
Future<void> search(String query, {String? filterOverride}) async {
final requestId = ++_currentRequestId;
// Preserve selected filter during loading
@@ -568,7 +573,7 @@ class TrackNotifier extends Notifier<TrackState> {
searchProvider != null &&
searchProvider.isNotEmpty;
final source = metadataSource ?? 'deezer';
const source = 'deezer';
_log.i(
'Search started: source=$source, query="$query", useExtensions=$useExtensions, filter=$currentFilter',
@@ -594,32 +599,20 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
} catch (e) {
_log.w('Extension search failed, falling back to built-in: $e');
_log.w('Extension search failed, falling back to Deezer: $e');
}
}
if (source == 'deezer') {
_log.d('Calling Deezer search API...');
results = await PlatformBridge.searchDeezerAll(
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 {
_log.d('Calling Spotify search API...');
results = await PlatformBridge.searchSpotifyAll(
query,
trackLimit: 20,
artistLimit: 2,
);
_log.i(
'Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists',
);
}
_log.d('Calling Deezer search API...');
results = await PlatformBridge.searchDeezerAll(
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',
);
if (!_isRequestValid(requestId)) {
_log.w('Search request cancelled (requestId=$requestId)');
@@ -823,6 +816,7 @@ class TrackNotifier extends Notifier<TrackState> {
discNumber: track.discNumber,
releaseDate: track.releaseDate,
albumType: track.albumType,
totalTracks: track.totalTracks,
source: track.source,
availability: ServiceAvailability(
tidal: availability['tidal'] as bool? ?? false,
@@ -904,6 +898,8 @@ class TrackNotifier extends Notifier<TrackState> {
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
releaseDate: data['release_date'] as String?,
albumType: data['album_type'] as String?,
totalTracks: data['total_tracks'] as int?,
);
}
@@ -926,6 +922,7 @@ class TrackNotifier extends Notifier<TrackState> {
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
releaseDate: data['release_date']?.toString(),
totalTracks: data['total_tracks'] as int?,
source:
source ??
data['source']?.toString() ??
+3 -3
View File
@@ -224,6 +224,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
releaseDate: data['release_date'] as String?,
albumType: data['album_type'] as String?,
totalTracks: data['total_tracks'] as int?,
);
}
@@ -305,7 +307,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
background: Stack(
fit: StackFit.expand,
children: [
// Full-screen cover background (no blur, full resolution)
if (widget.coverUrl != null)
CachedNetworkImage(
imageUrl:
@@ -326,7 +327,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
color: colorScheme.onSurfaceVariant,
),
),
// Bottom gradient for readability
Positioned(
left: 0,
right: 0,
@@ -345,7 +345,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
),
),
),
// Album info overlay at bottom
Positioned(
left: 20,
right: 20,
@@ -491,6 +490,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
},
),
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
+173 -139
View File
@@ -21,7 +21,6 @@ import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
/// Simple in-memory cache for artist data
class _ArtistCache {
static final Map<String, _CacheEntry> _cache = {};
static const Duration _ttl = Duration(minutes: 10);
@@ -69,7 +68,6 @@ class _CacheEntry {
});
}
/// Artist screen with Spotify-like design
class ArtistScreen extends ConsumerStatefulWidget {
final String artistId;
final String artistName;
@@ -296,7 +294,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
}
}
Track _parseTrack(Map<String, dynamic> data) {
Track _parseTrack(Map<String, dynamic> data, {ArtistAlbum? album}) {
int durationMs = 0;
final durationValue = data['duration_ms'];
if (durationValue is int) {
@@ -309,18 +307,22 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
albumArtist: data['album_artist']?.toString(),
albumName: (data['album_name'] ?? data['album'] ?? album?.name ?? '')
.toString(),
albumArtist: data['album_artist']?.toString() ?? widget.artistName,
artistId:
(data['artist_id'] ?? data['artistId'])?.toString() ??
widget.artistId,
albumId: data['album_id']?.toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
albumId: data['album_id']?.toString() ?? album?.id,
coverUrl: (data['cover_url'] ?? data['images'] ?? album?.coverUrl)
?.toString(),
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
releaseDate: data['release_date']?.toString(),
albumType: data['album_type']?.toString() ?? album?.albumType,
totalTracks: data['total_tracks'] as int? ?? album?.totalTracks,
source: data['provider_id']?.toString(),
);
}
@@ -345,7 +347,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
.where((a) => a.albumType == 'album')
.toList(growable: false);
_singlesBucket = albums
.where((a) => a.albumType == 'single')
.where((a) => a.albumType == 'single' || a.albumType == 'ep')
.toList(growable: false);
_compilationsBucket = albums
.where((a) => a.albumType == 'compilation')
@@ -416,6 +418,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
context.l10n.artistSingles,
singles,
colorScheme,
showTypeBadge: true,
),
),
if (compilations.isNotEmpty)
@@ -670,7 +673,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
List<ArtistAlbum> albums,
) {
final albumsOnly = albums.where((a) => a.albumType == 'album').toList();
final singles = albums.where((a) => a.albumType == 'single').toList();
final singles = albums
.where((a) => a.albumType == 'single' || a.albumType == 'ep')
.toList();
final totalTracks = albums.fold<int>(0, (sum, a) => sum + a.totalTracks);
final albumTracks = albumsOnly.fold<int>(
@@ -717,7 +722,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
),
),
const Divider(height: 1),
// Options
if (albums.isNotEmpty)
_DiscographyOptionTile(
icon: Icons.library_music,
@@ -830,7 +834,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
int failedCount = 0;
for (final album in albums) {
if (!_isFetchingDiscography) break; // Cancelled
if (!_isFetchingDiscography) break;
try {
final tracks = await _fetchAlbumTracks(album);
@@ -942,7 +946,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
if (result != null && result['tracks'] != null) {
final tracksList = result['tracks'] as List<dynamic>;
return tracksList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
.toList();
}
} else if (album.id.startsWith('deezer:')) {
@@ -963,7 +967,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
if (result != null && result['tracks'] != null) {
final tracksList = result['tracks'] as List<dynamic>;
return tracksList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
.toList();
}
@@ -972,7 +976,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
if (metadata['tracks'] != null) {
final tracksList = metadata['tracks'] as List<dynamic>;
return tracksList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
.toList();
}
}
@@ -1006,6 +1010,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
discNumber: data['disk_number'] as int? ?? data['disc_number'] as int?,
releaseDate: album.releaseDate,
albumType: album.albumType,
totalTracks: album.totalTracks,
);
}
@@ -1066,7 +1071,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
alignment: Alignment.topCenter, // Show top of image (faces)
alignment: Alignment.topCenter,
memCacheWidth: 800,
cacheManager: CoverCacheManager.instance,
placeholder: (context, url) =>
@@ -1155,7 +1160,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
],
),
),
// Download Discography button (icon only, right-aligned)
if (hasDiscography && !_isSelectionMode) ...[
const SizedBox(width: 12),
Container(
@@ -1188,6 +1192,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
],
),
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
@@ -1201,7 +1206,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
);
}
/// Build Popular tracks section like Spotify
Widget _buildPopularSection(ColorScheme colorScheme) {
if (_topTracks == null || _topTracks!.isEmpty) {
return const SizedBox.shrink();
@@ -1416,7 +1420,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
);
}
/// Handle tap on popular track item
void _handlePopularTrackTap(Track track, {required bool isQueued}) async {
if (isQueued) return;
@@ -1528,8 +1531,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
Widget _buildAlbumSection(
String title,
List<ArtistAlbum> albums,
ColorScheme colorScheme,
) {
ColorScheme colorScheme, {
bool showTypeBadge = false,
}) {
final sectionHeight = _artistAlbumSectionHeight();
final tileSize = _artistAlbumTileSize();
@@ -1560,6 +1564,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
colorScheme,
tileSize: tileSize,
sectionHeight: sectionHeight,
showTypeBadge: showTypeBadge,
),
);
},
@@ -1574,47 +1579,65 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
ColorScheme colorScheme, {
required double tileSize,
required double sectionHeight,
bool showTypeBadge = false,
}) {
final isSelected = _selectedAlbumIds.contains(album.id);
return GestureDetector(
onTap: () {
if (_isSelectionMode) {
_toggleAlbumSelection(album.id);
} else {
_navigateToAlbum(album);
}
},
onLongPress: () {
if (!_isSelectionMode) {
_enterSelectionMode(album.id);
}
},
child: Container(
width: tileSize,
height: sectionHeight,
margin: const EdgeInsets.symmetric(horizontal: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: album.coverUrl != null
? CachedNetworkImage(
imageUrl: album.coverUrl!,
width: tileSize,
height: tileSize,
fit: BoxFit.cover,
memCacheWidth: (tileSize * 2).round(),
cacheManager: CoverCacheManager.instance,
placeholder: (context, url) => Container(
return Semantics(
button: true,
selected: _isSelectionMode && isSelected,
label: _isSelectionMode
? 'Select album ${album.name}'
: 'Open album ${album.name}',
child: GestureDetector(
onTap: () {
if (_isSelectionMode) {
_toggleAlbumSelection(album.id);
} else {
_navigateToAlbum(album);
}
},
onLongPress: () {
if (!_isSelectionMode) {
_enterSelectionMode(album.id);
}
},
child: Container(
width: tileSize,
height: sectionHeight,
margin: const EdgeInsets.symmetric(horizontal: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: album.coverUrl != null
? CachedNetworkImage(
imageUrl: album.coverUrl!,
width: tileSize,
height: tileSize,
color: colorScheme.surfaceContainerHighest,
),
errorWidget: (context, url, error) => Container(
fit: BoxFit.cover,
memCacheWidth: (tileSize * 2).round(),
cacheManager: CoverCacheManager.instance,
placeholder: (context, url) => Container(
width: tileSize,
height: tileSize,
color: colorScheme.surfaceContainerHighest,
),
errorWidget: (context, url, error) => Container(
width: tileSize,
height: tileSize,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
color: colorScheme.onSurfaceVariant,
size: 40,
),
),
)
: Container(
width: tileSize,
height: tileSize,
color: colorScheme.surfaceContainerHighest,
@@ -1624,99 +1647,110 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
size: 40,
),
),
)
: Container(
width: tileSize,
height: tileSize,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
color: colorScheme.onSurfaceVariant,
size: 40,
),
if (_isSelectionMode)
Positioned.fill(
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: isSelected
? colorScheme.primary.withValues(alpha: 0.3)
: Colors.black.withValues(alpha: 0.1),
border: isSelected
? Border.all(color: colorScheme.primary, width: 3)
: null,
),
),
),
if (_isSelectionMode)
Positioned(
top: 8,
right: 8,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 28,
height: 28,
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary
: colorScheme.surface.withValues(alpha: 0.9),
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
),
),
),
// Selection overlay
if (_isSelectionMode)
Positioned.fill(
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: isSelected
? colorScheme.primary.withValues(alpha: 0.3)
: Colors.black.withValues(alpha: 0.1),
border: isSelected
? Border.all(color: colorScheme.primary, width: 3)
child: isSelected
? Icon(
Icons.check,
color: colorScheme.onPrimary,
size: 18,
)
: null,
),
),
),
// Checkbox
if (_isSelectionMode)
Positioned(
top: 8,
right: 8,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 28,
height: 28,
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary
: colorScheme.surface.withValues(alpha: 0.9),
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
if (showTypeBadge)
Positioned(
left: 6,
bottom: 6,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(4),
),
child: Text(
album.albumType == 'ep' ? 'EP' : 'Single',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
),
child: isSelected
? Icon(
Icons.check,
color: colorScheme.onPrimary,
size: 18,
)
: null,
),
),
],
),
const SizedBox(height: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
album.name,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(height: 2),
Text(
album.totalTracks > 0
? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} ${context.l10n.tracksCount(album.totalTracks)}'
: album.releaseDate.length >= 4
? album.releaseDate.substring(0, 4)
: album.releaseDate,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
const SizedBox(height: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
album.name,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(height: 2),
Text(
album.totalTracks > 0
? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} ${context.l10n.tracksCount(album.totalTracks)}'
: album.releaseDate.length >= 4
? album.releaseDate.substring(0, 4)
: album.releaseDate,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
),
);
+5 -11
View File
@@ -18,7 +18,6 @@ import 'package:spotiflac_android/providers/settings_provider.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
class DownloadedAlbumScreen extends ConsumerStatefulWidget {
final String albumName;
final String artistName;
@@ -361,7 +360,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
);
final tracks = _getAlbumTracks(allHistoryItems);
// Show empty state if no tracks found
if (tracks.isEmpty) {
return Scaffold(
appBar: AppBar(title: Text(widget.albumName)),
@@ -480,7 +478,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
background: Stack(
fit: StackFit.expand,
children: [
// Full-screen cover background
if (embeddedCoverPath != null)
Image.file(
File(embeddedCoverPath),
@@ -508,7 +505,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
color: colorScheme.onSurfaceVariant,
),
),
// Bottom gradient for readability
Positioned(
left: 0,
right: 0,
@@ -527,7 +523,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
),
),
),
// Album info overlay at bottom
Positioned(
left: 20,
right: 20,
@@ -635,6 +630,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
},
),
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
@@ -711,10 +707,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final discTracks = discMap[discNumber];
if (discTracks == null || discTracks.isEmpty) continue;
// Add disc separator
children.add(_buildDiscSeparator(context, colorScheme, discNumber));
// Add tracks for this disc
for (final track in discTracks) {
children.add(
KeyedSubtree(
@@ -858,6 +852,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
trailing: _isSelectionMode
? null
: IconButton(
tooltip: 'Play track',
onPressed: () => _openFile(track),
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
style: IconButton.styleFrom(
@@ -897,7 +892,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
return;
}
// Share SAF content URIs via native intent
if (safUris.isNotEmpty) {
try {
if (safUris.length == 1) {
@@ -908,13 +902,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
} catch (_) {}
}
// Share regular files via SharePlus
if (filesToShare.isNotEmpty) {
await SharePlus.instance.share(ShareParams(files: filesToShare));
}
}
/// Show batch convert bottom sheet
void _showBatchConvertSheet(
BuildContext context,
List<DownloadHistoryItem> allTracks,
@@ -1336,6 +1328,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
children: [
IconButton.filledTonal(
onPressed: _exitSelectionMode,
tooltip: MaterialLocalizations.of(
context,
).closeButtonTooltip,
icon: const Icon(Icons.close),
style: IconButton.styleFrom(
backgroundColor: colorScheme.surfaceContainerHighest,
@@ -1388,7 +1383,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
),
const SizedBox(height: 12),
// Action buttons row: Share, Convert
Row(
children: [
Expanded(
+188 -132
View File
@@ -520,7 +520,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
final settings = ref.read(settingsProvider);
final extState = ref.read(extensionProvider);
final searchProvider = settings.searchProvider;
// Use filterOverride if provided, otherwise read from state
final selectedFilter =
filterOverride ?? ref.read(trackProvider).selectedSearchFilter;
@@ -535,7 +534,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
extState.extensions.any((e) => e.id == searchProvider && e.enabled);
if (isExtensionEnabled) {
// Build options with filter if selected
Map<String, dynamic>? options;
if (selectedFilter != null) {
options = {'filter': selectedFilter};
@@ -551,11 +549,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
await ref
.read(trackProvider.notifier)
.search(
query,
metadataSource: settings.metadataSource,
filterOverride: selectedFilter,
);
.search(query, filterOverride: selectedFilter);
}
ref.read(settingsProvider.notifier).setHasSearchedBefore();
}
@@ -585,12 +579,28 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (url.isEmpty) return;
if (url.startsWith('http') || url.startsWith('spotify:')) {
await ref.read(trackProvider.notifier).fetchFromUrl(url);
_navigateToDetailIfNeeded();
final trackState = ref.read(trackProvider);
if (trackState.error != null && mounted) {
final l10n = context.l10n;
final errorMsg = trackState.error!;
final isRateLimit =
errorMsg.contains('429') ||
errorMsg.toLowerCase().contains('rate limit') ||
errorMsg.toLowerCase().contains('too many requests');
final displayMessage = errorMsg == 'url_not_recognized'
? l10n.errorUrlNotRecognizedMessage
: isRateLimit
? l10n.errorRateLimitedMessage
: l10n.errorUrlFetchFailed;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(displayMessage)));
ref.read(trackProvider.notifier).clear();
} else {
_navigateToDetailIfNeeded();
}
} else {
final settings = ref.read(settingsProvider);
await ref
.read(trackProvider.notifier)
.search(url, metadataSource: settings.metadataSource);
await ref.read(trackProvider.notifier).search(url);
}
ref.read(settingsProvider.notifier).setHasSearchedBefore();
}
@@ -1116,7 +1126,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
// Search filter bar (only shown when has search results)
if (hasActualResults && !showRecentAccess)
Consumer(
builder: (context, ref, _) {
@@ -1265,7 +1274,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
(searchArtists != null && searchArtists.isNotEmpty) ||
(searchAlbums != null && searchAlbums.isNotEmpty) ||
(searchPlaylists != null && searchPlaylists.isNotEmpty) ||
isLoading;
isLoading ||
error != null;
return SliverMainAxisGroup(
slivers: _buildSearchResults(
@@ -1286,8 +1296,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
],
),
), // Close RefreshIndicator
), // Close GestureDetector
),
),
);
}
@@ -1335,24 +1345,49 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
return KeyedSubtree(
key: ValueKey(item.id),
child: GestureDetector(
onTap: () => _navigateToMetadataScreen(item),
child: Container(
width: coverSize,
margin: const EdgeInsets.only(right: 12),
child: Column(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: embeddedCoverPath != null
? Image.file(
File(embeddedCoverPath),
width: coverSize,
height: coverSize,
fit: BoxFit.cover,
cacheWidth: (coverSize * 2).round(),
cacheHeight: (coverSize * 2).round(),
errorBuilder: (_, _, _) => Container(
child: Semantics(
button: true,
label: 'Open track ${item.trackName} by ${item.artistName}',
child: GestureDetector(
onTap: () => _navigateToMetadataScreen(item),
child: Container(
width: coverSize,
margin: const EdgeInsets.only(right: 12),
child: Column(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: embeddedCoverPath != null
? Image.file(
File(embeddedCoverPath),
width: coverSize,
height: coverSize,
fit: BoxFit.cover,
cacheWidth: (coverSize * 2).round(),
cacheHeight: (coverSize * 2).round(),
errorBuilder: (_, _, _) => Container(
width: coverSize,
height: coverSize,
color:
colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
size: 32,
),
),
)
: item.coverUrl != null
? CachedNetworkImage(
imageUrl: item.coverUrl!,
width: coverSize,
height: coverSize,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).round(),
memCacheHeight: (coverSize * 2).round(),
cacheManager: CoverCacheManager.instance,
)
: Container(
width: coverSize,
height: coverSize,
color: colorScheme.surfaceContainerHighest,
@@ -1362,38 +1397,18 @@ class _HomeTabState extends ConsumerState<HomeTab>
size: 32,
),
),
)
: item.coverUrl != null
? CachedNetworkImage(
imageUrl: item.coverUrl!,
width: coverSize,
height: coverSize,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).round(),
memCacheHeight: (coverSize * 2).round(),
cacheManager: CoverCacheManager.instance,
)
: Container(
width: coverSize,
height: coverSize,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
size: 32,
),
),
),
const SizedBox(height: 6),
Text(
item.trackName,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
],
),
const SizedBox(height: 6),
Text(
item.trackName,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
],
),
),
),
),
@@ -1434,7 +1449,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
return _buildExploreSection(sections[sectionIndex], colorScheme);
}
// Bottom padding
return const SizedBox(height: 16);
}, childCount: totalCount),
),
@@ -1498,31 +1512,45 @@ class _HomeTabState extends ConsumerState<HomeTab>
final cardSize = _exploreCardSize(context);
final iconSize = cardSize * 0.3;
return GestureDetector(
onTap: () => _navigateToExploreItem(item),
child: SizedBox(
width: cardSize,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Column(
crossAxisAlignment: isArtist
? CrossAxisAlignment.center
: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(
isArtist ? cardSize / 2 : 8,
),
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
? CachedNetworkImage(
imageUrl: item.coverUrl!,
width: cardSize,
height: cardSize,
fit: BoxFit.cover,
memCacheWidth: (cardSize * 2).round(),
memCacheHeight: (cardSize * 2).round(),
cacheManager: CoverCacheManager.instance,
errorWidget: (context, url, error) => Container(
return Semantics(
button: true,
label: 'Open ${item.type} ${item.name}',
child: GestureDetector(
onTap: () => _navigateToExploreItem(item),
child: SizedBox(
width: cardSize,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Column(
crossAxisAlignment: isArtist
? CrossAxisAlignment.center
: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(
isArtist ? cardSize / 2 : 8,
),
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
? CachedNetworkImage(
imageUrl: item.coverUrl!,
width: cardSize,
height: cardSize,
fit: BoxFit.cover,
memCacheWidth: (cardSize * 2).round(),
memCacheHeight: (cardSize * 2).round(),
cacheManager: CoverCacheManager.instance,
errorWidget: (context, url, error) => Container(
width: cardSize,
height: cardSize,
color: colorScheme.surfaceContainerHighest,
child: Icon(
_getIconForType(item.type),
color: colorScheme.onSurfaceVariant,
size: iconSize,
),
),
)
: Container(
width: cardSize,
height: cardSize,
color: colorScheme.surfaceContainerHighest,
@@ -1532,42 +1560,32 @@ class _HomeTabState extends ConsumerState<HomeTab>
size: iconSize,
),
),
)
: Container(
width: cardSize,
height: cardSize,
color: colorScheme.surfaceContainerHighest,
child: Icon(
_getIconForType(item.type),
color: colorScheme.onSurfaceVariant,
size: iconSize,
),
),
),
const SizedBox(height: 8),
Text(
item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: isArtist ? TextAlign.center : TextAlign.start,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
color: colorScheme.onSurface,
),
),
if (item.artists.isNotEmpty && !isArtist)
ClickableArtistName(
artistName: item.artists,
coverUrl: item.coverUrl,
extensionId: item.providerId,
const SizedBox(height: 8),
Text(
item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: isArtist ? TextAlign.center : TextAlign.start,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
fontWeight: FontWeight.w500,
color: colorScheme.onSurface,
),
),
],
if (item.artists.isNotEmpty && !isArtist)
ClickableArtistName(
artistName: item.artists,
coverUrl: item.coverUrl,
extensionId: item.providerId,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
],
),
),
),
),
@@ -2022,6 +2040,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
IconButton(
tooltip: 'Dismiss',
icon: Icon(
Icons.close,
size: 20,
@@ -2218,11 +2237,14 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
final l10n = context.l10n;
final isRateLimit =
error.contains('429') ||
error.toLowerCase().contains('rate limit') ||
error.toLowerCase().contains('too many requests');
final isUrlNotRecognized = error == 'url_not_recognized';
if (isRateLimit) {
return Card(
elevation: 0,
@@ -2239,7 +2261,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Rate Limited',
l10n.errorRateLimited,
style: TextStyle(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.bold,
@@ -2247,7 +2269,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
const SizedBox(height: 4),
Text(
'Too many requests. Please wait a moment before searching again.',
l10n.errorRateLimitedMessage,
style: TextStyle(
color: colorScheme.onErrorContainer,
fontSize: 12,
@@ -2262,6 +2284,42 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
}
if (isUrlNotRecognized) {
return Card(
elevation: 0,
color: colorScheme.errorContainer.withValues(alpha: 0.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.link_off, color: colorScheme.error),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.errorUrlNotRecognized,
style: TextStyle(
color: colorScheme.error,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
l10n.errorUrlNotRecognizedMessage,
style: TextStyle(color: colorScheme.error, fontSize: 12),
),
],
),
),
],
),
),
);
}
return Card(
elevation: 0,
color: colorScheme.errorContainer.withValues(alpha: 0.5),
@@ -2273,7 +2331,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 12),
Expanded(
child: Text(error, style: TextStyle(color: colorScheme.error)),
child: Text(
l10n.errorUrlFetchFailed,
style: TextStyle(color: colorScheme.error),
),
),
],
),
@@ -2705,7 +2766,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
scrollDirection: Axis.horizontal,
child: Row(
children: [
// "All" chip (no filter)
Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
@@ -2728,7 +2788,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
),
// Filter chips from extension
...filters.map((filter) {
final isSelected = selectedFilter == filter.id;
return Padding(
@@ -2830,7 +2889,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
prefixIcon: _SearchProviderDropdown(
onProviderChanged: () {
_lastSearchQuery = null;
// Reset filter when provider changes
ref.read(trackProvider.notifier).setSearchFilter(null);
setState(() {});
final text = _urlController.text.trim();
@@ -2904,9 +2962,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
final currentProvider = ref.watch(
settingsProvider.select((s) => s.searchProvider),
);
final metadataSource = ref.watch(
settingsProvider.select((s) => s.metadataSource),
);
final extensions = ref.watch(extensionProvider.select((s) => s.extensions));
final colorScheme = Theme.of(context).colorScheme;
@@ -2984,7 +3039,7 @@ class _SearchProviderDropdown extends ConsumerWidget {
const SizedBox(width: 12),
Expanded(
child: Text(
metadataSource == 'spotify' ? 'Spotify' : 'Deezer',
'Deezer',
style: TextStyle(
fontWeight:
currentProvider == null || currentProvider.isEmpty
@@ -4386,6 +4441,7 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> {
),
),
IconButton(
tooltip: MaterialLocalizations.of(context).showMenuTooltip,
icon: Icon(
Icons.more_vert,
color: widget.colorScheme.onSurfaceVariant,
+1 -4
View File
@@ -32,6 +32,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
@@ -158,7 +159,6 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header: drag handle + thumbnail + playlist info
Column(
children: [
const SizedBox(height: 8),
@@ -210,7 +210,6 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
// Rename
_PlaylistOptionTile(
icon: Icons.edit_outlined,
title: context.l10n.collectionRenamePlaylist,
@@ -225,7 +224,6 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
},
),
// Change cover
_PlaylistOptionTile(
icon: Icons.image_outlined,
title: context.l10n.collectionPlaylistChangeCover,
@@ -235,7 +233,6 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
},
),
// Delete
_PlaylistOptionTile(
icon: Icons.delete_outline,
iconColor: colorScheme.error,
+9 -15
View File
@@ -37,7 +37,6 @@ class _LibraryTracksFolderScreenState
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
// Multi-select state
bool _isSelectionMode = false;
final Set<String> _selectedKeys = {};
@@ -145,8 +144,6 @@ class _LibraryTracksFolderScreenState
return url;
}
// Selection helpers
void _enterSelectionMode(String key) {
HapticFeedback.mediumImpact();
setState(() {
@@ -181,8 +178,6 @@ class _LibraryTracksFolderScreenState
});
}
// Batch actions
Future<void> _removeSelected(List<CollectionTrackEntry> entries) async {
final keysToRemove = _selectedKeys.toSet();
if (keysToRemove.isEmpty) return;
@@ -426,7 +421,6 @@ class _LibraryTracksFolderScreenState
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Drag handle
Container(
width: 32,
height: 4,
@@ -437,11 +431,13 @@ class _LibraryTracksFolderScreenState
),
),
// Header: [X close] [count] [Select All / Deselect]
Row(
children: [
IconButton.filledTonal(
onPressed: _exitSelectionMode,
tooltip: MaterialLocalizations.of(
context,
).closeButtonTooltip,
icon: const Icon(Icons.close),
style: IconButton.styleFrom(
backgroundColor: colorScheme.surfaceContainerHighest,
@@ -493,7 +489,6 @@ class _LibraryTracksFolderScreenState
const SizedBox(height: 12),
// Action buttons row
Row(
children: [
if (isWishlist)
@@ -525,7 +520,6 @@ class _LibraryTracksFolderScreenState
const SizedBox(height: 8),
// Remove button (full width, red)
SizedBox(
width: double.infinity,
child: FilledButton.icon(
@@ -714,7 +708,6 @@ class _LibraryTracksFolderScreenState
)
else
coverFallback,
// Bottom gradient for readability
Positioned(
left: 0,
right: 0,
@@ -733,7 +726,6 @@ class _LibraryTracksFolderScreenState
),
),
),
// Title and track count overlay
Positioned(
left: 20,
right: 20,
@@ -811,6 +803,9 @@ class _LibraryTracksFolderScreenState
},
),
leading: IconButton(
tooltip: _isSelectionMode
? MaterialLocalizations.of(context).closeButtonTooltip
: MaterialLocalizations.of(context).backButtonTooltip,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
@@ -829,9 +824,8 @@ class _LibraryTracksFolderScreenState
);
}
// Header actions
Widget _buildHeaderActionPlaceholder() => const SizedBox(width: 48, height: 48);
Widget _buildHeaderActionPlaceholder() =>
const SizedBox(width: 48, height: 48);
Widget _buildDownloadAllCenterButton(List<CollectionTrackEntry> entries) {
final tracks = entries.map((e) => e.track).toList(growable: false);
@@ -1152,6 +1146,7 @@ class _CollectionTrackTile extends ConsumerWidget {
trailing: isSelectionMode
? null
: IconButton(
tooltip: MaterialLocalizations.of(context).showMenuTooltip,
icon: Icon(
Icons.more_vert,
color: colorScheme.onSurfaceVariant,
@@ -1263,7 +1258,6 @@ class _CollectionTrackTile extends ConsumerWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header: drag handle + cover + track info
Column(
children: [
const SizedBox(height: 8),
+29 -20
View File
@@ -13,7 +13,6 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
/// Screen to display tracks from a local library album
class LocalAlbumScreen extends ConsumerStatefulWidget {
final String albumName;
final String artistName;
@@ -39,6 +38,14 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final ScrollController _scrollController = ScrollController();
late List<LocalLibraryItem> _sortedTracksCache;
late Map<int, List<LocalLibraryItem>> _discGroupsCache;
void _showCueVirtualTrackSnackBar() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(cueVirtualTrackRequiresSplitMessage),
),
);
}
late List<int> _sortedDiscNumbersCache;
late bool _hasMultipleDiscsCache;
String? _commonQualityCache;
@@ -83,7 +90,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
List<LocalLibraryItem> _buildSortedTracks() {
final tracks = List<LocalLibraryItem>.from(widget.tracks);
tracks.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);
@@ -180,9 +186,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
for (final id in idsToDelete) {
final item = tracksById[id];
if (item != null) {
try {
await deleteFile(item.filePath);
} catch (_) {}
if (!isCueVirtualPath(item.filePath)) {
try {
await deleteFile(item.filePath);
} catch (_) {}
}
await libraryNotifier.removeItem(id);
deletedCount++;
}
@@ -197,7 +205,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
),
);
// Go back if all tracks were deleted
if (deletedCount == currentTracks.length) {
Navigator.pop(context);
}
@@ -206,6 +213,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
}
Future<void> _openFile(LocalLibraryItem track) async {
if (isCueVirtualPath(track.filePath)) {
_showCueVirtualTrackSnackBar();
return;
}
try {
await ref
.read(playbackProvider.notifier)
@@ -233,7 +244,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final bottomPadding = MediaQuery.of(context).padding.bottom;
final tracks = _sortedTracksCache;
// Show empty state if no tracks found
if (tracks.isEmpty) {
return Scaffold(
appBar: AppBar(title: Text(widget.albumName)),
@@ -326,7 +336,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
background: Stack(
fit: StackFit.expand,
children: [
// Full-screen cover background
if (widget.coverPath != null)
Image.file(
File(widget.coverPath!),
@@ -343,7 +352,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
color: colorScheme.onSurfaceVariant,
),
),
// Bottom gradient for readability
Positioned(
left: 0,
right: 0,
@@ -362,7 +370,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
),
),
),
// Album info overlay at bottom
Positioned(
left: 20,
right: 20,
@@ -494,6 +501,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
},
),
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
@@ -733,6 +741,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
trailing: _isSelectionMode
? null
: IconButton(
tooltip: 'Play track',
onPressed: () => _openFile(track),
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
style: IconButton.styleFrom(
@@ -888,7 +897,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return false;
}
/// Batch re-enrich selected local tracks
Future<void> _reEnrichSelected(List<LocalLibraryItem> allTracks) async {
final tracksById = {for (final t in allTracks) t.id: t};
final selected = <LocalLibraryItem>[];
@@ -958,13 +966,18 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return;
}
final localLibraryPath = ref.read(settingsProvider).localLibraryPath.trim();
final settings = ref.read(settingsProvider);
final localLibraryPath = settings.localLibraryPath.trim();
final iosBookmark = settings.localLibraryBookmark;
try {
if (localLibraryPath.isNotEmpty &&
!ref.read(localLibraryProvider).isScanning) {
await ref
.read(localLibraryProvider.notifier)
.startScan(localLibraryPath);
.startScan(
localLibraryPath,
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
);
} else {
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
}
@@ -988,7 +1001,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
).showSnackBar(SnackBar(content: Text(summary)));
}
/// Show batch convert bottom sheet
void _showBatchConvertSheet(
BuildContext context,
List<LocalLibraryItem> allTracks,
@@ -1261,7 +1273,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
String? safTempPath;
if (isSaf) {
// Copy SAF file to temp for conversion
safTempPath = await PlatformBridge.copyContentUriToTemp(
item.filePath,
);
@@ -1296,7 +1307,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
if (isSaf) {
// For SAF: derive the parent tree URI and relative dir from the content URI,
// then create new SAF file and delete old one
//
// Parse the SAF URI to get the tree document path:
// content://...tree/...document/.../oldName.flac
// We need tree URI and relative dir to create the new file
@@ -1375,14 +1385,12 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
continue;
}
// Delete old SAF file
try {
await PlatformBridge.safDelete(item.filePath);
} catch (_) {}
await localDb.deleteByPath(item.filePath);
}
// Clean up temp files
try {
await File(newPath).delete();
} catch (_) {}
@@ -1400,7 +1408,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
} catch (_) {}
}
// Reload local library to pick up converted files
ref.read(localLibraryProvider.notifier).reloadFromStorage();
_exitSelectionMode();
@@ -1461,6 +1468,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
children: [
IconButton.filledTonal(
onPressed: _exitSelectionMode,
tooltip: MaterialLocalizations.of(
context,
).closeButtonTooltip,
icon: const Icon(Icons.close),
style: IconButton.styleFrom(
backgroundColor: colorScheme.surfaceContainerHighest,
@@ -1513,7 +1523,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
),
const SizedBox(height: 12),
// Action buttons row: Re-enrich, Convert
Row(
children: [
Expanded(
+17 -1
View File
@@ -102,13 +102,29 @@ class _MainShellState extends ConsumerState<MainShell> {
if (_currentIndex != 0) {
_onNavTap(0);
}
ref.read(trackProvider.notifier).fetchFromUrl(url);
ref.read(settingsProvider.notifier).setHasSearchedBefore();
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.loadingSharedLink)));
}
await ref.read(trackProvider.notifier).fetchFromUrl(url);
final trackState = ref.read(trackProvider);
if (trackState.error != null && mounted) {
final l10n = context.l10n;
final errorMsg = trackState.error!;
final isRateLimit = errorMsg.contains('429') ||
errorMsg.toLowerCase().contains('rate limit') ||
errorMsg.toLowerCase().contains('too many requests');
final displayMessage = errorMsg == 'url_not_recognized'
? l10n.errorUrlNotRecognizedMessage
: isRateLimit
? l10n.errorRateLimitedMessage
: l10n.errorUrlFetchFailed;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(displayMessage)),
);
}
}
Future<void> _checkForUpdates() async {

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