Compare commits

...

224 Commits

Author SHA1 Message Date
zarzet 06f2b9ec97 ci(ios): strip CRLF from ffmpeg plugin scripts before pod install
The ffmpeg_kit_flutter_new_full pub package ships setup_ios.sh with CRLF line endings, so its podspec prepare_command failed with '/bin/bash^M: bad interpreter'. Normalize the plugin's shell scripts in the pub cache before building iOS.
2026-06-14 02:28:57 +07:00
zarzet 7fee4cea4f chore: bump version to 4.6.0 2026-06-14 02:08:52 +07:00
zarzet 526897b23b feat(playlist): blurred backdrop with full cover in playlist header
Replaces the cropped BoxFit.cover header with a blurred cover backdrop plus the full square cover centered, so covers with baked-in text are no longer awkwardly cropped. Title, track count and actions now sit in one centered column that adapts to header height.
2026-06-13 20:47:12 +07:00
zarzet c10c2a290c feat(ui): add bottom inset so scrollable content clears the transparent navbar 2026-06-13 20:31:39 +07:00
zarzet fb5204b0a6 fix(metadata): use high-res cover in track metadata header 2026-06-13 20:31:24 +07:00
zarzet 9db4048bc0 feat(library): show active downloads inside the library grid
Active downloads now render as the first tiles of the library list/grid instead of a separate top section, with a compact Downloading header that animates in/out. Completed items hand off seamlessly via a short-lived bridge tile (with cover precache) so the song never blinks out, and the order is reversed so the soonest-to-finish sits next to where it lands.
2026-06-13 20:31:13 +07:00
zarzet 63c68b4d4d fix(download): honor selected provider when it equals the track source
When the chosen download service matched the track's source extension it was skipped in both the source preflight and the fallback loop, so downloads silently fell back to another provider. It is now attempted in the loop, and an explicitly selected provider bypasses the fallback allow-list.
2026-06-13 20:31:02 +07:00
zarzet 953ef37882 fix(download): request fallback provider's own highest quality 2026-06-13 16:29:30 +07:00
zarzet da85a2dcc2 feat(ui): reduce bottom navbar height 2026-06-13 16:09:54 +07:00
zarzet 49869792cf chore: trim redundant comments 2026-06-13 15:37:00 +07:00
zarzet fb2dda1ed1 feat(ui): frosted translucent bottom navbar 2026-06-13 15:36:59 +07:00
zarzet fad4c4ea36 feat(lyrics): show actual lyrics source in metadata 2026-06-13 15:36:47 +07:00
zarzet 6b5345a6e5 fix(downloads/extensions): iOS background task, serialize extension mutations, safer batch convert sheet
- iOS: begin/end UIBackgroundTask while a download queue is active so in-flight downloads survive backgrounding for the limited window iOS allows

- extensions: serialize install/upgrade/remove in the Go manager (mutationMu) and in the Dart store provider to stop concurrent goja VM teardown/reload from hard-crashing the app

- main: add runZonedGuarded + FlutterError/PlatformDispatcher onError so uncaught Dart errors are logged, not fatal

- batch convert sheet: precompute localized title/label before showModalBottomSheet to avoid Localizations lookup via a deactivated context
2026-06-13 02:42:23 +07:00
zarzet ca413a16fa fix(ui): center modals on large screens, modernize edit-metadata + convert sheets, themed badges, fix artist skeleton and format-editor crash
- app: clear displayFeatures so bottom sheets/dialogs center on large/foldable screens

- edit metadata sheet: card sections, modern label-above inputs, elegant collapsible headers, removed title icon

- convert + batch convert: modern card-based sheets; shared BatchConvertSheet widget

- queue: keep selection toolbar hidden until modal close animation finishes

- 24-bit and In Library badges now use primary dynamic color

- artist skeleton: remove duplicate name/listeners lines, keep cover placeholder

- files settings: own filename-format controller in a StatefulWidget to fix use-after-dispose crash
2026-06-13 02:08:06 +07:00
zarzet b8b670642c feat(audio): add WAV and AIFF support + settings-style metadata menu
WAV/AIFF: library scan, quality probe, native tag read/write via embedded ID3 chunk (RIFF id3 / AIFF ID3), cover art, ReadFileMetadata, ExtractLyrics, and FLAC<->WAV/AIFF conversion (PCM, bit-depth preserved via ffprobe). Treat WAV/AIFF as lossless across all convert sheets (no bitrate picker, Lossless labels) via isLosslessConversionTarget. Native MIME maps for SAF. Redesign the track metadata three-dot menu to a settings-style grouped card with a single divider above Share.
2026-06-12 21:10:37 +07:00
zarzet 2a2e2924eb feat(lyrics,replaygain): add LyricsPlus provider and ReplayGain batch scanning
LyricsPlus (KPOE): word-by-word synced lyrics with multi-server failover, converted to enhanced LRC. ReplayGain: standalone EBU R128 (re)scan writing REPLAYGAIN_TRACK_* tags via native writers or FFmpeg, with batch action in queue/album screens and SAF support.
2026-06-12 01:59:26 +07:00
zarzet adea3de737 chore(deps): update Flutter and Go dependencies
Bump riverpod, go_router, sqflite, permission_handler, ffmpeg_kit, flutter_local_notifications, json_annotation and riverpod_generator/lint to stable; refresh go.mod/go.sum via go get -u.
2026-06-12 01:55:58 +07:00
zarzet 7d300a39c9 refactor: generalize Tidal-specific naming to legacy/DASH terminology
- Rename downloadProviderMatchesBuiltIn -> downloadProviderReplacesLegacyProvider

- Rename Tidal DASH ffmpeg helpers and lossy format pickers to generic names

- Add utils.decryptCTRSegments crypto API + raw/bytes file read path in extension runtime

- Update l10n strings/descriptions to drop hardcoded service names

- Bump version to 4.5.7+134
2026-06-11 01:08:20 +07:00
zarzet 688a5f2add fix(l10n): remove redundant ICU plural categories causing gen-l10n warnings 2026-06-07 05:30:51 +07:00
zarzet d736e5aafe refactor(download): remove concurrent download option
The download API only permits one request at a time, so parallel
downloads are removed to avoid wasted/blocked API calls. Downloads
now always run sequentially (one track at a time).

- Drop concurrentDownloads from AppSettings + JSON serialization
- Remove setConcurrentDownloads and the settings UI (1-5 chips + warning)
- Strip optionsConcurrent* l10n keys from all ARBs and regenerate
- Rework queue worker into _processQueueSequential (single active download)
- Update marketing copy and adjust tests
2026-06-06 21:58:45 +07:00
zarzet 3a536ad348 chore(about): credit Mickael81 as French translator 2026-06-04 22:46:56 +07:00
zarzet 5dedeb4971 fix(android): override predictive-back page transition
Flutter's default Android route transition (PredictiveBackPageTransitionsBuilder) mis-routes the predictive-back gesture to a nested Navigator instead of the topmost route (flutter#152323), popping the page behind a root modal instead of closing the modal first. This regressed after the Flutter upgrade in 4.5.6. Force FadeForwardsPageTransitionsBuilder on Android (the same non-gesture animation that builder delegates to) so back closes modals/sheets/dialogs first, then pops the page - restoring 4.5.5 behavior. Keep Cupertino transitions on iOS/macOS.
2026-06-04 22:46:45 +07:00
zarzet 7624e24ea6 fix(queue): simplify queue header and rate-limit indicator layout 2026-06-04 21:03:12 +07:00
zarzet 7b248d8ab4 feat(l10n): enable French and German locales 2026-06-04 21:03:02 +07:00
zarzet fdb2009856 Merge branch 'l10n_main': Crowdin translation updates (#412)
Resolve ARB conflicts via per-key union: apply latest Crowdin
translations for shared keys while preserving newer app keys added on
main after the branch point. Drop hyphenated ARB duplicates
(app_ar-SA, app_es-ES, app_pt-PT, app_tr-TR, app_uk-UA) that break
Flutter gen-l10n; keep underscore filenames. Add Arabic (app_ar.arb)
and regenerate app_localizations.
2026-06-04 20:50:42 +07:00
Zarz Eleutherius 8419a75b04 New translations app_en.arb (Arabic)
[ci skip]
2026-06-04 20:24:31 +07:00
zarzet 5d474d6fe8 fix(l10n): correct crowdin language mapping
Map placeholders to the project's actual Crowdin language ids and drop the bogus bare keys (es, pt, zh) that aren't real Crowdin codes and broke crowdin-cli config validation. Add Arabic (ar) mapped to app_ar.arb so future syncs use underscore filenames instead of hyphenated ones (e.g. app_ar-SA.arb) that break Flutter gen-l10n.
2026-06-04 20:20:05 +07:00
Zarz Eleutherius e597505a1c New translations app_en.arb (French)
[ci skip]
2026-06-02 03:38:17 +07:00
github-actions[bot] 8675d263e7 chore: update AltStore source to v4.5.6 2026-06-01 18:23:57 +00:00
zarzet 1ce66b9e03 fix: align ios deployment target for file picker 2026-06-02 01:09:53 +07:00
Zarz Eleutherius cfda124995 New translations app_en.arb (Hindi)
[ci skip]
2026-06-02 01:09:09 +07:00
Zarz Eleutherius 212f1cacca New translations app_en.arb (Chinese Traditional)
[ci skip]
2026-06-02 01:09:07 +07:00
Zarz Eleutherius dd89de7cad New translations app_en.arb (Ukrainian)
[ci skip]
2026-06-02 01:09:05 +07:00
Zarz Eleutherius 8b4372dc7f New translations app_en.arb (Turkish)
[ci skip]
2026-06-02 01:09:03 +07:00
Zarz Eleutherius 2a25557632 New translations app_en.arb (Russian)
[ci skip]
2026-06-02 01:09:01 +07:00
Zarz Eleutherius 0cbb339948 New translations app_en.arb (Portuguese)
[ci skip]
2026-06-02 01:08:59 +07:00
Zarz Eleutherius 1496f51e30 New translations app_en.arb (Dutch)
[ci skip]
2026-06-02 01:08:58 +07:00
Zarz Eleutherius d1c5fe0605 New translations app_en.arb (Korean)
[ci skip]
2026-06-02 01:08:56 +07:00
Zarz Eleutherius 56786f60ff New translations app_en.arb (Japanese)
[ci skip]
2026-06-02 01:08:54 +07:00
Zarz Eleutherius af5d36f69f New translations app_en.arb (German)
[ci skip]
2026-06-02 01:08:52 +07:00
Zarz Eleutherius e40da71ef8 New translations app_en.arb (Arabic)
[ci skip]
2026-06-02 01:08:50 +07:00
Zarz Eleutherius 26b8bf422c New translations app_en.arb (Indonesian)
[ci skip]
2026-06-02 01:08:49 +07:00
Zarz Eleutherius 0a545706bd New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-06-02 01:08:47 +07:00
Zarz Eleutherius 9ebac610c7 New translations app_en.arb (Spanish)
[ci skip]
2026-06-02 01:08:45 +07:00
Zarz Eleutherius 5fc8a6af2a New translations app_en.arb (French)
[ci skip]
2026-06-02 01:08:43 +07:00
zarzet 8e68af79aa fix: prevent queue header action clipping 2026-06-02 00:58:43 +07:00
zarzet 6246e6e821 chore: update flutter and native dependencies 2026-06-02 00:58:42 +07:00
zarzet 421d5ffdc8 feat: polish search empty state and share caching 2026-06-02 00:58:42 +07:00
zarzet b82dabe316 fix: align cross-service sharing and fallback routing 2026-06-02 00:58:42 +07:00
zarzet ffdaf14ba5 feat: rebuild cross-extension sharing and queue controls
Co-authored-by: Amonoman <musaauron87@gmail.com>
2026-06-02 00:58:41 +07:00
zarzet f52527a41b chore: bump version to 4.5.6 (build 133) 2026-06-02 00:58:41 +07:00
zarzet 56a89c5fc6 fix: harden download errors and re-enrich sidecars 2026-06-02 00:58:40 +07:00
zarzet 4f5163be01 fix: resolve album-only autofill and placeholder re-enrich regressions
- Dart: _metadataMatchIsConfident now handles album-only case (title empty)
  by adding albumMatches fallback branch
- Go: selectBestReEnrichTrack treats placeholder values (Unknown Title,
  Unknown Artist) as empty via isPlaceholderReEnrichValue, so album-based
  fallback filtering works correctly
- Add test for placeholder album fallback in selectBestReEnrichTrack
2026-06-02 00:58:40 +07:00
zarzet 822c094c8c fix: stricter metadata matching, respect embedLyrics setting, improve Apple Music lyrics
- Re-enrich: reject candidates that don't match title/artist/album unless exact ISRC match
- Respect settings.embedLyrics instead of hardcoding true in re-enrich flows
- Skip lyrics resolution in NativeDownloadFinalizer when not needed
- Apple Music lyrics: use direct catalog API with token scraping instead of Paxsenix search
- Support ELRC/ELRCMultiPerson/Plain formats in Apple Music lyrics response
- Add confidence check in metadata auto-fill to prevent applying wrong metadata
- Add tests for stricter re-enrich matching logic
2026-06-02 00:58:40 +07:00
Zarz Eleutherius 1623f443bb New translations app_en.arb (Spanish)
[ci skip]
2026-05-31 09:12:29 +07:00
Zarz Eleutherius aa47bc4499 New translations app_en.arb (French)
[ci skip]
2026-05-28 18:50:01 +07:00
Zarz Eleutherius f461322842 New translations app_en.arb (French)
[ci skip]
2026-05-28 17:08:19 +07:00
Zarz Eleutherius cce05a0077 New translations app_en.arb (French)
[ci skip]
2026-05-28 16:08:05 +07:00
Zarz Eleutherius 98dc868f47 New translations app_en.arb (French)
[ci skip]
2026-05-28 14:30:31 +07:00
Zarz Eleutherius 821a41c10e New translations app_en.arb (French)
[ci skip]
2026-05-28 03:12:44 +07:00
Zarz Eleutherius 853ccd657a New translations app_en.arb (French)
[ci skip]
2026-05-28 01:57:58 +07:00
Zarz Eleutherius 680fc81db2 New translations app_en.arb (French)
[ci skip]
2026-05-27 23:40:18 +07:00
Zarz Eleutherius 36470eda24 New translations app_en.arb (French)
[ci skip]
2026-05-27 21:58:35 +07:00
Zarz Eleutherius a37dd6c8cb New translations app_en.arb (French)
[ci skip]
2026-05-27 04:52:32 +07:00
Zarz Eleutherius 588f742871 New translations app_en.arb (French)
[ci skip]
2026-05-27 03:45:18 +07:00
Zarz Eleutherius ff25a10e5b New translations app_en.arb (French)
[ci skip]
2026-05-27 02:33:22 +07:00
Zarz Eleutherius 499457f66a New translations app_en.arb (French)
[ci skip]
2026-05-26 23:49:07 +07:00
Zarz Eleutherius 6d15050009 New translations app_en.arb (French)
[ci skip]
2026-05-26 22:09:18 +07:00
Zarz Eleutherius 5ba30031c3 New translations app_en.arb (French)
[ci skip]
2026-05-26 05:23:50 +07:00
Zarz Eleutherius 82c0eef504 New translations app_en.arb (French)
[ci skip]
2026-05-26 04:27:43 +07:00
Zarz Eleutherius 616267e997 New translations app_en.arb (Arabic)
[ci skip]
2026-05-24 15:17:12 +07:00
Zarz Eleutherius 161b0c8c21 New translations app_en.arb (Arabic)
[ci skip]
2026-05-23 16:58:47 +07:00
Zarz Eleutherius facd185d6c New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-05-23 00:53:46 +07:00
Zarz Eleutherius 42858bf336 New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-05-22 23:40:00 +07:00
Zarz Eleutherius 716be88caf New translations app_en.arb (Arabic)
[ci skip]
2026-05-22 16:14:08 +07:00
Zarz Eleutherius b296726a9d New translations app_en.arb (Spanish)
[ci skip]
2026-05-21 04:09:42 +07:00
Zarz Eleutherius 092f18d7a5 New translations app_en.arb (Spanish)
[ci skip]
2026-05-21 02:23:55 +07:00
Zarz Eleutherius f1ef33e319 New translations app_en.arb (Spanish)
[ci skip]
2026-05-21 01:12:56 +07:00
Zarz Eleutherius fc9bc95418 New translations app_en.arb (Indonesian)
[ci skip]
2026-05-19 01:44:44 +07:00
Zarz Eleutherius c61e64f332 New translations app_en.arb (Spanish)
[ci skip]
2026-05-18 00:54:25 +07:00
Zarz Eleutherius 70ebb8ef1a New translations app_en.arb (Spanish)
[ci skip]
2026-05-17 23:29:19 +07:00
Zarz Eleutherius a4c6a92478 New translations app_en.arb (Spanish)
[ci skip]
2026-05-17 22:18:42 +07:00
Zarz Eleutherius 76b453e535 New translations app_en.arb (Spanish)
[ci skip]
2026-05-17 20:59:16 +07:00
Zarz Eleutherius 19acdd87f5 New translations app_en.arb (Spanish)
[ci skip]
2026-05-17 19:59:25 +07:00
Zarz Eleutherius 492e1335ef New translations app_en.arb (German)
[ci skip]
2026-05-16 20:27:00 +07:00
Zarz Eleutherius 23cde7add3 New translations app_en.arb (Spanish)
[ci skip]
2026-05-16 20:26:59 +07:00
Zarz Eleutherius a20c28db25 New translations app_en.arb (German)
[ci skip]
2026-05-16 19:31:57 +07:00
Zarz Eleutherius f07d46c49e New translations app_en.arb (German)
[ci skip]
2026-05-16 18:21:28 +07:00
Zarz Eleutherius e9781a24a6 New translations app_en.arb (Turkish)
[ci skip]
2026-05-15 20:32:29 +07:00
Zarz Eleutherius 15be15ba58 New translations app_en.arb (Turkish)
[ci skip]
2026-05-15 18:57:35 +07:00
github-actions[bot] 0952b76e11 chore: update AltStore source to v4.5.5 2026-05-14 23:25:38 +00:00
Zarz Eleutherius 8011d41e53 New translations app_en.arb (Arabic)
[ci skip]
2026-05-15 06:18:25 +07:00
Zarz Eleutherius 5412f23d26 New translations app_en.arb (Hindi)
[ci skip]
2026-05-15 06:18:23 +07:00
Zarz Eleutherius 0c39ff47f2 New translations app_en.arb (Indonesian)
[ci skip]
2026-05-15 06:18:21 +07:00
Zarz Eleutherius 537af905f6 New translations app_en.arb (Chinese Traditional)
[ci skip]
2026-05-15 06:18:20 +07:00
Zarz Eleutherius 6b4f70bde3 New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-05-15 06:18:18 +07:00
Zarz Eleutherius be2b6d2c1f New translations app_en.arb (Ukrainian)
[ci skip]
2026-05-15 06:18:17 +07:00
Zarz Eleutherius 0c1a6d8f19 New translations app_en.arb (Turkish)
[ci skip]
2026-05-15 06:18:15 +07:00
Zarz Eleutherius 2821997260 New translations app_en.arb (Russian)
[ci skip]
2026-05-15 06:18:13 +07:00
Zarz Eleutherius 0546a33b10 New translations app_en.arb (Portuguese)
[ci skip]
2026-05-15 06:18:11 +07:00
Zarz Eleutherius deb98d8dfb New translations app_en.arb (Dutch)
[ci skip]
2026-05-15 06:18:09 +07:00
Zarz Eleutherius 72c658eda7 New translations app_en.arb (Korean)
[ci skip]
2026-05-15 06:18:07 +07:00
Zarz Eleutherius df17f10c8a New translations app_en.arb (Japanese)
[ci skip]
2026-05-15 06:18:05 +07:00
Zarz Eleutherius 9cacf2dc8e New translations app_en.arb (German)
[ci skip]
2026-05-15 06:18:04 +07:00
Zarz Eleutherius c7bc9f5b1c New translations app_en.arb (Spanish)
[ci skip]
2026-05-15 06:18:02 +07:00
Zarz Eleutherius 49ba8ae0d2 New translations app_en.arb (French)
[ci skip]
2026-05-15 06:18:00 +07:00
zarzet 7291dbd9e2 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	apps.json
2026-05-15 06:11:51 +07:00
Zarz Eleutherius 3a62442ed0 New translations app_en.arb (Spanish)
[ci skip]
2026-05-15 01:05:23 +07:00
Zarz Eleutherius 3a1b92f9c4 New translations app_en.arb (Spanish)
[ci skip]
2026-05-14 23:24:51 +07:00
Zarz Eleutherius 30f97394ec New translations app_en.arb (French)
[ci skip]
2026-05-12 04:22:24 +07:00
Zarz Eleutherius 592308c1c6 New translations app_en.arb (French)
[ci skip]
2026-05-12 03:19:49 +07:00
Zarz Eleutherius 8bcfc63da0 New translations app_en.arb (French)
[ci skip]
2026-05-12 00:43:24 +07:00
Zarz Eleutherius a9cfff2692 New translations app_en.arb (French)
[ci skip]
2026-05-11 22:40:12 +07:00
Zarz Eleutherius 9e7ff56113 New translations app_en.arb (French)
[ci skip]
2026-05-11 18:52:31 +07:00
Zarz Eleutherius 9071143bbd New translations app_en.arb (French)
[ci skip]
2026-05-11 16:57:43 +07:00
Zarz Eleutherius 40770aff15 New translations app_en.arb (Turkish)
[ci skip]
2026-05-11 01:05:00 +07:00
Zarz Eleutherius 2bc5ef34ee New translations app_en.arb (Spanish)
[ci skip]
2026-05-10 06:34:38 +07:00
Zarz Eleutherius 6b9a3d95cd New translations app_en.arb (Spanish)
[ci skip]
2026-05-09 13:06:15 +07:00
Zarz Eleutherius 4fe51cef96 New translations app_en.arb (Spanish)
[ci skip]
2026-05-08 13:37:22 +07:00
github-actions[bot] d005e2e2e7 chore: update AltStore source to v4.5.1 2026-05-07 18:22:36 +00:00
Zarz Eleutherius 672ce024f8 New translations app_en.arb (French)
[ci skip]
2026-05-07 04:04:43 +07:00
Zarz Eleutherius 8224e93447 New translations app_en.arb (Russian)
[ci skip]
2026-05-07 02:40:48 +07:00
Zarz Eleutherius 1ba810fffb New translations app_en.arb (German)
[ci skip]
2026-05-07 02:40:46 +07:00
Zarz Eleutherius 1a725d0d31 New translations app_en.arb (French)
[ci skip]
2026-05-07 02:40:44 +07:00
Zarz Eleutherius 51c5b42a78 New translations app_en.arb (Arabic)
[ci skip]
2026-05-07 01:24:32 +07:00
Zarz Eleutherius 2908827018 New translations app_en.arb (German)
[ci skip]
2026-05-07 01:24:30 +07:00
Zarz Eleutherius b985cbf694 New translations app_en.arb (German)
[ci skip]
2026-05-06 23:33:52 +07:00
Zarz Eleutherius 1293d92896 New translations app_en.arb (Hindi)
[ci skip]
2026-05-06 22:15:45 +07:00
Zarz Eleutherius 705d41931d New translations app_en.arb (Indonesian)
[ci skip]
2026-05-06 22:15:43 +07:00
Zarz Eleutherius 29de69d323 New translations app_en.arb (Chinese Traditional)
[ci skip]
2026-05-06 22:15:41 +07:00
Zarz Eleutherius 28727d89f6 New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-05-06 22:15:39 +07:00
Zarz Eleutherius 4704bcf52f New translations app_en.arb (Ukrainian)
[ci skip]
2026-05-06 22:15:37 +07:00
Zarz Eleutherius 13c148fb6c New translations app_en.arb (Turkish)
[ci skip]
2026-05-06 22:15:35 +07:00
Zarz Eleutherius e6079452f9 New translations app_en.arb (Russian)
[ci skip]
2026-05-06 22:15:33 +07:00
Zarz Eleutherius b68b7d5c9b New translations app_en.arb (Portuguese)
[ci skip]
2026-05-06 22:15:31 +07:00
Zarz Eleutherius 741fcdb4d9 New translations app_en.arb (Dutch)
[ci skip]
2026-05-06 22:15:30 +07:00
Zarz Eleutherius 642f8c5398 New translations app_en.arb (Korean)
[ci skip]
2026-05-06 22:15:28 +07:00
Zarz Eleutherius 1c15d5e7d3 New translations app_en.arb (Japanese)
[ci skip]
2026-05-06 22:15:26 +07:00
Zarz Eleutherius e71090338c New translations app_en.arb (German)
[ci skip]
2026-05-06 22:15:24 +07:00
Zarz Eleutherius 7c0feaaae0 New translations app_en.arb (Spanish)
[ci skip]
2026-05-06 22:15:22 +07:00
Zarz Eleutherius 5aa3ff4bb5 New translations app_en.arb (French)
[ci skip]
2026-05-06 22:15:20 +07:00
Zarz Eleutherius d4c83db428 New translations app_en.arb (Russian)
[ci skip]
2026-05-06 17:21:54 +07:00
Zarz Eleutherius 9f2d51fd4d New translations app_en.arb (Russian)
[ci skip]
2026-05-06 14:56:46 +07:00
Zarz Eleutherius 36137e8970 New translations app_en.arb (Russian)
[ci skip]
2026-05-06 01:29:54 +07:00
Zarz Eleutherius 823e56926f New translations app_en.arb (German)
[ci skip]
2026-05-06 00:16:56 +07:00
Zarz Eleutherius dd8a54dd43 New translations app_en.arb (German)
[ci skip]
2026-05-05 15:20:56 +07:00
Zarz Eleutherius 1ff33b96fa New translations app_en.arb (German)
[ci skip]
2026-05-05 13:21:43 +07:00
Zarz Eleutherius 4be9273768 New translations app_en.arb (Hindi)
[ci skip]
2026-05-03 01:39:32 +07:00
Zarz Eleutherius f458ac2162 New translations app_en.arb (Indonesian)
[ci skip]
2026-05-03 01:39:31 +07:00
Zarz Eleutherius b5ea2bb4c1 New translations app_en.arb (Chinese Traditional)
[ci skip]
2026-05-03 01:39:29 +07:00
Zarz Eleutherius 284d257921 New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-05-03 01:39:28 +07:00
Zarz Eleutherius 30bf6b7f9a New translations app_en.arb (Ukrainian)
[ci skip]
2026-05-03 01:39:27 +07:00
Zarz Eleutherius 4941b6bd23 New translations app_en.arb (Turkish)
[ci skip]
2026-05-03 01:39:25 +07:00
Zarz Eleutherius 33d99817ec New translations app_en.arb (Russian)
[ci skip]
2026-05-03 01:39:24 +07:00
Zarz Eleutherius 37e1af50ad New translations app_en.arb (Portuguese)
[ci skip]
2026-05-03 01:39:22 +07:00
Zarz Eleutherius 8a6efb1303 New translations app_en.arb (Dutch)
[ci skip]
2026-05-03 01:39:21 +07:00
Zarz Eleutherius 7823b19b89 New translations app_en.arb (Korean)
[ci skip]
2026-05-03 01:39:19 +07:00
Zarz Eleutherius 2a9aa544a9 New translations app_en.arb (Japanese)
[ci skip]
2026-05-03 01:39:18 +07:00
Zarz Eleutherius f387c8ff85 New translations app_en.arb (German)
[ci skip]
2026-05-03 01:39:17 +07:00
Zarz Eleutherius 7e537aec0b New translations app_en.arb (Spanish)
[ci skip]
2026-05-03 01:39:15 +07:00
Zarz Eleutherius 66cd465565 New translations app_en.arb (French)
[ci skip]
2026-05-03 01:39:14 +07:00
Zarz Eleutherius 83afa40423 New translations app_en.arb (Hindi)
[ci skip]
2026-05-03 00:20:59 +07:00
Zarz Eleutherius 486e7eb101 New translations app_en.arb (Indonesian)
[ci skip]
2026-05-03 00:20:58 +07:00
Zarz Eleutherius 05eb9e60d3 New translations app_en.arb (Chinese Traditional)
[ci skip]
2026-05-03 00:20:56 +07:00
Zarz Eleutherius dde7095644 New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-05-03 00:20:55 +07:00
Zarz Eleutherius f1e9a2915d New translations app_en.arb (Ukrainian)
[ci skip]
2026-05-03 00:20:53 +07:00
Zarz Eleutherius ae3495d373 New translations app_en.arb (Turkish)
[ci skip]
2026-05-03 00:20:51 +07:00
Zarz Eleutherius 6fb2c1b688 New translations app_en.arb (Russian)
[ci skip]
2026-05-03 00:20:50 +07:00
Zarz Eleutherius 1526c558e7 New translations app_en.arb (Portuguese)
[ci skip]
2026-05-03 00:20:49 +07:00
Zarz Eleutherius 324e0f053b New translations app_en.arb (Dutch)
[ci skip]
2026-05-03 00:20:47 +07:00
Zarz Eleutherius 25cb33c78e New translations app_en.arb (Korean)
[ci skip]
2026-05-03 00:20:46 +07:00
Zarz Eleutherius 942b6d9569 New translations app_en.arb (Japanese)
[ci skip]
2026-05-03 00:20:44 +07:00
Zarz Eleutherius cd46c79383 New translations app_en.arb (German)
[ci skip]
2026-05-03 00:20:43 +07:00
Zarz Eleutherius 0bdcdcc229 New translations app_en.arb (Spanish)
[ci skip]
2026-05-03 00:20:42 +07:00
Zarz Eleutherius 1a5863a7fb New translations app_en.arb (French)
[ci skip]
2026-05-03 00:20:40 +07:00
Zarz Eleutherius 701015ad55 New translations app_en.arb (Spanish)
[ci skip]
2026-05-01 04:51:14 +07:00
Zarz Eleutherius 63cfac626a New translations app_en.arb (French) 2026-04-28 05:12:53 +07:00
Zarz Eleutherius e6c5a21bfc New translations app_en.arb (French) 2026-04-28 04:13:54 +07:00
Zarz Eleutherius 2d80739141 New translations app_en.arb (Spanish) 2026-04-26 01:44:55 +07:00
Zarz Eleutherius 6494102e15 New translations app_en.arb (French) 2026-04-24 15:12:52 +07:00
Zarz Eleutherius 0e6aa2efd9 New translations app_en.arb (French) 2026-04-24 05:23:14 +07:00
Zarz Eleutherius f412c216c5 New translations app_en.arb (French) 2026-04-24 00:51:45 +07:00
Zarz Eleutherius af15e3d914 New translations app_en.arb (French) 2026-04-23 23:54:53 +07:00
Zarz Eleutherius b00ff3f3f0 New translations app_en.arb (German) 2026-04-23 21:06:22 +07:00
Zarz Eleutherius 1607e6830e New translations app_en.arb (French) 2026-04-23 19:03:21 +07:00
Zarz Eleutherius 817e0bf2bd New translations app_en.arb (French) 2026-04-23 16:51:44 +07:00
Zarz Eleutherius 0f12fbce6a New translations app_en.arb (French) 2026-04-23 14:54:33 +07:00
Zarz Eleutherius 953a09d75f New translations app_en.arb (Ukrainian) 2026-04-22 01:52:19 +07:00
Zarz Eleutherius 5098989614 New translations app_en.arb (Russian) 2026-04-20 18:24:22 +07:00
Zarz Eleutherius 5828bcffdd New translations app_en.arb (Korean) 2026-04-20 18:24:21 +07:00
Zarz Eleutherius ae87a7d58f New translations app_en.arb (Korean) 2026-04-20 16:22:00 +07:00
Zarz Eleutherius 32ab78a213 New translations app_en.arb (Russian) 2026-04-19 21:20:37 +07:00
Zarz Eleutherius 69583d172c New translations app_en.arb (Russian) 2026-04-19 19:52:56 +07:00
Zarz Eleutherius 38367c1c77 New translations app_en.arb (Russian) 2026-04-19 18:31:58 +07:00
Zarz Eleutherius 2f6bf91a1c New translations app_en.arb (German) 2026-04-19 02:58:18 +07:00
Zarz Eleutherius 60b062bbaf New translations app_en.arb (German) 2026-04-19 02:01:11 +07:00
Zarz Eleutherius 30e8b604a9 New translations app_en.arb (Ukrainian) 2026-04-18 23:47:31 +07:00
Zarz Eleutherius 7c3ab92e17 New translations app_en.arb (Turkish) 2026-04-18 23:47:29 +07:00
Zarz Eleutherius 37b101c70f New translations app_en.arb (Portuguese) 2026-04-18 23:47:28 +07:00
Zarz Eleutherius b7be46e6ae New translations app_en.arb (Spanish) 2026-04-18 23:47:25 +07:00
Zarz Eleutherius bf1f79866b New translations app_en.arb (Hindi) 2026-04-18 23:35:11 +07:00
Zarz Eleutherius a6460426a2 New translations app_en.arb (Indonesian) 2026-04-18 23:35:10 +07:00
Zarz Eleutherius 304ba14d20 New translations app_en.arb (Chinese Traditional) 2026-04-18 23:35:09 +07:00
Zarz Eleutherius db47233d92 New translations app_en.arb (Chinese Simplified) 2026-04-18 23:35:08 +07:00
Zarz Eleutherius 74eeb98be8 New translations app_en.arb (Russian) 2026-04-18 23:35:06 +07:00
Zarz Eleutherius 331da0f897 New translations app_en.arb (Dutch) 2026-04-18 23:35:04 +07:00
Zarz Eleutherius 73964ee648 New translations app_en.arb (Korean) 2026-04-18 23:35:03 +07:00
Zarz Eleutherius a5e8402141 New translations app_en.arb (Japanese) 2026-04-18 23:35:02 +07:00
Zarz Eleutherius c5e7fcf29b New translations app_en.arb (German) 2026-04-18 23:35:01 +07:00
Zarz Eleutherius d3cf6d30a7 New translations app_en.arb (French) 2026-04-18 23:34:59 +07:00
Zarz Eleutherius 74e14f7a43 New translations app_en.arb (Hindi) 2026-04-18 22:24:11 +07:00
Zarz Eleutherius 02e347adb0 New translations app_en.arb (Indonesian) 2026-04-18 22:24:10 +07:00
Zarz Eleutherius 56983cb85b New translations app_en.arb (Chinese Traditional) 2026-04-18 22:24:09 +07:00
Zarz Eleutherius 7917c656b0 New translations app_en.arb (Chinese Simplified) 2026-04-18 22:24:08 +07:00
Zarz Eleutherius fc34c1e548 New translations app_en.arb (Ukrainian) 2026-04-18 22:24:07 +07:00
Zarz Eleutherius f32aeaa0ff New translations app_en.arb (Turkish) 2026-04-18 22:24:06 +07:00
Zarz Eleutherius 86097a932c New translations app_en.arb (Russian) 2026-04-18 22:24:05 +07:00
Zarz Eleutherius f74f24c41f New translations app_en.arb (Portuguese) 2026-04-18 22:24:04 +07:00
Zarz Eleutherius 8e99e7b07e New translations app_en.arb (Dutch) 2026-04-18 22:24:03 +07:00
Zarz Eleutherius e06aab6e87 New translations app_en.arb (Korean) 2026-04-18 22:24:01 +07:00
Zarz Eleutherius a81e56fb26 New translations app_en.arb (Japanese) 2026-04-18 22:24:00 +07:00
Zarz Eleutherius 9a09b119c5 New translations app_en.arb (German) 2026-04-18 22:23:59 +07:00
Zarz Eleutherius 4b28ca1055 New translations app_en.arb (Spanish) 2026-04-18 22:23:58 +07:00
Zarz Eleutherius d684d9f8d1 New translations app_en.arb (French) 2026-04-18 22:23:57 +07:00
139 changed files with 42777 additions and 8950 deletions
+9
View File
@@ -257,6 +257,15 @@ jobs:
- name: Get Flutter dependencies
run: flutter pub get
- name: Normalize ffmpeg plugin shell scripts (strip CRLF)
run: |
find "$HOME/.pub-cache/hosted" -path "*ffmpeg_kit_flutter_new_full*/scripts/*.sh" -type f -print0 |
while IFS= read -r -d '' f; do
perl -pi -e 's/\r$//' "$f"
chmod +x "$f"
echo "Normalized line endings: $f"
done
- name: Generate app icons
run: dart run flutter_launcher_icons
+1
View File
@@ -66,6 +66,7 @@ extension/
AGENTS.md
# Temp/misc
.tmp/
nul
NUL
network_requests.txt
+3 -6
View File
@@ -59,7 +59,7 @@ Extensions let the community add new music sources and features without waiting
## Related Projects
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music available for Windows, macOS & Linux.
Download music in true lossless FLAC from extension-provided sources on Windows, macOS & Linux.
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
Python library for SpotiFLAC integration, maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu).
@@ -80,7 +80,7 @@ Starting from version 3.8.0, SpotiFLAC uses a decentralized extension repository
<summary><b>Why is my download failing with "Song not found"?</b></summary>
<br>
The track may not be available on the streaming services. Try enabling more providers under **Settings > Download > Provider Priority**, or install additional extensions like Amazon Music from the Store.
The track may not be available from your enabled providers. Try enabling more providers under **Settings > Extensions > Provider Priority**, or install additional download extensions from the Store.
</details>
@@ -88,10 +88,7 @@ The track may not be available on the streaming services. Try enabling more prov
<summary><b>Why are some tracks downloading in lower quality?</b></summary>
<br>
Quality depends on what's available from the streaming service and its extensions. Built-in providers:
- **Tidal** up to 24-bit/192kHz
- **Qobuz** up to 24-bit/192kHz
- **Deezer** up to 16-bit/44.1kHz
Quality depends on what's available from the source and the installed download extension. Check each extension's quality options and service notes in the app.
</details>
+3 -7
View File
@@ -9,6 +9,9 @@
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
plugins:
riverpod_lint: 3.1.4-dev.3
analyzer:
exclude:
- build/**
@@ -19,9 +22,6 @@ analyzer:
strict-casts: true
strict-inference: true
strict-raw-types: true
plugins:
- custom_lint
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
@@ -44,9 +44,5 @@ linter:
cancel_subscriptions: true
close_sinks: true
custom_lint:
rules:
- avoid_public_notifier_properties
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+2 -2
View File
@@ -120,8 +120,8 @@ dependencies {
// Include all AAR and JAR files from libs folder
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0-beta02")
implementation("androidx.documentfile:documentfile:1.1.0")
implementation("androidx.activity:activity-ktx:1.13.0")
implementation("com.antonkarpenko:ffmpeg-kit-full:2.1.0")
@@ -690,7 +690,8 @@ class DownloadService : Service() {
request.itemId,
request.requestJson,
request.itemJson,
result
result,
settingsJson
) {
nativeWorkerCancelRequested ||
nativeWorkerPaused ||
@@ -307,6 +307,8 @@ class MainActivity: FlutterFragmentActivity() {
".mp3" -> "audio/mpeg"
".opus" -> "audio/ogg"
".flac" -> "audio/flac"
".wav" -> "audio/wav"
".aiff", ".aif", ".aifc" -> "audio/aiff"
".lrc" -> "application/octet-stream"
else -> "application/octet-stream"
}
@@ -791,6 +793,8 @@ class MainActivity: FlutterFragmentActivity() {
"audio/mpeg" -> ".mp3"
"audio/ogg" -> ".opus"
"audio/flac" -> ".flac"
"audio/wav", "audio/x-wav", "audio/wave", "audio/vnd.wave" -> ".wav"
"audio/aiff", "audio/x-aiff" -> ".aiff"
else -> ""
}
}
@@ -1037,6 +1041,48 @@ class MainActivity: FlutterFragmentActivity() {
}
}
/**
* Write a ".lrc" sidecar next to a SAF audio document. The sidecar reuses
* the audio file's base name (e.g. "Song.flac" -> "Song.lrc") and is created
* in the same parent directory. Used by re-enrich when the user's lyrics
* mode requests an external/both sidecar. Best-effort: failures are logged
* and swallowed so they never abort the metadata enrichment itself.
*/
private fun writeSafSidecarLrc(audioUri: Uri, lrcContent: String): Boolean {
if (lrcContent.isBlank()) return false
try {
val parent = safParentDir(audioUri) ?: run {
android.util.Log.w("SpotiFLAC", "LRC sidecar: no SAF parent dir")
return false
}
val audioName = try {
DocumentFile.fromSingleUri(this, audioUri)?.name
} catch (_: Exception) {
null
} ?: return false
val baseName = audioName.substringBeforeLast('.', audioName)
val lrcName = "$baseName.lrc"
val target = createOrReuseDocumentFile(
parent,
"application/octet-stream",
lrcName
) ?: run {
android.util.Log.w("SpotiFLAC", "LRC sidecar: failed to create $lrcName")
return false
}
contentResolver.openOutputStream(target.uri, "wt")?.use { output ->
output.write(lrcContent.toByteArray(Charsets.UTF_8))
} ?: return false
android.util.Log.d("SpotiFLAC", "LRC sidecar written: $lrcName")
return true
} catch (e: Exception) {
android.util.Log.w("SpotiFLAC", "LRC sidecar write failed: ${e.message}")
return false
}
}
/**
* Extract the audio filename referenced by a CUE sheet file.
* Reads the FILE "name" TYPE line from the .cue text.
@@ -1071,6 +1117,16 @@ class MainActivity: FlutterFragmentActivity() {
".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a", ".mp4", ".aac"
)
// Audio file extensions that the local library scanner accepts. Must stay in
// sync with supportedAudioFormats in go_backend/library_scan.go so that every
// format the Go engine can read (FLAC, M4A/MP4/AAC, MP3, Opus/OGG, APE/WV/MPC,
// WAV, AIFF) is also enumerated here during the SAF folder walk. (.cue is
// handled separately.)
private val libraryScanAudioExtensions = setOf(
".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg",
".ape", ".wv", ".mpc", ".wav", ".aiff", ".aif"
)
private fun getSafChildFileLookup(
dir: DocumentFile,
cache: MutableMap<String, Map<String, DocumentFile>>,
@@ -1140,7 +1196,7 @@ class MainActivity: FlutterFragmentActivity() {
it.currentFile = "Scanning folders..."
}
val supportedAudioExt = setOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg")
val supportedAudioExt = libraryScanAudioExtensions
val audioFiles = mutableListOf<Pair<DocumentFile, String>>()
val cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
val visitedDirUris = mutableSetOf<String>()
@@ -1440,7 +1496,7 @@ class MainActivity: FlutterFragmentActivity() {
it.currentFile = "Scanning folders..."
}
val supportedAudioExt = setOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg")
val supportedAudioExt = libraryScanAudioExtensions
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>()
val cueFilesToScan = mutableListOf<Triple<DocumentFile, DocumentFile, Long>>()
val unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
@@ -2604,6 +2660,23 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"writeSafSidecarLrc" -> {
val safUri = call.argument<String>("saf_uri") ?: ""
val lyrics = call.argument<String>("lyrics") ?: ""
val response = withContext(Dispatchers.IO) {
try {
val uri = Uri.parse(safUri)
if (writeSafSidecarLrc(uri, lyrics)) {
"""{"success":true}"""
} else {
"""{"success":false,"error":"Failed to write LRC sidecar"}"""
}
} catch (e: Exception) {
"""{"success":false,"error":"${e.message?.replace("\"", "'")}"}"""
}
}
result.success(response)
}
"downloadCoverToFile" -> {
val coverUrl = call.argument<String>("cover_url") ?: ""
val outputPath = call.argument<String>("output_path") ?: ""
@@ -2761,6 +2834,9 @@ class MainActivity: FlutterFragmentActivity() {
if (!writeUriFromPath(uri, tempPath)) {
return@withContext """{"error":"Failed to write enriched metadata back to SAF file"}"""
}
if (obj.optBoolean("write_external_lrc", false)) {
writeSafSidecarLrc(uri, obj.optString("lyrics", ""))
}
raw
} catch (e: Exception) {
try { File(tempPath).delete() } catch (_: Exception) {}
@@ -3095,6 +3171,17 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"findCollectionAcrossExtensions" -> {
val requestJson = call.arguments as? String ?: "{}"
val response: String = withContext(Dispatchers.IO) {
val method = Gobackend::class.java.getMethod(
"findCollectionAcrossExtensionsJSON",
String::class.java
)
method.invoke(null, requestJson) as? String ?: "[]"
}
result.success(response)
}
"enrichTrackWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val trackJson = call.argument<String>("track") ?: "{}"
@@ -146,6 +146,7 @@ object NativeDownloadFinalizer {
requestJson: String,
itemJson: String,
result: JSONObject,
settingsJson: String = "{}",
shouldCancel: () -> Boolean = { false },
): JSONObject {
if (!result.optBoolean("success", false)) return result
@@ -217,15 +218,20 @@ object NativeDownloadFinalizer {
refreshFinalAudioQualityMetadata(context, result, state)
}
val history = buildHistoryRow(effectiveInput, state)
upsertHistory(context, history)
val saveDownloadHistory = parseObject(settingsJson)
.optBoolean("save_download_history", true)
val history = if (saveDownloadHistory) {
buildHistoryRow(effectiveInput, state).also { upsertHistory(context, it) }
} else {
null
}
result.put("file_path", state.filePath)
if (state.fileName.isNotBlank()) result.put("file_name", state.fileName)
if (state.quality.isNotBlank()) result.put("quality", state.quality)
result.put("native_finalized", true)
result.put("history_written", true)
result.put("history_item", historyToJson(history))
result.put("history_written", history != null)
if (history != null) result.put("history_item", historyToJson(history))
} catch (e: CancellationException) {
cleanupFailedFinalizationOutput(context, result, initialPath, state.filePath)
result.put("success", false)
@@ -1081,10 +1087,11 @@ object NativeDownloadFinalizer {
val genre = resultString(input, "genre").ifBlank { requestString(input, "genre") }
val label = resultString(input, "label").ifBlank { requestString(input, "label") }
val copyright = resultString(input, "copyright").ifBlank { requestString(input, "copyright") }
val lyrics = resolveLyricsLrc(input)
val shouldEmbedLyrics = input.request.optBoolean("embed_lyrics", false) &&
(input.request.optString("lyrics_mode", "embed") == "embed" ||
input.request.optString("lyrics_mode", "embed") == "both") &&
val lyricsMode = input.request.optString("lyrics_mode", "embed")
val shouldResolveLyrics = input.request.optBoolean("embed_lyrics", false) &&
(lyricsMode == "embed" || lyricsMode == "both")
val lyrics = if (shouldResolveLyrics) resolveLyricsLrc(input) else ""
val shouldEmbedLyrics = shouldResolveLyrics &&
lyrics.isNotBlank() &&
lyrics != "[instrumental:true]"
if (format == "flac") {
+4
View File
@@ -1,2 +1,6 @@
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
+1 -1
View File
@@ -19,7 +19,7 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.13.2" apply false
id("com.android.application") version "9.2.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.21" apply false
}
+4 -4
View File
@@ -7,12 +7,12 @@
"name": "SpotiFLAC Mobile",
"bundleIdentifier": "com.zarzet.spotiflac",
"developerName": "zarzet",
"version": "4.5.5",
"versionDate": "2026-05-14",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.5.5/SpotiFLAC-v4.5.5-ios-unsigned.ipa",
"version": "4.5.6",
"versionDate": "2026-06-01",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.5.6/SpotiFLAC-v4.5.6-ios-unsigned.ipa",
"localizedDescription": "SpotiFLAC Mobile is written in Flutter. Download tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
"size": 37191956
"size": 34059797
}
]
}
+4 -5
View File
@@ -3,9 +3,11 @@ files:
translation: /lib/l10n/arb/app_%locale%.arb
languages_mapping:
locale:
# Short codes for single-variant languages
# Keys MUST be the project's Crowdin language ids; values are the
# %locale% suffix used in app_%locale%.arb (underscores so Flutter
# gen-l10n parses them — hyphenated filenames break gen-l10n).
ar: ar
de: de
es: es
es-ES: es_ES
fr: fr
hi: hi
@@ -13,12 +15,9 @@ files:
ja: ja
ko: ko
nl: nl
pt: pt
pt-PT: pt_PT
ru: ru
tr: tr
uk: uk
zh: zh
# Full codes for Chinese variants
zh-CN: zh_CN
zh-TW: zh_TW
+3
View File
@@ -1624,6 +1624,9 @@ func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, strin
}
return data, mimeType, nil
case ".wav", ".aiff", ".aif", ".aifc":
return extractWAVAIFFCover(filePath)
default:
return nil, "", fmt.Errorf("unsupported format: %s", ext)
}
+442
View File
@@ -0,0 +1,442 @@
package gobackend
import (
"encoding/json"
"sort"
"strings"
"sync"
)
type CrossExtensionShareResult struct {
ExtensionID string `json:"extension_id"`
DisplayName string `json:"display_name"`
Found bool `json:"found"`
URL string `json:"url,omitempty"`
ItemName string `json:"item_name,omitempty"`
ItemArtists string `json:"item_artists,omitempty"`
Error string `json:"error,omitempty"`
}
var crossExtensionShareResultCache = struct {
sync.RWMutex
entries map[string]string
order []string
}{
entries: make(map[string]string),
}
const crossExtensionShareResultCacheLimit = 128
func FindCollectionAcrossExtensionsJSON(requestJSON string) (string, error) {
var req struct {
Name string `json:"name"`
Artists string `json:"artists"`
Type string `json:"type"`
SourceExtensionID string `json:"source_extension_id"`
}
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return "", err
}
req.Name = strings.TrimSpace(req.Name)
req.Artists = strings.TrimSpace(req.Artists)
req.Type = strings.ToLower(strings.TrimSpace(req.Type))
req.SourceExtensionID = strings.TrimSpace(req.SourceExtensionID)
if req.Name == "" {
return "[]", nil
}
if req.Type == "" {
req.Type = "album"
}
providers := getExtensionManager().GetMetadataProviders()
work := make([]*extensionProviderWrapper, 0, len(providers))
for _, provider := range providers {
if provider == nil || provider.extension == nil {
continue
}
if provider.extension.ID == req.SourceExtensionID {
continue
}
work = append(work, provider)
}
cacheKey := crossExtensionShareCacheKey(req.Name, req.Artists, req.Type, req.SourceExtensionID, work)
if cached := getCrossExtensionShareCache(cacheKey); cached != "" {
return cached, nil
}
query := req.Name
if req.Artists != "" {
query += " " + req.Artists
}
results := make([]CrossExtensionShareResult, len(work))
var wg sync.WaitGroup
for i, provider := range work {
wg.Add(1)
go func(index int, p *extensionProviderWrapper) {
defer wg.Done()
results[index] = findCollectionForExtension(
p,
req.Type,
req.Name,
req.Artists,
query,
)
}(i, provider)
}
wg.Wait()
data, err := json.Marshal(results)
if err != nil {
return "[]", err
}
response := string(data)
if crossExtensionShareResultsCacheable(results) {
setCrossExtensionShareCache(cacheKey, response)
}
return response, nil
}
func crossExtensionShareCacheKey(name string, artists string, itemType string, sourceExtensionID string, providers []*extensionProviderWrapper) string {
providerKeys := make([]string, 0, len(providers))
for _, provider := range providers {
if provider == nil || provider.extension == nil {
continue
}
ext := provider.extension
displayName := ""
if ext.Manifest != nil {
displayName = ext.Manifest.DisplayName
}
providerKeys = append(providerKeys, strings.Join([]string{
strings.TrimSpace(ext.ID),
strings.TrimSpace(displayName),
strings.TrimSpace(ext.SourceDir),
}, "\x1f"))
}
sort.Strings(providerKeys)
return strings.Join([]string{
normalizeLooseTitle(itemType),
normalizeLooseTitle(name),
normalizeLooseArtistName(artists),
strings.TrimSpace(sourceExtensionID),
strings.Join(providerKeys, "\x1e"),
}, "\x1d")
}
func getCrossExtensionShareCache(key string) string {
if key == "" {
return ""
}
crossExtensionShareResultCache.RLock()
defer crossExtensionShareResultCache.RUnlock()
return crossExtensionShareResultCache.entries[key]
}
func setCrossExtensionShareCache(key string, value string) {
if key == "" || value == "" {
return
}
crossExtensionShareResultCache.Lock()
defer crossExtensionShareResultCache.Unlock()
if _, exists := crossExtensionShareResultCache.entries[key]; !exists {
crossExtensionShareResultCache.order = append(crossExtensionShareResultCache.order, key)
}
crossExtensionShareResultCache.entries[key] = value
for len(crossExtensionShareResultCache.order) > crossExtensionShareResultCacheLimit {
oldest := crossExtensionShareResultCache.order[0]
crossExtensionShareResultCache.order = crossExtensionShareResultCache.order[1:]
delete(crossExtensionShareResultCache.entries, oldest)
}
}
func crossExtensionShareResultsCacheable(results []CrossExtensionShareResult) bool {
for _, result := range results {
if result.Found {
continue
}
errText := strings.ToLower(strings.TrimSpace(result.Error))
if errText == "" ||
errText == "no results" ||
errText == "unsupported collection type" ||
strings.HasSuffix(errText, " not found") ||
strings.Contains(errText, "found without shareable link") {
continue
}
return false
}
return true
}
func findCollectionForExtension(
provider *extensionProviderWrapper,
itemType string,
name string,
artists string,
query string,
) CrossExtensionShareResult {
result := CrossExtensionShareResult{
ExtensionID: provider.extension.ID,
}
if provider.extension.Manifest != nil {
result.DisplayName = provider.extension.Manifest.DisplayName
}
if result.DisplayName == "" {
result.DisplayName = provider.extension.ID
}
searchResult, err := searchCollectionCandidates(provider, itemType, query)
if err != nil {
result.Error = err.Error()
return result
}
if searchResult == nil || len(searchResult.Tracks) == 0 {
result.Error = "no results"
return result
}
var best *ExtTrackMetadata
switch itemType {
case "artist":
best = bestArtistTrack(searchResult.Tracks, name)
case "album":
best = bestAlbumTrack(searchResult.Tracks, name, artists)
default:
result.Error = "unsupported collection type"
return result
}
if best == nil {
result.Error = itemType + " not found"
return result
}
url := resolveCollectionShareURL(provider.extension, itemType, best)
if url == "" {
result.Error = itemType + " found without shareable link"
return result
}
result.Found = true
result.URL = url
if itemType == "artist" {
result.ItemName = collectionArtistName(*best)
} else {
result.ItemName = collectionAlbumName(*best)
result.ItemArtists = best.Artists
}
return result
}
func searchCollectionCandidates(provider *extensionProviderWrapper, itemType string, query string) (*ExtSearchResult, error) {
filter := ""
switch itemType {
case "album":
filter = "albums"
case "artist":
filter = "artists"
}
if filter != "" {
tracks, err := provider.CustomSearch(query, map[string]interface{}{
"filter": filter,
"limit": 10,
})
if err == nil && len(tracks) > 0 {
return &ExtSearchResult{Tracks: tracks, Total: len(tracks)}, nil
}
}
return provider.SearchTracks(query, 10)
}
func bestAlbumTrack(tracks []ExtTrackMetadata, albumName string, artists string) *ExtTrackMetadata {
targetAlbum := normalizeLooseTitle(albumName)
targetArtists := normalizeLooseArtistName(artists)
bestScore := 0
bestIndex := -1
for i := range tracks {
track := tracks[i]
album := normalizeLooseTitle(collectionAlbumName(track))
trackArtists := normalizeLooseArtistName(track.Artists + " " + track.AlbumArtist)
score := 0
if isCollectionItemType(track, "album") {
score += 25
}
if album == targetAlbum {
score += 100
} else if album != "" && targetAlbum != "" && (strings.Contains(album, targetAlbum) || strings.Contains(targetAlbum, album)) {
score += 50
}
if targetArtists != "" && (strings.Contains(trackArtists, targetArtists) || strings.Contains(targetArtists, trackArtists)) {
score += 30
}
if score > bestScore {
bestScore = score
bestIndex = i
}
}
if bestIndex < 0 || bestScore < 50 {
return nil
}
return &tracks[bestIndex]
}
func bestArtistTrack(tracks []ExtTrackMetadata, artistName string) *ExtTrackMetadata {
targetArtist := normalizeLooseArtistName(artistName)
bestScore := 0
bestIndex := -1
for i := range tracks {
artist := normalizeLooseArtistName(collectionArtistName(tracks[i]))
score := 0
if isCollectionItemType(tracks[i], "artist") {
score += 25
}
if artist == targetArtist {
score += 100
} else if artist != "" && targetArtist != "" && (strings.Contains(artist, targetArtist) || strings.Contains(targetArtist, artist)) {
score += 60
}
if score > bestScore {
bestScore = score
bestIndex = i
}
}
if bestIndex < 0 || bestScore < 60 {
return nil
}
return &tracks[bestIndex]
}
func resolveCollectionShareURL(ext *loadedExtension, itemType string, track *ExtTrackMetadata) string {
if track == nil {
return ""
}
if itemType == "album" {
if isCollectionItemType(*track, "album") {
if url := normalizeShareURL(track.ExternalURL); url != "" {
return url
}
}
if url := normalizeShareURL(track.AlbumURL); url != "" {
return url
}
if url := urlFromExternalLinks(track.ExternalLinks, "album"); url != "" {
return url
}
if url := templateShareURL(ext, "album", firstNonEmptyString(track.AlbumID, collectionID(*track, "album"), track.AlbumURL)); url != "" {
return url
}
return ""
}
if isCollectionItemType(*track, "artist") {
if url := normalizeShareURL(track.ExternalURL); url != "" {
return url
}
}
if url := normalizeShareURL(track.ArtistURL); url != "" {
return url
}
if url := urlFromExternalLinks(track.ExternalLinks, "artist"); url != "" {
return url
}
if url := templateShareURL(ext, "artist", firstNonEmptyString(track.ArtistID, collectionID(*track, "artist"))); url != "" {
return url
}
return ""
}
func collectionAlbumName(track ExtTrackMetadata) string {
if isCollectionItemType(track, "album") {
return track.Name
}
return track.AlbumName
}
func collectionArtistName(track ExtTrackMetadata) string {
if isCollectionItemType(track, "artist") {
return track.Name
}
return track.Artists
}
func collectionID(track ExtTrackMetadata, itemType string) string {
if isCollectionItemType(track, itemType) {
return track.ID
}
return ""
}
func isCollectionItemType(track ExtTrackMetadata, itemType string) bool {
return strings.EqualFold(strings.TrimSpace(track.ItemType), itemType)
}
func normalizeShareURL(value string) string {
trimmed := strings.TrimSpace(value)
if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") {
return trimmed
}
return ""
}
func urlFromExternalLinks(links map[string]string, preferredKey string) string {
for key, value := range links {
if strings.Contains(strings.ToLower(key), preferredKey) {
if url := normalizeShareURL(value); url != "" {
return url
}
}
}
return ""
}
func templateShareURL(ext *loadedExtension, itemType string, id string) string {
if ext == nil || ext.Manifest == nil || ext.Manifest.Capabilities == nil {
return ""
}
id = stripProviderPrefix(strings.TrimSpace(id))
if id == "" {
return ""
}
templates, ok := ext.Manifest.Capabilities["shareUrlTemplates"].(map[string]interface{})
if !ok {
return ""
}
rawTemplate, ok := templates[itemType].(string)
if !ok {
return ""
}
rawTemplate = strings.TrimSpace(rawTemplate)
if rawTemplate == "" {
return ""
}
return strings.ReplaceAll(rawTemplate, "{id}", id)
}
func stripProviderPrefix(id string) string {
if index := strings.Index(id, ":"); index > 0 && index < len(id)-1 {
return id[index+1:]
}
return id
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}
+100
View File
@@ -0,0 +1,100 @@
package gobackend
import "testing"
func TestCrossExtensionShareUsesAlbumCollectionItems(t *testing.T) {
ext := &loadedExtension{
Manifest: &ExtensionManifest{
Capabilities: map[string]interface{}{
"shareUrlTemplates": map[string]interface{}{
"album": "https://music.apple.com/us/album/{id}",
},
},
},
}
tracks := []ExtTrackMetadata{
{
ID: "1440783617",
Name: "Nevermind",
Artists: "Nirvana",
ItemType: "album",
},
}
best := bestAlbumTrack(tracks, "Nevermind", "Nirvana")
if best == nil {
t.Fatal("expected album collection item to match")
}
if url := resolveCollectionShareURL(ext, "album", best); url != "https://music.apple.com/us/album/1440783617" {
t.Fatalf("album share URL = %q", url)
}
}
func TestCrossExtensionShareUsesArtistCollectionItems(t *testing.T) {
ext := &loadedExtension{
Manifest: &ExtensionManifest{
Capabilities: map[string]interface{}{
"shareUrlTemplates": map[string]interface{}{
"artist": "https://music.youtube.com/browse/{id}",
},
},
},
}
tracks := []ExtTrackMetadata{
{
ID: "UCrPe3hLA51968GwxHSZ1llw",
Name: "Nirvana",
ItemType: "artist",
},
}
best := bestArtistTrack(tracks, "Nirvana")
if best == nil {
t.Fatal("expected artist collection item to match")
}
if url := resolveCollectionShareURL(ext, "artist", best); url != "https://music.youtube.com/browse/UCrPe3hLA51968GwxHSZ1llw" {
t.Fatalf("artist share URL = %q", url)
}
}
func TestCrossExtensionShareCacheKeyIsProviderOrderStable(t *testing.T) {
apple := &extensionProviderWrapper{
extension: &loadedExtension{
ID: "apple",
SourceDir: "/extensions/apple",
Manifest: &ExtensionManifest{DisplayName: "Apple Music"},
},
}
qobuz := &extensionProviderWrapper{
extension: &loadedExtension{
ID: "qobuz",
SourceDir: "/extensions/qobuz",
Manifest: &ExtensionManifest{DisplayName: "Qobuz"},
},
}
first := crossExtensionShareCacheKey("Nevermind", "Nirvana", "album", "spotify", []*extensionProviderWrapper{apple, qobuz})
second := crossExtensionShareCacheKey("Nevermind", "Nirvana", "album", "spotify", []*extensionProviderWrapper{qobuz, apple})
if first != second {
t.Fatalf("cache key should not depend on provider order:\n%s\n%s", first, second)
}
}
func TestCrossExtensionShareCacheableSkipsTransientErrors(t *testing.T) {
cacheable := []CrossExtensionShareResult{
{ExtensionID: "apple", Found: true, URL: "https://music.apple.com/us/album/1"},
{ExtensionID: "qobuz", Error: "album not found"},
{ExtensionID: "tidal", Error: "no results"},
}
if !crossExtensionShareResultsCacheable(cacheable) {
t.Fatal("expected found and deterministic not-found results to be cacheable")
}
transient := []CrossExtensionShareResult{
{ExtensionID: "apple", Found: true, URL: "https://music.apple.com/us/album/1"},
{ExtensionID: "qobuz", Error: "request failed: timeout"},
}
if crossExtensionShareResultsCacheable(transient) {
t.Fatal("expected transient extension errors to skip cache")
}
}
+1 -1
View File
@@ -264,7 +264,7 @@ func ResolveCueAudioPath(cuePath string, cueFileName string) string {
}
baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName))
commonExts := []string{".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
commonExts := []string{".flac", ".wav", ".aiff", ".aif", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
for _, ext := range commonExts {
candidate = filepath.Join(cueDir, baseName+ext)
if _, err := os.Stat(candidate); err == nil {
+176 -39
View File
@@ -379,6 +379,7 @@ type reEnrichRequest struct {
CoverURL string `json:"cover_url"`
MaxQuality bool `json:"max_quality"`
EmbedLyrics bool `json:"embed_lyrics"`
LyricsMode string `json:"lyrics_mode,omitempty"`
ArtistTagMode string `json:"artist_tag_mode,omitempty"`
SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"`
@@ -414,6 +415,21 @@ func (r *reEnrichRequest) shouldUpdateField(field string) bool {
return false
}
// lyricsEmbedEnabled reports whether lyrics should be written into the audio
// file's tags. It mirrors the download path semantics: 'embed' and 'both' embed,
// 'external' does not. An empty mode keeps the legacy behavior (embed) so older
// callers that do not send lyrics_mode are unaffected.
func (r *reEnrichRequest) lyricsEmbedEnabled() bool {
return strings.ToLower(strings.TrimSpace(r.LyricsMode)) != "external"
}
// lyricsSidecarEnabled reports whether a .lrc sidecar file should be written
// next to the audio file. Only 'external' and 'both' request a sidecar.
func (r *reEnrichRequest) lyricsSidecarEnabled() bool {
mode := strings.ToLower(strings.TrimSpace(r.LyricsMode))
return mode == "external" || mode == "both"
}
func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
if req == nil {
return
@@ -578,7 +594,7 @@ func buildReEnrichFFmpegMetadata(req *reEnrichRequest, lyricsLRC string) map[str
}
}
if req.shouldUpdateField("lyrics") {
if lyricsLRC != "" {
if lyricsLRC != "" && req.lyricsEmbedEnabled() {
metadata["LYRICS"] = lyricsLRC
metadata["UNSYNCEDLYRICS"] = lyricsLRC
}
@@ -594,12 +610,24 @@ func selectBestReEnrichTrack(req reEnrichRequest, tracks []ExtTrackMetadata) *Ex
downloadReq := reEnrichDownloadRequest(req)
currentISRC := strings.TrimSpace(req.ISRC)
currentAlbum := strings.TrimSpace(req.AlbumName)
effectiveTrackName := req.TrackName
if isPlaceholderReEnrichValue(effectiveTrackName) {
effectiveTrackName = ""
}
effectiveArtistName := req.ArtistName
if isPlaceholderReEnrichValue(effectiveArtistName) {
effectiveArtistName = ""
}
var best *ExtTrackMetadata
bestScore := -1 << 30
for i := range tracks {
track := &tracks[i]
score := 0
exactISRCMatch := currentISRC != "" && strings.EqualFold(currentISRC, strings.TrimSpace(track.ISRC))
titleMatches := effectiveTrackName != "" && track.Name != "" && titlesMatch(effectiveTrackName, track.Name)
artistMatches := effectiveArtistName != "" && track.Artists != "" && artistsMatch(effectiveArtistName, track.Artists)
albumMatches := currentAlbum != "" && track.AlbumName != "" && titlesMatch(currentAlbum, track.AlbumName)
resolved := resolvedTrackInfo{
Title: track.Name,
@@ -607,22 +635,39 @@ func selectBestReEnrichTrack(req reEnrichRequest, tracks []ExtTrackMetadata) *Ex
ISRC: track.ISRC,
Duration: track.DurationMS / 1000,
}
if trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
verified := trackMatchesRequest(downloadReq, resolved, "ReEnrich")
if !exactISRCMatch {
if effectiveTrackName != "" && !titleMatches {
continue
}
if effectiveArtistName != "" && !artistMatches {
continue
}
if effectiveTrackName == "" && effectiveArtistName == "" && currentAlbum != "" && !albumMatches {
continue
}
if effectiveTrackName == "" && effectiveArtistName == "" && currentAlbum == "" && !verified {
continue
}
}
if verified {
score += 2000
}
if currentISRC != "" && strings.EqualFold(currentISRC, strings.TrimSpace(track.ISRC)) {
if exactISRCMatch {
score += 10000
}
if req.TrackName != "" && track.Name != "" && titlesMatch(req.TrackName, track.Name) {
if titleMatches {
score += 400
}
if req.ArtistName != "" && track.Artists != "" && artistsMatch(req.ArtistName, track.Artists) {
if artistMatches {
score += 320
}
if currentAlbum != "" && track.AlbumName != "" {
switch {
case titlesMatch(currentAlbum, track.AlbumName):
case albumMatches:
score += 120
case strings.Contains(strings.ToLower(track.AlbumName), strings.ToLower(currentAlbum)),
strings.Contains(strings.ToLower(currentAlbum), strings.ToLower(track.AlbumName)):
@@ -1115,6 +1160,8 @@ func ReadFileMetadata(filePath string) (string, error) {
isApe := strings.HasSuffix(lower, ".ape")
isWv := strings.HasSuffix(lower, ".wv")
isMpc := strings.HasSuffix(lower, ".mpc")
isWav := strings.HasSuffix(lower, ".wav")
isAiff := strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc")
result := map[string]interface{}{
"title": "",
@@ -1361,6 +1408,51 @@ func ReadFileMetadata(filePath string) (string, error) {
result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak
}
}
} else if isWav || isAiff {
var meta *AudioMetadata
var quality *WAVQuality
var qualityErr error
if isAiff {
result["format"] = "aiff"
result["audio_codec"] = "pcm"
meta, _ = ReadAIFFTags(filePath)
quality, qualityErr = GetAIFFQuality(filePath)
} else {
result["format"] = "wav"
result["audio_codec"] = "pcm"
meta, _ = ReadWAVTags(filePath)
quality, qualityErr = GetWAVQuality(filePath)
}
if meta != nil {
result["title"] = meta.Title
result["artist"] = meta.Artist
result["album"] = meta.Album
result["album_artist"] = meta.AlbumArtist
result["date"] = meta.Date
if meta.Date == "" {
result["date"] = meta.Year
}
result["track_number"] = meta.TrackNumber
result["total_tracks"] = meta.TotalTracks
result["disc_number"] = meta.DiscNumber
result["total_discs"] = meta.TotalDiscs
result["isrc"] = meta.ISRC
result["lyrics"] = meta.Lyrics
result["genre"] = meta.Genre
result["label"] = meta.Label
result["copyright"] = meta.Copyright
result["composer"] = meta.Composer
result["comment"] = meta.Comment
result["replaygain_track_gain"] = meta.ReplayGainTrackGain
result["replaygain_track_peak"] = meta.ReplayGainTrackPeak
result["replaygain_album_gain"] = meta.ReplayGainAlbumGain
result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak
}
if qualityErr == nil && quality != nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
result["duration"] = quality.Duration
}
} else {
return "", fmt.Errorf("unsupported file format: %s", filePath)
}
@@ -1429,6 +1521,8 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
isFlac := strings.HasSuffix(lower, ".flac")
isApeFile := strings.HasSuffix(lower, ".ape") || strings.HasSuffix(lower, ".wv") || strings.HasSuffix(lower, ".mpc")
isM4AFile := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".m4b")
isWavFile := strings.HasSuffix(lower, ".wav")
isAiffFile := strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc")
coverPath := strings.TrimSpace(fields["cover_path"])
if hasOnlyM4AReplayGainFields(fields) && (isM4AFile || isMP4ContainerFile(filePath)) {
@@ -1457,6 +1551,24 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
return string(jsonBytes), nil
}
// WAV / AIFF: write tags into an embedded ID3v2.4 chunk natively.
if isWavFile {
if err := WriteWAVTags(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write WAV metadata: %w", err)
}
resp := map[string]any{"success": true, "method": "native_wav"}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
if isAiffFile {
if err := WriteAIFFTags(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write AIFF metadata: %w", err)
}
resp := map[string]any{"success": true, "method": "native_aiff"}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
// APE/WV/MPC: write APEv2 tags natively
if isApeFile {
trackNum := 0
@@ -1706,9 +1818,13 @@ func GetLyricsLRCWithSource(spotifyID, trackName, artistName string, filePath st
if filePath != "" {
lyrics, err := ExtractLyrics(filePath)
if err == nil && lyrics != "" {
source := extractLyricsSourceFromLRC(lyrics)
if source == "" {
source = "Embedded"
}
result := map[string]interface{}{
"lyrics": lyrics,
"source": "Embedded",
"source": source,
"sync_type": "EMBEDDED",
"instrumental": false,
}
@@ -1911,6 +2027,11 @@ func normalizeExtensionTrackMetadataMap(
"artists": track.Artists,
"album_name": track.AlbumName,
"album_artist": track.AlbumArtist,
"album_id": track.AlbumID,
"album_url": track.AlbumURL,
"artist_id": track.ArtistID,
"artist_url": track.ArtistURL,
"external_urls": track.ExternalURL,
"duration_ms": track.DurationMS,
"images": coverURL,
"cover_url": coverURL,
@@ -2329,37 +2450,7 @@ func GetTidalURLFromDeezerTrack(deezerTrackID string) (string, error) {
}
func errorResponse(msg string) (string, error) {
errorType := "unknown"
lowerMsg := strings.ToLower(msg)
if strings.Contains(lowerMsg, "isp blocking") ||
strings.Contains(lowerMsg, "try using vpn") ||
strings.Contains(lowerMsg, "change dns") {
errorType = "isp_blocked"
} else if strings.Contains(lowerMsg, "cancel") {
errorType = "cancelled"
} else if strings.Contains(lowerMsg, "permission") ||
strings.Contains(lowerMsg, "operation not permitted") ||
strings.Contains(lowerMsg, "access denied") ||
strings.Contains(lowerMsg, "failed to create file") ||
strings.Contains(lowerMsg, "failed to create directory") {
errorType = "permission"
} else if strings.Contains(lowerMsg, "not found") ||
strings.Contains(lowerMsg, "not available") ||
strings.Contains(lowerMsg, "no results") ||
strings.Contains(lowerMsg, "track not found") ||
strings.Contains(lowerMsg, "all services failed") {
errorType = "not_found"
} else if strings.Contains(lowerMsg, "rate limit") ||
strings.Contains(lowerMsg, "429") ||
strings.Contains(lowerMsg, "too many requests") {
errorType = "rate_limit"
} else if strings.Contains(lowerMsg, "network") ||
strings.Contains(lowerMsg, "connection") ||
strings.Contains(lowerMsg, "timeout") ||
strings.Contains(lowerMsg, "dial") {
errorType = "network"
}
errorType := classifyDownloadErrorType(msg)
resp := DownloadResponse{
Success: false,
@@ -2370,6 +2461,41 @@ func errorResponse(msg string) (string, error) {
return string(jsonBytes), nil
}
func classifyDownloadErrorType(msg string) string {
lowerMsg := strings.ToLower(msg)
if strings.Contains(lowerMsg, "isp blocking") ||
strings.Contains(lowerMsg, "try using vpn") ||
strings.Contains(lowerMsg, "change dns") {
return "isp_blocked"
} else if strings.Contains(lowerMsg, "cancel") {
return "cancelled"
} else if strings.Contains(lowerMsg, "rate limit") ||
strings.Contains(lowerMsg, "429") ||
strings.Contains(lowerMsg, "too many requests") {
return "rate_limit"
} else if strings.Contains(lowerMsg, "permission") ||
strings.Contains(lowerMsg, "operation not permitted") ||
strings.Contains(lowerMsg, "access denied") ||
strings.Contains(lowerMsg, "failed to create file") ||
strings.Contains(lowerMsg, "failed to create directory") {
return "permission"
} else if strings.Contains(lowerMsg, "not found") ||
strings.Contains(lowerMsg, "not available") ||
strings.Contains(lowerMsg, "no results") ||
strings.Contains(lowerMsg, "track not found") ||
strings.Contains(lowerMsg, "all services failed") {
return "not_found"
} else if strings.Contains(lowerMsg, "network") ||
strings.Contains(lowerMsg, "connection") ||
strings.Contains(lowerMsg, "timeout") ||
strings.Contains(lowerMsg, "dial") {
return "network"
}
return "unknown"
}
func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error {
if coverURL == "" {
return fmt.Errorf("no cover URL provided")
@@ -2716,7 +2842,9 @@ func ReEnrichFile(requestJSON string) (string, error) {
metadata.ISRC = req.ISRC
}
if req.shouldUpdateField("lyrics") {
metadata.Lyrics = lyricsLRC
if req.lyricsEmbedEnabled() {
metadata.Lyrics = lyricsLRC
}
}
if req.shouldUpdateField("extra") {
metadata.Genre = req.Genre
@@ -2751,6 +2879,11 @@ func ReEnrichFile(requestJSON string) (string, error) {
"method": "native",
"success": true,
"enriched_metadata": enrichedMeta,
"lyrics": lyricsLRC,
"write_external_lrc": req.EmbedLyrics &&
req.shouldUpdateField("lyrics") &&
req.lyricsSidecarEnabled() &&
strings.TrimSpace(lyricsLRC) != "",
}
jsonBytes, _ := json.Marshal(result)
return string(jsonBytes), nil
@@ -2766,6 +2899,10 @@ func ReEnrichFile(requestJSON string) (string, error) {
"lyrics": lyricsLRC,
"enriched_metadata": enrichedMeta,
"metadata": ffmpegMetadata,
"write_external_lrc": req.EmbedLyrics &&
req.shouldUpdateField("lyrics") &&
req.lyricsSidecarEnabled() &&
strings.TrimSpace(lyricsLRC) != "",
}
jsonBytes, _ := json.Marshal(result)
+20
View File
@@ -11,6 +11,26 @@ import (
"time"
)
func TestDownloadErrorClassificationPrioritizesRateLimit(t *testing.T) {
got := classifyDownloadErrorType("All providers failed. Last error: HTTP status 429: too many requests")
if got != "rate_limit" {
t.Fatalf("expected rate_limit, got %q", got)
}
responseJSON, err := errorResponse("All services failed. Last error: rate limit exceeded")
if err != nil {
t.Fatalf("errorResponse returned error: %v", err)
}
var response DownloadResponse
if err := json.Unmarshal([]byte(responseJSON), &response); err != nil {
t.Fatalf("invalid response JSON: %v", err)
}
if response.ErrorType != "rate_limit" {
t.Fatalf("expected rate_limit response, got %q", response.ErrorType)
}
}
func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
dir := t.TempDir()
dataDir := filepath.Join(dir, "data")
+84
View File
@@ -407,6 +407,90 @@ func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
}
}
func TestSelectBestReEnrichTrackRejectsMismatchedSearchResults(t *testing.T) {
req := reEnrichRequest{
TrackName: "Song Title",
ArtistName: "Artist Name",
AlbumName: "Album Name",
DurationMs: 180000,
}
tracks := []ExtTrackMetadata{
{
ID: "wrong-rich-metadata",
Name: "Different Song",
Artists: "Different Artist",
AlbumName: "Album Name",
DurationMS: 180000,
ReleaseDate: "2024-03-09",
TrackNumber: 4,
DiscNumber: 1,
ISRC: "WRONG1234567",
ProviderID: "deezer",
},
}
if best := selectBestReEnrichTrack(req, tracks); best != nil {
t.Fatalf("selected track = %q, want no match", best.ID)
}
}
func TestSelectBestReEnrichTrackAllowsExactISRCDespiteMetadataMismatch(t *testing.T) {
req := reEnrichRequest{
TrackName: "Song Title",
ArtistName: "Artist Name",
ISRC: "USRC17607839",
DurationMs: 999999000,
}
tracks := []ExtTrackMetadata{
{
ID: "same-isrc",
Name: "Different Song",
Artists: "Different Artist",
DurationMS: 180000,
ISRC: "USRC17607839",
ProviderID: "deezer",
},
}
best := selectBestReEnrichTrack(req, tracks)
if best == nil {
t.Fatal("expected exact ISRC candidate to be selected")
}
if best.ID != "same-isrc" {
t.Fatalf("selected track = %q, want exact ISRC candidate", best.ID)
}
}
func TestSelectBestReEnrichTrackPlaceholderFallsBackToAlbum(t *testing.T) {
req := reEnrichRequest{
TrackName: "Unknown Title",
ArtistName: "Unknown Artist",
AlbumName: "Harry Styles",
DurationMs: 180000,
}
tracks := []ExtTrackMetadata{
{
ID: "album-match",
Name: "Sign of the Times",
Artists: "Harry Styles",
AlbumName: "Harry Styles",
DurationMS: 180000,
ProviderID: "deezer",
},
}
best := selectBestReEnrichTrack(req, tracks)
if best == nil {
t.Fatal("expected album-matching candidate to be selected when title/artist are placeholders")
}
if best.ID != "album-match" {
t.Fatalf("selected track = %q, want album-match", best.ID)
}
}
func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
req := reEnrichRequest{
TrackName: "Song",
+58
View File
@@ -8,12 +8,14 @@ import (
"net/http"
"net/url"
"strings"
"sync"
"time"
)
const (
extensionHealthDefaultTimeout = 4 * time.Second
extensionHealthMaxBodyBytes = 64 * 1024
extensionHealthDefaultCache = 60 * time.Second
)
type ExtensionHealthResult struct {
@@ -38,6 +40,16 @@ type ExtensionHealthCheckResult struct {
CheckedAt string `json:"checked_at"`
}
type cachedExtensionHealthResult struct {
result ExtensionHealthResult
expiresAt time.Time
}
var (
extensionHealthCacheMu sync.Mutex
extensionHealthCache = map[string]cachedExtensionHealthResult{}
)
func CheckExtensionHealthJSON(extensionID string) (string, error) {
manager := getExtensionManager()
ext, err := manager.GetExtension(extensionID)
@@ -53,6 +65,38 @@ func CheckExtensionHealthJSON(extensionID string) (string, error) {
return string(bytes), nil
}
func CheckExtensionHealthCached(ext *loadedExtension) ExtensionHealthResult {
if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 {
return CheckExtensionHealth(ext)
}
cacheKey := strings.TrimSpace(ext.ID)
if cacheKey == "" {
return CheckExtensionHealth(ext)
}
now := time.Now()
extensionHealthCacheMu.Lock()
cached, ok := extensionHealthCache[cacheKey]
if ok && now.Before(cached.expiresAt) {
extensionHealthCacheMu.Unlock()
return cached.result
}
extensionHealthCacheMu.Unlock()
result := CheckExtensionHealth(ext)
ttl := extensionHealthCacheTTL(ext.Manifest.ServiceHealth)
extensionHealthCacheMu.Lock()
extensionHealthCache[cacheKey] = cachedExtensionHealthResult{
result: result,
expiresAt: now.Add(ttl),
}
extensionHealthCacheMu.Unlock()
return result
}
func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult {
now := time.Now().UTC().Format(time.RFC3339)
result := ExtensionHealthResult{
@@ -98,6 +142,20 @@ func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult {
return result
}
func extensionHealthCacheTTL(checks []ExtensionHealthCheck) time.Duration {
ttl := extensionHealthDefaultCache
for _, check := range checks {
if check.CacheTTLSeconds <= 0 {
continue
}
checkTTL := time.Duration(check.CacheTTLSeconds) * time.Second
if checkTTL < ttl {
ttl = checkTTL
}
}
return ttl
}
func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthCheck) ExtensionHealthCheckResult {
method := strings.ToUpper(strings.TrimSpace(check.Method))
if method == "" {
+21 -2
View File
@@ -118,7 +118,11 @@ func (ext *loadedExtension) lockReadyVM() (*goja.Runtime, error) {
}
type extensionManager struct {
mu sync.RWMutex
mu sync.RWMutex
// mutationMu serializes install/upgrade/remove (heavy FS + goja VM
// teardown/reload), which are not safe to run concurrently. Acquired before
// m.mu; "*Locked" helpers assume it is held.
mutationMu sync.Mutex
extensions map[string]*loadedExtension
extensionsDir string
dataDir string
@@ -156,6 +160,12 @@ func (m *extensionManager) SetDirectories(extensionsDir, dataDir string) error {
}
func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtension, error) {
m.mutationMu.Lock()
defer m.mutationMu.Unlock()
return m.loadExtensionFromFileLocked(filePath)
}
func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loadedExtension, error) {
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
}
@@ -212,7 +222,7 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens
if exists {
versionCompare := compareVersions(manifest.Version, existingVersion)
if versionCompare > 0 {
return m.UpgradeExtension(filePath)
return m.upgradeExtensionLocked(filePath)
} else if versionCompare == 0 {
return nil, fmt.Errorf("extension '%s' v%s is already installed", existingDisplayName, existingVersion)
} else {
@@ -736,6 +746,9 @@ func (m *extensionManager) loadExtensionFromDirectory(dirPath string) (*loadedEx
}
func (m *extensionManager) RemoveExtension(extensionID string) error {
m.mutationMu.Lock()
defer m.mutationMu.Unlock()
ext, err := m.GetExtension(extensionID)
if err != nil {
return err
@@ -756,6 +769,12 @@ func (m *extensionManager) RemoveExtension(extensionID string) error {
// Only allows upgrades (new version > current version), not downgrades
func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension, error) {
m.mutationMu.Lock()
defer m.mutationMu.Unlock()
return m.upgradeExtensionLocked(filePath)
}
func (m *extensionManager) upgradeExtensionLocked(filePath string) (*loadedExtension, error) {
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
}
+128 -11
View File
@@ -22,6 +22,11 @@ type ExtTrackMetadata struct {
Artists string `json:"artists"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist,omitempty"`
AlbumID string `json:"album_id,omitempty"`
AlbumURL string `json:"album_url,omitempty"`
ArtistID string `json:"artist_id,omitempty"`
ArtistURL string `json:"artist_url,omitempty"`
ExternalURL string `json:"external_urls,omitempty"`
DurationMS int `json:"duration_ms"`
CoverURL string `json:"cover_url,omitempty"`
Images string `json:"images,omitempty"`
@@ -377,6 +382,64 @@ func shouldStopProviderFallback(availability *ExtAvailabilityResult) bool {
return availability != nil && availability.SkipFallback
}
func fallbackRuntimeHealthStatus(ext *loadedExtension) string {
if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 {
return "unknown"
}
status := strings.ToLower(strings.TrimSpace(CheckExtensionHealthCached(ext).Status))
switch status {
case "online", "degraded", "offline":
return status
default:
return "unknown"
}
}
func prioritizeFallbackProvidersByHealth(priority []string, extManager *extensionManager, sourceProvider string) []string {
if len(priority) == 0 || extManager == nil {
return priority
}
online := make([]string, 0, len(priority))
degraded := make([]string, 0, len(priority))
unknown := make([]string, 0, len(priority))
for _, rawProviderID := range priority {
providerID := strings.TrimSpace(rawProviderID)
if providerID == "" {
continue
}
if strings.EqualFold(providerID, sourceProvider) || !isExtensionFallbackAllowed(providerID) {
unknown = append(unknown, providerID)
continue
}
ext, err := extManager.GetExtension(providerID)
if err != nil || ext == nil || !ext.Enabled || ext.Error != "" || ext.Manifest == nil || !ext.Manifest.IsDownloadProvider() {
unknown = append(unknown, providerID)
continue
}
switch fallbackRuntimeHealthStatus(ext) {
case "online":
online = append(online, providerID)
case "degraded":
degraded = append(degraded, providerID)
case "offline":
GoLog("[DownloadWithExtensionFallback] Skipping extension provider %s (service health offline)\n", providerID)
default:
unknown = append(unknown, providerID)
}
}
result := make([]string, 0, len(online)+len(degraded)+len(unknown))
result = append(result, online...)
result = append(result, degraded...)
result = append(result, unknown...)
return result
}
func resolveExtensionAvailabilityReason(availability *ExtAvailabilityResult, err error) string {
if availability != nil {
if reason := strings.TrimSpace(availability.Reason); reason != "" {
@@ -391,10 +454,14 @@ func resolveExtensionAvailabilityReason(availability *ExtAvailabilityResult, err
func buildExtensionFallbackStoppedResponse(providerID string, availability *ExtAvailabilityResult, err error) *DownloadResponse {
reason := resolveExtensionAvailabilityReason(availability, err)
errorType := classifyDownloadErrorType(reason)
if errorType == "unknown" {
errorType = "extension_error"
}
return &DownloadResponse{
Success: false,
Error: fmt.Sprintf("Fallback stopped by %s: %s", providerID, reason),
ErrorType: "extension_error",
ErrorType: errorType,
Service: providerID,
}
}
@@ -680,6 +747,11 @@ func parseExtensionTrackValue(vm *goja.Runtime, value goja.Value) ExtTrackMetada
Artists: gojaObjectString(obj, "artists"),
AlbumName: gojaObjectString(obj, "album_name", "albumName"),
AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"),
AlbumID: gojaObjectString(obj, "album_id", "albumId"),
AlbumURL: gojaObjectString(obj, "album_url", "albumUrl"),
ArtistID: gojaObjectString(obj, "artist_id", "artistId"),
ArtistURL: gojaObjectString(obj, "artist_url", "artistUrl"),
ExternalURL: gojaObjectString(obj, "external_urls", "externalUrls", "external_url", "externalUrl", "url"),
DurationMS: gojaObjectInt(obj, "duration_ms", "durationMs"),
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
Images: gojaObjectString(obj, "images"),
@@ -1786,7 +1858,9 @@ func isRetiredBuiltInDownloadProvider(providerID string) bool {
}
switch normalized {
case "deezer", "qobuz", "tidal":
return true
return !hasEnabledExtensionProvider(normalized, func(manifest *ExtensionManifest) bool {
return manifest.IsDownloadProvider()
})
default:
return false
}
@@ -1799,12 +1873,36 @@ func isRetiredBuiltInMetadataProvider(providerID string) bool {
}
switch normalized {
case "deezer", "spotify", "qobuz", "tidal":
return true
return !hasEnabledExtensionProvider(normalized, func(manifest *ExtensionManifest) bool {
return manifest.IsMetadataProvider()
})
default:
return false
}
}
func hasEnabledExtensionProvider(providerID string, matches func(*ExtensionManifest) bool) bool {
if providerID == "" || matches == nil {
return false
}
manager := getExtensionManager()
manager.mu.RLock()
defer manager.mu.RUnlock()
for id, ext := range manager.extensions {
if !strings.EqualFold(strings.TrimSpace(id), providerID) {
continue
}
if ext == nil || !ext.Enabled || ext.Error != "" || ext.Manifest == nil {
return false
}
return matches(ext.Manifest)
}
return false
}
func SetExtensionFallbackProviderIDs(providerIDs []string) {
extensionFallbackProviderIDsMu.Lock()
defer extensionFallbackProviderIDsMu.Unlock()
@@ -2374,6 +2472,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
}
priority = prioritizeFallbackProvidersByHealth(priority, extManager, req.Source)
for _, providerID := range priority {
if isDownloadCancelled(req.ItemID) {
return nil, ErrDownloadCancelled
@@ -2383,11 +2483,13 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if providerID == "" {
continue
}
if providerID == req.Source {
// Skip the origin extension only when it differs from the explicitly
// selected provider; otherwise it must still be attempted here.
if providerID == req.Source && req.Source != selectedProvider {
continue
}
if !isExtensionFallbackAllowed(providerID) {
if providerID != selectedProvider && !isExtensionFallbackAllowed(providerID) {
GoLog("[DownloadWithExtensionFallback] Skipping extension provider %s (not enabled for fallback)\n", providerID)
continue
}
@@ -2430,7 +2532,16 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
StartItemProgress(req.ItemID)
}
result, err := provider.Download(availability.TrackID, req.Quality, outputPath, req.ItemID, func(percent int) {
// Fallback provider: request its own highest quality, not the
// source provider's quality token.
fallbackQuality := req.Quality
if len(ext.Manifest.QualityOptions) > 0 {
if best := strings.TrimSpace(ext.Manifest.QualityOptions[0].ID); best != "" {
fallbackQuality = best
}
}
result, err := provider.Download(availability.TrackID, fallbackQuality, outputPath, req.ItemID, func(percent int) {
if req.ItemID != "" {
normalized := float64(percent) / 100.0
if normalized < 0 {
@@ -2519,10 +2630,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
if lastErr != nil {
errorType := classifyDownloadErrorType(lastErr.Error())
if errorType == "unknown" {
errorType = "not_found"
}
return &DownloadResponse{
Success: false,
Error: "All providers failed. Last error: " + lastErr.Error(),
ErrorType: "not_found",
ErrorType: errorType,
}, nil
}
@@ -2557,9 +2672,10 @@ func buildOutputPath(req DownloadRequest) string {
}
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
if filename == "" {
filename = sanitizeFilename(fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName))
if strings.TrimSpace(filename) == "" {
filename = fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName)
}
filename = sanitizeFilename(filename)
ext := strings.TrimSpace(req.OutputExt)
if ext == "" {
@@ -2615,9 +2731,10 @@ func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) stri
}
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
if filename == "" {
filename = sanitizeFilename(fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName))
if strings.TrimSpace(filename) == "" {
filename = fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName)
}
filename = sanitizeFilename(filename)
outputExt := strings.TrimSpace(req.OutputExt)
if outputExt == "" {
+159
View File
@@ -10,6 +10,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -92,6 +93,125 @@ func TestSetProviderPriorityRemovesRetiredDeezerDownloader(t *testing.T) {
}
}
func TestSetProviderPriorityKeepsExtensionNamedLikeRetiredDownloader(t *testing.T) {
original := GetProviderPriority()
defer SetProviderPriority(original)
manager := getExtensionManager()
ext := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
ext.ID = "deezer"
ext.Manifest.Name = "deezer"
manager.mu.Lock()
previous, hadPrevious := manager.extensions[ext.ID]
manager.extensions[ext.ID] = ext
manager.mu.Unlock()
defer func() {
manager.mu.Lock()
if hadPrevious {
manager.extensions[ext.ID] = previous
} else {
delete(manager.extensions, ext.ID)
}
manager.mu.Unlock()
}()
SetProviderPriority([]string{"deezer", "custom-ext"})
got := GetProviderPriority()
want := []string{"deezer", "custom-ext"}
if len(got) != len(want) {
t.Fatalf("unexpected priority length: got %v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("unexpected priority at %d: got %v want %v", i, got, want)
}
}
}
func TestPrioritizeFallbackProvidersByHealthPrefersOnlineAndSkipsOffline(t *testing.T) {
manager := getExtensionManager()
amazon := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
amazon.ID = "amazon"
amazon.Manifest.Name = "amazon"
amazon.Manifest.ServiceHealth = []ExtensionHealthCheck{{
ID: "main",
URL: "://bad",
Required: true,
}}
plain := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
plain.ID = "plain"
plain.Manifest.Name = "plain"
deezer := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
deezer.ID = "deezer"
deezer.Manifest.Name = "deezer"
deezer.Manifest.ServiceHealth = []ExtensionHealthCheck{{
ID: "main",
URL: "https://example.test/health",
}}
manager.mu.Lock()
previousAmazon, hadAmazon := manager.extensions[amazon.ID]
previousPlain, hadPlain := manager.extensions[plain.ID]
previousDeezer, hadDeezer := manager.extensions[deezer.ID]
manager.extensions[amazon.ID] = amazon
manager.extensions[plain.ID] = plain
manager.extensions[deezer.ID] = deezer
manager.mu.Unlock()
defer func() {
manager.mu.Lock()
if hadAmazon {
manager.extensions[amazon.ID] = previousAmazon
} else {
delete(manager.extensions, amazon.ID)
}
if hadPlain {
manager.extensions[plain.ID] = previousPlain
} else {
delete(manager.extensions, plain.ID)
}
if hadDeezer {
manager.extensions[deezer.ID] = previousDeezer
} else {
delete(manager.extensions, deezer.ID)
}
manager.mu.Unlock()
extensionHealthCacheMu.Lock()
delete(extensionHealthCache, deezer.ID)
extensionHealthCacheMu.Unlock()
}()
extensionHealthCacheMu.Lock()
extensionHealthCache[deezer.ID] = cachedExtensionHealthResult{
result: ExtensionHealthResult{
ExtensionID: deezer.ID,
Status: "online",
CheckedAt: time.Now().UTC().Format(time.RFC3339),
},
expiresAt: time.Now().Add(time.Minute),
}
extensionHealthCacheMu.Unlock()
got := prioritizeFallbackProvidersByHealth(
[]string{"amazon", "plain", "deezer"},
manager,
"",
)
want := []string{"deezer", "plain"}
if len(got) != len(want) {
t.Fatalf("unexpected provider order length: got %v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("unexpected provider order at %d: got %v want %v", i, got, want)
}
}
}
func TestNormalizeDownloadDecryptionInfoPromotesLegacyKey(t *testing.T) {
normalized := normalizeDownloadDecryptionInfo(nil, " 001122 ")
if normalized == nil {
@@ -286,6 +406,45 @@ func TestBuildOutputPathForExtensionUsesTempDirForFDOutput(t *testing.T) {
}
}
func TestBuildOutputPathSanitizesTemplateFilename(t *testing.T) {
SetAllowedDownloadDirs(nil)
outputDir := t.TempDir()
outputPath := buildOutputPath(DownloadRequest{
TrackName: `Gehra Hua (From "Dhurandhar")`,
ArtistName: "Artist",
OutputDir: outputDir,
OutputExt: ".flac",
FilenameFormat: "{artist} - {title}",
})
base := filepath.Base(outputPath)
if strings.ContainsAny(base, `<>:"/\|?*`) {
t.Fatalf("output filename still contains illegal characters: %q", base)
}
if strings.Contains(base, `"`) {
t.Fatalf("output filename still contains straight double quote: %q", base)
}
}
func TestBuildOutputPathForExtensionSanitizesTemplateFilename(t *testing.T) {
SetAllowedDownloadDirs(nil)
ext := &loadedExtension{DataDir: t.TempDir()}
resolved := buildOutputPathForExtension(DownloadRequest{
TrackName: `Gehra Hua (From "Dhurandhar")`,
ArtistName: "Artist",
OutputFD: 123,
OutputExt: ".flac",
FilenameFormat: "{artist} - {title}",
}, ext)
base := filepath.Base(resolved)
if strings.ContainsAny(base, `<>:"/\|?*`) {
t.Fatalf("extension output filename still contains illegal characters: %q", base)
}
}
func TestShouldStopProviderFallback(t *testing.T) {
if shouldStopProviderFallback(nil) {
t.Fatal("nil availability should not stop fallback")
+1
View File
@@ -504,6 +504,7 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
utilsObj.Set("decrypt", r.cryptoDecrypt)
utilsObj.Set("encryptBlockCipher", r.encryptBlockCipher)
utilsObj.Set("decryptBlockCipher", r.decryptBlockCipher)
utilsObj.Set("decryptCTRSegments", r.decryptCTRSegments)
utilsObj.Set("generateKey", r.cryptoGenerateKey)
utilsObj.Set("randomUserAgent", r.randomUserAgent)
utilsObj.Set("appVersion", r.appVersion)
+205 -30
View File
@@ -158,6 +158,11 @@ func decodeRuntimeBytesValue(raw interface{}, encoding string) ([]byte, error) {
cloned := make([]byte, len(value))
copy(cloned, value)
return cloned, nil
case goja.ArrayBuffer:
src := value.Bytes()
cloned := make([]byte, len(src))
copy(cloned, src)
return cloned, nil
case []interface{}:
decoded := make([]byte, len(value))
for i, item := range value {
@@ -279,7 +284,10 @@ func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt
"error": err.Error(),
})
}
if parsedOptions.Mode != "cbc" {
switch parsedOptions.Mode {
case "cbc", "ctr":
// supported
default:
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("unsupported block cipher mode: %s", parsedOptions.Mode),
@@ -303,37 +311,49 @@ func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt
}
if len(parsedOptions.IV) != block.BlockSize() {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("iv must be %d bytes for %s", block.BlockSize(), parsedOptions.Algorithm),
})
}
data := inputData
if !decrypt && parsedOptions.Padding == "pkcs7" {
data = applyPKCS7Padding(data, block.BlockSize())
}
if len(data)%block.BlockSize() != 0 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("input length must be a multiple of %d bytes", block.BlockSize()),
})
}
output := make([]byte, len(data))
if decrypt {
cipher.NewCBCDecrypter(block, parsedOptions.IV).CryptBlocks(output, data)
if parsedOptions.Padding == "pkcs7" {
output, err = removePKCS7Padding(output, block.BlockSize())
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
ivLabel := "iv"
if parsedOptions.Mode == "ctr" {
ivLabel = "iv (counter)"
}
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("%s must be %d bytes for %s", ivLabel, block.BlockSize(), parsedOptions.Algorithm),
})
}
var output []byte
if parsedOptions.Mode == "ctr" {
// CTR is a stream mode: encryption and decryption are identical,
// require no padding, and accept arbitrary input lengths.
output = make([]byte, len(inputData))
cipher.NewCTR(block, parsedOptions.IV).XORKeyStream(output, inputData)
} else {
cipher.NewCBCEncrypter(block, parsedOptions.IV).CryptBlocks(output, data)
data := inputData
if !decrypt && parsedOptions.Padding == "pkcs7" {
data = applyPKCS7Padding(data, block.BlockSize())
}
if len(data)%block.BlockSize() != 0 {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("input length must be a multiple of %d bytes", block.BlockSize()),
})
}
output = make([]byte, len(data))
if decrypt {
cipher.NewCBCDecrypter(block, parsedOptions.IV).CryptBlocks(output, data)
if parsedOptions.Padding == "pkcs7" {
output, err = removePKCS7Padding(output, block.BlockSize())
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
}
} else {
cipher.NewCBCEncrypter(block, parsedOptions.IV).CryptBlocks(output, data)
}
}
encoded, err := encodeRuntimeBytes(output, parsedOptions.OutputEncoding)
@@ -358,3 +378,158 @@ func (r *extensionRuntime) encryptBlockCipher(call goja.FunctionCall) goja.Value
func (r *extensionRuntime) decryptBlockCipher(call goja.FunctionCall) goja.Value {
return r.transformBlockCipher(call, true)
}
// decryptCTRSegments decrypts many independently-IV'd AES-CTR segments inside a
// single buffer in one host call. This exists to avoid thousands of JS->Go
// bridge crossings when an extension decrypts per-sample CENC media (each
// sample has its own IV/counter and cannot be merged into one stream).
//
// It is a generic primitive: any extension can use it for "one buffer, many
// CTR segments" workloads, not just Apple CENC.
//
// For best performance, pass the buffer as an ArrayBuffer/Uint8Array and set
// outputEncoding:"bytes" to get an ArrayBuffer back. This avoids base64
// encode/decode of the (potentially multi-MB) payload entirely, which is the
// dominant cost under the goja interpreter.
//
// JS signature:
// utils.decryptCTRSegments(data, {
// algorithm: "aes", // optional, default "aes"
// key: "<hex>", keyEncoding: "hex",
// segments: [ { offset: <int>, size: <int>, iv: "<base64>" }, ... ],
// ivEncoding: "base64", // encoding of each segment.iv, default base64
// inputEncoding: "bytes", // "bytes" for ArrayBuffer/Uint8Array, else base64/hex
// outputEncoding: "bytes" // "bytes" -> ArrayBuffer; else base64/hex string
// })
// Returns { success, data, segments_processed } or { success:false, error }.
func (r *extensionRuntime) decryptCTRSegments(call goja.FunctionCall) goja.Value {
fail := func(msg string) goja.Value {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": msg,
})
}
if len(call.Arguments) < 2 {
return fail("data and options are required")
}
options := parseRuntimeOptionsArgument(call, 1)
if options == nil {
return fail("options object is required")
}
algorithm := strings.ToLower(runtimeOptionString(options, "algorithm", "aes"))
inputEncoding := strings.ToLower(runtimeOptionString(options, "inputEncoding", "base64"))
outputEncoding := strings.ToLower(runtimeOptionString(options, "outputEncoding", "base64"))
ivEncoding := strings.ToLower(runtimeOptionString(options, "ivEncoding", "base64"))
key, err := decodeRuntimeBytesString(
runtimeOptionString(options, "key", ""),
runtimeOptionString(options, "keyEncoding", "hex"),
)
if err != nil {
return fail(fmt.Sprintf("invalid key: %v", err))
}
if len(key) == 0 {
return fail("key is required")
}
var block cipher.Block
switch algorithm {
case "aes":
block, err = aes.NewCipher(key)
case "blowfish":
block, err = blowfish.NewCipher(key)
default:
return fail("unsupported algorithm: " + algorithm)
}
if err != nil {
return fail(err.Error())
}
blockSize := block.BlockSize()
// Decode the payload. For "bytes" input we operate on the raw []byte
// (ArrayBuffer/Uint8Array) without any base64 round-trip.
var data []byte
if inputEncoding == "bytes" || inputEncoding == "raw" {
data, err = decodeRuntimeBytesValue(call.Arguments[0].Export(), "")
if err != nil {
return fail("invalid byte payload: " + err.Error())
}
} else {
data, err = decodeRuntimeBytesValue(call.Arguments[0].Export(), inputEncoding)
if err != nil {
return fail(err.Error())
}
}
rawSegments, ok := options["segments"]
if !ok || rawSegments == nil {
return fail("segments array is required")
}
segments, ok := rawSegments.([]interface{})
if !ok {
return fail("segments must be an array")
}
processed := 0
for i, rawSeg := range segments {
seg, ok := rawSeg.(map[string]interface{})
if !ok {
return fail(fmt.Sprintf("segment %d is not an object", i))
}
offset := int(runtimeOptionInt64(seg, "offset", -1))
size := int(runtimeOptionInt64(seg, "size", -1))
if offset < 0 || size < 0 {
return fail(fmt.Sprintf("segment %d has invalid offset/size", i))
}
if size == 0 {
continue
}
if offset+size > len(data) {
return fail(fmt.Sprintf("segment %d out of bounds (offset=%d size=%d len=%d)", i, offset, size, len(data)))
}
iv, err := decodeRuntimeBytesString(runtimeOptionString(seg, "iv", ""), ivEncoding)
if err != nil {
return fail(fmt.Sprintf("segment %d has invalid iv: %v", i, err))
}
if len(iv) != blockSize {
// Accept short IVs by left-aligning into a block-sized counter
// (CENC commonly uses 8-byte IVs for a 16-byte AES counter).
if len(iv) > blockSize {
return fail(fmt.Sprintf("segment %d iv longer than block size (%d > %d)", i, len(iv), blockSize))
}
padded := make([]byte, blockSize)
copy(padded, iv)
iv = padded
}
segData := data[offset : offset+size]
cipher.NewCTR(block, iv).XORKeyStream(segData, segData)
processed++
}
// Return raw bytes as an ArrayBuffer when requested (zero-copy-ish, no
// base64). Otherwise fall back to an encoded string.
if outputEncoding == "bytes" || outputEncoding == "raw" {
return r.vm.ToValue(map[string]interface{}{
"success": true,
"data": r.vm.NewArrayBuffer(data),
"segments_processed": processed,
})
}
encoded, err := encodeRuntimeBytes(data, outputEncoding)
if err != nil {
return fail(err.Error())
}
return r.vm.ToValue(map[string]interface{}{
"success": true,
"data": encoded,
"segments_processed": processed,
})
}
+300
View File
@@ -183,3 +183,303 @@ func TestExtensionRuntime_BlockCipherCBCSupportsAES(t *testing.T) {
t.Fatalf("unexpected decrypted value: %q", result.String())
}
}
func TestExtensionRuntime_BlockCipherCTRSupportsAES(t *testing.T) {
vm := newBinaryTestRuntime(t, false)
// NIST SP 800-38A, F.5.1 CTR-AES128.Encrypt test vector.
// Key: 2b7e151628aed2a6abf7158809cf4f3c
// Counter: f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff
// Plaintext: 6bc1bee22e409f96e93d7e117393172a (block 1)
// Ciphertext: 874d6191b620e3261bef6864990db6ce (block 1)
result, err := vm.RunString(`
(function() {
var options = {
algorithm: "aes",
mode: "ctr",
key: "2b7e151628aed2a6abf7158809cf4f3c",
keyEncoding: "hex",
iv: "f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff",
ivEncoding: "hex",
inputEncoding: "hex",
outputEncoding: "hex"
};
var enc = utils.encryptBlockCipher("6bc1bee22e409f96e93d7e117393172a", options);
if (!enc.success) throw new Error(enc.error);
// CTR is symmetric: decrypt is the same transform as encrypt.
var dec = utils.decryptBlockCipher(enc.data, options);
if (!dec.success) throw new Error(dec.error);
return JSON.stringify({enc: enc.data, dec: dec.data});
})()
`)
if err != nil {
t.Fatalf("aes ctr block cipher failed: %v", err)
}
decoded := decodeJSONResult[struct {
Enc string `json:"enc"`
Dec string `json:"dec"`
}](t, result)
if decoded.Enc != "874d6191b620e3261bef6864990db6ce" {
t.Fatalf("ctr ciphertext = %q, want NIST vector 874d6191b620e3261bef6864990db6ce", decoded.Enc)
}
if decoded.Dec != "6bc1bee22e409f96e93d7e117393172a" {
t.Fatalf("ctr round-trip dec = %q", decoded.Dec)
}
}
func TestExtensionRuntime_BlockCipherCTRHandlesNonBlockLength(t *testing.T) {
vm := newBinaryTestRuntime(t, false)
// CTR is a stream mode, so arbitrary (non-16-byte-aligned) input lengths
// must round-trip without any padding.
result, err := vm.RunString(`
(function() {
var options = {
algorithm: "aes",
mode: "ctr",
key: "000102030405060708090a0b0c0d0e0f",
keyEncoding: "hex",
iv: "0f0e0d0c0b0a09080706050403020100",
ivEncoding: "hex",
inputEncoding: "utf8",
outputEncoding: "base64"
};
var enc = utils.encryptBlockCipher("stream ctr of odd length", options);
if (!enc.success) throw new Error(enc.error);
var dec = utils.decryptBlockCipher(enc.data, {
algorithm: "aes",
mode: "ctr",
key: options.key,
keyEncoding: options.keyEncoding,
iv: options.iv,
ivEncoding: options.ivEncoding,
inputEncoding: "base64",
outputEncoding: "utf8"
});
if (!dec.success) throw new Error(dec.error);
return dec.data;
})()
`)
if err != nil {
t.Fatalf("aes ctr stream length failed: %v", err)
}
if result.String() != "stream ctr of odd length" {
t.Fatalf("unexpected ctr decrypted value: %q", result.String())
}
}
func TestExtensionRuntime_BlockCipherCTRRejectsBadIV(t *testing.T) {
vm := newBinaryTestRuntime(t, false)
result, err := vm.RunString(`
(function() {
var res = utils.encryptBlockCipher("00112233", {
algorithm: "aes",
mode: "ctr",
key: "000102030405060708090a0b0c0d0e0f",
keyEncoding: "hex",
iv: "0001",
ivEncoding: "hex",
inputEncoding: "hex",
outputEncoding: "hex"
});
return JSON.stringify({success: res.success, error: res.error || ""});
})()
`)
if err != nil {
t.Fatalf("aes ctr bad iv eval failed: %v", err)
}
decoded := decodeJSONResult[struct {
Success bool `json:"success"`
Error string `json:"error"`
}](t, result)
if decoded.Success {
t.Fatal("expected failure for undersized CTR iv")
}
if decoded.Error == "" {
t.Fatal("expected error message for undersized CTR iv")
}
}
func TestExtensionRuntime_DecryptCTRSegmentsMatchesPerSegment(t *testing.T) {
vm := newBinaryTestRuntime(t, false)
// Build a buffer of 3 segments encrypted with distinct 8-byte IVs (CENC
// style), then verify the batch primitive decrypts all of them in one call,
// matching what per-segment decryptBlockCipher would produce.
result, err := vm.RunString(`
(function() {
var keyHex = "000102030405060708090a0b0c0d0e0f";
function b64(bytes){return utils.base64Encode(utils.toHex ? bytes : bytes);}
// segment plaintexts (hex) and 8-byte IVs (hex)
var segs = [
{ pt: "11111111111111111111", iv: "0000000000000001" },
{ pt: "2222222222", iv: "0000000000000002" },
{ pt: "333333333333333333333333", iv: "00000000000000ff" }
];
// Encrypt each segment individually using single-shot CTR with a
// 16-byte counter (8-byte iv left-aligned), producing ciphertext hex.
function ivToB64(ivHex){
// pad 8-byte hex iv to 16 bytes then base64
var full = ivHex + "00000000000000000000000000000000".slice(ivHex.length);
return utils.base64Encode(utils.hexToBytes ? utils.hexToBytes(full) : full);
}
var cipherHex = "";
var offsets = [];
var off = 0;
var ivB64s = [];
for (var i=0;i<segs.length;i++){
var ivFullHex = (segs[i].iv + "00000000000000000000000000000000").slice(0,32);
var enc = utils.encryptBlockCipher(segs[i].pt, {
algorithm:"aes", mode:"ctr", key:keyHex, keyEncoding:"hex",
iv: ivFullHex, ivEncoding:"hex",
inputEncoding:"hex", outputEncoding:"hex"
});
if(!enc.success) throw new Error("enc seg "+i+": "+enc.error);
cipherHex += enc.data;
var sz = segs[i].pt.length/2;
offsets.push({offset: off, size: sz, ivHex: ivFullHex});
off += sz;
}
// Now decrypt the whole concatenated buffer in ONE batch call.
var segments = offsets.map(function(o){
return { offset:o.offset, size:o.size, iv:o.ivHex };
});
var batch = utils.decryptCTRSegments(cipherHex, {
algorithm:"aes", key:keyHex, keyEncoding:"hex",
segments: segments, ivEncoding:"hex",
inputEncoding:"hex", outputEncoding:"hex"
});
if(!batch.success) throw new Error("batch: "+batch.error);
var expected = "";
for (var j=0;j<segs.length;j++) expected += segs[j].pt;
return JSON.stringify({
out: batch.data,
expected: expected,
processed: batch.segments_processed
});
})()
`)
if err != nil {
t.Fatalf("batch CTR eval failed: %v", err)
}
decoded := decodeJSONResult[struct {
Out string `json:"out"`
Expected string `json:"expected"`
Processed int `json:"processed"`
}](t, result)
if decoded.Out != decoded.Expected {
t.Fatalf("batch decrypt mismatch:\n got=%s\nwant=%s", decoded.Out, decoded.Expected)
}
if decoded.Processed != 3 {
t.Fatalf("segments_processed = %d, want 3", decoded.Processed)
}
}
func TestExtensionRuntime_DecryptCTRSegmentsRejectsOutOfBounds(t *testing.T) {
vm := newBinaryTestRuntime(t, false)
result, err := vm.RunString(`
(function() {
var res = utils.decryptCTRSegments("00112233", {
algorithm:"aes", key:"000102030405060708090a0b0c0d0e0f", keyEncoding:"hex",
inputEncoding:"hex", outputEncoding:"hex",
ivEncoding:"hex",
segments: [ { offset: 0, size: 99, iv: "00000000000000000000000000000000" } ]
});
return JSON.stringify({ success: res.success, error: res.error || "" });
})()
`)
if err != nil {
t.Fatalf("oob eval failed: %v", err)
}
decoded := decodeJSONResult[struct {
Success bool `json:"success"`
Error string `json:"error"`
}](t, result)
if decoded.Success {
t.Fatal("expected out-of-bounds segment to fail")
}
if decoded.Error == "" {
t.Fatal("expected error message for out-of-bounds segment")
}
}
func TestExtensionRuntime_DecryptCTRSegmentsRawBytes(t *testing.T) {
vm := newBinaryTestRuntime(t, false)
// Verify the zero-base64 path: pass an ArrayBuffer in, request bytes out,
// and confirm round-trip correctness against single-shot CTR.
result, err := vm.RunString(`
(function() {
var keyHex = "000102030405060708090a0b0c0d0e0f";
var ivFullHex = "0000000000000001" + "00000000000000000000000000000000".slice(16);
// Plaintext as a Uint8Array of 20 bytes.
var pt = new Uint8Array(20);
for (var i = 0; i < pt.length; i++) pt[i] = (i * 7 + 3) & 0xff;
// Encrypt single-shot to get ciphertext (hex output for clarity).
var ptHex = "";
for (var j = 0; j < pt.length; j++) { var h = pt[j].toString(16); ptHex += (h.length === 1 ? "0" : "") + h; }
var enc = utils.encryptBlockCipher(ptHex, {
algorithm:"aes", mode:"ctr", key:keyHex, keyEncoding:"hex",
iv: ivFullHex, ivEncoding:"hex", inputEncoding:"hex", outputEncoding:"base64"
});
if (!enc.success) throw new Error("enc: " + enc.error);
// Decode ciphertext base64 into a Uint8Array to feed the raw path.
var cipherBytes = utils.base64Decode ? null : null;
// Build ArrayBuffer from base64 via Uint8Array manually:
var b64 = enc.data;
var bin = (typeof atob === "function") ? null : null;
// Simpler: ask the host to give us bytes by decrypting nothing is hard,
// so just pass the base64 ciphertext through decryptCTRSegments using
// base64 input but bytes output, then re-run with bytes input.
var step1 = utils.decryptCTRSegments(b64, {
algorithm:"aes", key:keyHex, keyEncoding:"hex",
segments: [ { offset:0, size:20, iv: ivFullHex } ],
ivEncoding:"hex", inputEncoding:"base64", outputEncoding:"bytes"
});
if (!step1.success) throw new Error("step1: " + step1.error);
if (typeof step1.data === "string") throw new Error("expected ArrayBuffer output, got string");
var outArr = new Uint8Array(step1.data);
var outHex = "";
for (var k = 0; k < outArr.length; k++) { var hh = outArr[k].toString(16); outHex += (hh.length === 1 ? "0" : "") + hh; }
return JSON.stringify({ out: outHex, expected: ptHex, len: outArr.length });
})()
`)
if err != nil {
t.Fatalf("raw-bytes eval failed: %v", err)
}
decoded := decodeJSONResult[struct {
Out string `json:"out"`
Expected string `json:"expected"`
Len int `json:"len"`
}](t, result)
if decoded.Out != decoded.Expected {
t.Fatalf("raw-bytes decrypt mismatch:\n got=%s\nwant=%s", decoded.Out, decoded.Expected)
}
if decoded.Len != 20 {
t.Fatalf("output length = %d, want 20", decoded.Len)
}
}
+14 -2
View File
@@ -663,7 +663,6 @@ func (r *extensionRuntime) fileReadBytes(call goja.FunctionCall) goja.Value {
"error": "offset must be >= 0",
})
}
file, err := os.Open(fullPath)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -716,6 +715,20 @@ func (r *extensionRuntime) fileReadBytes(call goja.FunctionCall) goja.Value {
}
}
if strings.EqualFold(strings.TrimSpace(encoding), "bytes") ||
strings.EqualFold(strings.TrimSpace(encoding), "raw") {
// Return raw bytes as an ArrayBuffer to avoid base64 encode/decode of
// large payloads under the goja interpreter.
return r.vm.ToValue(map[string]interface{}{
"success": true,
"data": r.vm.NewArrayBuffer(data),
"bytes_read": len(data),
"offset": offset,
"size": size,
"eof": offset+int64(len(data)) >= size,
})
}
encoded, err := encodeRuntimeBytes(data, encoding)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
@@ -733,7 +746,6 @@ func (r *extensionRuntime) fileReadBytes(call goja.FunctionCall) goja.Value {
"eof": offset+int64(len(data)) >= size,
})
}
func (r *extensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{
+13 -13
View File
@@ -5,25 +5,25 @@ go 1.25.0
toolchain go1.25.9
require (
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d
github.com/go-flac/flacpicture/v2 v2.0.2
github.com/go-flac/flacvorbis/v2 v2.0.2
github.com/go-flac/go-flac/v2 v2.0.4
github.com/refraction-networking/utls v1.8.2
golang.org/x/crypto v0.50.0
golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b
golang.org/x/net v0.53.0
golang.org/x/text v0.36.0
golang.org/x/crypto v0.53.0
golang.org/x/mobile v0.0.0-20260602190626-68735029466e
golang.org/x/net v0.56.0
golang.org/x/text v0.38.0
)
require (
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
github.com/klauspost/compress v1.18.5 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/tools v0.44.0 // indirect
github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect
github.com/klauspost/compress v1.18.6 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/tools v0.45.0 // indirect
)
+30 -30
View File
@@ -1,13 +1,13 @@
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk=
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d h1:xbM5U2EvWKkHxzEQJ2DEn20FwolWZahuTnVHr6WL3Q4=
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d/go.mod h1:Sc+QOu1WruvaaeT/cxFez/pXHpI9ZDjg/E8QNfSVveI=
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
@@ -16,12 +16,14 @@ github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sY
github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/google/pprof v0.0.0-20260604005048-7023385849c0 h1:h1QTMDl6q9wDvDCJVpKQSjgleGFYnd2fOxmg2K+6BGE=
github.com/google/pprof v0.0.0-20260604005048-7023385849c0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
@@ -30,23 +32,21 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b h1:Qt2eaXcZ8x20iAcoZ6AceeMMtnjuPHvC51KRCH1DKSQ=
golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b/go.mod h1:5Fu78lew5ucMXt8w2KYcwvxu2rkC/liHzUvaoiI+H/M=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/mobile v0.0.0-20260602190626-68735029466e h1:YxPXu/HWDTcSSrzSX+sCltsfcNCa/ZYVG43oslMouNU=
golang.org/x/mobile v0.0.0-20260602190626-68735029466e/go.mod h1:ltIbhcRzKgwHa4ZxKJeiv0nyzcXUUYCqMyO0Y+vPmXw=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+4 -11
View File
@@ -77,6 +77,7 @@ var sharedTransport = &http.Transport{
WriteBufferSize: 64 * 1024,
ReadBufferSize: 64 * 1024,
DisableCompression: true,
TLSClientConfig: newTLSCompatibilityConfig(false),
}
var extensionAPITransport = &http.Transport{
@@ -95,6 +96,7 @@ var extensionAPITransport = &http.Transport{
WriteBufferSize: 64 * 1024,
ReadBufferSize: 64 * 1024,
DisableCompression: false,
TLSClientConfig: newTLSCompatibilityConfig(false),
}
var metadataTransport = &http.Transport{
@@ -113,6 +115,7 @@ var metadataTransport = &http.Transport{
WriteBufferSize: 32 * 1024,
ReadBufferSize: 32 * 1024,
DisableCompression: true,
TLSClientConfig: newTLSCompatibilityConfig(false),
}
var sharedClient = &http.Client{
@@ -176,17 +179,7 @@ func GetNetworkCompatibilityOptions() NetworkCompatibilityOptions {
}
func applyTLSCompatibility(transport *http.Transport, insecureTLS bool) {
if insecureTLS {
cfg := &tls.Config{InsecureSkipVerify: true}
if transport.TLSClientConfig != nil {
cfg = transport.TLSClientConfig.Clone()
cfg.InsecureSkipVerify = true
}
transport.TLSClientConfig = cfg
return
}
transport.TLSClientConfig = nil
transport.TLSClientConfig = newTLSCompatibilityConfig(insecureTLS)
}
type compatibilityTransport struct {
+25
View File
@@ -1,6 +1,8 @@
package gobackend
import (
"crypto/x509"
"encoding/pem"
"errors"
"io"
"net/http"
@@ -25,11 +27,34 @@ func TestHTTPUtilityHelpers(t *testing.T) {
if GetSharedClient() == nil || GetDownloadClient() == nil {
t.Fatal("expected shared clients")
}
if sharedTransport.TLSClientConfig == nil || sharedTransport.TLSClientConfig.RootCAs == nil {
t.Fatal("expected supplemental TLS root pool")
}
block, _ := pem.Decode([]byte(isrgRootX2PEM))
if block == nil {
t.Fatal("failed to decode ISRG Root X2")
}
rootX2, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("failed to parse ISRG Root X2: %v", err)
}
if _, err := rootX2.Verify(x509.VerifyOptions{
Roots: supplementalRootCAs(),
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
}); err != nil {
t.Fatalf("ISRG Root X2 should verify with supplemental roots: %v", err)
}
SetNetworkCompatibilityOptions(true, true)
if opts := GetNetworkCompatibilityOptions(); !opts.AllowHTTP || !opts.InsecureTLS {
t.Fatalf("network opts = %#v", opts)
}
if !sharedTransport.TLSClientConfig.InsecureSkipVerify {
t.Fatal("expected insecure TLS config to be applied")
}
SetNetworkCompatibilityOptions(false, false)
if sharedTransport.TLSClientConfig == nil || sharedTransport.TLSClientConfig.InsecureSkipVerify {
t.Fatal("expected secure TLS config to be restored")
}
if !canFallbackToHTTP(&http.Request{Method: http.MethodGet}) {
t.Fatal("GET should fallback")
}
+5 -2
View File
@@ -42,9 +42,12 @@ func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return nil, err
}
opts := GetNetworkCompatibilityOptions()
tlsConn := utls.UClient(conn, &utls.Config{
ServerName: host,
NextProtos: []string{"h2", "http/1.1"},
RootCAs: supplementalRootCAs(),
InsecureSkipVerify: opts.InsecureTLS,
ServerName: host,
NextProtos: []string{"h2", "http/1.1"},
}, utls.HelloChrome_Auto)
if err := tlsConn.Handshake(); err != nil {
+8 -1
View File
@@ -76,6 +76,9 @@ var supportedAudioFormats = map[string]bool{
".ape": true,
".wv": true,
".mpc": true,
".wav": true,
".aiff": true,
".aif": true,
".cue": true,
}
@@ -340,6 +343,10 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ
return scanOggFile(filePath, result, displayNameHint)
case ".ape", ".wv", ".mpc":
return scanAPEFile(filePath, result, displayNameHint)
case ".wav":
return scanWAVFile(filePath, result, displayNameHint)
case ".aiff", ".aif", ".aifc":
return scanAIFFFile(filePath, result, displayNameHint)
default:
return scanFromFilename(filePath, displayNameHint, result)
}
@@ -479,7 +486,7 @@ func libraryFormatForM4ACodec(codec string) string {
func isLosslessLibraryFormat(format string) bool {
switch strings.ToLower(strings.TrimSpace(format)) {
case "flac", "alac":
case "flac", "alac", "wav", "aiff", "aif", "aifc":
return true
default:
return false
+84 -1
View File
@@ -31,6 +31,7 @@ const (
LyricsProviderYouTube = "youtube"
LyricsProviderKugou = "kugou"
LyricsProviderGenius = "genius"
LyricsProviderLyricsPlus = "lyricsplus"
)
var DefaultLyricsProviders = []string{
@@ -112,6 +113,7 @@ func SetLyricsProviderOrder(providers []string) {
LyricsProviderYouTube: true,
LyricsProviderKugou: true,
LyricsProviderGenius: true,
LyricsProviderLyricsPlus: true,
}
var valid []string
@@ -151,6 +153,7 @@ func GetAvailableLyricsProviders() []map[string]interface{} {
{"id": LyricsProviderYouTube, "name": "YouTube", "has_proxy_dependency": true, "description": "YouTube lyrics"},
{"id": LyricsProviderKugou, "name": "Kugou", "has_proxy_dependency": true, "description": "Kugou lyrics"},
{"id": LyricsProviderGenius, "name": "Genius", "has_proxy_dependency": true, "description": "Genius lyrics"},
{"id": LyricsProviderLyricsPlus, "name": "LyricsPlus", "has_proxy_dependency": true, "description": "Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ)"},
}
}
@@ -612,6 +615,37 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
lyrics, err = geniusClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec)
}
case LyricsProviderLyricsPlus:
lyricsPlusClient := NewLyricsPlusClient()
lyrics, err = lyricsPlusClient.FetchLyrics(
trackName,
primaryArtist,
"",
durationSec,
fetchOptions.MultiPersonWordByWord,
fetchOptions.AppleElrcWordSync,
)
if err != nil && primaryArtist != artistName {
lyrics, err = lyricsPlusClient.FetchLyrics(
trackName,
artistName,
"",
durationSec,
fetchOptions.MultiPersonWordByWord,
fetchOptions.AppleElrcWordSync,
)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = lyricsPlusClient.FetchLyrics(
simplifiedTrack,
primaryArtist,
"",
durationSec,
fetchOptions.MultiPersonWordByWord,
fetchOptions.AppleElrcWordSync,
)
}
default:
GoLog("[Lyrics] Unknown provider: %s, skipping\n", providerName)
continue
@@ -843,6 +877,41 @@ func msToLRCTimestampInline(ms int64) string {
return fmt.Sprintf("%02d:%02d.%02d", minutes, seconds, centiseconds)
}
// extractLyricsSourceFromLRC reads the provider recorded in the LRC [by:] tag,
// e.g. "[by:SpotiFLAC-Mobile (source: LRCLIB)]". Returns "" when absent.
const lrcSourceMarker = "(source: "
func lyricsSourceUsesPaxsenix(source string) bool {
s := strings.ToLower(strings.TrimSpace(source))
if s == "" {
return false
}
if strings.HasPrefix(s, "lrclib") ||
strings.HasPrefix(s, "extension:") ||
strings.HasPrefix(s, "heuristic") {
return false
}
return true
}
func extractLyricsSourceFromLRC(lrc string) string {
for _, line := range strings.Split(lrc, "\n") {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(strings.ToLower(trimmed), "[by:") {
continue
}
idx := strings.Index(trimmed, lrcSourceMarker)
if idx < 0 {
return ""
}
rest := strings.TrimSpace(trimmed[idx+len(lrcSourceMarker):])
rest = strings.TrimSuffix(rest, "]")
rest = strings.TrimSuffix(rest, ")")
return strings.TrimSpace(rest)
}
return ""
}
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
if lyrics == nil || len(lyrics.Lines) == 0 {
return ""
@@ -852,7 +921,21 @@ func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName stri
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
builder.WriteString("[by:Implemented by SpotiFLAC-Mobile using Paxsenix API]\n")
source := strings.TrimSpace(lyrics.Source)
if source == "" {
source = strings.TrimSpace(lyrics.Provider)
}
credit := "SpotiFLAC-Mobile"
if lyricsSourceUsesPaxsenix(source) {
credit = "SpotiFLAC-Mobile via Paxsenix API"
}
if source == "" {
builder.WriteString(fmt.Sprintf("[by:%s]\n", credit))
} else {
builder.WriteString(
fmt.Sprintf("[by:%s %s%s)]\n", credit, lrcSourceMarker, source),
)
}
builder.WriteString("\n")
if lyrics.SyncType == "LINE_SYNCED" {
+213 -23
View File
@@ -7,7 +7,9 @@ import (
"math"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
)
@@ -15,6 +17,8 @@ type AppleMusicClient struct {
httpClient *http.Client
}
const appleMusicCatalogBaseURL = "https://amp-api.music.apple.com/v1/catalog/us"
type appleMusicSearchResult struct {
ID string `json:"id"`
SongName string `json:"songName"`
@@ -23,9 +27,33 @@ type appleMusicSearchResult struct {
Duration int `json:"duration"`
}
type appleMusicCatalogSearchResponse struct {
Results struct {
Songs *struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
} `json:"songs"`
} `json:"results"`
Resources *struct {
Songs map[string]struct {
Attributes struct {
Name string `json:"name"`
ArtistName string `json:"artistName"`
AlbumName string `json:"albumName"`
DurationInMillis int `json:"durationInMillis"`
} `json:"attributes"`
} `json:"songs"`
} `json:"resources"`
}
type paxResponse struct {
Type string `json:"type"` // "Syllable" or "Line"
Content []paxLyrics `json:"content"` // List of lyric lines
Type string `json:"type"` // "Syllable" or "Line"
Content []paxLyrics `json:"content"`
ELRC string `json:"elrc"`
ELRCMultiPerson string `json:"elrcMultiPerson"`
Plain string `json:"plain"`
TTMLContent string `json:"ttmlContent"`
}
type paxLyrics struct {
@@ -44,6 +72,11 @@ type paxLyricDetail struct {
EndTime *int `json:"endtime"`
}
var (
appleMusicTokenMu sync.Mutex
appleMusicCachedToken string
)
func NewAppleMusicClient() *AppleMusicClient {
return &AppleMusicClient{
httpClient: NewMetadataHTTPClient(20 * time.Second),
@@ -100,36 +133,164 @@ func selectBestAppleMusicSearchResult(results []appleMusicSearchResult, trackNam
return &results[bestIndex]
}
func (c *AppleMusicClient) getAppleMusicToken() (string, error) {
appleMusicTokenMu.Lock()
defer appleMusicTokenMu.Unlock()
if appleMusicCachedToken != "" {
return appleMusicCachedToken, nil
}
req, err := http.NewRequest("GET", "https://beta.music.apple.com", nil)
if err != nil {
return "", fmt.Errorf("failed to create apple music page request: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to fetch apple music page: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("apple music page returned HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read apple music page: %w", err)
}
indexPath := regexp.MustCompile(`/assets/index~[^"' <]+\.js`).FindString(string(body))
if indexPath == "" {
return "", fmt.Errorf("apple music index script not found")
}
jsReq, err := http.NewRequest("GET", "https://beta.music.apple.com"+indexPath, nil)
if err != nil {
return "", fmt.Errorf("failed to create apple music script request: %w", err)
}
jsReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
jsResp, err := c.httpClient.Do(jsReq)
if err != nil {
return "", fmt.Errorf("failed to fetch apple music script: %w", err)
}
defer jsResp.Body.Close()
if jsResp.StatusCode != http.StatusOK {
return "", fmt.Errorf("apple music script returned HTTP %d", jsResp.StatusCode)
}
jsBody, err := io.ReadAll(jsResp.Body)
if err != nil {
return "", fmt.Errorf("failed to read apple music script: %w", err)
}
token := regexp.MustCompile(`eyJh[^"' <]+`).FindString(string(jsBody))
if token == "" {
return "", fmt.Errorf("apple music token not found")
}
appleMusicCachedToken = token
return token, nil
}
func clearAppleMusicToken() {
appleMusicTokenMu.Lock()
defer appleMusicTokenMu.Unlock()
appleMusicCachedToken = ""
}
func (c *AppleMusicClient) searchSongWithToken(token, query string) ([]appleMusicSearchResult, error) {
params := url.Values{}
params.Set("term", query)
params.Set("types", "songs")
params.Set("limit", "25")
params.Set("l", "en-US")
params.Set("platform", "web")
params.Set("format[resources]", "map")
params.Set("include[songs]", "artists")
params.Set("extend", "artistUrl")
searchURL := appleMusicCatalogBaseURL + "/search?" + params.Encode()
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create apple music catalog request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Origin", "https://music.apple.com")
req.Header.Set("Referer", "https://music.apple.com/")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0")
req.Header.Set("Accept", "application/json")
req.Header.Set("Accept-Language", "en-US,en;q=0.5")
req.Header.Set("x-apple-renewal", "true")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("apple music catalog search failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return nil, fmt.Errorf("apple music catalog search unauthorized")
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("apple music catalog search returned HTTP %d", resp.StatusCode)
}
var searchResp appleMusicCatalogSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return nil, fmt.Errorf("failed to decode apple music catalog response: %w", err)
}
if searchResp.Results.Songs == nil || searchResp.Resources == nil {
return nil, nil
}
results := make([]appleMusicSearchResult, 0, len(searchResp.Results.Songs.Data))
for _, item := range searchResp.Results.Songs.Data {
detail, ok := searchResp.Resources.Songs[item.ID]
if !ok {
continue
}
attr := detail.Attributes
results = append(results, appleMusicSearchResult{
ID: item.ID,
SongName: attr.Name,
ArtistName: attr.ArtistName,
AlbumName: attr.AlbumName,
Duration: attr.DurationInMillis,
})
}
return results, nil
}
func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
query := trackName + " " + artistName
if strings.TrimSpace(query) == "" {
return "", fmt.Errorf("empty search query")
}
encodedQuery := url.QueryEscape(query)
searchURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/search?q=%s", encodedQuery)
req, err := http.NewRequest("GET", searchURL, nil)
token, err := c.getAppleMusicToken()
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
return "", err
}
req.Header.Set("User-Agent", appUserAgent())
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
searchResp, err := c.searchSongWithToken(token, strings.TrimSpace(query))
if err != nil && strings.Contains(strings.ToLower(err.Error()), "unauthorized") {
clearAppleMusicToken()
token, tokenErr := c.getAppleMusicToken()
if tokenErr != nil {
return "", tokenErr
}
searchResp, err = c.searchSongWithToken(token, strings.TrimSpace(query))
}
if err != nil {
return "", fmt.Errorf("apple music search failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("apple music search returned HTTP %d", resp.StatusCode)
}
var searchResp []appleMusicSearchResult
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return "", fmt.Errorf("failed to decode apple music response: %w", err)
return "", err
}
best := selectBestAppleMusicSearchResult(searchResp, trackName, artistName, durationSec)
@@ -174,8 +335,33 @@ func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
}
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool, preserveWordTiming bool) (string, error) {
var stringPayload string
if err := json.Unmarshal([]byte(rawJSON), &stringPayload); err == nil {
stringPayload = strings.TrimSpace(stringPayload)
if stringPayload != "" {
return stringPayload, nil
}
}
var paxResp paxResponse
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil && paxResp.Content != nil {
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil &&
(paxResp.Content != nil ||
strings.TrimSpace(paxResp.ELRCMultiPerson) != "" ||
strings.TrimSpace(paxResp.ELRC) != "" ||
strings.TrimSpace(paxResp.Plain) != "" ||
strings.TrimSpace(paxResp.TTMLContent) != "") {
if preserveWordTiming && multiPersonWordByWord && strings.TrimSpace(paxResp.ELRCMultiPerson) != "" {
return strings.TrimSpace(paxResp.ELRCMultiPerson), nil
}
if preserveWordTiming && strings.TrimSpace(paxResp.ELRC) != "" {
return strings.TrimSpace(paxResp.ELRC), nil
}
if strings.TrimSpace(paxResp.Plain) != "" && len(paxResp.Content) == 0 {
return strings.TrimSpace(paxResp.Plain), nil
}
if len(paxResp.Content) == 0 {
return "", fmt.Errorf("unsupported apple music lyrics payload")
}
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord, preserveWordTiming), nil
}
@@ -270,6 +456,10 @@ func (c *AppleMusicClient) FetchLyrics(
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord, preserveWordTiming)
if err != nil {
trimmedRaw := strings.TrimSpace(rawLyrics)
if strings.HasPrefix(trimmedRaw, "{") || strings.HasPrefix(trimmedRaw, "[") {
return nil, err
}
lrcText = rawLyrics
}
+243
View File
@@ -0,0 +1,243 @@
package gobackend
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// LyricsPlus (KPOE) provider.
//
// LyricsPlus aggregates word-by-word ("karaoke") synced lyrics from Apple
// Music, Musixmatch, Spotify and QQ Music via a community-run backend. It
// frequently has word-level timing for tracks that other providers only offer
// line-synced or not at all.
//
// API: GET {server}/v2/lyrics/get?title=&artist=&album=&duration=&isrc=
// The response is the KPOE JSON format which we convert into the same enhanced
// LRC text the Apple/QQ providers emit, so embedding/export behaves identically.
// Public LyricsPlus / KPOE servers (mirrors). Tried in order with failover.
// Sourced from the upstream YouLy+ client server list.
var lyricsPlusServers = []string{
"https://lyricsplus.prjktla.my.id",
"https://lyricsplus.atomix.one",
"https://lyricsplus.binimum.org",
"https://lyricsplus.prjktla.workers.dev",
"https://lyricsplus-seven.vercel.app",
"https://lyrics-plus-backend.vercel.app",
}
type LyricsPlusClient struct {
httpClient *http.Client
}
func NewLyricsPlusClient() *LyricsPlusClient {
return &LyricsPlusClient{httpClient: NewMetadataHTTPClient(15 * time.Second)}
}
type lyricsPlusSyllable struct {
Text string `json:"text"`
Time float64 `json:"time"` // absolute ms
Duration float64 `json:"duration"` // ms
IsBackground bool `json:"isBackground"`
}
type lyricsPlusLine struct {
Time float64 `json:"time"` // absolute ms
Duration float64 `json:"duration"` // ms
Text string `json:"text"`
Syllabus []lyricsPlusSyllable `json:"syllabus"`
}
type lyricsPlusResponse struct {
Type string `json:"type"` // "Word" | "Line" | "Syllable" | "None"
Lyrics []lyricsPlusLine `json:"lyrics"`
}
// FetchLyrics tries each LyricsPlus server in order until one returns usable
// lyrics. multiPersonWordByWord and preserveWordTiming mirror the Apple/QQ
// options so word/background timing is only emitted when the user enabled it.
func (c *LyricsPlusClient) FetchLyrics(
trackName,
artistName,
isrc string,
durationSec float64,
multiPersonWordByWord bool,
preserveWordTiming bool,
) (*LyricsResponse, error) {
if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" {
return nil, fmt.Errorf("lyricsplus: missing track or artist")
}
var lastErr error
for _, server := range lyricsPlusServers {
lyrics, err := c.fetchFromServer(server, trackName, artistName, isrc, durationSec, multiPersonWordByWord, preserveWordTiming)
if err == nil && lyricsHasUsableText(lyrics) {
return lyrics, nil
}
if err != nil {
lastErr = err
GoLog("[Lyrics] LyricsPlus server %s failed: %v\n", server, err)
}
}
if lastErr != nil {
return nil, lastErr
}
return nil, fmt.Errorf("lyricsplus: no lyrics found")
}
func (c *LyricsPlusClient) fetchFromServer(
server,
trackName,
artistName,
isrc string,
durationSec float64,
multiPersonWordByWord bool,
preserveWordTiming bool,
) (*LyricsResponse, error) {
base := strings.TrimRight(strings.TrimSpace(server), "/")
if base == "" {
return nil, fmt.Errorf("empty server")
}
params := url.Values{}
params.Set("title", trackName)
params.Set("artist", artistName)
if durationSec > 0 {
params.Set("duration", strconv.FormatFloat(durationSec, 'f', 3, 64))
}
if strings.TrimSpace(isrc) != "" {
params.Set("isrc", strings.TrimSpace(isrc))
}
fullURL := base + "/v2/lyrics/get?" + params.Encode()
req, err := http.NewRequest("GET", fullURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", appUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
// Retry without the ISRC filter, which can be too strict.
if strings.TrimSpace(isrc) != "" {
return c.fetchFromServer(server, trackName, artistName, "", durationSec, multiPersonWordByWord, preserveWordTiming)
}
return nil, fmt.Errorf("lyrics not found")
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
var payload lyricsPlusResponse
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, fmt.Errorf("failed to decode lyricsplus response: %w", err)
}
if len(payload.Lyrics) == 0 {
return nil, fmt.Errorf("lyricsplus returned no lines")
}
lrcText := buildLyricsPlusLRC(&payload, multiPersonWordByWord, preserveWordTiming)
if strings.TrimSpace(lrcText) == "" {
return nil, fmt.Errorf("lyricsplus produced empty lyrics")
}
lyrics := lyricsResponseFromText(lrcText, "LyricsPlus")
return lyrics, nil
}
// buildLyricsPlusLRC converts the KPOE JSON into enhanced LRC text. When word
// timing is available and enabled, each syllable is emitted as an inline
// <mm:ss.xx> tag (matching the Apple/QQ output); otherwise a line-synced LRC
// is produced from the full line text.
func buildLyricsPlusLRC(resp *lyricsPlusResponse, multiPersonWordByWord bool, preserveWordTiming bool) string {
isWordType := strings.EqualFold(resp.Type, "Word") || strings.EqualFold(resp.Type, "Syllable")
var sb strings.Builder
first := true
for _, line := range resp.Lyrics {
lineText := line.Text
hasSyllables := len(line.Syllabus) > 0
timestamp := msToLRCTimestamp(int64(line.Time))
if isWordType && preserveWordTiming && hasSyllables {
mainSyllables := make([]lyricsPlusSyllable, 0, len(line.Syllabus))
bgSyllables := make([]lyricsPlusSyllable, 0)
for _, syl := range line.Syllabus {
if syl.IsBackground {
bgSyllables = append(bgSyllables, syl)
} else {
mainSyllables = append(mainSyllables, syl)
}
}
if len(mainSyllables) == 0 {
mainSyllables = line.Syllabus
bgSyllables = nil
}
if !first {
sb.WriteString("\n")
}
first = false
sb.WriteString(timestamp)
appendLyricsPlusSyllables(&sb, mainSyllables)
if multiPersonWordByWord && len(bgSyllables) > 0 {
sb.WriteString("\n[bg:")
appendLyricsPlusSyllables(&sb, bgSyllables)
sb.WriteString("]")
}
continue
}
// Line-synced fallback. Reconstruct text from syllables if needed.
if strings.TrimSpace(lineText) == "" && hasSyllables {
var lineBuilder strings.Builder
for _, syl := range line.Syllabus {
lineBuilder.WriteString(syl.Text)
}
lineText = lineBuilder.String()
}
lineText = strings.TrimSpace(lineText)
if lineText == "" {
continue
}
if !first {
sb.WriteString("\n")
}
first = false
sb.WriteString(timestamp)
sb.WriteString(lineText)
}
return strings.TrimSpace(sb.String())
}
// appendLyricsPlusSyllables writes each syllable as "<mm:ss.xx>text". KPOE
// already embeds spacing inside the syllable text, so no extra spaces are added.
func appendLyricsPlusSyllables(sb *strings.Builder, syllables []lyricsPlusSyllable) {
for _, syl := range syllables {
sb.WriteString("<")
sb.WriteString(msToLRCTimestampInline(int64(syl.Time)))
sb.WriteString(">")
sb.WriteString(syl.Text)
}
}
+12 -5
View File
@@ -131,14 +131,18 @@ func TestLyricsCacheParsingAndLRCLibClient(t *testing.T) {
}
func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
clearAppleMusicToken()
defer clearAppleMusicToken()
paxJSON := `{"type":"Syllable","content":[{"timestamp":1000,"oppositeTurn":true,"background":true,"text":[{"text":"Hel","part":true,"timestamp":1000},{"text":"lo","part":false,"timestamp":1200,"endtime":1500}],"backgroundText":[{"text":"bg","part":false,"timestamp":900}]}]}`
apple := &AppleMusicClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/apple-music/search"):
if req.URL.Query().Get("q") == "bad" {
return &http.Response{StatusCode: 500, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`error`)), Request: req}, nil
}
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[{"id":"apple-2","songName":"Other","artistName":"Other","duration":1000},{"id":"apple-1","songName":"Song","artistName":"Artist","albumName":"Album","duration":180000}]`)), Request: req}, nil
case req.URL.Host == "beta.music.apple.com" && (req.URL.Path == "" || req.URL.Path == "/"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`<script src="/assets/index~test.js"></script>`)), Request: req}, nil
case req.URL.Host == "beta.music.apple.com" && req.URL.Path == "/assets/index~test.js":
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`const token="eyJhbGci.test";`)), Request: req}, nil
case req.URL.Host == "amp-api.music.apple.com" && strings.Contains(req.URL.Path, "/v1/catalog/us/search"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"results":{"songs":{"data":[{"id":"apple-2"},{"id":"apple-1"}]}},"resources":{"songs":{"apple-2":{"attributes":{"name":"Other","artistName":"Other","durationInMillis":1000}},"apple-1":{"attributes":{"name":"Song","artistName":"Artist","albumName":"Album","durationInMillis":180000}}}}}`)), Request: req}, nil
case strings.Contains(req.URL.Path, "/apple-music/lyrics"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(paxJSON)), Request: req}, nil
default:
@@ -177,6 +181,9 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
if !strings.Contains(elrc, "<00:") {
t.Fatalf("elrc pax should include inline word timing: %q", elrc)
}
if preferred, err := formatPaxLyricsToLRC(`{"elrcMultiPerson":"[00:01.00]v1:<00:01.00>Hello","content":[{"timestamp":1000,"text":[{"text":"Fallback","part":false}]}]}`, true, true); err != nil || !strings.Contains(preferred, "Hello") {
t.Fatalf("preferred apple elrc = %q/%v", preferred, err)
}
if _, err := apple.SearchSong("", "", 0); err == nil {
t.Fatal("expected empty apple search error")
}
+26
View File
@@ -906,6 +906,32 @@ func ExtractLyrics(filePath string) (string, error) {
return extractLyricsFromSidecarLRC(filePath)
}
if strings.HasSuffix(lower, ".wav") {
meta, err := ReadWAVTags(filePath)
if err == nil && meta != nil {
if strings.TrimSpace(meta.Lyrics) != "" {
return meta.Lyrics, nil
}
if looksLikeEmbeddedLyrics(meta.Comment) {
return meta.Comment, nil
}
}
return extractLyricsFromSidecarLRC(filePath)
}
if strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc") {
meta, err := ReadAIFFTags(filePath)
if err == nil && meta != nil {
if strings.TrimSpace(meta.Lyrics) != "" {
return meta.Lyrics, nil
}
if looksLikeEmbeddedLyrics(meta.Comment) {
return meta.Comment, nil
}
}
return extractLyricsFromSidecarLRC(filePath)
}
return extractLyricsFromSidecarLRC(filePath)
}
+82
View File
@@ -0,0 +1,82 @@
package gobackend
import (
"crypto/tls"
"crypto/x509"
"sync"
)
const isrgRootX1PEM = `-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
-----END CERTIFICATE-----`
const isrgRootX2PEM = `-----BEGIN CERTIFICATE-----
MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQsw
CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg
R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00
MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBT
ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYw
EAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW
+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9
ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0T
AQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI
zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdW
tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1
/q4AaOeMSQ+2b1tbFfLn
-----END CERTIFICATE-----`
var (
supplementalRootCAsOnce sync.Once
supplementalRootCAsPool *x509.CertPool
)
func supplementalRootCAs() *x509.CertPool {
supplementalRootCAsOnce.Do(func() {
pool, err := x509.SystemCertPool()
if err != nil || pool == nil {
pool = x509.NewCertPool()
}
for _, pem := range []string{isrgRootX1PEM, isrgRootX2PEM} {
pool.AppendCertsFromPEM([]byte(pem))
}
supplementalRootCAsPool = pool
})
return supplementalRootCAsPool
}
func newTLSCompatibilityConfig(insecureTLS bool) *tls.Config {
return &tls.Config{
RootCAs: supplementalRootCAs(),
InsecureSkipVerify: insecureTLS,
}
}
+975
View File
@@ -0,0 +1,975 @@
package gobackend
// WAV (RIFF) and AIFF/AIFC support: quality probing, tag reading/writing, and
// cover-art extraction. These containers are not handled by go-flac, so chunks
// are parsed/written by hand here.
//
// Tags are stored as an embedded ID3v2.4 tag (UTF-8): WAV uses a lowercase
// "id3 " chunk, AIFF uses an uppercase "ID3 " chunk. ID3v2.4 is chosen because
// the existing ID3 reader (parseID3v23Frames with version=4) reads synchsafe
// frame sizes and UTF-8 text, so anything we write is read back losslessly.
//
// Reading also recognises a WAV "LIST"/"INFO" block as a fallback for files
// that carry only RIFF INFO tags (common from other taggers).
import (
"bytes"
"encoding/binary"
"fmt"
"io"
"math"
"os"
"path/filepath"
"strconv"
"strings"
)
// WAVQuality / AIFFQuality mirror the other GetXQuality result shapes.
type WAVQuality struct {
SampleRate int
BitDepth int
Channels int
Duration int
}
const (
wavMaxMetaChunk = 16 * 1024 * 1024 // safety cap for buffering a metadata chunk
id3ChunkWAV = "id3 "
id3ChunkAIFF = "ID3 "
wavFormatPCM = 0x0001
wavFormatFloat = 0x0003
wavFormatExtensn = 0xFFFE
)
// ---------- low-level chunk size helpers ----------
func putUint32(dst []byte, le bool, v uint32) {
if le {
binary.LittleEndian.PutUint32(dst, v)
} else {
binary.BigEndian.PutUint32(dst, v)
}
}
func readUint32(b []byte, le bool) uint32 {
if le {
return binary.LittleEndian.Uint32(b)
}
return binary.BigEndian.Uint32(b)
}
func synchsafeEncode(n int) []byte {
return []byte{
byte((n >> 21) & 0x7f),
byte((n >> 14) & 0x7f),
byte((n >> 7) & 0x7f),
byte(n & 0x7f),
}
}
func synchsafeDecode(b []byte) int {
if len(b) < 4 {
return 0
}
return int(b[0])<<21 | int(b[1])<<14 | int(b[2])<<7 | int(b[3])
}
// parseExtendedFloat80 decodes an 80-bit IEEE 754 extended float (used by the
// AIFF COMM chunk for the sample rate).
func parseExtendedFloat80(b []byte) float64 {
if len(b) < 10 {
return 0
}
sign := 1.0
if b[0]&0x80 != 0 {
sign = -1.0
}
exponent := int(b[0]&0x7f)<<8 | int(b[1])
var mantissa uint64
for i := 2; i < 10; i++ {
mantissa = mantissa<<8 | uint64(b[i])
}
if exponent == 0 && mantissa == 0 {
return 0
}
return sign * float64(mantissa) * math.Pow(2, float64(exponent-16383-63))
}
// ---------- WAV (RIFF) ----------
type wavProbe struct {
sampleRate int
bitDepth int
channels int
byteRate int
dataSize int64
id3 []byte
info map[string]string
}
// streamProbeWAV walks the top-level RIFF chunks, buffering only the small
// metadata chunks (fmt/id3/LIST) and skipping the large data chunk.
func streamProbeWAV(f *os.File) (*wavProbe, error) {
header := make([]byte, 12)
if _, err := io.ReadFull(f, header); err != nil {
return nil, err
}
if string(header[0:4]) != "RIFF" || string(header[8:12]) != "WAVE" {
return nil, fmt.Errorf("not a WAVE file")
}
p := &wavProbe{info: map[string]string{}}
hdr := make([]byte, 8)
for {
if _, err := io.ReadFull(f, hdr); err != nil {
break
}
id := string(hdr[0:4])
size := readUint32(hdr[4:8], true)
pad := int64(size) & 1
switch id {
case "fmt ":
buf := make([]byte, size)
if _, err := io.ReadFull(f, buf); err != nil {
return p, nil
}
if len(buf) >= 16 {
format := binary.LittleEndian.Uint16(buf[0:2])
p.channels = int(binary.LittleEndian.Uint16(buf[2:4]))
p.sampleRate = int(binary.LittleEndian.Uint32(buf[4:8]))
p.byteRate = int(binary.LittleEndian.Uint32(buf[8:12]))
p.bitDepth = int(binary.LittleEndian.Uint16(buf[14:16]))
if format == wavFormatExtensn && len(buf) >= 26 {
// Valid bits per sample lives in the extension; the real
// PCM format tag is in the GUID, but bitDepth from the
// container field is sufficient for display.
if vb := int(binary.LittleEndian.Uint16(buf[18:20])); vb > 0 {
p.bitDepth = vb
}
}
}
if pad == 1 {
f.Seek(pad, io.SeekCurrent)
}
case "data":
p.dataSize = int64(size)
f.Seek(int64(size)+pad, io.SeekCurrent)
case id3ChunkWAV, "ID3 ":
if size > 0 && size <= wavMaxMetaChunk {
buf := make([]byte, size)
if _, err := io.ReadFull(f, buf); err == nil {
p.id3 = buf
}
if pad == 1 {
f.Seek(pad, io.SeekCurrent)
}
} else {
f.Seek(int64(size)+pad, io.SeekCurrent)
}
case "LIST":
if size > 0 && size <= wavMaxMetaChunk {
buf := make([]byte, size)
if _, err := io.ReadFull(f, buf); err == nil {
parseRIFFInfo(buf, p.info)
}
if pad == 1 {
f.Seek(pad, io.SeekCurrent)
}
} else {
f.Seek(int64(size)+pad, io.SeekCurrent)
}
default:
f.Seek(int64(size)+pad, io.SeekCurrent)
}
}
return p, nil
}
// parseRIFFInfo reads a LIST/INFO block ("INFO" + sub-chunks like INAM, IART).
func parseRIFFInfo(buf []byte, out map[string]string) {
if len(buf) < 4 || string(buf[0:4]) != "INFO" {
return
}
pos := 4
for pos+8 <= len(buf) {
id := string(buf[pos : pos+4])
size := int(binary.LittleEndian.Uint32(buf[pos+4 : pos+8]))
pos += 8
if size <= 0 || pos+size > len(buf) {
break
}
val := strings.TrimRight(string(buf[pos:pos+size]), "\x00")
out[id] = strings.TrimSpace(val)
pos += size
if size&1 == 1 {
pos++
}
}
}
func wavMetadataFromProbe(p *wavProbe) *AudioMetadata {
if p == nil {
return nil
}
if len(p.id3) > 0 {
if meta, err := readID3v2FromBytes(p.id3); err == nil && meta != nil &&
(meta.Title != "" || meta.Artist != "" || meta.Album != "") {
return meta
}
}
if len(p.info) > 0 {
meta := &AudioMetadata{
Title: p.info["INAM"],
Artist: p.info["IART"],
Album: p.info["IPRD"],
Genre: cleanGenre(p.info["IGNR"]),
Date: p.info["ICRD"],
Comment: p.info["ICMT"],
Copyright: p.info["ICOP"],
Composer: p.info["IMUS"],
}
if n, err := strconv.Atoi(strings.TrimSpace(p.info["ITRK"])); err == nil {
meta.TrackNumber = n
}
if meta.Date != "" && len(meta.Date) >= 4 {
meta.Year = meta.Date[:4]
}
if meta.Title != "" || meta.Artist != "" || meta.Album != "" {
return meta
}
}
return nil
}
// GetWAVQuality probes PCM parameters and computes duration from the data size.
func GetWAVQuality(filePath string) (*WAVQuality, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
p, err := streamProbeWAV(f)
if err != nil {
return nil, err
}
q := &WAVQuality{
SampleRate: p.sampleRate,
BitDepth: p.bitDepth,
Channels: p.channels,
}
if p.byteRate > 0 && p.dataSize > 0 {
q.Duration = int(p.dataSize / int64(p.byteRate))
} else if p.sampleRate > 0 && p.channels > 0 && p.bitDepth > 0 && p.dataSize > 0 {
bytesPerSec := int64(p.sampleRate * p.channels * p.bitDepth / 8)
if bytesPerSec > 0 {
q.Duration = int(p.dataSize / bytesPerSec)
}
}
return q, nil
}
// ReadWAVTags reads tags from a WAV file (ID3 chunk preferred, RIFF INFO fallback).
func ReadWAVTags(filePath string) (*AudioMetadata, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
p, err := streamProbeWAV(f)
if err != nil {
return nil, err
}
meta := wavMetadataFromProbe(p)
if meta == nil {
return nil, fmt.Errorf("no WAV tags found")
}
return meta, nil
}
// ---------- AIFF / AIFC ----------
type aiffProbe struct {
sampleRate int
bitDepth int
channels int
numFrames int64
id3 []byte
nameChunk string
authChunk string
annoChunk string
copyrightChunk string
}
func streamProbeAIFF(f *os.File) (*aiffProbe, error) {
header := make([]byte, 12)
if _, err := io.ReadFull(f, header); err != nil {
return nil, err
}
form := string(header[8:12])
if string(header[0:4]) != "FORM" || (form != "AIFF" && form != "AIFC") {
return nil, fmt.Errorf("not an AIFF file")
}
p := &aiffProbe{}
hdr := make([]byte, 8)
for {
if _, err := io.ReadFull(f, hdr); err != nil {
break
}
id := string(hdr[0:4])
size := readUint32(hdr[4:8], false)
pad := int64(size) & 1
switch id {
case "COMM":
buf := make([]byte, size)
if _, err := io.ReadFull(f, buf); err != nil {
return p, nil
}
if len(buf) >= 18 {
p.channels = int(binary.BigEndian.Uint16(buf[0:2]))
p.numFrames = int64(binary.BigEndian.Uint32(buf[2:6]))
p.bitDepth = int(binary.BigEndian.Uint16(buf[6:8]))
p.sampleRate = int(parseExtendedFloat80(buf[8:18]) + 0.5)
}
if pad == 1 {
f.Seek(pad, io.SeekCurrent)
}
case id3ChunkAIFF, "id3 ":
if size > 0 && size <= wavMaxMetaChunk {
buf := make([]byte, size)
if _, err := io.ReadFull(f, buf); err == nil {
p.id3 = buf
}
if pad == 1 {
f.Seek(pad, io.SeekCurrent)
}
} else {
f.Seek(int64(size)+pad, io.SeekCurrent)
}
case "NAME", "AUTH", "ANNO", "(c) ":
if size > 0 && size <= wavMaxMetaChunk {
buf := make([]byte, size)
if _, err := io.ReadFull(f, buf); err == nil {
val := strings.TrimRight(strings.TrimSpace(string(buf)), "\x00")
switch id {
case "NAME":
p.nameChunk = val
case "AUTH":
p.authChunk = val
case "ANNO":
p.annoChunk = val
case "(c) ":
p.copyrightChunk = val
}
}
if pad == 1 {
f.Seek(pad, io.SeekCurrent)
}
} else {
f.Seek(int64(size)+pad, io.SeekCurrent)
}
default:
f.Seek(int64(size)+pad, io.SeekCurrent)
}
}
return p, nil
}
func aiffMetadataFromProbe(p *aiffProbe) *AudioMetadata {
if p == nil {
return nil
}
if len(p.id3) > 0 {
if meta, err := readID3v2FromBytes(p.id3); err == nil && meta != nil &&
(meta.Title != "" || meta.Artist != "" || meta.Album != "") {
return meta
}
}
if p.nameChunk != "" || p.authChunk != "" {
meta := &AudioMetadata{
Title: p.nameChunk,
Artist: p.authChunk,
Comment: p.annoChunk,
Copyright: p.copyrightChunk,
}
return meta
}
return nil
}
// GetAIFFQuality probes PCM parameters and computes duration from frame count.
func GetAIFFQuality(filePath string) (*WAVQuality, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
p, err := streamProbeAIFF(f)
if err != nil {
return nil, err
}
q := &WAVQuality{
SampleRate: p.sampleRate,
BitDepth: p.bitDepth,
Channels: p.channels,
}
if p.sampleRate > 0 && p.numFrames > 0 {
q.Duration = int(p.numFrames / int64(p.sampleRate))
}
return q, nil
}
// ReadAIFFTags reads tags from an AIFF file (ID3 chunk preferred, AIFF text chunks fallback).
func ReadAIFFTags(filePath string) (*AudioMetadata, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
p, err := streamProbeAIFF(f)
if err != nil {
return nil, err
}
meta := aiffMetadataFromProbe(p)
if meta == nil {
return nil, fmt.Errorf("no AIFF tags found")
}
return meta, nil
}
// ---------- ID3v2 reading from a buffered chunk ----------
// readID3v2FromBytes parses an in-memory ID3v2 tag (the contents of a WAV "id3 "
// or AIFF "ID3 " chunk) by reusing the existing frame parsers.
func readID3v2FromBytes(data []byte) (*AudioMetadata, error) {
if len(data) < 10 || string(data[0:3]) != "ID3" {
return nil, fmt.Errorf("no ID3v2 header")
}
majorVersion := data[3]
flags := data[5]
unsync := (flags & 0x80) != 0
extendedHeader := (flags & 0x40) != 0
footerPresent := (flags & 0x10) != 0
size := synchsafeDecode(data[6:10])
if size <= 0 || 10+size > len(data) {
size = len(data) - 10
}
tagData := data[10 : 10+size]
if footerPresent && len(tagData) >= 10 {
footerStart := len(tagData) - 10
if footerStart >= 0 && string(tagData[footerStart:footerStart+3]) == "3DI" {
tagData = tagData[:footerStart]
}
}
if extendedHeader {
if skip := extendedHeaderSize(tagData, majorVersion); skip > 0 && skip < len(tagData) {
tagData = tagData[skip:]
}
}
metadata := &AudioMetadata{}
if majorVersion == 2 {
parseID3v22Frames(tagData, metadata, unsync)
} else {
parseID3v23Frames(tagData, metadata, majorVersion, unsync)
}
return metadata, nil
}
// extractAPICFromID3 returns the first embedded picture (APIC/PIC) and its MIME.
func extractAPICFromID3(tag []byte) ([]byte, string) {
if len(tag) < 10 || string(tag[0:3]) != "ID3" {
return nil, ""
}
ver := tag[3]
size := synchsafeDecode(tag[6:10])
if size <= 0 || 10+size > len(tag) {
size = len(tag) - 10
}
data := tag[10 : 10+size]
pos := 0
for {
if ver == 2 {
if pos+6 > len(data) || data[pos] == 0 {
break
}
id := string(data[pos : pos+3])
fsz := int(data[pos+3])<<16 | int(data[pos+4])<<8 | int(data[pos+5])
if fsz <= 0 || pos+6+fsz > len(data) {
break
}
if id == "PIC" {
return parseAPICFrame(data[pos+6:pos+6+fsz], ver)
}
pos += 6 + fsz
continue
}
if pos+10 > len(data) || data[pos] == 0 {
break
}
id := string(data[pos : pos+4])
var fsz int
if ver == 4 {
fsz = synchsafeDecode(data[pos+4 : pos+8])
} else {
fsz = int(binary.BigEndian.Uint32(data[pos+4 : pos+8]))
}
if fsz <= 0 || pos+10+fsz > len(data) {
break
}
if id == "APIC" {
return parseAPICFrame(data[pos+10:pos+10+fsz], ver)
}
pos += 10 + fsz
}
return nil, ""
}
// ---------- ID3v2.4 building ----------
// buildID3v24Tag builds a UTF-8 ID3v2.4 tag from metadata plus optional cover.
func buildID3v24Tag(meta *AudioMetadata, coverData []byte, coverMIME string) []byte {
var frames bytes.Buffer
writeFrame := func(id string, payload []byte) {
frames.WriteString(id)
frames.Write(synchsafeEncode(len(payload)))
frames.Write([]byte{0, 0})
frames.Write(payload)
}
writeText := func(id, val string) {
if strings.TrimSpace(val) == "" {
return
}
payload := append([]byte{0x03}, []byte(val)...)
writeFrame(id, payload)
}
writeText("TIT2", meta.Title)
writeText("TPE1", meta.Artist)
writeText("TALB", meta.Album)
writeText("TPE2", meta.AlbumArtist)
writeText("TCON", meta.Genre)
writeText("TCOM", meta.Composer)
writeText("TPUB", meta.Label)
writeText("TCOP", meta.Copyright)
writeText("TSRC", meta.ISRC)
date := meta.Date
if date == "" {
date = meta.Year
}
writeText("TDRC", date)
if meta.TrackNumber > 0 {
if meta.TotalTracks > 0 {
writeText("TRCK", fmt.Sprintf("%d/%d", meta.TrackNumber, meta.TotalTracks))
} else {
writeText("TRCK", strconv.Itoa(meta.TrackNumber))
}
}
if meta.DiscNumber > 0 {
if meta.TotalDiscs > 0 {
writeText("TPOS", fmt.Sprintf("%d/%d", meta.DiscNumber, meta.TotalDiscs))
} else {
writeText("TPOS", strconv.Itoa(meta.DiscNumber))
}
}
if strings.TrimSpace(meta.Comment) != "" {
// COMM: encoding + language(3) + short desc(null) + text
payload := []byte{0x03}
payload = append(payload, []byte("eng")...)
payload = append(payload, 0x00) // empty description
payload = append(payload, []byte(meta.Comment)...)
writeFrame("COMM", payload)
}
if strings.TrimSpace(meta.Lyrics) != "" {
payload := []byte{0x03}
payload = append(payload, []byte("eng")...)
payload = append(payload, 0x00)
payload = append(payload, []byte(meta.Lyrics)...)
writeFrame("USLT", payload)
}
// ReplayGain as TXXX (description\0value), UTF-8.
writeTXXX := func(desc, val string) {
if strings.TrimSpace(val) == "" {
return
}
payload := []byte{0x03}
payload = append(payload, []byte(desc)...)
payload = append(payload, 0x00)
payload = append(payload, []byte(val)...)
writeFrame("TXXX", payload)
}
writeTXXX("REPLAYGAIN_TRACK_GAIN", meta.ReplayGainTrackGain)
writeTXXX("REPLAYGAIN_TRACK_PEAK", meta.ReplayGainTrackPeak)
writeTXXX("REPLAYGAIN_ALBUM_GAIN", meta.ReplayGainAlbumGain)
writeTXXX("REPLAYGAIN_ALBUM_PEAK", meta.ReplayGainAlbumPeak)
if len(coverData) > 0 {
if strings.TrimSpace(coverMIME) == "" {
coverMIME = "image/jpeg"
}
// APIC: encoding + mime(null) + picture-type(0x03 front) + desc(null) + data
payload := []byte{0x03}
payload = append(payload, []byte(coverMIME)...)
payload = append(payload, 0x00)
payload = append(payload, 0x03)
payload = append(payload, 0x00)
payload = append(payload, coverData...)
writeFrame("APIC", payload)
}
body := frames.Bytes()
var out bytes.Buffer
out.WriteString("ID3")
out.Write([]byte{0x04, 0x00}) // v2.4.0
out.WriteByte(0x00) // flags
out.Write(synchsafeEncode(len(body)))
out.Write(body)
return out.Bytes()
}
// ---------- tag writing (streaming chunk rewrite) ----------
// writeID3Chunk rewrites filePath, replacing any existing tag chunk (chunkID,
// matched case-insensitively) with a fresh ID3v2.4 chunk appended at the end.
// The audio data and all other chunks are preserved; container size is patched.
func writeID3Chunk(filePath, expectMagic, chunkID string, le bool, id3 []byte) error {
in, err := os.Open(filePath)
if err != nil {
return err
}
defer in.Close()
header := make([]byte, 12)
if _, err := io.ReadFull(in, header); err != nil {
return err
}
if string(header[0:4]) != expectMagic {
return fmt.Errorf("unexpected container magic %q", string(header[0:4]))
}
tmpPath := filePath + ".tagtmp"
out, err := os.Create(tmpPath)
if err != nil {
return err
}
cleanup := func() {
out.Close()
os.Remove(tmpPath)
}
if _, err := out.Write(header); err != nil {
cleanup()
return err
}
var bodyLen int64 = 4 // the 4-byte form type after the size field
hdr := make([]byte, 8)
for {
n, rerr := io.ReadFull(in, hdr)
if n < 8 {
break
}
if rerr != nil {
break
}
id := string(hdr[0:4])
size := readUint32(hdr[4:8], le)
pad := int64(size) & 1
if strings.EqualFold(id, chunkID) {
// Drop the existing tag chunk.
if _, err := in.Seek(int64(size)+pad, io.SeekCurrent); err != nil {
cleanup()
return err
}
continue
}
if _, err := out.Write(hdr); err != nil {
cleanup()
return err
}
if _, err := io.CopyN(out, in, int64(size)+pad); err != nil {
cleanup()
return err
}
bodyLen += 8 + int64(size) + pad
}
// Append the new tag chunk.
newSize := len(id3)
chunkHdr := make([]byte, 8)
copy(chunkHdr[0:4], chunkID)
putUint32(chunkHdr[4:8], le, uint32(newSize))
if _, err := out.Write(chunkHdr); err != nil {
cleanup()
return err
}
if _, err := out.Write(id3); err != nil {
cleanup()
return err
}
if newSize&1 == 1 {
if _, err := out.Write([]byte{0}); err != nil {
cleanup()
return err
}
}
bodyLen += 8 + int64(newSize) + int64(newSize&1)
// Patch the container size field (bytes 4..8).
sizeBuf := make([]byte, 4)
putUint32(sizeBuf, le, uint32(bodyLen))
if _, err := out.WriteAt(sizeBuf, 4); err != nil {
cleanup()
return err
}
if err := out.Close(); err != nil {
os.Remove(tmpPath)
return err
}
in.Close()
return os.Rename(tmpPath, filePath)
}
func loadCoverForTag(fields map[string]string) ([]byte, string) {
coverPath := strings.TrimSpace(fields["cover_path"])
if coverPath == "" {
return nil, ""
}
data, err := os.ReadFile(coverPath)
if err != nil || len(data) == 0 {
return nil, ""
}
mime := "image/jpeg"
if len(data) >= 8 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 {
mime = "image/png"
}
return data, mime
}
func audioMetadataFromEditFields(fields map[string]string) *AudioMetadata {
atoi := func(k string) int {
n := 0
if v, ok := fields[k]; ok && strings.TrimSpace(v) != "" {
fmt.Sscanf(strings.TrimSpace(v), "%d", &n)
}
return n
}
return &AudioMetadata{
Title: fields["title"],
Artist: fields["artist"],
Album: fields["album"],
AlbumArtist: fields["album_artist"],
Date: fields["date"],
TrackNumber: atoi("track_number"),
TotalTracks: atoi("track_total"),
DiscNumber: atoi("disc_number"),
TotalDiscs: atoi("disc_total"),
ISRC: fields["isrc"],
Lyrics: fields["lyrics"],
Genre: fields["genre"],
Label: fields["label"],
Copyright: fields["copyright"],
Composer: fields["composer"],
Comment: fields["comment"],
ReplayGainTrackGain: fields["replaygain_track_gain"],
ReplayGainTrackPeak: fields["replaygain_track_peak"],
ReplayGainAlbumGain: fields["replaygain_album_gain"],
ReplayGainAlbumPeak: fields["replaygain_album_peak"],
}
}
// mergeWAVEditFields merges edit fields onto existing tags so untouched fields
// (and cover art, when no new cover is provided) are preserved.
func mergeEditFieldsOntoExisting(existing *AudioMetadata, fields map[string]string) *AudioMetadata {
meta := audioMetadataFromEditFields(fields)
if existing == nil {
return meta
}
// Only overwrite fields that are present as keys in the edit set; otherwise
// keep the existing value. An empty value with the key present clears it.
keep := func(key, newVal, oldVal string) string {
if _, ok := fields[key]; ok {
return newVal
}
return oldVal
}
meta.Title = keep("title", meta.Title, existing.Title)
meta.Artist = keep("artist", meta.Artist, existing.Artist)
meta.Album = keep("album", meta.Album, existing.Album)
meta.AlbumArtist = keep("album_artist", meta.AlbumArtist, existing.AlbumArtist)
meta.Genre = keep("genre", meta.Genre, existing.Genre)
meta.Composer = keep("composer", meta.Composer, existing.Composer)
meta.Label = keep("label", meta.Label, existing.Label)
meta.Copyright = keep("copyright", meta.Copyright, existing.Copyright)
meta.ISRC = keep("isrc", meta.ISRC, existing.ISRC)
meta.Lyrics = keep("lyrics", meta.Lyrics, existing.Lyrics)
meta.Comment = keep("comment", meta.Comment, existing.Comment)
meta.Date = keep("date", meta.Date, existing.Date)
if _, ok := fields["track_number"]; !ok {
meta.TrackNumber = existing.TrackNumber
}
if _, ok := fields["track_total"]; !ok {
meta.TotalTracks = existing.TotalTracks
}
if _, ok := fields["disc_number"]; !ok {
meta.DiscNumber = existing.DiscNumber
}
if _, ok := fields["disc_total"]; !ok {
meta.TotalDiscs = existing.TotalDiscs
}
if _, ok := fields["replaygain_track_gain"]; !ok {
meta.ReplayGainTrackGain = existing.ReplayGainTrackGain
}
if _, ok := fields["replaygain_track_peak"]; !ok {
meta.ReplayGainTrackPeak = existing.ReplayGainTrackPeak
}
if _, ok := fields["replaygain_album_gain"]; !ok {
meta.ReplayGainAlbumGain = existing.ReplayGainAlbumGain
}
if _, ok := fields["replaygain_album_peak"]; !ok {
meta.ReplayGainAlbumPeak = existing.ReplayGainAlbumPeak
}
return meta
}
// WriteWAVTags writes/merges tags into a WAV file's "id3 " chunk.
func WriteWAVTags(filePath string, fields map[string]string) error {
existing, _ := ReadWAVTags(filePath)
meta := mergeEditFieldsOntoExisting(existing, fields)
coverData, coverMIME := loadCoverForTag(fields)
if coverData == nil {
// Preserve an existing embedded cover when no new one is supplied.
if f, err := os.Open(filePath); err == nil {
if p, perr := streamProbeWAV(f); perr == nil && len(p.id3) > 0 {
coverData, coverMIME = extractAPICFromID3(p.id3)
}
f.Close()
}
}
tag := buildID3v24Tag(meta, coverData, coverMIME)
return writeID3Chunk(filePath, "RIFF", id3ChunkWAV, true, tag)
}
// WriteAIFFTags writes/merges tags into an AIFF file's "ID3 " chunk.
func WriteAIFFTags(filePath string, fields map[string]string) error {
existing, _ := ReadAIFFTags(filePath)
meta := mergeEditFieldsOntoExisting(existing, fields)
coverData, coverMIME := loadCoverForTag(fields)
if coverData == nil {
if f, err := os.Open(filePath); err == nil {
if p, perr := streamProbeAIFF(f); perr == nil && len(p.id3) > 0 {
coverData, coverMIME = extractAPICFromID3(p.id3)
}
f.Close()
}
}
tag := buildID3v24Tag(meta, coverData, coverMIME)
return writeID3Chunk(filePath, "FORM", id3ChunkAIFF, false, tag)
}
// ---------- library scan integration ----------
func scanWAVFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
if metadata, err := ReadWAVTags(filePath); err == nil && metadata != nil {
applyAudioMetadataToScan(metadata, result)
}
if quality, err := GetWAVQuality(filePath); err == nil && quality != nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
result.Duration = quality.Duration
}
result.Bitrate = 0 // lossless PCM
result.Format = "wav"
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil
}
func scanAIFFFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
if metadata, err := ReadAIFFTags(filePath); err == nil && metadata != nil {
applyAudioMetadataToScan(metadata, result)
}
if quality, err := GetAIFFQuality(filePath); err == nil && quality != nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
result.Duration = quality.Duration
}
result.Bitrate = 0 // lossless PCM
result.Format = "aiff"
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil
}
func applyAudioMetadataToScan(metadata *AudioMetadata, result *LibraryScanResult) {
result.TrackName = metadata.Title
result.ArtistName = metadata.Artist
result.AlbumName = metadata.Album
result.AlbumArtist = metadata.AlbumArtist
result.ISRC = metadata.ISRC
result.TrackNumber = metadata.TrackNumber
result.TotalTracks = metadata.TotalTracks
result.DiscNumber = metadata.DiscNumber
result.TotalDiscs = metadata.TotalDiscs
if metadata.Date != "" {
result.ReleaseDate = metadata.Date
} else {
result.ReleaseDate = metadata.Year
}
result.Genre = metadata.Genre
result.Composer = metadata.Composer
result.Label = metadata.Label
result.Copyright = metadata.Copyright
}
// extractWAVAIFFCover returns embedded cover art (from the ID3 chunk) for a
// WAV or AIFF file, or an error when none is present.
func extractWAVAIFFCover(filePath string) ([]byte, string, error) {
ext := strings.ToLower(filepath.Ext(filePath))
f, err := os.Open(filePath)
if err != nil {
return nil, "", err
}
defer f.Close()
var id3 []byte
switch ext {
case ".aiff", ".aif", ".aifc":
if p, perr := streamProbeAIFF(f); perr == nil {
id3 = p.id3
}
default:
if p, perr := streamProbeWAV(f); perr == nil {
id3 = p.id3
}
}
if len(id3) == 0 {
return nil, "", fmt.Errorf("no embedded cover")
}
data, mime := extractAPICFromID3(id3)
if len(data) == 0 {
return nil, "", fmt.Errorf("no embedded cover")
}
return data, mime, nil
}
+1 -1
View File
@@ -21,6 +21,6 @@
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
<string>14.0</string>
</dict>
</plist>
+3 -3
View File
@@ -346,7 +346,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -472,7 +472,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -523,7 +523,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
+47
View File
@@ -20,6 +20,11 @@ import Gobackend // Import Go framework
/// Currently accessed security-scoped URL for library folder
private var activeSecurityScopedURL: URL?
/// Whether a download queue is active; while true a background task is
/// started on each background entry to extend execution time. Main-thread only.
private var downloadsActive = false
private var downloadBackgroundTask: UIBackgroundTaskIdentifier = .invalid
override func application(
_ application: UIApplication,
@@ -233,6 +238,20 @@ import Gobackend // Import Go framework
}
private func handleMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "beginBackgroundDownloadTask":
downloadsActive = true
result(nil)
return
case "endBackgroundDownloadTask":
downloadsActive = false
endBackgroundDownloadTask()
result(nil)
return
default:
break
}
DispatchQueue.global(qos: .userInitiated).async {
do {
let response = try self.invokeGoMethod(call: call)
@@ -246,6 +265,34 @@ import Gobackend // Import Go framework
}
}
}
override func applicationDidEnterBackground(_ application: UIApplication) {
super.applicationDidEnterBackground(application)
if downloadsActive {
beginBackgroundDownloadTask()
}
}
override func applicationWillEnterForeground(_ application: UIApplication) {
super.applicationWillEnterForeground(application)
endBackgroundDownloadTask()
}
private func beginBackgroundDownloadTask() {
if downloadBackgroundTask != .invalid { return }
downloadBackgroundTask = UIApplication.shared.beginBackgroundTask(
withName: "SpotiFLACDownloads"
) { [weak self] in
self?.endBackgroundDownloadTask()
}
}
private func endBackgroundDownloadTask() {
if downloadBackgroundTask != .invalid {
UIApplication.shared.endBackgroundTask(downloadBackgroundTask)
downloadBackgroundTask = .invalid
}
}
private func invokeGoMethod(call: FlutterMethodCall) throws -> Any? {
var error: NSError?
+9
View File
@@ -114,6 +114,15 @@ class SpotiFLACApp extends ConsumerWidget {
scrollBehavior: scrollBehavior,
themeAnimationDuration: const Duration(milliseconds: 300),
themeAnimationCurve: Curves.easeInOut,
// Treat the display as one continuous surface so bottom sheets and
// dialogs stay centered on large/foldable devices.
builder: (context, child) {
final mediaQuery = MediaQuery.of(context);
return MediaQuery(
data: mediaQuery.copyWith(displayFeatures: const []),
child: child ?? const SizedBox.shrink(),
);
},
routerConfig: router,
locale: locale,
localeResolutionCallback: (deviceLocale, supportedLocales) {
+2 -2
View File
@@ -1,8 +1,8 @@
import 'package:flutter/foundation.dart';
class AppInfo {
static const String version = '4.5.5';
static const String buildNumber = '132';
static const String version = '4.6.0';
static const String buildNumber = '135';
static const String fullVersion = '$version+$buildNumber';
static String get displayVersion => kDebugMode ? 'Internal' : version;
+193 -56
View File
@@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:intl/intl.dart' as intl;
import 'app_localizations_ar.dart';
import 'app_localizations_de.dart';
import 'app_localizations_en.dart';
import 'app_localizations_es.dart';
@@ -106,6 +107,7 @@ abstract class AppLocalizations {
/// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[
Locale('ar'),
Locale('de'),
Locale('en'),
Locale('es'),
@@ -378,10 +380,10 @@ abstract class AppLocalizations {
/// **'Choose which tab opens first for new search results.'**
String get optionsDefaultSearchTabSubtitle;
/// Hint to switch back to built-in providers
/// Hint to switch back from extension search
///
/// In en, this message translates to:
/// **'Tap Deezer or Spotify to switch back from extension'**
/// **'Choose the default search provider to switch back from an extension'**
String get optionsSwitchBack;
/// Auto-retry with other services
@@ -396,7 +398,7 @@ abstract class AppLocalizations {
/// **'Try other services if download fails'**
String get optionsAutoFallbackSubtitle;
/// Enable extension download providers
/// Legacy setting label for extension download providers
///
/// In en, this message translates to:
/// **'Use Extension Providers'**
@@ -405,13 +407,13 @@ abstract class AppLocalizations {
/// Status when extension providers enabled
///
/// In en, this message translates to:
/// **'Extensions will be tried first'**
/// **'Extension providers are enabled'**
String get optionsUseExtensionProvidersOn;
/// Status when extension providers disabled
/// Legacy status when extension providers would be disabled
///
/// In en, this message translates to:
/// **'Using built-in providers only'**
/// **'Extension providers are required'**
String get optionsUseExtensionProvidersOff;
/// Embed lyrics in audio files
@@ -456,6 +458,66 @@ abstract class AppLocalizations {
/// **'Disabled: no loudness normalization tags'**
String get optionsReplayGainSubtitleOff;
/// Three-dot menu option to scan loudness and write ReplayGain tags
///
/// In en, this message translates to:
/// **'Rescan ReplayGain'**
String get trackReplayGain;
/// Subtitle for the rescan ReplayGain menu option
///
/// In en, this message translates to:
/// **'Analyze loudness and write ReplayGain tags'**
String get trackReplayGainSubtitle;
/// Snackbar/progress message while scanning ReplayGain for a single track
///
/// In en, this message translates to:
/// **'Analyzing loudness...'**
String get trackReplayGainScanning;
/// Snackbar message after ReplayGain tags written for a single track
///
/// In en, this message translates to:
/// **'ReplayGain tags added'**
String get trackReplayGainSuccess;
/// Snackbar message when ReplayGain scan/write fails
///
/// In en, this message translates to:
/// **'Failed to add ReplayGain tags'**
String get trackReplayGainFailed;
/// Batch selection action button label for ReplayGain
///
/// In en, this message translates to:
/// **'ReplayGain ({count})'**
String selectionReplayGainCount(int count);
/// Title of the batch ReplayGain confirmation dialog
///
/// In en, this message translates to:
/// **'Add ReplayGain'**
String get replayGainBatchConfirmTitle;
/// Message of the batch ReplayGain confirmation dialog
///
/// In en, this message translates to:
/// **'Analyze loudness and write ReplayGain tags to {count} track(s)?'**
String replayGainBatchConfirmMessage(int count);
/// Progress dialog title while batch scanning ReplayGain
///
/// In en, this message translates to:
/// **'Analyzing ReplayGain...'**
String get replayGainBatchAnalyzing;
/// Snackbar after batch ReplayGain completes
///
/// In en, this message translates to:
/// **'ReplayGain added to {success} of {total} tracks'**
String replayGainBatchSuccess(int success, int total);
/// Setting title for how artist metadata is written into files
///
/// In en, this message translates to:
@@ -492,30 +554,6 @@ abstract class AppLocalizations {
/// **'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.'**
String get optionsArtistTagModeSplitVorbisSubtitle;
/// Number of parallel downloads
///
/// In en, this message translates to:
/// **'Concurrent Downloads'**
String get optionsConcurrentDownloads;
/// Download one at a time
///
/// In en, this message translates to:
/// **'Sequential (1 at a time)'**
String get optionsConcurrentSequential;
/// Multiple parallel downloads
///
/// In en, this message translates to:
/// **'{count} parallel downloads'**
String optionsConcurrentParallel(int count);
/// Warning about rate limits
///
/// In en, this message translates to:
/// **'Parallel downloads may trigger rate limiting'**
String get optionsConcurrentWarning;
/// Show/hide store tab
///
/// In en, this message translates to:
@@ -819,13 +857,13 @@ abstract class AppLocalizations {
/// Credit description for binimum
///
/// In en, this message translates to:
/// **'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!'**
/// **'The creator of QQDL & HiFi API. This project helped shape lossless download support.'**
String get aboutBinimumDesc;
/// Credit description for sachinsenal0x64
///
/// In en, this message translates to:
/// **'The original HiFi project creator. The foundation of Tidal integration!'**
/// **'The original HiFi project creator. A foundation for lossless-source integration.'**
String get aboutSachinsenalDesc;
/// Credit description for sjdonado
@@ -1404,6 +1442,12 @@ abstract class AppLocalizations {
/// **'No tracks found'**
String get errorNoTracksFound;
/// Subtitle shown under the empty search result state on the home screen
///
/// In en, this message translates to:
/// **'Try another keyword'**
String get searchEmptyResultSubtitle;
/// Error title - URL not handled by any extension or service
///
/// In en, this message translates to:
@@ -1782,10 +1826,10 @@ abstract class AppLocalizations {
/// **'Only enabled extensions with download-provider capability are listed here.'**
String get providerPriorityFallbackExtensionsHint;
/// Label for built-in providers (Tidal/Qobuz)
/// Legacy label retained for old generated localization compatibility
///
/// In en, this message translates to:
/// **'Built-in'**
/// **'Legacy'**
String get providerBuiltIn;
/// Label for extension-provided providers
@@ -2511,13 +2555,13 @@ abstract class AppLocalizations {
/// Default search provider option
///
/// In en, this message translates to:
/// **'Default (Deezer)'**
/// **'Default Search'**
String get extensionDefaultProvider;
/// Subtitle for default provider
///
/// In en, this message translates to:
/// **'Use built-in search'**
/// **'Use the default metadata search'**
String get extensionDefaultProviderSubtitle;
/// Extension detail - author
@@ -2808,73 +2852,73 @@ abstract class AppLocalizations {
/// **'24-bit / up to 192kHz'**
String get qualityHiResFlacMaxSubtitle;
/// Quality option label for Tidal lossy 320kbps
/// Quality option label for lossy 320kbps
///
/// In en, this message translates to:
/// **'Lossy 320kbps'**
String get downloadLossy320;
/// Setting title to pick output format for Tidal lossy downloads
/// Setting title to pick output format for lossy downloads
///
/// In en, this message translates to:
/// **'Lossy Format'**
String get downloadLossyFormat;
/// Title of the Tidal lossy format picker bottom sheet
/// Title of the lossy format picker bottom sheet
///
/// In en, this message translates to:
/// **'Lossy 320kbps Format'**
String get downloadLossy320Format;
/// Description in the Tidal lossy format picker
/// Description in the lossy format picker
///
/// In en, this message translates to:
/// **'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'**
/// **'Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.'**
String get downloadLossy320FormatDesc;
/// Tidal lossy format option - MP3 320kbps
/// Lossy format option - MP3 320kbps
///
/// In en, this message translates to:
/// **'MP3 320kbps'**
String get downloadLossyMp3;
/// Subtitle for MP3 320kbps Tidal lossy option
/// Subtitle for MP3 320kbps lossy option
///
/// In en, this message translates to:
/// **'Best compatibility, ~10MB per track'**
String get downloadLossyMp3Subtitle;
/// Tidal lossy format option - AAC in M4A container at 320kbps
/// Lossy format option - AAC in M4A container at 320kbps
///
/// In en, this message translates to:
/// **'AAC/M4A 320kbps'**
String get downloadLossyAac;
/// Subtitle for AAC/M4A 320kbps Tidal lossy option
/// Subtitle for AAC/M4A 320kbps lossy option
///
/// In en, this message translates to:
/// **'Best mobile compatibility, M4A container'**
String get downloadLossyAacSubtitle;
/// Tidal lossy format option - Opus 256kbps
/// Lossy format option - Opus 256kbps
///
/// In en, this message translates to:
/// **'Opus 256kbps'**
String get downloadLossyOpus256;
/// Subtitle for Opus 256kbps Tidal lossy option
/// Subtitle for Opus 256kbps lossy option
///
/// In en, this message translates to:
/// **'Best quality Opus, ~8MB per track'**
String get downloadLossyOpus256Subtitle;
/// Tidal lossy format option - Opus 128kbps
/// Lossy format option - Opus 128kbps
///
/// In en, this message translates to:
/// **'Opus 128kbps'**
String get downloadLossyOpus128;
/// Subtitle for Opus 128kbps Tidal lossy option
/// Subtitle for Opus 128kbps lossy option
///
/// In en, this message translates to:
/// **'Smallest size, ~4MB per track'**
@@ -3489,7 +3533,7 @@ abstract class AppLocalizations {
/// Description of local library feature
///
/// In en, this message translates to:
/// **'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'**
/// **'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, ALAC, M4A, MP3, Opus, OGG, WAV, AIFF, and APE formats. Metadata is read from file tags when available.'**
String get libraryAboutDescription;
/// Unit label for tracks count (without the number itself)
@@ -3771,7 +3815,7 @@ abstract class AppLocalizations {
/// Tutorial welcome tip 2
///
/// In en, this message translates to:
/// **'Get FLAC quality audio from Tidal, Qobuz, or Deezer'**
/// **'Get FLAC quality audio from installed download extensions'**
String get tutorialWelcomeTip2;
/// Tutorial welcome tip 3
@@ -4838,7 +4882,7 @@ abstract class AppLocalizations {
/// Info tip on lyrics provider priority page
///
/// In en, this message translates to:
/// **'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.'**
/// **'Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.'**
String get lyricsProvidersInfoText;
/// Section header for enabled providers
@@ -4901,6 +4945,12 @@ abstract class AppLocalizations {
/// **'QQ Music (good for Chinese songs, via proxy)'**
String get lyricsProviderQqMusicDesc;
/// Description for LyricsPlus provider
///
/// In en, this message translates to:
/// **'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)'**
String get lyricsProviderLyricsPlusDesc;
/// Generic description for extension-based lyrics providers
///
/// In en, this message translates to:
@@ -5158,13 +5208,13 @@ abstract class AppLocalizations {
/// Subtitle when quality picker is disabled due to extension service
///
/// In en, this message translates to:
/// **'Select Tidal or Qobuz to enable this option'**
/// **'Select a provider with quality options to enable this option'**
String get downloadSelectServiceToEnable;
/// Info shown when a non-built-in service is selected
/// Legacy info shown when a provider does not expose quality options
///
/// In en, this message translates to:
/// **'Select Tidal or Qobuz to choose audio quality'**
/// **'Select a provider with quality options to choose audio quality'**
String get downloadSelectTidalQobuz;
/// Subtitle when lyrics embedding is blocked by metadata toggle
@@ -5719,7 +5769,7 @@ abstract class AppLocalizations {
/// **'Re-analyzing audio...'**
String get audioAnalysisRescanning;
/// Extensions page - subtitle for built-in search provider option
/// Extensions page - subtitle for default metadata search provider option
///
/// In en, this message translates to:
/// **'Search with {providerName}'**
@@ -6209,6 +6259,18 @@ abstract class AppLocalizations {
/// **'Download completed'**
String get queueDownloadCompleted;
/// Title shown on a failed queue item when the download service rate limits requests
///
/// In en, this message translates to:
/// **'Service rate limited'**
String get queueRateLimitTitle;
/// Explanation shown on a failed queue item when the download service rate limits requests
///
/// In en, this message translates to:
/// **'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.'**
String get queueRateLimitMessage;
/// Accessibility label for picking an accent color
///
/// In en, this message translates to:
@@ -6982,6 +7044,78 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Releases'**
String get artistReleases;
/// Button to clear selected fields for auto-fill
///
/// In en, this message translates to:
/// **'None'**
String get editMetadataSelectNone;
/// Button to retry every failed download in the queue
///
/// In en, this message translates to:
/// **'Retry {count} failed'**
String queueRetryAllFailed(int count);
/// Settings switch title for storing completed downloads in history
///
/// In en, this message translates to:
/// **'Save download history'**
String get settingsSaveDownloadHistory;
/// Settings switch subtitle for storing completed downloads in history
///
/// In en, this message translates to:
/// **'Keep completed downloads in history and library views'**
String get settingsSaveDownloadHistorySubtitle;
/// Confirmation dialog title shown before disabling download history
///
/// In en, this message translates to:
/// **'Turn off download history?'**
String get dialogDisableHistoryTitle;
/// Confirmation dialog message shown before disabling download history
///
/// In en, this message translates to:
/// **'Existing history will be cleared. Downloaded files will not be deleted.'**
String get dialogDisableHistoryMessage;
/// Confirmation action to disable download history and clear existing entries
///
/// In en, this message translates to:
/// **'Turn off and clear'**
String get dialogDisableAndClear;
/// Title and tooltip for finding the current collection in other services
///
/// In en, this message translates to:
/// **'Open in Other Services'**
String get openInOtherServices;
/// Empty state when no extensions can be searched for cross-service links
///
/// In en, this message translates to:
/// **'No other compatible services'**
String get shareSheetNoExtensions;
/// Cross-service share sheet row subtitle when a service has no match
///
/// In en, this message translates to:
/// **'Not found'**
String get shareSheetNotFound;
/// Tooltip for copying a cross-service link
///
/// In en, this message translates to:
/// **'Copy Link'**
String get shareSheetCopyLink;
/// Snackbar after copying a cross-service link
///
/// In en, this message translates to:
/// **'{service} link copied'**
String shareSheetLinkCopied(Object service);
}
class _AppLocalizationsDelegate
@@ -6995,6 +7129,7 @@ class _AppLocalizationsDelegate
@override
bool isSupported(Locale locale) => <String>[
'ar',
'de',
'en',
'es',
@@ -7048,6 +7183,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
// Lookup logic when only language code is specified.
switch (locale.languageCode) {
case 'ar':
return AppLocalizationsAr();
case 'de':
return AppLocalizationsDe();
case 'en':
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+110 -29
View File
@@ -142,7 +142,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
'Choose the default search provider to switch back from an extension';
@override
String get optionsAutoFallback => 'Auto Fallback';
@@ -155,10 +155,12 @@ class AppLocalizationsEn extends AppLocalizations {
String get optionsUseExtensionProviders => 'Use Extension Providers';
@override
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
String get optionsUseExtensionProvidersOn =>
'Extension providers are enabled';
@override
String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
String get optionsUseExtensionProvidersOff =>
'Extension providers are required';
@override
String get optionsEmbedLyrics => 'Embed Lyrics';
@@ -185,6 +187,43 @@ class AppLocalizationsEn extends AppLocalizations {
String get optionsReplayGainSubtitleOff =>
'Disabled: no loudness normalization tags';
@override
String get trackReplayGain => 'Rescan ReplayGain';
@override
String get trackReplayGainSubtitle =>
'Analyze loudness and write ReplayGain tags';
@override
String get trackReplayGainScanning => 'Analyzing loudness...';
@override
String get trackReplayGainSuccess => 'ReplayGain tags added';
@override
String get trackReplayGainFailed => 'Failed to add ReplayGain tags';
@override
String selectionReplayGainCount(int count) {
return 'ReplayGain ($count)';
}
@override
String get replayGainBatchConfirmTitle => 'Add ReplayGain';
@override
String replayGainBatchConfirmMessage(int count) {
return 'Analyze loudness and write ReplayGain tags to $count track(s)?';
}
@override
String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...';
@override
String replayGainBatchSuccess(int success, int total) {
return 'ReplayGain added to $success of $total tracks';
}
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@@ -206,21 +245,6 @@ class AppLocalizationsEn extends AppLocalizations {
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override
String get optionsConcurrentDownloads => 'Concurrent Downloads';
@override
String get optionsConcurrentSequential => 'Sequential (1 at a time)';
@override
String optionsConcurrentParallel(int count) {
return '$count parallel downloads';
}
@override
String get optionsConcurrentWarning =>
'Parallel downloads may trigger rate limiting';
@override
String get optionsExtensionStore => 'Extension Repo';
@@ -385,11 +409,11 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get aboutBinimumDesc =>
'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!';
'The creator of QQDL & HiFi API. This project helped shape lossless download support.';
@override
String get aboutSachinsenalDesc =>
'The original HiFi project creator. The foundation of Tidal integration!';
'The original HiFi project creator. A foundation for lossless-source integration.';
@override
String get aboutSjdonadoDesc =>
@@ -742,6 +766,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@@ -950,7 +977,7 @@ class AppLocalizationsEn extends AppLocalizations {
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Built-in';
String get providerBuiltIn => 'Legacy';
@override
String get providerExtension => 'Extension';
@@ -1343,10 +1370,11 @@ class AppLocalizationsEn extends AppLocalizations {
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Default (Deezer)';
String get extensionDefaultProvider => 'Default Search';
@override
String get extensionDefaultProviderSubtitle => 'Use built-in search';
String get extensionDefaultProviderSubtitle =>
'Use the default metadata search';
@override
String get extensionAuthor => 'Author';
@@ -1520,7 +1548,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
'Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@@ -1907,7 +1935,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get libraryAboutDescription =>
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, ALAC, M4A, MP3, Opus, OGG, WAV, AIFF, and APE formats. Metadata is read from file tags when available.';
@override
String libraryTracksUnit(int count) {
@@ -2090,7 +2118,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
'Get FLAC quality audio from installed download extensions';
@override
String get tutorialWelcomeTip3 =>
@@ -2788,7 +2816,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get lyricsProvidersInfoText =>
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
'Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.';
@override
String lyricsProvidersEnabledSection(int count) {
@@ -2830,6 +2858,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get lyricsProviderQqMusicDesc =>
'QQ Music (good for Chinese songs, via proxy)';
@override
String get lyricsProviderLyricsPlusDesc =>
'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)';
@override
String get lyricsProviderExtensionDesc => 'Extension provider';
@@ -2984,11 +3016,11 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get downloadSelectServiceToEnable =>
'Select Tidal or Qobuz to enable this option';
'Select a provider with quality options to enable this option';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz to choose audio quality';
'Select a provider with quality options to choose audio quality';
@override
String get downloadEmbedLyricsDisabled => 'Enable metadata embedding first';
@@ -3667,6 +3699,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
@@ -4193,4 +4232,46 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+162 -76
View File
@@ -126,7 +126,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get optionsPrimaryProviderSubtitle =>
'Service used when searching by track name.';
'Service used for searching by track or album name';
@override
String optionsUsingExtension(String extensionName) {
@@ -142,7 +142,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
'Choose the default search provider to switch back from an extension';
@override
String get optionsAutoFallback => 'Auto Fallback';
@@ -155,17 +155,19 @@ class AppLocalizationsHi extends AppLocalizations {
String get optionsUseExtensionProviders => 'Use Extension Providers';
@override
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
String get optionsUseExtensionProvidersOn =>
'Extension providers are enabled';
@override
String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
String get optionsUseExtensionProvidersOff =>
'Extension providers are required';
@override
String get optionsEmbedLyrics => 'Embed Lyrics';
@override
String get optionsEmbedLyricsSubtitle =>
'Embed synced lyrics into FLAC files';
'Save synced lyrics alongside your downloaded tracks';
@override
String get optionsMaxQualityCover => 'Max Quality Cover';
@@ -185,6 +187,43 @@ class AppLocalizationsHi extends AppLocalizations {
String get optionsReplayGainSubtitleOff =>
'Disabled: no loudness normalization tags';
@override
String get trackReplayGain => 'Rescan ReplayGain';
@override
String get trackReplayGainSubtitle =>
'Analyze loudness and write ReplayGain tags';
@override
String get trackReplayGainScanning => 'Analyzing loudness...';
@override
String get trackReplayGainSuccess => 'ReplayGain tags added';
@override
String get trackReplayGainFailed => 'Failed to add ReplayGain tags';
@override
String selectionReplayGainCount(int count) {
return 'ReplayGain ($count)';
}
@override
String get replayGainBatchConfirmTitle => 'Add ReplayGain';
@override
String replayGainBatchConfirmMessage(int count) {
return 'Analyze loudness and write ReplayGain tags to $count track(s)?';
}
@override
String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...';
@override
String replayGainBatchSuccess(int success, int total) {
return 'ReplayGain added to $success of $total tracks';
}
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@@ -206,21 +245,6 @@ class AppLocalizationsHi extends AppLocalizations {
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override
String get optionsConcurrentDownloads => 'Concurrent Downloads';
@override
String get optionsConcurrentSequential => 'Sequential (1 at a time)';
@override
String optionsConcurrentParallel(int count) {
return '$count parallel downloads';
}
@override
String get optionsConcurrentWarning =>
'Parallel downloads may trigger rate limiting';
@override
String get optionsExtensionStore => 'Extension Repo';
@@ -385,11 +409,11 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get aboutBinimumDesc =>
'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!';
'The creator of QQDL & HiFi API. This project helped shape lossless download support.';
@override
String get aboutSachinsenalDesc =>
'The original HiFi project creator. The foundation of Tidal integration!';
'The original HiFi project creator. A foundation for lossless-source integration.';
@override
String get aboutSjdonadoDesc =>
@@ -742,6 +766,9 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@@ -950,7 +977,7 @@ class AppLocalizationsHi extends AppLocalizations {
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Built-in';
String get providerBuiltIn => 'Legacy';
@override
String get providerExtension => 'Extension';
@@ -1118,10 +1145,10 @@ class AppLocalizationsHi extends AppLocalizations {
String get settingsAppearanceSubtitle => 'Theme, colors, display';
@override
String get settingsDownloadSubtitle => 'Service, quality, filename format';
String get settingsDownloadSubtitle => 'Service, quality, fallback';
@override
String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates';
String get settingsOptionsSubtitle => 'Fallback, metadata, lyrics, cover art';
@override
String get settingsExtensionsSubtitle => 'Manage download providers';
@@ -1343,10 +1370,11 @@ class AppLocalizationsHi extends AppLocalizations {
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Default (Deezer)';
String get extensionDefaultProvider => 'Default Search';
@override
String get extensionDefaultProviderSubtitle => 'Use built-in search';
String get extensionDefaultProviderSubtitle =>
'Use the default metadata search';
@override
String get extensionAuthor => 'Author';
@@ -1520,7 +1548,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
'Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@@ -2090,7 +2118,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
'Get FLAC quality audio from installed download extensions';
@override
String get tutorialWelcomeTip3 =>
@@ -2420,7 +2448,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
'Convert to AAC/M4A, MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2773,14 +2801,14 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
'Folder named after Album Artist tag';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
'Folder named after Track Artist tag';
@override
String get lyricsProvidersTitle => 'Lyrics Providers';
String get lyricsProvidersTitle => 'Lyrics Provider Priority';
@override
String get lyricsProvidersDescription =>
@@ -2788,7 +2816,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get lyricsProvidersInfoText =>
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
'Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.';
@override
String lyricsProvidersEnabledSection(int count) {
@@ -2830,6 +2858,10 @@ class AppLocalizationsHi extends AppLocalizations {
String get lyricsProviderQqMusicDesc =>
'QQ Music (good for Chinese songs, via proxy)';
@override
String get lyricsProviderLyricsPlusDesc =>
'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)';
@override
String get lyricsProviderExtensionDesc => 'Extension provider';
@@ -2848,10 +2880,10 @@ class AppLocalizationsHi extends AppLocalizations {
String get safMigrationSuccess => 'Download folder updated to SAF mode';
@override
String get settingsDonate => 'Donate';
String get settingsDonate => 'Support Development';
@override
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
String get settingsDonateSubtitle => 'Buy the developer a coffee';
@override
String get tooltipLoveAll => 'Love All';
@@ -2911,20 +2943,20 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get downloadLocationSubtitle =>
'Choose storage mode for downloaded files.';
'Choose where to save your downloaded tracks';
@override
String get storageModeAppFolder => 'App folder (non-SAF)';
String get storageModeAppFolder => 'App Folder (Recommended)';
@override
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
String get storageModeAppFolderSubtitle =>
'Saves to Music/SpotiFLAC by default';
@override
String get storageModeSaf => 'SAF folder';
String get storageModeSaf => 'Custom Folder (SAF)';
@override
String get storageModeSafSubtitle =>
'Pick folder via Android Storage Access Framework';
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
@override
String downloadFilenameDescription(
@@ -2936,62 +2968,62 @@ class AppLocalizationsHi extends AppLocalizations {
Object track,
Object year,
) {
return 'Customize how your files are named.';
return 'Use $artist, $title, $album, $track, $year, $date, $disc as placeholders.';
}
@override
String get downloadFilenameInsertTag => 'Tap to insert tag:';
@override
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
String get downloadSeparateSinglesEnabled =>
'Singles and EPs saved in a separate folder';
@override
String get downloadSeparateSinglesDisabled => 'All files in same structure';
String get downloadSeparateSinglesDisabled =>
'Singles and albums saved in the same folder';
@override
String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
String get downloadCreatePlaylistSourceFolder => 'Playlist Source Folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
'A subfolder is created for each playlist';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
'All tracks saved directly to download folder';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
'Handled by folder organization setting';
@override
String get downloadSongLinkRegion => 'SongLink Region';
@override
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
String get downloadNetworkCompatibilityMode => 'Network Compatibility Mode';
@override
String get downloadNetworkCompatibilityModeEnabled =>
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
'Using legacy TLS settings for older networks';
@override
String get downloadNetworkCompatibilityModeDisabled =>
'Off: strict HTTPS certificate validation (recommended)';
'Using standard network settings';
@override
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
'Select a provider with quality options to enable this option';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
'Select a provider with quality options to choose audio quality';
@override
String get downloadEmbedLyricsDisabled =>
'Disabled while Embed Metadata is turned off';
String get downloadEmbedLyricsDisabled => 'Enable metadata embedding first';
@override
String get downloadNeteaseIncludeTranslation =>
@@ -2999,11 +3031,11 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get downloadNeteaseIncludeTranslationEnabled =>
'Append translated lyrics when available';
'Chinese translation lines included';
@override
String get downloadNeteaseIncludeTranslationDisabled =>
'Use original lyrics only';
'Original lyrics only';
@override
String get downloadNeteaseIncludeRomanization =>
@@ -3011,21 +3043,21 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get downloadNeteaseIncludeRomanizationEnabled =>
'Append romanized lyrics when available';
'Romanization lines included';
@override
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
String get downloadNeteaseIncludeRomanizationDisabled => 'No romanization';
@override
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
String get downloadAppleQqMultiPerson => 'Apple / QQ: Multi-Person Lyrics';
@override
String get downloadAppleQqMultiPersonEnabled =>
'Enable v1/v2 speaker and [bg:] tags';
'Speaker labels included for duets and group tracks';
@override
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
'Standard lyrics without speaker labels';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@@ -3042,46 +3074,45 @@ class AppLocalizationsHi extends AppLocalizations {
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@override
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
String get downloadMusixmatchLanguageAuto => 'Auto (original language)';
@override
String get downloadFilterContributing =>
'Filter contributing artists in Album Artist';
String get downloadFilterContributing => 'Filter Contributing Artists';
@override
String get downloadFilterContributingEnabled =>
'Album Artist metadata uses primary artist only';
'Contributing artists removed from Album Artist folder name';
@override
String get downloadFilterContributingDisabled =>
'Keep full Album Artist metadata value';
'Full Album Artist string used';
@override
String get downloadProvidersNoneEnabled => 'None enabled';
String get downloadProvidersNoneEnabled => 'No providers enabled';
@override
String get downloadMusixmatchLanguageCode => 'Language code';
@override
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
String get downloadMusixmatchLanguageHint => 'e.g. en, de, ja';
@override
String get downloadMusixmatchLanguageDesc =>
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
'Enter a BCP-47 language code (e.g. en, de, ja) to request translated lyrics from Musixmatch.';
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
String get downloadNetworkAnySubtitle => 'Use WiFi or mobile data';
@override
String get downloadNetworkWifiOnlySubtitle =>
'Pause downloads on mobile data';
'Downloads pause when on mobile data';
@override
String get downloadSongLinkRegionDesc =>
'Used as userCountry for SongLink API lookup.';
'Region used when resolving track links via SongLink. Choose the country where your streaming services are available.';
@override
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
@@ -3467,7 +3498,13 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks downloaded successfully',
one: '1 track downloaded successfully',
);
return '$_temp0';
}
@override
@@ -3662,6 +3699,13 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
@@ -4188,4 +4232,46 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
+198 -96
View File
@@ -21,7 +21,7 @@ class AppLocalizationsId extends AppLocalizations {
String get navSettings => 'Pengaturan';
@override
String get navStore => 'Repo';
String get navStore => 'Repositori';
@override
String get homeTitle => 'Beranda';
@@ -30,10 +30,10 @@ class AppLocalizationsId extends AppLocalizations {
String get homeSubtitle => 'Paste a supported URL or search by name';
@override
String get homeEmptyTitle => 'Belum ada provider pencarian';
String get homeEmptyTitle => 'Belum ada penyedia pencarian';
@override
String get homeEmptySubtitle => 'Pasang ekstensi untuk melanjutkan.';
String get homeEmptySubtitle => 'Instal ekstensi untuk melanjutkan.';
@override
String get homeSupports => 'Mendukung: URL Track, Album, Playlist, Artis';
@@ -82,11 +82,11 @@ class AppLocalizationsId extends AppLocalizations {
String get downloadFilenameFormat => 'Format Nama File';
@override
String get downloadSingleFilenameFormat => 'Single Filename Format';
String get downloadSingleFilenameFormat => 'Format Nama Berkas Tunggal';
@override
String get downloadSingleFilenameFormatDescription =>
'Filename pattern for singles and EPs. Uses the same tags as the album format.';
'Pola nama file untuk single dan EP. Menggunakan tag yang sama dengan format album.';
@override
String get downloadFolderOrganization => 'Organisasi Folder';
@@ -127,7 +127,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get optionsPrimaryProviderSubtitle =>
'Layanan yang digunakan saat mencari berdasarkan nama lagu.';
'Layanan yang digunakan untuk mencari berdasarkan nama lagu atau album';
@override
String optionsUsingExtension(String extensionName) {
@@ -139,11 +139,11 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
'Pilih tab mana yang terbuka terlebih dahulu untuk hasil pencarian baru.';
@override
String get optionsSwitchBack =>
'Ketuk Deezer atau Spotify untuk beralih dari ekstensi';
'Choose the default search provider to switch back from an extension';
@override
String get optionsAutoFallback => 'Cadangan Otomatis';
@@ -157,18 +157,18 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get optionsUseExtensionProvidersOn =>
'Ekstensi akan dicoba terlebih dahulu';
'Extension providers are enabled';
@override
String get optionsUseExtensionProvidersOff =>
'Hanya menggunakan provider bawaan';
'Extension providers are required';
@override
String get optionsEmbedLyrics => 'Sematkan Lirik';
@override
String get optionsEmbedLyricsSubtitle =>
'Sematkan lirik sinkron ke file FLAC';
'Simpan lirik yang disinkronkan bersama dengan lagu yang Anda unduh';
@override
String get optionsMaxQualityCover => 'Cover Kualitas Maksimal';
@@ -182,47 +182,69 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get optionsReplayGainSubtitleOn =>
'Scan loudness and embed ReplayGain tags (EBU R128)';
'Pindai kenyaringan dan sematkan tag ReplayGain (EBU R128)';
@override
String get optionsReplayGainSubtitleOff =>
'Disabled: no loudness normalization tags';
'Dinonaktifkan: tidak ada tag normalisasi kenyaringan';
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
String get trackReplayGain => 'Rescan ReplayGain';
@override
String get optionsArtistTagModeDescription =>
'Choose how multiple artists are written into embedded tags.';
String get trackReplayGainSubtitle =>
'Analyze loudness and write ReplayGain tags';
@override
String get optionsArtistTagModeJoined => 'Single joined value';
String get trackReplayGainScanning => 'Analyzing loudness...';
@override
String get optionsArtistTagModeJoinedSubtitle =>
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
String get trackReplayGainSuccess => 'ReplayGain tags added';
@override
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
String get trackReplayGainFailed => 'Failed to add ReplayGain tags';
@override
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override
String get optionsConcurrentDownloads => 'Unduhan Bersamaan';
@override
String get optionsConcurrentSequential => 'Berurutan (1 per waktu)';
@override
String optionsConcurrentParallel(int count) {
return '$count unduhan paralel';
String selectionReplayGainCount(int count) {
return 'ReplayGain ($count)';
}
@override
String get optionsConcurrentWarning =>
'Unduhan paralel dapat memicu pembatasan rate';
String get replayGainBatchConfirmTitle => 'Add ReplayGain';
@override
String replayGainBatchConfirmMessage(int count) {
return 'Analyze loudness and write ReplayGain tags to $count track(s)?';
}
@override
String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...';
@override
String replayGainBatchSuccess(int success, int total) {
return 'ReplayGain added to $success of $total tracks';
}
@override
String get optionsArtistTagMode => 'Mode Tag Artis';
@override
String get optionsArtistTagModeDescription =>
'Pilih bagaimana beberapa artis dicantumkan dalam tag yang disematkan.';
@override
String get optionsArtistTagModeJoined => 'Nilai gabungan tunggal';
@override
String get optionsArtistTagModeJoinedSubtitle =>
'Tuliskan satu nilai ARTIS seperti \"Artis A, Artis B\" untuk kompatibilitas pemain maksimal.';
@override
String get optionsArtistTagModeSplitVorbis => 'Tag terpisah untuk FLAC/Opus';
@override
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Tulis satu tag artis per artis untuk FLAC dan Opus; MP3 dan M4A tetap tergabung.';
@override
String get optionsExtensionStore => 'Extension Repo';
@@ -388,11 +410,11 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get aboutBinimumDesc =>
'Pembuat QQDL & HiFi API. Tanpa API ini, unduhan Tidal tidak akan ada!';
'The creator of QQDL & HiFi API. This project helped shape lossless download support.';
@override
String get aboutSachinsenalDesc =>
'Pembuat proyek HiFi asli. Fondasi dari integrasi Tidal!';
'The original HiFi project creator. A foundation for lossless-source integration.';
@override
String get aboutSjdonadoDesc =>
@@ -745,6 +767,9 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get errorNoTracksFound => 'Tidak ada lagu ditemukan';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Tautan tidak dikenali';
@@ -953,7 +978,7 @@ class AppLocalizationsId extends AppLocalizations {
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Bawaan';
String get providerBuiltIn => 'Legacy';
@override
String get providerExtension => 'Ekstensi';
@@ -1124,10 +1149,10 @@ class AppLocalizationsId extends AppLocalizations {
String get settingsAppearanceSubtitle => 'Tema, warna, tampilan';
@override
String get settingsDownloadSubtitle => 'Layanan, kualitas, format nama file';
String get settingsDownloadSubtitle => 'Service, quality, fallback';
@override
String get settingsOptionsSubtitle => 'Fallback, lirik, cover art, pembaruan';
String get settingsOptionsSubtitle => 'Fallback, metadata, lyrics, cover art';
@override
String get settingsExtensionsSubtitle => 'Kelola provider unduhan';
@@ -1349,10 +1374,11 @@ class AppLocalizationsId extends AppLocalizations {
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Default (Deezer)';
String get extensionDefaultProvider => 'Default Search';
@override
String get extensionDefaultProviderSubtitle => 'Gunakan pencarian bawaan';
String get extensionDefaultProviderSubtitle =>
'Use the default metadata search';
@override
String get extensionAuthor => 'Pembuat';
@@ -1528,7 +1554,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
'Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@@ -1916,7 +1942,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get libraryAboutDescription =>
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
'Memindai koleksi musik yang sudah ada untuk mendeteksi duplikat saat mengunduh. Mendukung format FLAC, ALAC, M4A, MP3, Opus, OGG, WAV, AIFF, dan APE. Metadata dibaca dari tag file jika tersedia.';
@override
String libraryTracksUnit(int count) {
@@ -2099,7 +2125,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Dapatkan audio berkualitas FLAC dari Tidal, Qobuz, atau Deezer';
'Get FLAC quality audio from installed download extensions';
@override
String get tutorialWelcomeTip3 =>
@@ -2429,7 +2455,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
'Convert to AAC/M4A, MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2782,14 +2808,14 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
'Folder named after Album Artist tag';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
'Folder named after Track Artist tag';
@override
String get lyricsProvidersTitle => 'Lyrics Providers';
String get lyricsProvidersTitle => 'Lyrics Provider Priority';
@override
String get lyricsProvidersDescription =>
@@ -2797,7 +2823,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get lyricsProvidersInfoText =>
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
'Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.';
@override
String lyricsProvidersEnabledSection(int count) {
@@ -2839,6 +2865,10 @@ class AppLocalizationsId extends AppLocalizations {
String get lyricsProviderQqMusicDesc =>
'QQ Music (good for Chinese songs, via proxy)';
@override
String get lyricsProviderLyricsPlusDesc =>
'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)';
@override
String get lyricsProviderExtensionDesc => 'Extension provider';
@@ -2857,10 +2887,10 @@ class AppLocalizationsId extends AppLocalizations {
String get safMigrationSuccess => 'Download folder updated to SAF mode';
@override
String get settingsDonate => 'Donate';
String get settingsDonate => 'Support Development';
@override
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
String get settingsDonateSubtitle => 'Buy the developer a coffee';
@override
String get tooltipLoveAll => 'Love All';
@@ -2920,20 +2950,20 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get downloadLocationSubtitle =>
'Choose storage mode for downloaded files.';
'Choose where to save your downloaded tracks';
@override
String get storageModeAppFolder => 'App folder (non-SAF)';
String get storageModeAppFolder => 'App Folder (Recommended)';
@override
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
String get storageModeAppFolderSubtitle =>
'Saves to Music/SpotiFLAC by default';
@override
String get storageModeSaf => 'SAF folder';
String get storageModeSaf => 'Custom Folder (SAF)';
@override
String get storageModeSafSubtitle =>
'Pick folder via Android Storage Access Framework';
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
@override
String downloadFilenameDescription(
@@ -2945,62 +2975,62 @@ class AppLocalizationsId extends AppLocalizations {
Object track,
Object year,
) {
return 'Customize how your files are named.';
return 'Use $artist, $title, $album, $track, $year, $date, $disc as placeholders.';
}
@override
String get downloadFilenameInsertTag => 'Tap to insert tag:';
@override
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
String get downloadSeparateSinglesEnabled =>
'Singles and EPs saved in a separate folder';
@override
String get downloadSeparateSinglesDisabled => 'All files in same structure';
String get downloadSeparateSinglesDisabled =>
'Singles and albums saved in the same folder';
@override
String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
String get downloadCreatePlaylistSourceFolder => 'Playlist Source Folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
'A subfolder is created for each playlist';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
'All tracks saved directly to download folder';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
'Handled by folder organization setting';
@override
String get downloadSongLinkRegion => 'SongLink Region';
@override
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
String get downloadNetworkCompatibilityMode => 'Network Compatibility Mode';
@override
String get downloadNetworkCompatibilityModeEnabled =>
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
'Using legacy TLS settings for older networks';
@override
String get downloadNetworkCompatibilityModeDisabled =>
'Off: strict HTTPS certificate validation (recommended)';
'Using standard network settings';
@override
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
'Select a provider with quality options to enable this option';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
'Select a provider with quality options to choose audio quality';
@override
String get downloadEmbedLyricsDisabled =>
'Disabled while Embed Metadata is turned off';
String get downloadEmbedLyricsDisabled => 'Enable metadata embedding first';
@override
String get downloadNeteaseIncludeTranslation =>
@@ -3008,11 +3038,11 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get downloadNeteaseIncludeTranslationEnabled =>
'Append translated lyrics when available';
'Chinese translation lines included';
@override
String get downloadNeteaseIncludeTranslationDisabled =>
'Use original lyrics only';
'Original lyrics only';
@override
String get downloadNeteaseIncludeRomanization =>
@@ -3020,21 +3050,21 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get downloadNeteaseIncludeRomanizationEnabled =>
'Append romanized lyrics when available';
'Romanization lines included';
@override
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
String get downloadNeteaseIncludeRomanizationDisabled => 'No romanization';
@override
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
String get downloadAppleQqMultiPerson => 'Apple / QQ: Multi-Person Lyrics';
@override
String get downloadAppleQqMultiPersonEnabled =>
'Enable v1/v2 speaker and [bg:] tags';
'Speaker labels included for duets and group tracks';
@override
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
'Standard lyrics without speaker labels';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@@ -3051,46 +3081,45 @@ class AppLocalizationsId extends AppLocalizations {
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@override
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
String get downloadMusixmatchLanguageAuto => 'Auto (original language)';
@override
String get downloadFilterContributing =>
'Filter contributing artists in Album Artist';
String get downloadFilterContributing => 'Filter Contributing Artists';
@override
String get downloadFilterContributingEnabled =>
'Album Artist metadata uses primary artist only';
'Contributing artists removed from Album Artist folder name';
@override
String get downloadFilterContributingDisabled =>
'Keep full Album Artist metadata value';
'Full Album Artist string used';
@override
String get downloadProvidersNoneEnabled => 'None enabled';
String get downloadProvidersNoneEnabled => 'No providers enabled';
@override
String get downloadMusixmatchLanguageCode => 'Language code';
@override
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
String get downloadMusixmatchLanguageHint => 'e.g. en, de, ja';
@override
String get downloadMusixmatchLanguageDesc =>
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
'Enter a BCP-47 language code (e.g. en, de, ja) to request translated lyrics from Musixmatch.';
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
String get downloadNetworkAnySubtitle => 'Use WiFi or mobile data';
@override
String get downloadNetworkWifiOnlySubtitle =>
'Pause downloads on mobile data';
'Downloads pause when on mobile data';
@override
String get downloadSongLinkRegionDesc =>
'Used as userCountry for SongLink API lookup.';
'Region used when resolving track links via SongLink. Choose the country where your streaming services are available.';
@override
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
@@ -3468,28 +3497,52 @@ class AppLocalizationsId extends AppLocalizations {
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Unduhan Selesai ($completed selesai, $failed gagal)';
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'Semua Unduhan Selesai';
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count lagu berhasil diunduh';
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks downloaded successfully',
one: '1 track downloaded successfully',
);
return '$_temp0';
}
@override
String notifDownloadsFinishedBody(int completed, int failed) {
return '$completed lagu diunduh, $failed gagal';
String _temp0 = intl.Intl.pluralLogic(
completed,
locale: localeName,
other: '$completed tracks downloaded',
one: '1 track downloaded',
);
String _temp1 = intl.Intl.pluralLogic(
failed,
locale: localeName,
other: '$failed failed',
one: '1 failed',
);
return '$_temp0, $_temp1';
}
@override
String get notifDownloadsCanceledTitle => 'Unduhan dibatalkan';
String get notifDownloadsCanceledTitle => 'Downloads canceled';
@override
String notifDownloadsCanceledBody(int count) {
return '$count unduhan dibatalkan oleh pengguna';
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count downloads canceled by user',
one: '1 download canceled by user',
);
return '$_temp0';
}
@override
@@ -3653,6 +3706,13 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
@@ -4179,4 +4239,46 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
+163 -76
View File
@@ -126,7 +126,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get optionsPrimaryProviderSubtitle =>
'Service used when searching by track name.';
'Service used for searching by track or album name';
@override
String optionsUsingExtension(String extensionName) {
@@ -142,7 +142,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
'Choose the default search provider to switch back from an extension';
@override
String get optionsAutoFallback => 'Auto Fallback';
@@ -155,16 +155,19 @@ class AppLocalizationsJa extends AppLocalizations {
String get optionsUseExtensionProviders => '拡張のプロバイダーを使用する';
@override
String get optionsUseExtensionProvidersOn => '最初に拡張で試みます';
String get optionsUseExtensionProvidersOn =>
'Extension providers are enabled';
@override
String get optionsUseExtensionProvidersOff => '内蔵のプロバイダーのみを使用する';
String get optionsUseExtensionProvidersOff =>
'Extension providers are required';
@override
String get optionsEmbedLyrics => '歌詞を埋め込む';
@override
String get optionsEmbedLyricsSubtitle => '同期する歌詞を FLAC ファイルに埋め込む';
String get optionsEmbedLyricsSubtitle =>
'Save synced lyrics alongside your downloaded tracks';
@override
String get optionsMaxQualityCover => '最大品質のカバー';
@@ -183,6 +186,43 @@ class AppLocalizationsJa extends AppLocalizations {
String get optionsReplayGainSubtitleOff =>
'Disabled: no loudness normalization tags';
@override
String get trackReplayGain => 'Rescan ReplayGain';
@override
String get trackReplayGainSubtitle =>
'Analyze loudness and write ReplayGain tags';
@override
String get trackReplayGainScanning => 'Analyzing loudness...';
@override
String get trackReplayGainSuccess => 'ReplayGain tags added';
@override
String get trackReplayGainFailed => 'Failed to add ReplayGain tags';
@override
String selectionReplayGainCount(int count) {
return 'ReplayGain ($count)';
}
@override
String get replayGainBatchConfirmTitle => 'Add ReplayGain';
@override
String replayGainBatchConfirmMessage(int count) {
return 'Analyze loudness and write ReplayGain tags to $count track(s)?';
}
@override
String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...';
@override
String replayGainBatchSuccess(int success, int total) {
return 'ReplayGain added to $success of $total tracks';
}
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@@ -204,21 +244,6 @@ class AppLocalizationsJa extends AppLocalizations {
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override
String get optionsConcurrentDownloads => '同時ダウンロード';
@override
String get optionsConcurrentSequential => 'Sequential (1 at a time)';
@override
String optionsConcurrentParallel(int count) {
return '$count 件の分割ダウンロード';
}
@override
String get optionsConcurrentWarning =>
'Parallel downloads may trigger rate limiting';
@override
String get optionsExtensionStore => 'Extension Repo';
@@ -381,11 +406,11 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get aboutBinimumDesc =>
'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!';
'The creator of QQDL & HiFi API. This project helped shape lossless download support.';
@override
String get aboutSachinsenalDesc =>
'The original HiFi project creator. The foundation of Tidal integration!';
'The original HiFi project creator. A foundation for lossless-source integration.';
@override
String get aboutSjdonadoDesc =>
@@ -737,6 +762,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get errorNoTracksFound => 'トラックがありません';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@@ -944,7 +972,7 @@ class AppLocalizationsJa extends AppLocalizations {
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => '内蔵';
String get providerBuiltIn => 'Legacy';
@override
String get providerExtension => '拡張';
@@ -1112,10 +1140,10 @@ class AppLocalizationsJa extends AppLocalizations {
String get settingsAppearanceSubtitle => 'テーマ、カラー、画面';
@override
String get settingsDownloadSubtitle => 'サービス、品質、ファイル名、形式';
String get settingsDownloadSubtitle => 'Service, quality, fallback';
@override
String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates';
String get settingsOptionsSubtitle => 'Fallback, metadata, lyrics, cover art';
@override
String get settingsExtensionsSubtitle => 'ダウンロードプロバイダーを管理';
@@ -1337,10 +1365,11 @@ class AppLocalizationsJa extends AppLocalizations {
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Default (Deezer)';
String get extensionDefaultProvider => 'Default Search';
@override
String get extensionDefaultProviderSubtitle => '内蔵の検索を使用する';
String get extensionDefaultProviderSubtitle =>
'Use the default metadata search';
@override
String get extensionAuthor => '作者';
@@ -1510,7 +1539,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
'Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@@ -2077,7 +2106,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
'Get FLAC quality audio from installed download extensions';
@override
String get tutorialWelcomeTip3 =>
@@ -2407,7 +2436,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
'Convert to AAC/M4A, MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'オーディオを変換';
@@ -2760,14 +2789,14 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
'Folder named after Album Artist tag';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
'Folder named after Track Artist tag';
@override
String get lyricsProvidersTitle => 'Lyrics Providers';
String get lyricsProvidersTitle => 'Lyrics Provider Priority';
@override
String get lyricsProvidersDescription =>
@@ -2775,7 +2804,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get lyricsProvidersInfoText =>
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
'Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.';
@override
String lyricsProvidersEnabledSection(int count) {
@@ -2817,6 +2846,10 @@ class AppLocalizationsJa extends AppLocalizations {
String get lyricsProviderQqMusicDesc =>
'QQ Music (good for Chinese songs, via proxy)';
@override
String get lyricsProviderLyricsPlusDesc =>
'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)';
@override
String get lyricsProviderExtensionDesc => 'Extension provider';
@@ -2835,10 +2868,10 @@ class AppLocalizationsJa extends AppLocalizations {
String get safMigrationSuccess => 'Download folder updated to SAF mode';
@override
String get settingsDonate => 'Donate';
String get settingsDonate => 'Support Development';
@override
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
String get settingsDonateSubtitle => 'Buy the developer a coffee';
@override
String get tooltipLoveAll => 'Love All';
@@ -2898,20 +2931,20 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get downloadLocationSubtitle =>
'Choose storage mode for downloaded files.';
'Choose where to save your downloaded tracks';
@override
String get storageModeAppFolder => 'App folder (non-SAF)';
String get storageModeAppFolder => 'App Folder (Recommended)';
@override
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
String get storageModeAppFolderSubtitle =>
'Saves to Music/SpotiFLAC by default';
@override
String get storageModeSaf => 'SAF folder';
String get storageModeSaf => 'Custom Folder (SAF)';
@override
String get storageModeSafSubtitle =>
'Pick folder via Android Storage Access Framework';
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
@override
String downloadFilenameDescription(
@@ -2923,62 +2956,62 @@ class AppLocalizationsJa extends AppLocalizations {
Object track,
Object year,
) {
return 'Customize how your files are named.';
return 'Use $artist, $title, $album, $track, $year, $date, $disc as placeholders.';
}
@override
String get downloadFilenameInsertTag => 'Tap to insert tag:';
@override
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
String get downloadSeparateSinglesEnabled =>
'Singles and EPs saved in a separate folder';
@override
String get downloadSeparateSinglesDisabled => 'All files in same structure';
String get downloadSeparateSinglesDisabled =>
'Singles and albums saved in the same folder';
@override
String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
String get downloadCreatePlaylistSourceFolder => 'Playlist Source Folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
'A subfolder is created for each playlist';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
'All tracks saved directly to download folder';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
'Handled by folder organization setting';
@override
String get downloadSongLinkRegion => 'SongLink Region';
@override
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
String get downloadNetworkCompatibilityMode => 'Network Compatibility Mode';
@override
String get downloadNetworkCompatibilityModeEnabled =>
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
'Using legacy TLS settings for older networks';
@override
String get downloadNetworkCompatibilityModeDisabled =>
'Off: strict HTTPS certificate validation (recommended)';
'Using standard network settings';
@override
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
'Select a provider with quality options to enable this option';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
'Select a provider with quality options to choose audio quality';
@override
String get downloadEmbedLyricsDisabled =>
'Disabled while Embed Metadata is turned off';
String get downloadEmbedLyricsDisabled => 'Enable metadata embedding first';
@override
String get downloadNeteaseIncludeTranslation =>
@@ -2986,11 +3019,11 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get downloadNeteaseIncludeTranslationEnabled =>
'Append translated lyrics when available';
'Chinese translation lines included';
@override
String get downloadNeteaseIncludeTranslationDisabled =>
'Use original lyrics only';
'Original lyrics only';
@override
String get downloadNeteaseIncludeRomanization =>
@@ -2998,21 +3031,21 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get downloadNeteaseIncludeRomanizationEnabled =>
'Append romanized lyrics when available';
'Romanization lines included';
@override
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
String get downloadNeteaseIncludeRomanizationDisabled => 'No romanization';
@override
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
String get downloadAppleQqMultiPerson => 'Apple / QQ: Multi-Person Lyrics';
@override
String get downloadAppleQqMultiPersonEnabled =>
'Enable v1/v2 speaker and [bg:] tags';
'Speaker labels included for duets and group tracks';
@override
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
'Standard lyrics without speaker labels';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@@ -3029,46 +3062,45 @@ class AppLocalizationsJa extends AppLocalizations {
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@override
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
String get downloadMusixmatchLanguageAuto => 'Auto (original language)';
@override
String get downloadFilterContributing =>
'Filter contributing artists in Album Artist';
String get downloadFilterContributing => 'Filter Contributing Artists';
@override
String get downloadFilterContributingEnabled =>
'Album Artist metadata uses primary artist only';
'Contributing artists removed from Album Artist folder name';
@override
String get downloadFilterContributingDisabled =>
'Keep full Album Artist metadata value';
'Full Album Artist string used';
@override
String get downloadProvidersNoneEnabled => 'None enabled';
String get downloadProvidersNoneEnabled => 'No providers enabled';
@override
String get downloadMusixmatchLanguageCode => 'Language code';
@override
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
String get downloadMusixmatchLanguageHint => 'e.g. en, de, ja';
@override
String get downloadMusixmatchLanguageDesc =>
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
'Enter a BCP-47 language code (e.g. en, de, ja) to request translated lyrics from Musixmatch.';
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
String get downloadNetworkAnySubtitle => 'Use WiFi or mobile data';
@override
String get downloadNetworkWifiOnlySubtitle =>
'Pause downloads on mobile data';
'Downloads pause when on mobile data';
@override
String get downloadSongLinkRegionDesc =>
'Used as userCountry for SongLink API lookup.';
'Region used when resolving track links via SongLink. Choose the country where your streaming services are available.';
@override
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
@@ -3454,7 +3486,13 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks downloaded successfully',
one: '1 track downloaded successfully',
);
return '$_temp0';
}
@override
@@ -3649,6 +3687,13 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
@@ -4175,4 +4220,46 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
+170 -79
View File
@@ -27,7 +27,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get homeTitle => 'Home';
@override
String get homeSubtitle => 'Paste a supported URL or search by name';
String get homeSubtitle => '지원되는 URL을 붙여 넣거나, 이름을 검색';
@override
String get homeEmptyTitle => 'No search providers yet';
@@ -97,10 +97,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get appearanceThemeSystem => 'System';
@override
String get appearanceThemeLight => 'Light';
String get appearanceThemeLight => '밝은';
@override
String get appearanceThemeDark => 'Dark';
String get appearanceThemeDark => '다크';
@override
String get appearanceDynamicColor => 'Dynamic Color';
@@ -124,7 +124,8 @@ class AppLocalizationsKo extends AppLocalizations {
String get optionsPrimaryProvider => '기본 제공자';
@override
String get optionsPrimaryProviderSubtitle => '음반 이름으로 검색할 때 사용되는 서비스';
String get optionsPrimaryProviderSubtitle =>
'Service used for searching by track or album name';
@override
String optionsUsingExtension(String extensionName) {
@@ -139,7 +140,8 @@ class AppLocalizationsKo extends AppLocalizations {
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack => 'Deezer 또는 Spotify를 탭하여 확장 기능에서 다시 전환하세요.';
String get optionsSwitchBack =>
'Choose the default search provider to switch back from an extension';
@override
String get optionsAutoFallback => '자동 재시도';
@@ -151,16 +153,19 @@ class AppLocalizationsKo extends AppLocalizations {
String get optionsUseExtensionProviders => '확장 기능 사용';
@override
String get optionsUseExtensionProvidersOn => '확장 기능을 우선적으로 사용합니다';
String get optionsUseExtensionProvidersOn =>
'Extension providers are enabled';
@override
String get optionsUseExtensionProvidersOff => '기본으로 제공되는 기능만 사용';
String get optionsUseExtensionProvidersOff =>
'Extension providers are required';
@override
String get optionsEmbedLyrics => '가사 삽입';
@override
String get optionsEmbedLyricsSubtitle => 'FLAC 파일에 동기화된 가사를 삽입합니다';
String get optionsEmbedLyricsSubtitle =>
'Save synced lyrics alongside your downloaded tracks';
@override
String get optionsMaxQualityCover => '고품질 커버 이미지';
@@ -179,6 +184,43 @@ class AppLocalizationsKo extends AppLocalizations {
String get optionsReplayGainSubtitleOff =>
'Disabled: no loudness normalization tags';
@override
String get trackReplayGain => 'Rescan ReplayGain';
@override
String get trackReplayGainSubtitle =>
'Analyze loudness and write ReplayGain tags';
@override
String get trackReplayGainScanning => 'Analyzing loudness...';
@override
String get trackReplayGainSuccess => 'ReplayGain tags added';
@override
String get trackReplayGainFailed => 'Failed to add ReplayGain tags';
@override
String selectionReplayGainCount(int count) {
return 'ReplayGain ($count)';
}
@override
String get replayGainBatchConfirmTitle => 'Add ReplayGain';
@override
String replayGainBatchConfirmMessage(int count) {
return 'Analyze loudness and write ReplayGain tags to $count track(s)?';
}
@override
String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...';
@override
String replayGainBatchSuccess(int success, int total) {
return 'ReplayGain added to $success of $total tracks';
}
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@@ -200,20 +242,6 @@ class AppLocalizationsKo extends AppLocalizations {
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override
String get optionsConcurrentDownloads => '동시 다운로드';
@override
String get optionsConcurrentSequential => '순차 다운로드 (한 번에 하나)';
@override
String optionsConcurrentParallel(int count) {
return '$count개 동시 다운로드';
}
@override
String get optionsConcurrentWarning => '동시에 다수의 음반을 다운로드하면 속도 제한이 발생할 수 있습니다';
@override
String get optionsExtensionStore => 'Extension Repo';
@@ -374,10 +402,11 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get aboutBinimumDesc =>
'QQDL HiFi API 개발자입니다. 이 API가 없었다면 Tidal 다운로드는 불가능했을 것입니다!';
'The creator of QQDL & HiFi API. This project helped shape lossless download support.';
@override
String get aboutSachinsenalDesc => '최초의 하이파이 프로젝트 창시자. 타이달 연동의 기반을 마련한 사람!';
String get aboutSachinsenalDesc =>
'The original HiFi project creator. A foundation for lossless-source integration.';
@override
String get aboutSjdonadoDesc =>
@@ -724,6 +753,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get errorNoTracksFound => '트랙을 찾을 수 없습니다';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@@ -826,7 +858,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get tooltipPlay => '재생';
@override
String get filenameFormat => '';
String get filenameFormat => 'Filename Format';
@override
String get filenameShowAdvancedTags => '고급 태그 표시';
@@ -932,7 +964,7 @@ class AppLocalizationsKo extends AppLocalizations {
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Built-in';
String get providerBuiltIn => 'Legacy';
@override
String get providerExtension => 'Extension';
@@ -1098,10 +1130,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get settingsAppearanceSubtitle => 'Theme, colors, display';
@override
String get settingsDownloadSubtitle => 'Service, quality, filename format';
String get settingsDownloadSubtitle => 'Service, quality, fallback';
@override
String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates';
String get settingsOptionsSubtitle => 'Fallback, metadata, lyrics, cover art';
@override
String get settingsExtensionsSubtitle => 'Manage download providers';
@@ -1323,10 +1355,11 @@ class AppLocalizationsKo extends AppLocalizations {
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Default (Deezer)';
String get extensionDefaultProvider => 'Default Search';
@override
String get extensionDefaultProviderSubtitle => 'Use built-in search';
String get extensionDefaultProviderSubtitle =>
'Use the default metadata search';
@override
String get extensionAuthor => 'Author';
@@ -1500,7 +1533,7 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
'Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@@ -2070,7 +2103,7 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
'Get FLAC quality audio from installed download extensions';
@override
String get tutorialWelcomeTip3 =>
@@ -2400,7 +2433,7 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
'Convert to AAC/M4A, MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2753,14 +2786,14 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
'Folder named after Album Artist tag';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
'Folder named after Track Artist tag';
@override
String get lyricsProvidersTitle => 'Lyrics Providers';
String get lyricsProvidersTitle => 'Lyrics Provider Priority';
@override
String get lyricsProvidersDescription =>
@@ -2768,7 +2801,7 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get lyricsProvidersInfoText =>
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
'Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.';
@override
String lyricsProvidersEnabledSection(int count) {
@@ -2810,6 +2843,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get lyricsProviderQqMusicDesc =>
'QQ Music (good for Chinese songs, via proxy)';
@override
String get lyricsProviderLyricsPlusDesc =>
'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)';
@override
String get lyricsProviderExtensionDesc => 'Extension provider';
@@ -2828,10 +2865,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get safMigrationSuccess => 'Download folder updated to SAF mode';
@override
String get settingsDonate => 'Donate';
String get settingsDonate => 'Support Development';
@override
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
String get settingsDonateSubtitle => 'Buy the developer a coffee';
@override
String get tooltipLoveAll => 'Love All';
@@ -2891,20 +2928,20 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get downloadLocationSubtitle =>
'Choose storage mode for downloaded files.';
'Choose where to save your downloaded tracks';
@override
String get storageModeAppFolder => 'App folder (non-SAF)';
String get storageModeAppFolder => 'App Folder (Recommended)';
@override
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
String get storageModeAppFolderSubtitle =>
'Saves to Music/SpotiFLAC by default';
@override
String get storageModeSaf => 'SAF folder';
String get storageModeSaf => 'Custom Folder (SAF)';
@override
String get storageModeSafSubtitle =>
'Pick folder via Android Storage Access Framework';
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
@override
String downloadFilenameDescription(
@@ -2916,62 +2953,62 @@ class AppLocalizationsKo extends AppLocalizations {
Object track,
Object year,
) {
return 'Customize how your files are named.';
return 'Use $artist, $title, $album, $track, $year, $date, $disc as placeholders.';
}
@override
String get downloadFilenameInsertTag => 'Tap to insert tag:';
@override
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
String get downloadSeparateSinglesEnabled =>
'Singles and EPs saved in a separate folder';
@override
String get downloadSeparateSinglesDisabled => 'All files in same structure';
String get downloadSeparateSinglesDisabled =>
'Singles and albums saved in the same folder';
@override
String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
String get downloadCreatePlaylistSourceFolder => 'Playlist Source Folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
'A subfolder is created for each playlist';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
'All tracks saved directly to download folder';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
'Handled by folder organization setting';
@override
String get downloadSongLinkRegion => 'SongLink Region';
@override
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
String get downloadNetworkCompatibilityMode => 'Network Compatibility Mode';
@override
String get downloadNetworkCompatibilityModeEnabled =>
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
'Using legacy TLS settings for older networks';
@override
String get downloadNetworkCompatibilityModeDisabled =>
'Off: strict HTTPS certificate validation (recommended)';
'Using standard network settings';
@override
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
'Select a provider with quality options to enable this option';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
'Select a provider with quality options to choose audio quality';
@override
String get downloadEmbedLyricsDisabled =>
'Disabled while Embed Metadata is turned off';
String get downloadEmbedLyricsDisabled => 'Enable metadata embedding first';
@override
String get downloadNeteaseIncludeTranslation =>
@@ -2979,11 +3016,11 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get downloadNeteaseIncludeTranslationEnabled =>
'Append translated lyrics when available';
'Chinese translation lines included';
@override
String get downloadNeteaseIncludeTranslationDisabled =>
'Use original lyrics only';
'Original lyrics only';
@override
String get downloadNeteaseIncludeRomanization =>
@@ -2991,21 +3028,21 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get downloadNeteaseIncludeRomanizationEnabled =>
'Append romanized lyrics when available';
'Romanization lines included';
@override
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
String get downloadNeteaseIncludeRomanizationDisabled => 'No romanization';
@override
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
String get downloadAppleQqMultiPerson => 'Apple / QQ: Multi-Person Lyrics';
@override
String get downloadAppleQqMultiPersonEnabled =>
'Enable v1/v2 speaker and [bg:] tags';
'Speaker labels included for duets and group tracks';
@override
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
'Standard lyrics without speaker labels';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@@ -3022,46 +3059,45 @@ class AppLocalizationsKo extends AppLocalizations {
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@override
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
String get downloadMusixmatchLanguageAuto => 'Auto (original language)';
@override
String get downloadFilterContributing =>
'Filter contributing artists in Album Artist';
String get downloadFilterContributing => 'Filter Contributing Artists';
@override
String get downloadFilterContributingEnabled =>
'Album Artist metadata uses primary artist only';
'Contributing artists removed from Album Artist folder name';
@override
String get downloadFilterContributingDisabled =>
'Keep full Album Artist metadata value';
'Full Album Artist string used';
@override
String get downloadProvidersNoneEnabled => 'None enabled';
String get downloadProvidersNoneEnabled => 'No providers enabled';
@override
String get downloadMusixmatchLanguageCode => 'Language code';
@override
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
String get downloadMusixmatchLanguageHint => 'e.g. en, de, ja';
@override
String get downloadMusixmatchLanguageDesc =>
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
'Enter a BCP-47 language code (e.g. en, de, ja) to request translated lyrics from Musixmatch.';
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
String get downloadNetworkAnySubtitle => 'Use WiFi or mobile data';
@override
String get downloadNetworkWifiOnlySubtitle =>
'Pause downloads on mobile data';
'Downloads pause when on mobile data';
@override
String get downloadSongLinkRegionDesc =>
'Used as userCountry for SongLink API lookup.';
'Region used when resolving track links via SongLink. Choose the country where your streaming services are available.';
@override
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
@@ -3447,7 +3483,13 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks downloaded successfully',
one: '1 track downloaded successfully',
);
return '$_temp0';
}
@override
@@ -3642,6 +3684,13 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
@@ -4168,4 +4217,46 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
+163 -77
View File
@@ -126,7 +126,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get optionsPrimaryProviderSubtitle =>
'Service used when searching by track name.';
'Service used for searching by track or album name';
@override
String optionsUsingExtension(String extensionName) {
@@ -142,7 +142,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
'Choose the default search provider to switch back from an extension';
@override
String get optionsAutoFallback => 'Auto Fallback';
@@ -155,17 +155,19 @@ class AppLocalizationsNl extends AppLocalizations {
String get optionsUseExtensionProviders => 'Use Extension Providers';
@override
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
String get optionsUseExtensionProvidersOn =>
'Extension providers are enabled';
@override
String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
String get optionsUseExtensionProvidersOff =>
'Extension providers are required';
@override
String get optionsEmbedLyrics => 'Embed Lyrics';
@override
String get optionsEmbedLyricsSubtitle =>
'Embed synced lyrics into FLAC files';
'Save synced lyrics alongside your downloaded tracks';
@override
String get optionsMaxQualityCover => 'Max Quality Cover';
@@ -185,6 +187,43 @@ class AppLocalizationsNl extends AppLocalizations {
String get optionsReplayGainSubtitleOff =>
'Disabled: no loudness normalization tags';
@override
String get trackReplayGain => 'Rescan ReplayGain';
@override
String get trackReplayGainSubtitle =>
'Analyze loudness and write ReplayGain tags';
@override
String get trackReplayGainScanning => 'Analyzing loudness...';
@override
String get trackReplayGainSuccess => 'ReplayGain tags added';
@override
String get trackReplayGainFailed => 'Failed to add ReplayGain tags';
@override
String selectionReplayGainCount(int count) {
return 'ReplayGain ($count)';
}
@override
String get replayGainBatchConfirmTitle => 'Add ReplayGain';
@override
String replayGainBatchConfirmMessage(int count) {
return 'Analyze loudness and write ReplayGain tags to $count track(s)?';
}
@override
String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...';
@override
String replayGainBatchSuccess(int success, int total) {
return 'ReplayGain added to $success of $total tracks';
}
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@@ -206,21 +245,6 @@ class AppLocalizationsNl extends AppLocalizations {
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override
String get optionsConcurrentDownloads => 'Concurrent Downloads';
@override
String get optionsConcurrentSequential => 'Sequentiële (1 per keer)';
@override
String optionsConcurrentParallel(int count) {
return '';
}
@override
String get optionsConcurrentWarning =>
'Parallel downloaden kan leiden tot rate-limiting';
@override
String get optionsExtensionStore => 'Extension Repo';
@@ -323,7 +347,7 @@ class AppLocalizationsNl extends AppLocalizations {
String get aboutContributors => 'Contributors';
@override
String get aboutMobileDeveloper => '';
String get aboutMobileDeveloper => 'Mobile version developer';
@override
String get aboutOriginalCreator => 'Creator of the original SpotiFLAC';
@@ -385,11 +409,11 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get aboutBinimumDesc =>
'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!';
'The creator of QQDL & HiFi API. This project helped shape lossless download support.';
@override
String get aboutSachinsenalDesc =>
'The original HiFi project creator. The foundation of Tidal integration!';
'The original HiFi project creator. A foundation for lossless-source integration.';
@override
String get aboutSjdonadoDesc =>
@@ -742,6 +766,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@@ -950,7 +977,7 @@ class AppLocalizationsNl extends AppLocalizations {
'Only enabled extensions with download-provider capability are listed here.';
@override
String get providerBuiltIn => 'Built-in';
String get providerBuiltIn => 'Legacy';
@override
String get providerExtension => 'Extension';
@@ -1118,10 +1145,10 @@ class AppLocalizationsNl extends AppLocalizations {
String get settingsAppearanceSubtitle => 'Theme, colors, display';
@override
String get settingsDownloadSubtitle => 'Service, quality, filename format';
String get settingsDownloadSubtitle => 'Service, quality, fallback';
@override
String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates';
String get settingsOptionsSubtitle => 'Fallback, metadata, lyrics, cover art';
@override
String get settingsExtensionsSubtitle => 'Manage download providers';
@@ -1343,10 +1370,11 @@ class AppLocalizationsNl extends AppLocalizations {
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Default (Deezer)';
String get extensionDefaultProvider => 'Default Search';
@override
String get extensionDefaultProviderSubtitle => 'Use built-in search';
String get extensionDefaultProviderSubtitle =>
'Use the default metadata search';
@override
String get extensionAuthor => 'Author';
@@ -1520,7 +1548,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
'Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@@ -2090,7 +2118,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
'Get FLAC quality audio from installed download extensions';
@override
String get tutorialWelcomeTip3 =>
@@ -2420,7 +2448,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
'Convert to AAC/M4A, MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2773,14 +2801,14 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
'Folder named after Album Artist tag';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
'Folder named after Track Artist tag';
@override
String get lyricsProvidersTitle => 'Lyrics Providers';
String get lyricsProvidersTitle => 'Lyrics Provider Priority';
@override
String get lyricsProvidersDescription =>
@@ -2788,7 +2816,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get lyricsProvidersInfoText =>
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
'Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.';
@override
String lyricsProvidersEnabledSection(int count) {
@@ -2830,6 +2858,10 @@ class AppLocalizationsNl extends AppLocalizations {
String get lyricsProviderQqMusicDesc =>
'QQ Music (good for Chinese songs, via proxy)';
@override
String get lyricsProviderLyricsPlusDesc =>
'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)';
@override
String get lyricsProviderExtensionDesc => 'Extension provider';
@@ -2848,10 +2880,10 @@ class AppLocalizationsNl extends AppLocalizations {
String get safMigrationSuccess => 'Download folder updated to SAF mode';
@override
String get settingsDonate => 'Donate';
String get settingsDonate => 'Support Development';
@override
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
String get settingsDonateSubtitle => 'Buy the developer a coffee';
@override
String get tooltipLoveAll => 'Love All';
@@ -2911,20 +2943,20 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get downloadLocationSubtitle =>
'Choose storage mode for downloaded files.';
'Choose where to save your downloaded tracks';
@override
String get storageModeAppFolder => 'App folder (non-SAF)';
String get storageModeAppFolder => 'App Folder (Recommended)';
@override
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
String get storageModeAppFolderSubtitle =>
'Saves to Music/SpotiFLAC by default';
@override
String get storageModeSaf => 'SAF folder';
String get storageModeSaf => 'Custom Folder (SAF)';
@override
String get storageModeSafSubtitle =>
'Pick folder via Android Storage Access Framework';
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
@override
String downloadFilenameDescription(
@@ -2936,62 +2968,62 @@ class AppLocalizationsNl extends AppLocalizations {
Object track,
Object year,
) {
return 'Customize how your files are named.';
return 'Use $artist, $title, $album, $track, $year, $date, $disc as placeholders.';
}
@override
String get downloadFilenameInsertTag => 'Tap to insert tag:';
@override
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
String get downloadSeparateSinglesEnabled =>
'Singles and EPs saved in a separate folder';
@override
String get downloadSeparateSinglesDisabled => 'All files in same structure';
String get downloadSeparateSinglesDisabled =>
'Singles and albums saved in the same folder';
@override
String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
String get downloadCreatePlaylistSourceFolder => 'Playlist Source Folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
'A subfolder is created for each playlist';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
'All tracks saved directly to download folder';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
'Handled by folder organization setting';
@override
String get downloadSongLinkRegion => 'SongLink Region';
@override
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
String get downloadNetworkCompatibilityMode => 'Network Compatibility Mode';
@override
String get downloadNetworkCompatibilityModeEnabled =>
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
'Using legacy TLS settings for older networks';
@override
String get downloadNetworkCompatibilityModeDisabled =>
'Off: strict HTTPS certificate validation (recommended)';
'Using standard network settings';
@override
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
'Select a provider with quality options to enable this option';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
'Select a provider with quality options to choose audio quality';
@override
String get downloadEmbedLyricsDisabled =>
'Disabled while Embed Metadata is turned off';
String get downloadEmbedLyricsDisabled => 'Enable metadata embedding first';
@override
String get downloadNeteaseIncludeTranslation =>
@@ -2999,11 +3031,11 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get downloadNeteaseIncludeTranslationEnabled =>
'Append translated lyrics when available';
'Chinese translation lines included';
@override
String get downloadNeteaseIncludeTranslationDisabled =>
'Use original lyrics only';
'Original lyrics only';
@override
String get downloadNeteaseIncludeRomanization =>
@@ -3011,21 +3043,21 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get downloadNeteaseIncludeRomanizationEnabled =>
'Append romanized lyrics when available';
'Romanization lines included';
@override
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
String get downloadNeteaseIncludeRomanizationDisabled => 'No romanization';
@override
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
String get downloadAppleQqMultiPerson => 'Apple / QQ: Multi-Person Lyrics';
@override
String get downloadAppleQqMultiPersonEnabled =>
'Enable v1/v2 speaker and [bg:] tags';
'Speaker labels included for duets and group tracks';
@override
String get downloadAppleQqMultiPersonDisabled =>
'Simplified word-by-word formatting';
'Standard lyrics without speaker labels';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@@ -3042,46 +3074,45 @@ class AppLocalizationsNl extends AppLocalizations {
String get downloadMusixmatchLanguage => 'Musixmatch Language';
@override
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
String get downloadMusixmatchLanguageAuto => 'Auto (original language)';
@override
String get downloadFilterContributing =>
'Filter contributing artists in Album Artist';
String get downloadFilterContributing => 'Filter Contributing Artists';
@override
String get downloadFilterContributingEnabled =>
'Album Artist metadata uses primary artist only';
'Contributing artists removed from Album Artist folder name';
@override
String get downloadFilterContributingDisabled =>
'Keep full Album Artist metadata value';
'Full Album Artist string used';
@override
String get downloadProvidersNoneEnabled => 'None enabled';
String get downloadProvidersNoneEnabled => 'No providers enabled';
@override
String get downloadMusixmatchLanguageCode => 'Language code';
@override
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
String get downloadMusixmatchLanguageHint => 'e.g. en, de, ja';
@override
String get downloadMusixmatchLanguageDesc =>
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
'Enter a BCP-47 language code (e.g. en, de, ja) to request translated lyrics from Musixmatch.';
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
String get downloadNetworkAnySubtitle => 'Use WiFi or mobile data';
@override
String get downloadNetworkWifiOnlySubtitle =>
'Pause downloads on mobile data';
'Downloads pause when on mobile data';
@override
String get downloadSongLinkRegionDesc =>
'Used as userCountry for SongLink API lookup.';
'Region used when resolving track links via SongLink. Choose the country where your streaming services are available.';
@override
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
@@ -3467,7 +3498,13 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks downloaded successfully',
one: '1 track downloaded successfully',
);
return '$_temp0';
}
@override
@@ -3662,6 +3699,13 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
@@ -4188,4 +4232,46 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+164 -84
View File
@@ -129,7 +129,7 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get optionsPrimaryProviderSubtitle =>
'Розширення будуть випробувані першими';
'Service used for searching by track or album name';
@override
String optionsUsingExtension(String extensionName) {
@@ -137,15 +137,15 @@ class AppLocalizationsUk extends AppLocalizations {
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
String get optionsDefaultSearchTab => 'Вкладка пошуку за замовчуванням';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
'Виберіть, яка вкладка відкриється першою для нових результатів пошуку.';
@override
String get optionsSwitchBack =>
'Натисніть Deezer або Spotify, щоб повернутися до розширення';
'Choose the default search provider to switch back from an extension';
@override
String get optionsAutoFallback => 'Автоматичний резервний варіант';
@@ -160,18 +160,18 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get optionsUseExtensionProvidersOn =>
'Розширення будуть випробувані першими';
'Extension providers are enabled';
@override
String get optionsUseExtensionProvidersOff =>
'Використати лише вбудованих постачальників';
'Extension providers are required';
@override
String get optionsEmbedLyrics => 'Вбудований текст пісні';
@override
String get optionsEmbedLyricsSubtitle =>
'Вбудовувати синхронізовані тексти пісень у файли FLAC';
'Save synced lyrics alongside your downloaded tracks';
@override
String get optionsMaxQualityCover => 'Максимальна якість обкладинки';
@@ -191,6 +191,43 @@ class AppLocalizationsUk extends AppLocalizations {
String get optionsReplayGainSubtitleOff =>
'Вимкнено: немає тегів нормалізації гучності';
@override
String get trackReplayGain => 'Rescan ReplayGain';
@override
String get trackReplayGainSubtitle =>
'Analyze loudness and write ReplayGain tags';
@override
String get trackReplayGainScanning => 'Analyzing loudness...';
@override
String get trackReplayGainSuccess => 'ReplayGain tags added';
@override
String get trackReplayGainFailed => 'Failed to add ReplayGain tags';
@override
String selectionReplayGainCount(int count) {
return 'ReplayGain ($count)';
}
@override
String get replayGainBatchConfirmTitle => 'Add ReplayGain';
@override
String replayGainBatchConfirmMessage(int count) {
return 'Analyze loudness and write ReplayGain tags to $count track(s)?';
}
@override
String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...';
@override
String replayGainBatchSuccess(int success, int total) {
return 'ReplayGain added to $success of $total tracks';
}
@override
String get optionsArtistTagMode => 'Режим тегу виконавця';
@@ -212,21 +249,6 @@ class AppLocalizationsUk extends AppLocalizations {
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Для FLAC та Opus на кожного виконавця додати окремий тег виконавця; MP3 та M4A залишаються об’єднаними.';
@override
String get optionsConcurrentDownloads => 'Кількість одночасних завантажень';
@override
String get optionsConcurrentSequential => 'Послідовно (по одному за раз)';
@override
String optionsConcurrentParallel(int count) {
return '$count паралельних завантажень';
}
@override
String get optionsConcurrentWarning =>
'Паралельні завантаження можуть призвести до обмеження швидкості';
@override
String get optionsExtensionStore => 'Репозиторій розширень';
@@ -395,11 +417,11 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get aboutBinimumDesc =>
'Творець QQDL та HiFi API. Без цього API завантажень Tidal\'а не існувало б!';
'The creator of QQDL & HiFi API. This project helped shape lossless download support.';
@override
String get aboutSachinsenalDesc =>
'Оригінальний творець HiFi-проектів. Основа інтеграції Tidal!';
'The original HiFi project creator. A foundation for lossless-source integration.';
@override
String get aboutSjdonadoDesc =>
@@ -755,6 +777,9 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get errorNoTracksFound => 'Треків не знайдено';
@override
String get searchEmptyResultSubtitle => 'Try another keyword';
@override
String get errorUrlNotRecognized => 'Посилання не розпізнано';
@@ -956,14 +981,14 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get providerPriorityFallbackExtensionsDescription =>
'Виберіть, які встановлені розширення завантаження можна використовувати під час автоматичного відновлення до попереднього режиму. Вбудовані постачальники все одно дотримуються порядку пріоритетності, зазначеного вище.';
'Choose which installed download extensions can be used during automatic fallback.';
@override
String get providerPriorityFallbackExtensionsHint =>
'Тут перелічені лише ввімкнені розширення з можливістю завантаження через постачальника послуг.';
@override
String get providerBuiltIn => 'Вбудований';
String get providerBuiltIn => 'Legacy';
@override
String get providerExtension => 'Розширення';
@@ -1134,11 +1159,10 @@ class AppLocalizationsUk extends AppLocalizations {
String get settingsAppearanceSubtitle => 'Тема, кольори, дисплей';
@override
String get settingsDownloadSubtitle => 'Сервіс, якість, формат назви файлу';
String get settingsDownloadSubtitle => 'Service, quality, fallback';
@override
String get settingsOptionsSubtitle =>
'Резервний варіант, тексти пісень, обкладинка, оновлення';
String get settingsOptionsSubtitle => 'Fallback, metadata, lyrics, cover art';
@override
String get settingsExtensionsSubtitle =>
@@ -1365,10 +1389,11 @@ class AppLocalizationsUk extends AppLocalizations {
String get storeEmptyNoResults => 'Розширень не знайдено';
@override
String get extensionDefaultProvider => 'За замовчуванням (Deezer)';
String get extensionDefaultProvider => 'Default Search';
@override
String get extensionDefaultProviderSubtitle => 'Використати вбудований пошук';
String get extensionDefaultProviderSubtitle =>
'Use the default metadata search';
@override
String get extensionAuthor => 'Автор';
@@ -1544,7 +1569,7 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get downloadLossy320FormatDesc =>
'Виберіть вихідний формат для завантажень Tidal 320 кбіт/с із втратами. Оригінальний потік AAC буде конвертовано у вибраний вами формат.';
'Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.';
@override
String get downloadLossyMp3 => 'MP3 320 кбіт/с';
@@ -2126,7 +2151,7 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Отримуйте аудіо у якості FLAC з Tidal, Qobuz або Deezer';
'Get FLAC quality audio from installed download extensions';
@override
String get tutorialWelcomeTip3 =>
@@ -2461,7 +2486,7 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get trackConvertFormatSubtitle =>
'Конвертувати в MP3, Opus, ALAC або FLAC';
'Convert to AAC/M4A, MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Конвертувати аудіо';
@@ -2818,14 +2843,14 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Папки виконавців використовують \"Виконавець альбому\", коли це можливо';
'Folder named after Album Artist tag';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Папки виконавців використовують лише виконавця доріжки';
'Folder named after Track Artist tag';
@override
String get lyricsProvidersTitle => 'Постачальники текстів пісень';
String get lyricsProvidersTitle => 'Lyrics Provider Priority';
@override
String get lyricsProvidersDescription =>
@@ -2833,7 +2858,7 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get lyricsProvidersInfoText =>
'Постачальники розширених текстів пісень завжди запускаються перед вбудованими постачальниками. Принаймні один постачальник має залишатися ввімкненим.';
'Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.';
@override
String lyricsProvidersEnabledSection(int count) {
@@ -2877,6 +2902,10 @@ class AppLocalizationsUk extends AppLocalizations {
String get lyricsProviderQqMusicDesc =>
'QQ Music (добре для китайських пісень, через проксі)';
@override
String get lyricsProviderLyricsPlusDesc =>
'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)';
@override
String get lyricsProviderExtensionDesc => 'Постачальник розширень';
@@ -2895,11 +2924,10 @@ class AppLocalizationsUk extends AppLocalizations {
String get safMigrationSuccess => 'Папку завантажень оновлено до режиму SAF';
@override
String get settingsDonate => 'Пожертвувати кошти';
String get settingsDonate => 'Support Development';
@override
String get settingsDonateSubtitle =>
'Підтримка розробки SpotiFLAC для мобільних пристроїв';
String get settingsDonateSubtitle => 'Buy the developer a coffee';
@override
String get tooltipLoveAll => 'Уподобати всіх';
@@ -2962,21 +2990,20 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get downloadLocationSubtitle =>
'Виберіть режим зберігання для завантажених файлів.';
'Choose where to save your downloaded tracks';
@override
String get storageModeAppFolder => 'Папка додатку (не SAF)';
String get storageModeAppFolder => 'App Folder (Recommended)';
@override
String get storageModeAppFolderSubtitle =>
'Використовувати шлях Music/SpotiFLAC за замовчуванням';
'Saves to Music/SpotiFLAC by default';
@override
String get storageModeSaf => 'Папка SAF';
String get storageModeSaf => 'Custom Folder (SAF)';
@override
String get storageModeSafSubtitle =>
'Вибрати папку через Android Storage Access Framework';
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
@override
String downloadFilenameDescription(
@@ -2988,73 +3015,73 @@ class AppLocalizationsUk extends AppLocalizations {
Object track,
Object year,
) {
return 'Налаштувати спосіб іменування ваших файлів.';
return 'Use $artist, $title, $album, $track, $year, $date, $disc as placeholders.';
}
@override
String get downloadFilenameInsertTag => 'Натисніть, щоб вставити тег:';
@override
String get downloadSeparateSinglesEnabled => 'Папки «Альбоми» та «Сингли»';
String get downloadSeparateSinglesEnabled =>
'Singles and EPs saved in a separate folder';
@override
String get downloadSeparateSinglesDisabled => 'Всі файли в одній структурі';
String get downloadSeparateSinglesDisabled =>
'Singles and albums saved in the same folder';
@override
String get downloadArtistNameFilters => 'Фільтри імені виконавця';
@override
String get downloadCreatePlaylistSourceFolder =>
'Створити папку джерела списку відтворення';
String get downloadCreatePlaylistSourceFolder => 'Playlist Source Folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Завантаження списків відтворення використовує Playlist/ плюс вашу звичайну структуру папок.';
'A subfolder is created for each playlist';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Завантаження списків відтворення використовують лише звичайну структуру папок.';
'All tracks saved directly to download folder';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'За допомогою списку відтворення завантаження вже розміщуються в папці зі списком відтворення.';
'Handled by folder organization setting';
@override
String get downloadSongLinkRegion => 'Регіон SongLink';
@override
String get downloadNetworkCompatibilityMode => 'Режим сумісності з мережею';
String get downloadNetworkCompatibilityMode => 'Network Compatibility Mode';
@override
String get downloadNetworkCompatibilityModeEnabled =>
'Увімкнено: спробувати HTTP + прийняти недійсні сертифікати TLS (небезпечно)';
'Using legacy TLS settings for older networks';
@override
String get downloadNetworkCompatibilityModeDisabled =>
'Вимкнено: сувора перевірка сертифіката HTTPS (рекомендовано)';
'Using standard network settings';
@override
String get downloadSelectServiceToEnable =>
'Виберіть вбудовану службу, яку потрібно ввімкнути';
'Select a provider with quality options to enable this option';
@override
String get downloadSelectTidalQobuz =>
'Виберіть Tidal або Qobuz вище, щоб налаштувати якість';
'Select a provider with quality options to choose audio quality';
@override
String get downloadEmbedLyricsDisabled =>
'Вимкнено, якщо вимкнено функцію «Вбудувати метадані»';
String get downloadEmbedLyricsDisabled => 'Enable metadata embedding first';
@override
String get downloadNeteaseIncludeTranslation => 'Netease: Включити переклад';
@override
String get downloadNeteaseIncludeTranslationEnabled =>
'Додати перекладені тексти пісень, коли вони доступні';
'Chinese translation lines included';
@override
String get downloadNeteaseIncludeTranslationDisabled =>
'Використовувати лише оригінальні тексти пісень';
'Original lyrics only';
@override
String get downloadNeteaseIncludeRomanization =>
@@ -3062,22 +3089,21 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get downloadNeteaseIncludeRomanizationEnabled =>
'Додати романізовані тексти пісень, коли це можливо';
'Romanization lines included';
@override
String get downloadNeteaseIncludeRomanizationDisabled => 'Вимкнути';
String get downloadNeteaseIncludeRomanizationDisabled => 'No romanization';
@override
String get downloadAppleQqMultiPerson =>
'Apple/QQ Багатокористувацький переклад слово за словом';
String get downloadAppleQqMultiPerson => 'Apple / QQ: Multi-Person Lyrics';
@override
String get downloadAppleQqMultiPersonEnabled =>
'Увімкнути теги динаміка v1/v2 та [bg:]';
'Speaker labels included for duets and group tracks';
@override
String get downloadAppleQqMultiPersonDisabled =>
'Спрощене послівне форматування';
'Standard lyrics without speaker labels';
@override
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
@@ -3094,46 +3120,45 @@ class AppLocalizationsUk extends AppLocalizations {
String get downloadMusixmatchLanguage => 'Мова Musixmatch';
@override
String get downloadMusixmatchLanguageAuto => 'Авто (оригінал)';
String get downloadMusixmatchLanguageAuto => 'Auto (original language)';
@override
String get downloadFilterContributing =>
'Фільтрувати виконавців-учасників у розділі «Виконавець альбому»';
String get downloadFilterContributing => 'Filter Contributing Artists';
@override
String get downloadFilterContributingEnabled =>
'Метадані виконавця альбому використовують лише основного виконавця';
'Contributing artists removed from Album Artist folder name';
@override
String get downloadFilterContributingDisabled =>
'Зберегти повне значення метаданих виконавця альбому';
'Full Album Artist string used';
@override
String get downloadProvidersNoneEnabled => 'Не ввімкнено';
String get downloadProvidersNoneEnabled => 'No providers enabled';
@override
String get downloadMusixmatchLanguageCode => 'Код мови';
@override
String get downloadMusixmatchLanguageHint => 'авто / en / es / ja';
String get downloadMusixmatchLanguageHint => 'e.g. en, de, ja';
@override
String get downloadMusixmatchLanguageDesc =>
'Встановити потрібний код мови (наприклад: en, es, ja). Залиште поле порожнім для автоматичного вибору.';
'Enter a BCP-47 language code (e.g. en, de, ja) to request translated lyrics from Musixmatch.';
@override
String get downloadMusixmatchAuto => 'Авто';
@override
String get downloadNetworkAnySubtitle => 'Wi-Fi + мобільний інтернет';
String get downloadNetworkAnySubtitle => 'Use WiFi or mobile data';
@override
String get downloadNetworkWifiOnlySubtitle =>
'Призупинити завантаження через мобільний інтернет';
'Downloads pause when on mobile data';
@override
String get downloadSongLinkRegionDesc =>
'Використовувати як userCountry для пошуку SongLink API.';
'Region used when resolving track links via SongLink. Choose the country where your streaming services are available.';
@override
String get snackbarUnsupportedAudioFormat => 'Непідтримуваний аудіоформат';
@@ -3526,7 +3551,13 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String notifTracksDownloadedSuccess(int count) {
return '$count треки успішно завантажено';
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks downloaded successfully',
one: '1 track downloaded successfully',
);
return '$_temp0';
}
@override
@@ -3606,7 +3637,7 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String notifDownloadingUpdate(String version) {
return 'Завантаження SpotiFLAC Mobile v$version';
return 'Downloading SpotiFLAC Mobile v$version';
}
@override
@@ -3619,7 +3650,7 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC Mobile v$version завантажений. Натисніть щоб установити.';
return 'SpotiFLAC Mobile v$version downloaded. Tap to install.';
}
@override
@@ -3721,6 +3752,13 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get queueDownloadCompleted => 'Download completed';
@override
String get queueRateLimitTitle => 'Service rate limited';
@override
String get queueRateLimitMessage =>
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
@override
String appearanceSelectAccentColor(String hex) {
return 'Select accent color $hex';
@@ -4247,4 +4285,46 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get artistReleases => 'Releases';
@override
String get editMetadataSelectNone => 'None';
@override
String queueRetryAllFailed(int count) {
return 'Retry $count failed';
}
@override
String get settingsSaveDownloadHistory => 'Save download history';
@override
String get settingsSaveDownloadHistorySubtitle =>
'Keep completed downloads in history and library views';
@override
String get dialogDisableHistoryTitle => 'Turn off download history?';
@override
String get dialogDisableHistoryMessage =>
'Existing history will be cleared. Downloaded files will not be deleted.';
@override
String get dialogDisableAndClear => 'Turn off and clear';
@override
String get openInOtherServices => 'Open in Other Services';
@override
String get shareSheetNoExtensions => 'No other compatible services';
@override
String get shareSheetNotFound => 'Not found';
@override
String get shareSheetCopyLink => 'Copy Link';
@override
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1187 -235
View File
File diff suppressed because it is too large Load Diff
+162 -53
View File
@@ -174,9 +174,9 @@
"@optionsDefaultSearchTabSubtitle": {
"description": "Subtitle for the preferred default search tab setting"
},
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
"optionsSwitchBack": "Choose the default search provider to switch back from an extension",
"@optionsSwitchBack": {
"description": "Hint to switch back to built-in providers"
"description": "Hint to switch back from extension search"
},
"optionsAutoFallback": "Auto Fallback",
"@optionsAutoFallback": {
@@ -188,15 +188,15 @@
},
"optionsUseExtensionProviders": "Use Extension Providers",
"@optionsUseExtensionProviders": {
"description": "Enable extension download providers"
"description": "Legacy setting label for extension download providers"
},
"optionsUseExtensionProvidersOn": "Extensions will be tried first",
"optionsUseExtensionProvidersOn": "Extension providers are enabled",
"@optionsUseExtensionProvidersOn": {
"description": "Status when extension providers enabled"
},
"optionsUseExtensionProvidersOff": "Using built-in providers only",
"optionsUseExtensionProvidersOff": "Extension providers are required",
"@optionsUseExtensionProvidersOff": {
"description": "Status when extension providers disabled"
"description": "Legacy status when extension providers would be disabled"
},
"optionsEmbedLyrics": "Embed Lyrics",
"@optionsEmbedLyrics": {
@@ -226,6 +226,64 @@
"@optionsReplayGainSubtitleOff": {
"description": "Subtitle when ReplayGain is disabled"
},
"trackReplayGain": "Rescan ReplayGain",
"@trackReplayGain": {
"description": "Three-dot menu option to scan loudness and write ReplayGain tags"
},
"trackReplayGainSubtitle": "Analyze loudness and write ReplayGain tags",
"@trackReplayGainSubtitle": {
"description": "Subtitle for the rescan ReplayGain menu option"
},
"trackReplayGainScanning": "Analyzing loudness...",
"@trackReplayGainScanning": {
"description": "Snackbar/progress message while scanning ReplayGain for a single track"
},
"trackReplayGainSuccess": "ReplayGain tags added",
"@trackReplayGainSuccess": {
"description": "Snackbar message after ReplayGain tags written for a single track"
},
"trackReplayGainFailed": "Failed to add ReplayGain tags",
"@trackReplayGainFailed": {
"description": "Snackbar message when ReplayGain scan/write fails"
},
"selectionReplayGainCount": "ReplayGain ({count})",
"@selectionReplayGainCount": {
"description": "Batch selection action button label for ReplayGain",
"placeholders": {
"count": {
"type": "int"
}
}
},
"replayGainBatchConfirmTitle": "Add ReplayGain",
"@replayGainBatchConfirmTitle": {
"description": "Title of the batch ReplayGain confirmation dialog"
},
"replayGainBatchConfirmMessage": "Analyze loudness and write ReplayGain tags to {count} track(s)?",
"@replayGainBatchConfirmMessage": {
"description": "Message of the batch ReplayGain confirmation dialog",
"placeholders": {
"count": {
"type": "int"
}
}
},
"replayGainBatchAnalyzing": "Analyzing ReplayGain...",
"@replayGainBatchAnalyzing": {
"description": "Progress dialog title while batch scanning ReplayGain"
},
"replayGainBatchSuccess": "ReplayGain added to {success} of {total} tracks",
"@replayGainBatchSuccess": {
"description": "Snackbar after batch ReplayGain completes",
"placeholders": {
"success": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"optionsArtistTagMode": "Artist Tag Mode",
"@optionsArtistTagMode": {
"description": "Setting title for how artist metadata is written into files"
@@ -250,27 +308,6 @@
"@optionsArtistTagModeSplitVorbisSubtitle": {
"description": "Subtitle for split Vorbis artist tag mode"
},
"optionsConcurrentDownloads": "Concurrent Downloads",
"@optionsConcurrentDownloads": {
"description": "Number of parallel downloads"
},
"optionsConcurrentSequential": "Sequential (1 at a time)",
"@optionsConcurrentSequential": {
"description": "Download one at a time"
},
"optionsConcurrentParallel": "{count} parallel downloads",
"@optionsConcurrentParallel": {
"description": "Multiple parallel downloads",
"placeholders": {
"count": {
"type": "int"
}
}
},
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting",
"@optionsConcurrentWarning": {
"description": "Warning about rate limits"
},
"optionsExtensionStore": "Extension Repo",
"@optionsExtensionStore": {
"description": "Show/hide store tab"
@@ -486,11 +523,11 @@
"@aboutVersion": {
"description": "Version info label"
},
"aboutBinimumDesc": "The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn't exist!",
"aboutBinimumDesc": "The creator of QQDL & HiFi API. This project helped shape lossless download support.",
"@aboutBinimumDesc": {
"description": "Credit description for binimum"
},
"aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!",
"aboutSachinsenalDesc": "The original HiFi project creator. A foundation for lossless-source integration.",
"@aboutSachinsenalDesc": {
"description": "Credit description for sachinsenal0x64"
},
@@ -961,6 +998,10 @@
"@errorNoTracksFound": {
"description": "Error - search returned no results"
},
"searchEmptyResultSubtitle": "Try another keyword",
"@searchEmptyResultSubtitle": {
"description": "Subtitle shown under the empty search result state on the home screen"
},
"errorUrlNotRecognized": "Link not recognized",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
@@ -1231,9 +1272,9 @@
"@providerPriorityFallbackExtensionsHint": {
"description": "Hint below the extension fallback selection list"
},
"providerBuiltIn": "Built-in",
"providerBuiltIn": "Legacy",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz)"
"description": "Legacy label retained for old generated localization compatibility"
},
"providerExtension": "Extension",
"@providerExtension": {
@@ -1755,11 +1796,11 @@
"@storeEmptyNoResults": {
"description": "Message when search/filter returns no results"
},
"extensionDefaultProvider": "Default (Deezer)",
"extensionDefaultProvider": "Default Search",
"@extensionDefaultProvider": {
"description": "Default search provider option"
},
"extensionDefaultProviderSubtitle": "Use built-in search",
"extensionDefaultProviderSubtitle": "Use the default metadata search",
"@extensionDefaultProviderSubtitle": {
"description": "Subtitle for default provider"
},
@@ -1988,51 +2029,51 @@
},
"downloadLossy320": "Lossy 320kbps",
"@downloadLossy320": {
"description": "Quality option label for Tidal lossy 320kbps"
"description": "Quality option label for lossy 320kbps"
},
"downloadLossyFormat": "Lossy Format",
"@downloadLossyFormat": {
"description": "Setting title to pick output format for Tidal lossy downloads"
"description": "Setting title to pick output format for lossy downloads"
},
"downloadLossy320Format": "Lossy 320kbps Format",
"@downloadLossy320Format": {
"description": "Title of the Tidal lossy format picker bottom sheet"
"description": "Title of the lossy format picker bottom sheet"
},
"downloadLossy320FormatDesc": "Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.",
"downloadLossy320FormatDesc": "Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.",
"@downloadLossy320FormatDesc": {
"description": "Description in the Tidal lossy format picker"
"description": "Description in the lossy format picker"
},
"downloadLossyMp3": "MP3 320kbps",
"@downloadLossyMp3": {
"description": "Tidal lossy format option - MP3 320kbps"
"description": "Lossy format option - MP3 320kbps"
},
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
"@downloadLossyMp3Subtitle": {
"description": "Subtitle for MP3 320kbps Tidal lossy option"
"description": "Subtitle for MP3 320kbps lossy option"
},
"downloadLossyAac": "AAC/M4A 320kbps",
"@downloadLossyAac": {
"description": "Tidal lossy format option - AAC in M4A container at 320kbps"
"description": "Lossy format option - AAC in M4A container at 320kbps"
},
"downloadLossyAacSubtitle": "Best mobile compatibility, M4A container",
"@downloadLossyAacSubtitle": {
"description": "Subtitle for AAC/M4A 320kbps Tidal lossy option"
"description": "Subtitle for AAC/M4A 320kbps lossy option"
},
"downloadLossyOpus256": "Opus 256kbps",
"@downloadLossyOpus256": {
"description": "Tidal lossy format option - Opus 256kbps"
"description": "Lossy format option - Opus 256kbps"
},
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
"@downloadLossyOpus256Subtitle": {
"description": "Subtitle for Opus 256kbps Tidal lossy option"
"description": "Subtitle for Opus 256kbps lossy option"
},
"downloadLossyOpus128": "Opus 128kbps",
"@downloadLossyOpus128": {
"description": "Tidal lossy format option - Opus 128kbps"
"description": "Lossy format option - Opus 128kbps"
},
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
"@downloadLossyOpus128Subtitle": {
"description": "Subtitle for Opus 128kbps Tidal lossy option"
"description": "Subtitle for Opus 128kbps lossy option"
},
"qualityNote": "Actual quality depends on track availability from the service",
"@qualityNote": {
@@ -2515,7 +2556,7 @@
"@libraryAbout": {
"description": "Section header for about info"
},
"libraryAboutDescription": "Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.",
"libraryAboutDescription": "Scans your existing music collection to detect duplicates when downloading. Supports FLAC, ALAC, M4A, MP3, Opus, OGG, WAV, AIFF, and APE formats. Metadata is read from file tags when available.",
"@libraryAboutDescription": {
"description": "Description of local library feature"
},
@@ -2741,7 +2782,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
"tutorialWelcomeTip2": "Get FLAC quality audio from installed download extensions",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},
@@ -3700,7 +3741,7 @@
"@lyricsProvidersDescription": {
"description": "Description on the lyrics provider priority page"
},
"lyricsProvidersInfoText": "Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.",
"lyricsProvidersInfoText": "Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.",
"@lyricsProvidersInfoText": {
"description": "Info tip on lyrics provider priority page"
},
@@ -3754,6 +3795,10 @@
"@lyricsProviderQqMusicDesc": {
"description": "Description for QQ Music provider"
},
"lyricsProviderLyricsPlusDesc": "Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)",
"@lyricsProviderLyricsPlusDesc": {
"description": "Description for LyricsPlus provider"
},
"lyricsProviderExtensionDesc": "Extension provider",
"@lyricsProviderExtensionDesc": {
"description": "Generic description for extension-based lyrics providers"
@@ -3938,13 +3983,13 @@
"@downloadNetworkCompatibilityModeDisabled": {
"description": "Subtitle when network compatibility mode is off"
},
"downloadSelectServiceToEnable": "Select Tidal or Qobuz to enable this option",
"downloadSelectServiceToEnable": "Select a provider with quality options to enable this option",
"@downloadSelectServiceToEnable": {
"description": "Subtitle when quality picker is disabled due to extension service"
},
"downloadSelectTidalQobuz": "Select Tidal or Qobuz to choose audio quality",
"downloadSelectTidalQobuz": "Select a provider with quality options to choose audio quality",
"@downloadSelectTidalQobuz": {
"description": "Info shown when a non-built-in service is selected"
"description": "Legacy info shown when a provider does not expose quality options"
},
"downloadEmbedLyricsDisabled": "Enable metadata embedding first",
"@downloadEmbedLyricsDisabled": {
@@ -4354,7 +4399,7 @@
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
"description": "Extensions page - subtitle for default metadata search provider option",
"placeholders": {
"providerName": {
"type": "String"
@@ -4808,6 +4853,14 @@
"@queueDownloadCompleted": {
"description": "Accessibility label for completed download state in queue"
},
"queueRateLimitTitle": "Service rate limited",
"@queueRateLimitTitle": {
"description": "Title shown on a failed queue item when the download service rate limits requests"
},
"queueRateLimitMessage": "This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.",
"@queueRateLimitMessage": {
"description": "Explanation shown on a failed queue item when the download service rate limits requests"
},
"appearanceSelectAccentColor": "Select accent color {hex}",
"@appearanceSelectAccentColor": {
"description": "Accessibility label for picking an accent color",
@@ -5486,5 +5539,61 @@
"artistReleases": "Releases",
"@artistReleases": {
"description": "Section header for all artist releases"
},
"editMetadataSelectNone": "None",
"@editMetadataSelectNone": {
"description": "Button to clear selected fields for auto-fill"
},
"queueRetryAllFailed": "Retry {count} failed",
"@queueRetryAllFailed": {
"description": "Button to retry every failed download in the queue",
"placeholders": {
"count": {
"type": "int"
}
}
},
"settingsSaveDownloadHistory": "Save download history",
"@settingsSaveDownloadHistory": {
"description": "Settings switch title for storing completed downloads in history"
},
"settingsSaveDownloadHistorySubtitle": "Keep completed downloads in history and library views",
"@settingsSaveDownloadHistorySubtitle": {
"description": "Settings switch subtitle for storing completed downloads in history"
},
"dialogDisableHistoryTitle": "Turn off download history?",
"@dialogDisableHistoryTitle": {
"description": "Confirmation dialog title shown before disabling download history"
},
"dialogDisableHistoryMessage": "Existing history will be cleared. Downloaded files will not be deleted.",
"@dialogDisableHistoryMessage": {
"description": "Confirmation dialog message shown before disabling download history"
},
"dialogDisableAndClear": "Turn off and clear",
"@dialogDisableAndClear": {
"description": "Confirmation action to disable download history and clear existing entries"
},
"openInOtherServices": "Open in Other Services",
"@openInOtherServices": {
"description": "Title and tooltip for finding the current collection in other services"
},
"shareSheetNoExtensions": "No other compatible services",
"@shareSheetNoExtensions": {
"description": "Empty state when no extensions can be searched for cross-service links"
},
"shareSheetNotFound": "Not found",
"@shareSheetNotFound": {
"description": "Cross-service share sheet row subtitle when a service has no match"
},
"shareSheetCopyLink": "Copy Link",
"@shareSheetCopyLink": {
"description": "Tooltip for copying a cross-service link"
},
"shareSheetLinkCopied": "{service} link copied",
"@shareSheetLinkCopied": {
"description": "Snackbar after copying a cross-service link",
"placeholders": {
"service": {}
}
}
}
+29 -50
View File
@@ -142,9 +142,9 @@
}
}
},
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
"optionsSwitchBack": "Choose the default search provider to switch back from an extension",
"@optionsSwitchBack": {
"description": "Hint to switch back to built-in providers"
"description": "Hint to switch back from extension search"
},
"optionsAutoFallback": "Auto Fallback",
"@optionsAutoFallback": {
@@ -156,15 +156,15 @@
},
"optionsUseExtensionProviders": "Use Extension Providers",
"@optionsUseExtensionProviders": {
"description": "Enable extension download providers"
"description": "Legacy setting label for extension download providers"
},
"optionsUseExtensionProvidersOn": "Extensions will be tried first",
"optionsUseExtensionProvidersOn": "Extension providers are enabled",
"@optionsUseExtensionProvidersOn": {
"description": "Status when extension providers enabled"
},
"optionsUseExtensionProvidersOff": "Using built-in providers only",
"optionsUseExtensionProvidersOff": "Extension providers are required",
"@optionsUseExtensionProvidersOff": {
"description": "Status when extension providers disabled"
"description": "Legacy status when extension providers would be disabled"
},
"optionsEmbedLyrics": "Embed Lyrics",
"@optionsEmbedLyrics": {
@@ -182,27 +182,6 @@
"@optionsMaxQualityCoverSubtitle": {
"description": "Subtitle for max quality cover"
},
"optionsConcurrentDownloads": "Concurrent Downloads",
"@optionsConcurrentDownloads": {
"description": "Number of parallel downloads"
},
"optionsConcurrentSequential": "Sequential (1 at a time)",
"@optionsConcurrentSequential": {
"description": "Download one at a time"
},
"optionsConcurrentParallel": "{count} parallel downloads",
"@optionsConcurrentParallel": {
"description": "Multiple parallel downloads",
"placeholders": {
"count": {
"type": "int"
}
}
},
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting",
"@optionsConcurrentWarning": {
"description": "Warning about rate limits"
},
"optionsExtensionStore": "Extension Store",
"@optionsExtensionStore": {
"description": "Show/hide store tab"
@@ -390,11 +369,11 @@
"@aboutVersion": {
"description": "Version info label"
},
"aboutBinimumDesc": "The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn't exist!",
"aboutBinimumDesc": "The creator of QQDL & HiFi API. This project helped shape lossless download support.",
"@aboutBinimumDesc": {
"description": "Credit description for binimum"
},
"aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!",
"aboutSachinsenalDesc": "The original HiFi project creator. A foundation for lossless-source integration.",
"@aboutSachinsenalDesc": {
"description": "Credit description for sachinsenal0x64"
},
@@ -999,9 +978,9 @@
"@providerPriorityInfo": {
"description": "Info tip about fallback behavior"
},
"providerBuiltIn": "Built-in",
"providerBuiltIn": "Legacy",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz)"
"description": "Legacy label retained for old generated localization compatibility"
},
"providerExtension": "Extension",
"@providerExtension": {
@@ -1394,11 +1373,11 @@
"@storeClearFilters": {
"description": "Button to clear all filters"
},
"extensionDefaultProvider": "Default (Deezer/Spotify)",
"extensionDefaultProvider": "Default Search",
"@extensionDefaultProvider": {
"description": "Default search provider option"
},
"extensionDefaultProviderSubtitle": "Use built-in search",
"extensionDefaultProviderSubtitle": "Use the default metadata search",
"@extensionDefaultProviderSubtitle": {
"description": "Subtitle for default provider"
},
@@ -2071,43 +2050,43 @@
},
"downloadLossy320": "Lossy 320kbps",
"@downloadLossy320": {
"description": "Quality option label for Tidal lossy 320kbps"
"description": "Quality option label for lossy 320kbps"
},
"downloadLossyFormat": "Lossy Format",
"@downloadLossyFormat": {
"description": "Setting title to pick output format for Tidal lossy downloads"
"description": "Setting title to pick output format for lossy downloads"
},
"downloadLossy320Format": "Lossy 320kbps Format",
"@downloadLossy320Format": {
"description": "Title of the Tidal lossy format picker bottom sheet"
"description": "Title of the lossy format picker bottom sheet"
},
"downloadLossy320FormatDesc": "Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.",
"downloadLossy320FormatDesc": "Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.",
"@downloadLossy320FormatDesc": {
"description": "Description in the Tidal lossy format picker"
"description": "Description in the lossy format picker"
},
"downloadLossyMp3": "MP3 320kbps",
"@downloadLossyMp3": {
"description": "Tidal lossy format option - MP3 320kbps"
"description": "lossy format option - MP3 320kbps"
},
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
"@downloadLossyMp3Subtitle": {
"description": "Subtitle for MP3 320kbps Tidal lossy option"
"description": "Subtitle for MP3 320kbps lossy option"
},
"downloadLossyOpus256": "Opus 256kbps",
"@downloadLossyOpus256": {
"description": "Tidal lossy format option - Opus 256kbps"
"description": "lossy format option - Opus 256kbps"
},
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
"@downloadLossyOpus256Subtitle": {
"description": "Subtitle for Opus 256kbps Tidal lossy option"
"description": "Subtitle for Opus 256kbps lossy option"
},
"downloadLossyOpus128": "Opus 128kbps",
"@downloadLossyOpus128": {
"description": "Tidal lossy format option - Opus 128kbps"
"description": "lossy format option - Opus 128kbps"
},
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
"@downloadLossyOpus128Subtitle": {
"description": "Subtitle for Opus 128kbps Tidal lossy option"
"description": "Subtitle for Opus 128kbps lossy option"
},
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
"@downloadUseAlbumArtistForFolders": {
@@ -2697,7 +2676,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
"tutorialWelcomeTip2": "Get FLAC quality audio from installed download extensions",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},
@@ -3600,7 +3579,7 @@
"@lyricsProvidersDescription": {
"description": "Description on the lyrics provider priority page"
},
"lyricsProvidersInfoText": "Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.",
"lyricsProvidersInfoText": "Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.",
"@lyricsProvidersInfoText": {
"description": "Info tip on lyrics provider priority page"
},
@@ -3838,13 +3817,13 @@
"@downloadNetworkCompatibilityModeDisabled": {
"description": "Subtitle when network compatibility mode is off"
},
"downloadSelectServiceToEnable": "Select Tidal or Qobuz to enable this option",
"downloadSelectServiceToEnable": "Select a provider with quality options to enable this option",
"@downloadSelectServiceToEnable": {
"description": "Subtitle when quality picker is disabled due to extension service"
},
"downloadSelectTidalQobuz": "Select Tidal or Qobuz to choose audio quality",
"downloadSelectTidalQobuz": "Select a provider with quality options to choose audio quality",
"@downloadSelectTidalQobuz": {
"description": "Info shown when a non-built-in service is selected"
"description": "Legacy info shown when a provider does not expose quality options"
},
"downloadEmbedLyricsDisabled": "Enable metadata embedding first",
"@downloadEmbedLyricsDisabled": {
@@ -4206,7 +4185,7 @@
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
"description": "Extensions page - subtitle for default metadata search provider option",
"placeholders": {
"providerName": {
"type": "String"
+1392 -440
View File
File diff suppressed because it is too large Load Diff
+1851 -899
View File
File diff suppressed because it is too large Load Diff
+1049 -97
View File
File diff suppressed because it is too large Load Diff
+1035 -116
View File
File diff suppressed because it is too large Load Diff
+1048 -96
View File
File diff suppressed because it is too large Load Diff
+1054 -102
View File
File diff suppressed because it is too large Load Diff
+1050 -98
View File
File diff suppressed because it is too large Load Diff
+29 -50
View File
@@ -142,9 +142,9 @@
}
}
},
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
"optionsSwitchBack": "Choose the default search provider to switch back from an extension",
"@optionsSwitchBack": {
"description": "Hint to switch back to built-in providers"
"description": "Hint to switch back from extension search"
},
"optionsAutoFallback": "Auto Fallback",
"@optionsAutoFallback": {
@@ -156,15 +156,15 @@
},
"optionsUseExtensionProviders": "Use Extension Providers",
"@optionsUseExtensionProviders": {
"description": "Enable extension download providers"
"description": "Legacy setting label for extension download providers"
},
"optionsUseExtensionProvidersOn": "Extensions will be tried first",
"optionsUseExtensionProvidersOn": "Extension providers are enabled",
"@optionsUseExtensionProvidersOn": {
"description": "Status when extension providers enabled"
},
"optionsUseExtensionProvidersOff": "Using built-in providers only",
"optionsUseExtensionProvidersOff": "Extension providers are required",
"@optionsUseExtensionProvidersOff": {
"description": "Status when extension providers disabled"
"description": "Legacy status when extension providers would be disabled"
},
"optionsEmbedLyrics": "Embed Lyrics",
"@optionsEmbedLyrics": {
@@ -182,27 +182,6 @@
"@optionsMaxQualityCoverSubtitle": {
"description": "Subtitle for max quality cover"
},
"optionsConcurrentDownloads": "Concurrent Downloads",
"@optionsConcurrentDownloads": {
"description": "Number of parallel downloads"
},
"optionsConcurrentSequential": "Sequential (1 at a time)",
"@optionsConcurrentSequential": {
"description": "Download one at a time"
},
"optionsConcurrentParallel": "{count} parallel downloads",
"@optionsConcurrentParallel": {
"description": "Multiple parallel downloads",
"placeholders": {
"count": {
"type": "int"
}
}
},
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting",
"@optionsConcurrentWarning": {
"description": "Warning about rate limits"
},
"optionsExtensionStore": "Extension Store",
"@optionsExtensionStore": {
"description": "Show/hide store tab"
@@ -390,11 +369,11 @@
"@aboutVersion": {
"description": "Version info label"
},
"aboutBinimumDesc": "The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn't exist!",
"aboutBinimumDesc": "The creator of QQDL & HiFi API. This project helped shape lossless download support.",
"@aboutBinimumDesc": {
"description": "Credit description for binimum"
},
"aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!",
"aboutSachinsenalDesc": "The original HiFi project creator. A foundation for lossless-source integration.",
"@aboutSachinsenalDesc": {
"description": "Credit description for sachinsenal0x64"
},
@@ -999,9 +978,9 @@
"@providerPriorityInfo": {
"description": "Info tip about fallback behavior"
},
"providerBuiltIn": "Built-in",
"providerBuiltIn": "Legacy",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz)"
"description": "Legacy label retained for old generated localization compatibility"
},
"providerExtension": "Extension",
"@providerExtension": {
@@ -1394,11 +1373,11 @@
"@storeClearFilters": {
"description": "Button to clear all filters"
},
"extensionDefaultProvider": "Default (Deezer/Spotify)",
"extensionDefaultProvider": "Default Search",
"@extensionDefaultProvider": {
"description": "Default search provider option"
},
"extensionDefaultProviderSubtitle": "Use built-in search",
"extensionDefaultProviderSubtitle": "Use the default metadata search",
"@extensionDefaultProviderSubtitle": {
"description": "Subtitle for default provider"
},
@@ -2071,43 +2050,43 @@
},
"downloadLossy320": "Lossy 320kbps",
"@downloadLossy320": {
"description": "Quality option label for Tidal lossy 320kbps"
"description": "Quality option label for lossy 320kbps"
},
"downloadLossyFormat": "Lossy Format",
"@downloadLossyFormat": {
"description": "Setting title to pick output format for Tidal lossy downloads"
"description": "Setting title to pick output format for lossy downloads"
},
"downloadLossy320Format": "Lossy 320kbps Format",
"@downloadLossy320Format": {
"description": "Title of the Tidal lossy format picker bottom sheet"
"description": "Title of the lossy format picker bottom sheet"
},
"downloadLossy320FormatDesc": "Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.",
"downloadLossy320FormatDesc": "Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.",
"@downloadLossy320FormatDesc": {
"description": "Description in the Tidal lossy format picker"
"description": "Description in the lossy format picker"
},
"downloadLossyMp3": "MP3 320kbps",
"@downloadLossyMp3": {
"description": "Tidal lossy format option - MP3 320kbps"
"description": "lossy format option - MP3 320kbps"
},
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
"@downloadLossyMp3Subtitle": {
"description": "Subtitle for MP3 320kbps Tidal lossy option"
"description": "Subtitle for MP3 320kbps lossy option"
},
"downloadLossyOpus256": "Opus 256kbps",
"@downloadLossyOpus256": {
"description": "Tidal lossy format option - Opus 256kbps"
"description": "lossy format option - Opus 256kbps"
},
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
"@downloadLossyOpus256Subtitle": {
"description": "Subtitle for Opus 256kbps Tidal lossy option"
"description": "Subtitle for Opus 256kbps lossy option"
},
"downloadLossyOpus128": "Opus 128kbps",
"@downloadLossyOpus128": {
"description": "Tidal lossy format option - Opus 128kbps"
"description": "lossy format option - Opus 128kbps"
},
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
"@downloadLossyOpus128Subtitle": {
"description": "Subtitle for Opus 128kbps Tidal lossy option"
"description": "Subtitle for Opus 128kbps lossy option"
},
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
"@downloadUseAlbumArtistForFolders": {
@@ -2697,7 +2676,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
"tutorialWelcomeTip2": "Get FLAC quality audio from installed download extensions",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},
@@ -3600,7 +3579,7 @@
"@lyricsProvidersDescription": {
"description": "Description on the lyrics provider priority page"
},
"lyricsProvidersInfoText": "Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.",
"lyricsProvidersInfoText": "Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.",
"@lyricsProvidersInfoText": {
"description": "Info tip on lyrics provider priority page"
},
@@ -3838,13 +3817,13 @@
"@downloadNetworkCompatibilityModeDisabled": {
"description": "Subtitle when network compatibility mode is off"
},
"downloadSelectServiceToEnable": "Select Tidal or Qobuz to enable this option",
"downloadSelectServiceToEnable": "Select a provider with quality options to enable this option",
"@downloadSelectServiceToEnable": {
"description": "Subtitle when quality picker is disabled due to extension service"
},
"downloadSelectTidalQobuz": "Select Tidal or Qobuz to choose audio quality",
"downloadSelectTidalQobuz": "Select a provider with quality options to choose audio quality",
"@downloadSelectTidalQobuz": {
"description": "Info shown when a non-built-in service is selected"
"description": "Legacy info shown when a provider does not expose quality options"
},
"downloadEmbedLyricsDisabled": "Enable metadata embedding first",
"@downloadEmbedLyricsDisabled": {
@@ -4206,7 +4185,7 @@
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
"description": "Extensions page - subtitle for default metadata search provider option",
"placeholders": {
"providerName": {
"type": "String"
+1048 -96
View File
File diff suppressed because it is too large Load Diff
+1204 -252
View File
File diff suppressed because it is too large Load Diff
+1162 -210
View File
File diff suppressed because it is too large Load Diff
+1054 -102
View File
File diff suppressed because it is too large Load Diff
+29 -50
View File
@@ -142,9 +142,9 @@
}
}
},
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
"optionsSwitchBack": "Choose the default search provider to switch back from an extension",
"@optionsSwitchBack": {
"description": "Hint to switch back to built-in providers"
"description": "Hint to switch back from extension search"
},
"optionsAutoFallback": "Auto Fallback",
"@optionsAutoFallback": {
@@ -156,15 +156,15 @@
},
"optionsUseExtensionProviders": "Use Extension Providers",
"@optionsUseExtensionProviders": {
"description": "Enable extension download providers"
"description": "Legacy setting label for extension download providers"
},
"optionsUseExtensionProvidersOn": "Extensions will be tried first",
"optionsUseExtensionProvidersOn": "Extension providers are enabled",
"@optionsUseExtensionProvidersOn": {
"description": "Status when extension providers enabled"
},
"optionsUseExtensionProvidersOff": "Using built-in providers only",
"optionsUseExtensionProvidersOff": "Extension providers are required",
"@optionsUseExtensionProvidersOff": {
"description": "Status when extension providers disabled"
"description": "Legacy status when extension providers would be disabled"
},
"optionsEmbedLyrics": "Embed Lyrics",
"@optionsEmbedLyrics": {
@@ -182,27 +182,6 @@
"@optionsMaxQualityCoverSubtitle": {
"description": "Subtitle for max quality cover"
},
"optionsConcurrentDownloads": "Concurrent Downloads",
"@optionsConcurrentDownloads": {
"description": "Number of parallel downloads"
},
"optionsConcurrentSequential": "Sequential (1 at a time)",
"@optionsConcurrentSequential": {
"description": "Download one at a time"
},
"optionsConcurrentParallel": "{count} parallel downloads",
"@optionsConcurrentParallel": {
"description": "Multiple parallel downloads",
"placeholders": {
"count": {
"type": "int"
}
}
},
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting",
"@optionsConcurrentWarning": {
"description": "Warning about rate limits"
},
"optionsExtensionStore": "Extension Store",
"@optionsExtensionStore": {
"description": "Show/hide store tab"
@@ -390,11 +369,11 @@
"@aboutVersion": {
"description": "Version info label"
},
"aboutBinimumDesc": "The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn't exist!",
"aboutBinimumDesc": "The creator of QQDL & HiFi API. This project helped shape lossless download support.",
"@aboutBinimumDesc": {
"description": "Credit description for binimum"
},
"aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!",
"aboutSachinsenalDesc": "The original HiFi project creator. A foundation for lossless-source integration.",
"@aboutSachinsenalDesc": {
"description": "Credit description for sachinsenal0x64"
},
@@ -999,9 +978,9 @@
"@providerPriorityInfo": {
"description": "Info tip about fallback behavior"
},
"providerBuiltIn": "Built-in",
"providerBuiltIn": "Legacy",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz)"
"description": "Legacy label retained for old generated localization compatibility"
},
"providerExtension": "Extension",
"@providerExtension": {
@@ -1394,11 +1373,11 @@
"@storeClearFilters": {
"description": "Button to clear all filters"
},
"extensionDefaultProvider": "Default (Deezer/Spotify)",
"extensionDefaultProvider": "Default Search",
"@extensionDefaultProvider": {
"description": "Default search provider option"
},
"extensionDefaultProviderSubtitle": "Use built-in search",
"extensionDefaultProviderSubtitle": "Use the default metadata search",
"@extensionDefaultProviderSubtitle": {
"description": "Subtitle for default provider"
},
@@ -2071,43 +2050,43 @@
},
"downloadLossy320": "Lossy 320kbps",
"@downloadLossy320": {
"description": "Quality option label for Tidal lossy 320kbps"
"description": "Quality option label for lossy 320kbps"
},
"downloadLossyFormat": "Lossy Format",
"@downloadLossyFormat": {
"description": "Setting title to pick output format for Tidal lossy downloads"
"description": "Setting title to pick output format for lossy downloads"
},
"downloadLossy320Format": "Lossy 320kbps Format",
"@downloadLossy320Format": {
"description": "Title of the Tidal lossy format picker bottom sheet"
"description": "Title of the lossy format picker bottom sheet"
},
"downloadLossy320FormatDesc": "Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.",
"downloadLossy320FormatDesc": "Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.",
"@downloadLossy320FormatDesc": {
"description": "Description in the Tidal lossy format picker"
"description": "Description in the lossy format picker"
},
"downloadLossyMp3": "MP3 320kbps",
"@downloadLossyMp3": {
"description": "Tidal lossy format option - MP3 320kbps"
"description": "lossy format option - MP3 320kbps"
},
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
"@downloadLossyMp3Subtitle": {
"description": "Subtitle for MP3 320kbps Tidal lossy option"
"description": "Subtitle for MP3 320kbps lossy option"
},
"downloadLossyOpus256": "Opus 256kbps",
"@downloadLossyOpus256": {
"description": "Tidal lossy format option - Opus 256kbps"
"description": "lossy format option - Opus 256kbps"
},
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
"@downloadLossyOpus256Subtitle": {
"description": "Subtitle for Opus 256kbps Tidal lossy option"
"description": "Subtitle for Opus 256kbps lossy option"
},
"downloadLossyOpus128": "Opus 128kbps",
"@downloadLossyOpus128": {
"description": "Tidal lossy format option - Opus 128kbps"
"description": "lossy format option - Opus 128kbps"
},
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
"@downloadLossyOpus128Subtitle": {
"description": "Subtitle for Opus 128kbps Tidal lossy option"
"description": "Subtitle for Opus 128kbps lossy option"
},
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
"@downloadUseAlbumArtistForFolders": {
@@ -2697,7 +2676,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
"tutorialWelcomeTip2": "Get FLAC quality audio from installed download extensions",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},
@@ -3600,7 +3579,7 @@
"@lyricsProvidersDescription": {
"description": "Description on the lyrics provider priority page"
},
"lyricsProvidersInfoText": "Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.",
"lyricsProvidersInfoText": "Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.",
"@lyricsProvidersInfoText": {
"description": "Info tip on lyrics provider priority page"
},
@@ -3838,13 +3817,13 @@
"@downloadNetworkCompatibilityModeDisabled": {
"description": "Subtitle when network compatibility mode is off"
},
"downloadSelectServiceToEnable": "Select Tidal or Qobuz to enable this option",
"downloadSelectServiceToEnable": "Select a provider with quality options to enable this option",
"@downloadSelectServiceToEnable": {
"description": "Subtitle when quality picker is disabled due to extension service"
},
"downloadSelectTidalQobuz": "Select Tidal or Qobuz to choose audio quality",
"downloadSelectTidalQobuz": "Select a provider with quality options to choose audio quality",
"@downloadSelectTidalQobuz": {
"description": "Info shown when a non-built-in service is selected"
"description": "Legacy info shown when a provider does not expose quality options"
},
"downloadEmbedLyricsDisabled": "Enable metadata embedding first",
"@downloadEmbedLyricsDisabled": {
@@ -4206,7 +4185,7 @@
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
"description": "Extensions page - subtitle for default metadata search provider option",
"placeholders": {
"providerName": {
"type": "String"
+1057 -105
View File
File diff suppressed because it is too large Load Diff
+1048 -96
View File
File diff suppressed because it is too large Load Diff
+14 -10
View File
@@ -14,23 +14,27 @@ const int translationThreshold = 70;
/// Only these languages will be available in the app.
const List<Locale> filteredSupportedLocales = <Locale>[
Locale('en'),
Locale('ru'),
Locale('fr'),
Locale('de'),
Locale('es', 'ES'),
Locale('id'),
Locale('pt', 'PT'),
Locale('ja'),
Locale('tr'),
Locale('uk'),
Locale('ru'),
Locale('tr'),
Locale('id'),
Locale('ja'),
Locale('pt', 'PT'),
];
/// Set of locale codes for quick lookup.
const Set<String> filteredLocaleCodes = <String>{
'en',
'ru',
'fr',
'de',
'es_ES',
'id',
'pt_PT',
'ja',
'tr',
'uk',
'ru',
'tr',
'id',
'ja',
'pt_PT',
};
+34 -11
View File
@@ -15,20 +15,43 @@ import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/services/share_intent_service.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/utils/local_library_scan_prefs.dart';
import 'package:spotiflac_android/utils/logger.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final runtimeProfile = await _resolveRuntimeProfile();
_configureImageCache(runtimeProfile);
final _log = AppLogger('Main');
runApp(
ProviderScope(
child: _EagerInitialization(
child: SpotiFLACApp(
disableOverscrollEffects: runtimeProfile.disableOverscrollEffects,
void main() {
// Catch uncaught Dart errors so a failing async path is logged, not fatal.
// Native (Go) crashes still can't be caught here.
runZonedGuarded(
() async {
WidgetsFlutterBinding.ensureInitialized();
final previousOnError = FlutterError.onError;
FlutterError.onError = (details) {
previousOnError?.call(details);
_log.e('Uncaught Flutter error: ${details.exceptionAsString()}');
};
WidgetsBinding.instance.platformDispatcher.onError = (error, stack) {
_log.e('Uncaught platform error: $error');
return true;
};
final runtimeProfile = await _resolveRuntimeProfile();
_configureImageCache(runtimeProfile);
runApp(
ProviderScope(
child: _EagerInitialization(
child: SpotiFLACApp(
disableOverscrollEffects: runtimeProfile.disableOverscrollEffects,
),
),
),
),
),
);
},
(error, stack) {
_log.e('Uncaught zone error: $error');
},
);
}
+1 -1
View File
@@ -89,7 +89,7 @@ class DownloadItem {
case DownloadErrorType.notFound:
return 'Song not found on any service';
case DownloadErrorType.rateLimit:
return 'Rate limit reached, try again later';
return 'Service rate limit reached. Wait before retrying.';
case DownloadErrorType.network:
return 'Connection failed, check your internet';
case DownloadErrorType.permission:
+5 -5
View File
@@ -22,7 +22,6 @@ class AppSettings {
final bool embedReplayGain; // Calculate and embed ReplayGain tags
final bool maxQualityCover;
final bool isFirstLaunch;
final int concurrentDownloads;
final bool checkForUpdates;
final String updateChannel;
final bool hasSearchedBefore;
@@ -47,7 +46,7 @@ class AppSettings {
final String locale;
final String lyricsMode;
final String
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'aac_320', 'opus_256', or 'opus_128'
tidalHighFormat; // Legacy key for 320kbps lossy output format: 'mp3_320', 'aac_320', 'opus_256', or 'opus_128'
final bool
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
final bool
@@ -91,6 +90,7 @@ class AppSettings {
final bool
deduplicateDownloads; // Skip downloading tracks already present in history
final bool saveDownloadHistory; // Record completed downloads in local history
const AppSettings({
this.defaultService = '',
@@ -107,7 +107,6 @@ class AppSettings {
this.embedReplayGain = false,
this.maxQualityCover = true,
this.isFirstLaunch = true,
this.concurrentDownloads = 1,
this.checkForUpdates = true,
this.updateChannel = 'stable',
this.hasSearchedBefore = false,
@@ -152,6 +151,7 @@ class AppSettings {
this.musixmatchLanguage = '',
this.lastSeenVersion = '',
this.deduplicateDownloads = true,
this.saveDownloadHistory = true,
});
AppSettings copyWith({
@@ -169,7 +169,6 @@ class AppSettings {
bool? embedReplayGain,
bool? maxQualityCover,
bool? isFirstLaunch,
int? concurrentDownloads,
bool? checkForUpdates,
String? updateChannel,
bool? hasSearchedBefore,
@@ -217,6 +216,7 @@ class AppSettings {
String? musixmatchLanguage,
String? lastSeenVersion,
bool? deduplicateDownloads,
bool? saveDownloadHistory,
}) {
return AppSettings(
defaultService: defaultService ?? this.defaultService,
@@ -234,7 +234,6 @@ class AppSettings {
embedReplayGain: embedReplayGain ?? this.embedReplayGain,
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
updateChannel: updateChannel ?? this.updateChannel,
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
@@ -300,6 +299,7 @@ class AppSettings {
musixmatchLanguage: musixmatchLanguage ?? this.musixmatchLanguage,
lastSeenVersion: lastSeenVersion ?? this.lastSeenVersion,
deduplicateDownloads: deduplicateDownloads ?? this.deduplicateDownloads,
saveDownloadHistory: saveDownloadHistory ?? this.saveDownloadHistory,
);
}
+2 -2
View File
@@ -21,7 +21,6 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
embedReplayGain: json['embedReplayGain'] as bool? ?? false,
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1,
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
updateChannel: json['updateChannel'] as String? ?? 'stable',
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
@@ -82,6 +81,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
musixmatchLanguage: json['musixmatchLanguage'] as String? ?? '',
lastSeenVersion: json['lastSeenVersion'] as String? ?? '',
deduplicateDownloads: json['deduplicateDownloads'] as bool? ?? true,
saveDownloadHistory: json['saveDownloadHistory'] as bool? ?? true,
);
Map<String, dynamic> _$AppSettingsToJson(
@@ -101,7 +101,6 @@ Map<String, dynamic> _$AppSettingsToJson(
'embedReplayGain': instance.embedReplayGain,
'maxQualityCover': instance.maxQualityCover,
'isFirstLaunch': instance.isFirstLaunch,
'concurrentDownloads': instance.concurrentDownloads,
'checkForUpdates': instance.checkForUpdates,
'updateChannel': instance.updateChannel,
'hasSearchedBefore': instance.hasSearchedBefore,
@@ -147,4 +146,5 @@ Map<String, dynamic> _$AppSettingsToJson(
'musixmatchLanguage': instance.musixmatchLanguage,
'lastSeenVersion': instance.lastSeenVersion,
'deduplicateDownloads': instance.deduplicateDownloads,
'saveDownloadHistory': instance.saveDownloadHistory,
};
+223 -145
View File
@@ -1767,7 +1767,6 @@ class DownloadQueueState {
final String singleFilenameFormat;
final String audioQuality;
final bool autoFallback;
final int concurrentDownloads;
const DownloadQueueState({
this.items = const [],
@@ -1780,7 +1779,6 @@ class DownloadQueueState {
this.singleFilenameFormat = '{title} - {artist}',
this.audioQuality = 'LOSSLESS',
this.autoFallback = true,
this.concurrentDownloads = 1,
});
DownloadQueueState copyWith({
@@ -1794,7 +1792,6 @@ class DownloadQueueState {
String? singleFilenameFormat,
String? audioQuality,
bool? autoFallback,
int? concurrentDownloads,
}) {
final resolvedItems = items ?? this.items;
return DownloadQueueState(
@@ -1814,7 +1811,6 @@ class DownloadQueueState {
singleFilenameFormat: singleFilenameFormat ?? this.singleFilenameFormat,
audioQuality: audioQuality ?? this.audioQuality,
autoFallback: autoFallback ?? this.autoFallback,
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
);
}
@@ -1962,14 +1958,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
@override
DownloadQueueState build() {
ref.listen<AppSettings>(settingsProvider, (previous, next) {
final previousConcurrent =
previous?.concurrentDownloads ?? state.concurrentDownloads;
updateSettings(next);
if (previousConcurrent != next.concurrentDownloads) {
_log.i(
'Concurrent downloads updated: $previousConcurrent -> ${next.concurrentDownloads}',
);
}
if (previous?.downloadNetworkMode != next.downloadNetworkMode) {
_handleDownloadNetworkModeChanged(next.downloadNetworkMode);
}
@@ -3095,7 +3084,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (extensionPreferred != null) {
return extensionPreferred;
}
if (_usesBuiltInCompatibleDownloadProvider(service, 'tidal') &&
if (_downloadProviderReplacesLegacyProvider(service, 'tidal') &&
quality == 'HIGH') {
return '.m4a';
}
@@ -3106,13 +3095,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return '.flac';
}
bool _usesBuiltInCompatibleDownloadProvider(
bool _downloadProviderReplacesLegacyProvider(
String service,
String builtInProviderId,
String legacyProviderId,
) {
return ref
.read(extensionProvider.notifier)
.downloadProviderMatchesBuiltIn(service, builtInProviderId);
.downloadProviderReplacesLegacyProvider(service, legacyProviderId);
}
String _normalizeQueuedService(String service) {
@@ -3601,7 +3590,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
void updateSettings(AppSettings settings) {
final concurrentDownloads = settings.concurrentDownloads.clamp(1, 5);
state = state.copyWith(
outputDir: settings.downloadDirectory.isNotEmpty
? settings.downloadDirectory
@@ -3610,7 +3598,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
singleFilenameFormat: settings.singleFilenameFormat,
audioQuality: settings.audioQuality,
autoFallback: settings.autoFallback,
concurrentDownloads: concurrentDownloads,
);
}
@@ -4023,6 +4010,57 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
void retryAllFailed() {
final failedIds = state.items
.where(
(item) =>
item.status == DownloadStatus.failed ||
item.status == DownloadStatus.skipped,
)
.map((item) => item.id)
.toSet();
if (failedIds.isEmpty) {
_log.d('retryAllFailed: no failed downloads to retry');
return;
}
_log.i('Retrying ${failedIds.length} failed download(s)');
_locallyCancelledItemIds.removeAll(failedIds);
_pausePendingItemIds.removeAll(failedIds);
for (final item in state.items) {
if (!failedIds.contains(item.id)) continue;
final rgKey = _albumRgKey(item.track);
final rgAcc = _albumRgData[rgKey];
if (rgAcc == null) continue;
rgAcc.entries.removeWhere((entry) => entry.trackId == item.track.id);
if (rgAcc.entries.isEmpty) {
_albumRgData.remove(rgKey);
}
}
final items = state.items
.map((item) {
if (!failedIds.contains(item.id)) return item;
return item.copyWith(
status: DownloadStatus.queued,
progress: 0,
speedMBps: 0,
bytesReceived: 0,
bytesTotal: 0,
error: null,
);
})
.toList(growable: false);
state = state.copyWith(items: items, isPaused: false);
_saveQueueToStorage();
if (!state.isProcessing) {
Future.microtask(() => _processQueue());
}
}
void removeItem(String id) {
final removedItem = state.items.where((item) => item.id == id).firstOrNull;
_locallyCancelledItemIds.remove(id);
@@ -5336,6 +5374,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
DownloadRequestPayload.nativeWorkerContractVersion,
'run_id': runId,
'created_at': DateTime.now().toIso8601String(),
'save_download_history': settings.saveDownloadHistory,
},
);
@@ -5555,13 +5594,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String payloadTidalId = '';
if (trackForPayload.id.startsWith('qobuz:')) {
payloadQobuzId = trackForPayload.id.substring(6);
if (_usesBuiltInCompatibleDownloadProvider(item.service, 'qobuz')) {
if (_downloadProviderReplacesLegacyProvider(item.service, 'qobuz')) {
payloadSpotifyId = '';
}
}
if (trackForPayload.id.startsWith('tidal:')) {
payloadTidalId = trackForPayload.id.substring(6);
if (_usesBuiltInCompatibleDownloadProvider(item.service, 'tidal')) {
if (_downloadProviderReplacesLegacyProvider(item.service, 'tidal')) {
payloadSpotifyId = '';
}
}
@@ -5769,22 +5808,26 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
progress: 1.0,
filePath: filePath,
);
final historyItem = result['history_item'];
if (historyItem is Map) {
try {
ref
.read(downloadHistoryProvider.notifier)
.adoptNativeHistoryItem(
DownloadHistoryItem.fromJson(
Map<String, dynamic>.from(historyItem),
),
);
} catch (e) {
_log.w('Failed to adopt native history item: $e');
if (settings.saveDownloadHistory) {
final historyItem = result['history_item'];
if (historyItem is Map) {
try {
ref
.read(downloadHistoryProvider.notifier)
.adoptNativeHistoryItem(
DownloadHistoryItem.fromJson(
Map<String, dynamic>.from(historyItem),
),
);
} catch (e) {
_log.w('Failed to adopt native history item: $e');
await ref
.read(downloadHistoryProvider.notifier)
.reloadFromStorage();
}
} else if (result['history_written'] == true) {
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
}
} else if (result['history_written'] == true) {
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
}
_completedInSession++;
await _notificationService.showDownloadComplete(
@@ -5989,51 +6032,53 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
backendComposer,
);
ref
.read(downloadHistoryProvider.notifier)
.addToHistory(
DownloadHistoryItem(
id: item.id,
trackName: historyTitle,
artistName: historyArtist,
albumName: historyAlbum,
albumArtist: normalizeOptionalString(trackToDownload.albumArtist),
coverUrl: normalizeCoverReference(trackToDownload.coverUrl),
filePath: filePath,
storageMode: context.storageMode,
downloadTreeUri: context.storageMode == 'saf'
? context.downloadTreeUri
: null,
safRelativeDir: context.storageMode == 'saf'
? context.safRelativeDir
: null,
safFileName: context.storageMode == 'saf'
? ((resultSafFileName != null && resultSafFileName.isNotEmpty)
? resultSafFileName
: context.safFileName)
: null,
safRepaired: false,
service: result['service'] as String? ?? item.service,
downloadedAt: DateTime.now(),
isrc: historyIsrc,
spotifyId: trackToDownload.id,
trackNumber: historyTrackNumber,
totalTracks: historyTotalTracks,
discNumber: historyDiscNumber,
totalDiscs: historyTotalDiscs,
duration: trackToDownload.duration,
releaseDate: historyReleaseDate,
quality: actualQuality,
bitDepth: isLossyOutput ? null : actualBitDepth,
sampleRate: isLossyOutput ? null : actualSampleRate,
bitrate: isLossyOutput ? actualBitrate : null,
format: historyFormat,
genre: normalizeOptionalString(backendGenre),
composer: historyComposer,
label: normalizeOptionalString(backendLabel),
copyright: normalizeOptionalString(backendCopyright),
),
);
if (settings.saveDownloadHistory) {
ref
.read(downloadHistoryProvider.notifier)
.addToHistory(
DownloadHistoryItem(
id: item.id,
trackName: historyTitle,
artistName: historyArtist,
albumName: historyAlbum,
albumArtist: normalizeOptionalString(trackToDownload.albumArtist),
coverUrl: normalizeCoverReference(trackToDownload.coverUrl),
filePath: filePath,
storageMode: context.storageMode,
downloadTreeUri: context.storageMode == 'saf'
? context.downloadTreeUri
: null,
safRelativeDir: context.storageMode == 'saf'
? context.safRelativeDir
: null,
safFileName: context.storageMode == 'saf'
? ((resultSafFileName != null && resultSafFileName.isNotEmpty)
? resultSafFileName
: context.safFileName)
: null,
safRepaired: false,
service: result['service'] as String? ?? item.service,
downloadedAt: DateTime.now(),
isrc: historyIsrc,
spotifyId: trackToDownload.id,
trackNumber: historyTrackNumber,
totalTracks: historyTotalTracks,
discNumber: historyDiscNumber,
totalDiscs: historyTotalDiscs,
duration: trackToDownload.duration,
releaseDate: historyReleaseDate,
quality: actualQuality,
bitDepth: isLossyOutput ? null : actualBitDepth,
sampleRate: isLossyOutput ? null : actualSampleRate,
bitrate: isLossyOutput ? actualBitrate : null,
format: historyFormat,
genre: normalizeOptionalString(backendGenre),
composer: historyComposer,
label: normalizeOptionalString(backendLabel),
copyright: normalizeOptionalString(backendCopyright),
),
);
}
removeItem(item.id);
}
@@ -6551,6 +6596,32 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
DownloadErrorType _downloadErrorTypeFromMessage(String errorMsg) {
final lowerMsg = errorMsg.toLowerCase();
if (errorMsg.contains('429') ||
lowerMsg.contains('rate limit') ||
lowerMsg.contains('too many requests')) {
return DownloadErrorType.rateLimit;
}
if (lowerMsg.contains('not found') ||
lowerMsg.contains('not available') ||
lowerMsg.contains('no results')) {
return DownloadErrorType.notFound;
}
if (lowerMsg.contains('permission') ||
lowerMsg.contains('operation not permitted') ||
lowerMsg.contains('access denied')) {
return DownloadErrorType.permission;
}
if (lowerMsg.contains('network') ||
lowerMsg.contains('connection') ||
lowerMsg.contains('timeout') ||
lowerMsg.contains('dial')) {
return DownloadErrorType.network;
}
return DownloadErrorType.unknown;
}
Future<void> _processQueue() async {
if (state.isProcessing) return;
@@ -6605,6 +6676,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
// iOS: request a background execution window (no foreground service).
if (Platform.isIOS && _totalQueuedAtStart > 0) {
await PlatformBridge.beginBackgroundDownloadTask();
}
if (!isSafMode && state.outputDir.isEmpty) {
_log.d('Output dir empty, initializing...');
await _initOutputDir();
@@ -6697,9 +6773,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
_log.d('Concurrent downloads: ${state.concurrentDownloads}');
try {
await _processQueueParallel();
await _processQueueSequential();
} finally {
if (iosDownloadBookmarkActive) {
await PlatformBridge.stopAccessingIosBookmark();
@@ -6724,6 +6799,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
if (Platform.isIOS) {
await PlatformBridge.endBackgroundDownloadTask();
}
if (_downloadCount > 0) {
_log.d('Final connection cleanup...');
try {
@@ -6775,19 +6854,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
Future<void> _processQueueParallel() async {
Future<void> _processQueueSequential() async {
final activeDownloads = <String, Future<void>>{};
var lastLoggedMaxConcurrent = -1;
_startMultiProgressPolling();
while (true) {
if (state.isPaused) {
if (activeDownloads.isEmpty) {
_log.d('Queue is paused and no active downloads remain');
_log.d('Queue is paused and no active download remains');
break;
}
_log.d('Queue is paused, waiting for active downloads...');
_log.d('Queue is paused, waiting for active download...');
await Future.any([
Future.wait(activeDownloads.values),
Future<void>.delayed(_queueSchedulingInterval),
@@ -6795,12 +6873,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
continue;
}
final maxConcurrent = max(1, state.concurrentDownloads);
if (lastLoggedMaxConcurrent != maxConcurrent) {
_log.d('Parallel worker max concurrency now: $maxConcurrent');
lastLoggedMaxConcurrent = maxConcurrent;
}
final queuedItems = state.items
.where(
(item) =>
@@ -6814,7 +6886,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
break;
}
while (activeDownloads.length < maxConcurrent &&
// One download at a time: only start the next item once the current
// download has finished, to stay within the API's single-request limit.
if (activeDownloads.isEmpty &&
queuedItems.isNotEmpty &&
!state.isPaused) {
final item = queuedItems.removeAt(0);
@@ -6827,9 +6901,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
});
activeDownloads[item.id] = future;
_log.d(
'Started parallel download: ${item.track.name} (${activeDownloads.length}/$maxConcurrent active)',
);
_log.d('Started download: ${item.track.name}');
}
if (activeDownloads.isNotEmpty) {
@@ -7239,13 +7311,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String payloadTidalId = '';
if (trackToDownload.id.startsWith('qobuz:')) {
payloadQobuzId = trackToDownload.id.substring(6);
if (_usesBuiltInCompatibleDownloadProvider(item.service, 'qobuz')) {
if (_downloadProviderReplacesLegacyProvider(item.service, 'qobuz')) {
payloadSpotifyId = '';
}
}
if (trackToDownload.id.startsWith('tidal:')) {
payloadTidalId = trackToDownload.id.substring(6);
if (_usesBuiltInCompatibleDownloadProvider(item.service, 'tidal')) {
if (_downloadProviderReplacesLegacyProvider(item.service, 'tidal')) {
payloadSpotifyId = '';
}
}
@@ -7580,28 +7652,28 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
(filePath.endsWith('.flac') ||
resultOutputExt == '.flac' ||
(mimeType != null && mimeType.contains('flac')));
final shouldForceTidalSafM4aHandling =
final shouldForceDashSafM4aHandling =
!wasExisting &&
isContentUriPath &&
effectiveSafMode &&
_usesBuiltInCompatibleDownloadProvider(actualService, 'tidal') &&
_downloadProviderReplacesLegacyProvider(actualService, 'tidal') &&
filePath.endsWith('.flac') &&
(mimeType == null || mimeType.contains('flac'));
if (shouldForceTidalSafM4aHandling) {
if (shouldForceDashSafM4aHandling) {
_log.w(
'Tidal SAF file is labeled FLAC but backend returned DASH/M4A stream; converting it back to FLAC.',
'SAF file is labeled FLAC but backend returned DASH/M4A stream; converting it back to FLAC.',
);
}
if (isM4aFile || shouldForceTidalSafM4aHandling) {
if (isM4aFile || shouldForceDashSafM4aHandling) {
final currentFilePath = filePath;
if (isContentUriPath && effectiveSafMode) {
if (quality == 'HIGH') {
final tidalHighFormat = settings.tidalHighFormat;
_log.i(
'Tidal HIGH quality (SAF), converting M4A to $tidalHighFormat...',
'Lossy 320kbps quality (SAF), converting M4A to $tidalHighFormat...',
);
final tempPath = await _copySafToTemp(currentFilePath);
@@ -7919,7 +7991,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (quality == 'HIGH') {
final tidalHighFormat = settings.tidalHighFormat;
_log.i(
'Tidal HIGH quality download, converting M4A to $tidalHighFormat...',
'Lossy 320kbps quality download, converting M4A to $tidalHighFormat...',
);
try {
@@ -8633,47 +8705,51 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
backendComposer,
);
ref
.read(downloadHistoryProvider.notifier)
.addToHistory(
DownloadHistoryItem(
id: item.id,
trackName: historyTitle,
artistName: historyArtist,
albumName: historyAlbum,
albumArtist: historyAlbumArtist,
coverUrl: normalizeCoverReference(trackToDownload.coverUrl),
filePath: filePath,
storageMode: effectiveSafMode ? 'saf' : 'app',
downloadTreeUri: effectiveSafMode
? settings.downloadTreeUri
: null,
safRelativeDir: effectiveSafMode ? effectiveOutputDir : null,
safFileName: effectiveSafMode
? (finalSafFileName ?? safFileName)
: null,
safRepaired: false,
service: result['service'] as String? ?? item.service,
downloadedAt: DateTime.now(),
isrc: historyIsrc,
spotifyId: trackToDownload.id,
trackNumber: historyTrackNumber,
totalTracks: historyTotalTracks,
discNumber: historyDiscNumber,
totalDiscs: historyTotalDiscs,
duration: trackToDownload.duration,
releaseDate: historyReleaseDate,
quality: actualQuality,
bitDepth: historyBitDepth,
sampleRate: historySampleRate,
bitrate: historyBitrate,
format: finalFormat,
genre: effectiveGenre,
composer: historyComposer,
label: effectiveLabel,
copyright: effectiveCopyright,
),
);
if (settings.saveDownloadHistory) {
ref
.read(downloadHistoryProvider.notifier)
.addToHistory(
DownloadHistoryItem(
id: item.id,
trackName: historyTitle,
artistName: historyArtist,
albumName: historyAlbum,
albumArtist: historyAlbumArtist,
coverUrl: normalizeCoverReference(trackToDownload.coverUrl),
filePath: filePath,
storageMode: effectiveSafMode ? 'saf' : 'app',
downloadTreeUri: effectiveSafMode
? settings.downloadTreeUri
: null,
safRelativeDir: effectiveSafMode
? effectiveOutputDir
: null,
safFileName: effectiveSafMode
? (finalSafFileName ?? safFileName)
: null,
safRepaired: false,
service: result['service'] as String? ?? item.service,
downloadedAt: DateTime.now(),
isrc: historyIsrc,
spotifyId: trackToDownload.id,
trackNumber: historyTrackNumber,
totalTracks: historyTotalTracks,
discNumber: historyDiscNumber,
totalDiscs: historyTotalDiscs,
duration: trackToDownload.duration,
releaseDate: historyReleaseDate,
quality: actualQuality,
bitDepth: historyBitDepth,
sampleRate: historySampleRate,
bitrate: historyBitrate,
format: finalFormat,
genre: effectiveGenre,
composer: historyComposer,
label: effectiveLabel,
copyright: effectiveCopyright,
),
);
}
removeItem(item.id);
}
@@ -8723,7 +8799,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
errorType = DownloadErrorType.permission;
break;
default:
errorType = DownloadErrorType.unknown;
errorType = _downloadErrorTypeFromMessage(errorMsg);
}
_log.e('Download failed: $errorMsg (type: $errorTypeStr)');
@@ -8777,6 +8853,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
errorMsg.contains('track not found on Deezer')) {
errorMsg = 'Track not found on Deezer (Metadata Unavailable)';
errorType = DownloadErrorType.notFound;
} else {
errorType = _downloadErrorTypeFromMessage(errorMsg);
}
updateItemStatus(
+6 -6
View File
@@ -1283,20 +1283,20 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
.firstOrNull;
}
bool downloadProviderMatchesBuiltIn(
bool downloadProviderReplacesLegacyProvider(
String providerId,
String builtInProviderId,
String legacyProviderId,
) {
final normalizedProvider = providerId.trim().toLowerCase();
final normalizedBuiltIn = builtInProviderId.trim().toLowerCase();
if (normalizedProvider.isEmpty || normalizedBuiltIn.isEmpty) return false;
if (normalizedProvider == normalizedBuiltIn) return true;
final normalizedLegacy = legacyProviderId.trim().toLowerCase();
if (normalizedProvider.isEmpty || normalizedLegacy.isEmpty) return false;
if (normalizedProvider == normalizedLegacy) return true;
final extension = state.extensions
.where((ext) => ext.enabled && ext.hasDownloadProvider)
.where((ext) => ext.id.toLowerCase() == normalizedProvider)
.firstOrNull;
return extension?.replacesBuiltInProviders.contains(normalizedBuiltIn) ??
return extension?.replacesBuiltInProviders.contains(normalizedLegacy) ??
false;
}
+5 -6
View File
@@ -392,12 +392,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setConcurrentDownloads(int count) {
final clamped = count.clamp(1, 5);
state = state.copyWith(concurrentDownloads: clamped);
_saveSettings();
}
void setCheckForUpdates(bool enabled) {
state = state.copyWith(checkForUpdates: enabled);
_saveSettings();
@@ -600,6 +594,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = state.copyWith(deduplicateDownloads: enabled);
_saveSettings();
}
void setSaveDownloadHistory(bool enabled) {
state = state.copyWith(saveDownloadHistory: enabled);
_saveSettings();
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
+36 -1
View File
@@ -1,4 +1,5 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dart:async';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
@@ -205,6 +206,21 @@ class StoreState {
}
class StoreNotifier extends Notifier<StoreState> {
/// Serializes install/upgrade so two never race the native VM teardown/reload.
Future<void> _mutationChain = Future<void>.value();
Future<T> _runSerialized<T>(Future<T> Function() action) {
final completer = Completer<T>();
_mutationChain = _mutationChain.then((_) async {
try {
completer.complete(await action());
} catch (e, st) {
completer.completeError(e, st);
}
});
return completer.future;
}
@override
StoreState build() {
return const StoreState();
@@ -330,6 +346,16 @@ class StoreNotifier extends Notifier<StoreState> {
String extensionId,
String tempDir,
String extensionsDir,
) {
return _runSerialized(
() => _installExtensionInternal(extensionId, tempDir, extensionsDir),
);
}
Future<bool> _installExtensionInternal(
String extensionId,
String tempDir,
String extensionsDir,
) async {
state = state.copyWith(
isDownloading: true,
@@ -366,7 +392,16 @@ class StoreNotifier extends Notifier<StoreState> {
}
}
Future<bool> updateExtension(String extensionId, String tempDir) async {
Future<bool> updateExtension(String extensionId, String tempDir) {
return _runSerialized(
() => _updateExtensionInternal(extensionId, tempDir),
);
}
Future<bool> _updateExtensionInternal(
String extensionId,
String tempDir,
) async {
state = state.copyWith(
isDownloading: true,
downloadingId: extensionId,
+63 -16
View File
@@ -14,6 +14,7 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/image_cache_utils.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
@@ -21,6 +22,7 @@ import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
import 'package:spotiflac_android/widgets/cross_extension_share_sheet.dart';
class _AlbumCache {
static final Map<String, _CacheEntry> _cache = {};
@@ -335,6 +337,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
final colorScheme = Theme.of(context).colorScheme;
final tracks = _tracks ?? [];
final pageBackgroundColor = colorScheme.surface;
final bottomInset = context.navBarBottomInset;
return Scaffold(
backgroundColor: pageBackgroundColor,
@@ -360,7 +363,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
_buildTrackList(context, colorScheme, tracks),
],
const SliverToBoxAdapter(child: SizedBox(height: 32)),
SliverToBoxAdapter(child: SizedBox(height: 32 + bottomInset)),
],
),
);
@@ -566,18 +569,24 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
children: [
_buildLoveAllButton(),
const SizedBox(width: 12),
FilledButton.icon(
onPressed: () => _downloadAll(context),
icon: Icon(Icons.download, size: 18),
label: Text(
context.l10n.downloadAllCount(tracks.length),
),
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
Flexible(
child: FilledButton.icon(
onPressed: () => _downloadAll(context),
icon: Icon(Icons.download, size: 18),
label: Text(
context.l10n.downloadAllCount(
tracks.length,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
),
),
@@ -608,6 +617,23 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
),
onPressed: () => Navigator.pop(context),
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
tooltip: context.l10n.openInOtherServices,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: const Icon(Icons.open_in_new_rounded, color: Colors.white),
),
onPressed: () => _showShareSheet(context, tracks, artistName),
),
),
],
);
}
@@ -846,6 +872,27 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
);
}
void _showShareSheet(
BuildContext context,
List<Track> tracks,
String? artistName,
) {
final sourceExtensionId = _directMetadataProviderId() ?? '';
final resolvedArtists =
artistName ??
tracks.firstOrNull?.albumArtist ??
tracks.firstOrNull?.artistName ??
'';
CrossExtensionShareSheet.show(
context,
name: widget.albumName,
artists: resolvedArtists,
type: 'album',
sourceExtensionId: sourceExtensionId,
);
}
Future<void> _loveAll(List<Track> tracks) async {
final notifier = ref.read(libraryCollectionsProvider.notifier);
final state = ref.read(libraryCollectionsProvider);
@@ -1043,7 +1090,7 @@ class _AlbumTrackItem extends ConsumerWidget {
vertical: 2,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Row(
@@ -1052,7 +1099,7 @@ class _AlbumTrackItem extends ConsumerWidget {
Icon(
Icons.folder_outlined,
size: 10,
color: colorScheme.onTertiaryContainer,
color: colorScheme.onPrimaryContainer,
),
const SizedBox(width: 3),
Text(
@@ -1060,7 +1107,7 @@ class _AlbumTrackItem extends ConsumerWidget {
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w500,
color: colorScheme.onTertiaryContainer,
color: colorScheme.onPrimaryContainer,
),
),
],
+34 -3
View File
@@ -15,6 +15,7 @@ import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/home_tab.dart'
show ExtensionAlbumScreen;
@@ -23,6 +24,7 @@ import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
import 'package:spotiflac_android/widgets/cross_extension_share_sheet.dart';
class _ArtistCache {
static final Map<String, _CacheEntry> _cache = {};
@@ -455,6 +457,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
final albumsOnly = _albumsOnlyBucket;
final singles = _singlesBucket;
final compilations = _compilationsBucket;
final bottomInset = context.navBarBottomInset;
final hasDiscography =
!_isLoadingDiscography && _error == null && albums.isNotEmpty;
@@ -541,6 +544,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
SliverToBoxAdapter(
child: SizedBox(height: _isSelectionMode ? 120 : 32),
),
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
],
),
if (_isSelectionMode)
@@ -1333,6 +1337,33 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
),
onPressed: () => Navigator.pop(context),
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
tooltip: context.l10n.openInOtherServices,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: const Icon(Icons.open_in_new_rounded, color: Colors.white),
),
onPressed: () => _showShareSheet(context),
),
),
],
);
}
void _showShareSheet(BuildContext context) {
CrossExtensionShareSheet.show(
context,
name: widget.artistName,
artists: '',
type: 'artist',
sourceExtensionId: _directMetadataProviderId() ?? '',
);
}
@@ -1558,7 +1589,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
vertical: 2,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Row(
@@ -1567,7 +1598,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
Icon(
Icons.folder_outlined,
size: 10,
color: colorScheme.onTertiaryContainer,
color: colorScheme.onPrimaryContainer,
),
const SizedBox(width: 3),
Text(
@@ -1575,7 +1606,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w500,
color: colorScheme.onTertiaryContainer,
color: colorScheme.onPrimaryContainer,
),
),
],
+129 -185
View File
@@ -7,6 +7,7 @@ import 'package:share_plus/share_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/services/replaygain_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/history_database.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
@@ -14,8 +15,10 @@ import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/image_cache_utils.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
import 'package:spotiflac_android/widgets/batch_convert_sheet.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
@@ -356,6 +359,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final bottomPadding = MediaQuery.of(context).padding.bottom;
final bottomInset = context.navBarBottomInset;
final tracksValue = ref.watch(
downloadedAlbumTracksProvider(
@@ -411,6 +415,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
SliverToBoxAdapter(
child: SizedBox(height: _isSelectionMode ? 120 : 32),
),
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
],
),
@@ -966,18 +971,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
if (formats.isEmpty) return;
String selectedFormat = formats.first;
bool isLosslessTarget =
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
String defaultBitrateForFormat(String format) {
if (format == 'Opus') return '128k';
if (format == 'AAC') return '256k';
return '320k';
}
String selectedBitrate = isLosslessTarget
? '320k'
: defaultBitrateForFormat(selectedFormat);
final sheetTitle = context.l10n.selectionBatchConvertConfirmTitle;
final sheetConfirmLabel = context.l10n.selectionConvertCount(
_selectedIds.length,
);
showModalBottomSheet<void>(
context: context,
@@ -985,145 +982,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (sheetContext) {
return StatefulBuilder(
builder: (context, setSheetState) {
final colorScheme = Theme.of(context).colorScheme;
final bitrates = ['128k', '192k', '256k', '320k'];
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.4,
),
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 16),
Text(
context.l10n.selectionBatchConvertConfirmTitle,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 20),
Text(
context.l10n.trackConvertTargetFormat,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: formats.map((format) {
final isSelected = format == selectedFormat;
return ChoiceChip(
label: Text(format),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() {
selectedFormat = format;
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
if (!isLosslessTarget) {
selectedBitrate = defaultBitrateForFormat(
format,
);
}
});
}
},
);
}).toList(),
),
if (!isLosslessTarget) ...[
const SizedBox(height: 16),
Text(
context.l10n.trackConvertBitrate,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: bitrates.map((br) {
final isSelected = br == selectedBitrate;
return ChoiceChip(
label: Text(br),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() => selectedBitrate = br);
}
},
);
}).toList(),
),
],
if (isLosslessTarget) ...[
const SizedBox(height: 16),
Row(
children: [
Icon(
Icons.verified,
size: 16,
color: colorScheme.primary,
),
const SizedBox(width: 6),
Text(
context.l10n.trackConvertLosslessHint,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.primary),
),
],
),
],
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () {
Navigator.pop(context);
_performBatchConversion(
allTracks: allTracks,
targetFormat: selectedFormat,
bitrate: selectedBitrate,
);
},
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
child: Text(
context.l10n.selectionConvertCount(
_selectedIds.length,
),
),
),
),
],
),
),
);
},
);
},
builder: (sheetContext) => BatchConvertSheet(
formats: formats,
title: sheetTitle,
confirmLabel: sheetConfirmLabel,
onConvert: (format, bitrate) {
Navigator.pop(sheetContext);
_performBatchConversion(
allTracks: allTracks,
targetFormat: format,
bitrate: bitrate,
);
},
),
);
}
@@ -1161,7 +1032,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
return;
}
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final isLossless = isLosslessConversionTarget(targetFormat);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
@@ -1197,8 +1068,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final total = selected.length;
final historyDb = HistoryDatabase.instance;
final newQuality =
(targetFormat.toUpperCase() == 'ALAC' ||
targetFormat.toUpperCase() == 'FLAC')
isLosslessConversionTarget(targetFormat)
? '${targetFormat.toUpperCase()} Lossless'
: '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
final settings = ref.read(settingsProvider);
@@ -1303,27 +1173,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final baseName = dotIdx > 0
? oldFileName.substring(0, dotIdx)
: oldFileName;
String newExt;
String mimeType;
switch (targetFormat.toLowerCase()) {
case 'opus':
newExt = '.opus';
mimeType = 'audio/opus';
break;
case 'alac':
case 'aac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
case 'flac':
newExt = '.flac';
mimeType = 'audio/flac';
break;
default:
newExt = '.mp3';
mimeType = 'audio/mpeg';
break;
}
final convTarget = convertTargetExtAndMime(targetFormat);
final newExt = convTarget.ext;
final mimeType = convTarget.mime;
final newFileName = '$baseName$newExt';
final safUri = await PlatformBridge.createSafFileFromPath(
@@ -1412,6 +1264,80 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}
}
Future<void> _runBatchReplayGain(List<DownloadHistoryItem> tracks) async {
final tracksById = {for (final t in tracks) t.id: t};
final selected = <DownloadHistoryItem>[];
for (final id in _selectedIds) {
final item = tracksById[id];
if (item == null) continue;
selected.add(item);
}
if (selected.isEmpty) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(ctx.l10n.replayGainBatchConfirmTitle),
content: Text(
ctx.l10n.replayGainBatchConfirmMessage(selected.length),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(ctx.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(ctx.l10n.replayGainBatchConfirmTitle),
),
],
),
);
if (confirmed != true || !mounted) return;
var cancelled = false;
int successCount = 0;
final total = selected.length;
BatchProgressDialog.show(
context: context,
title: context.l10n.replayGainBatchAnalyzing,
total: total,
icon: Icons.graphic_eq,
onCancel: () {
cancelled = true;
BatchProgressDialog.dismiss(context);
},
);
for (int i = 0; i < total; i++) {
if (!mounted || cancelled) break;
final item = selected[i];
BatchProgressDialog.update(current: i + 1, detail: item.trackName);
try {
final ok = await ReplayGainService.applyToFile(item.filePath);
if (ok) successCount++;
} catch (_) {}
}
_exitSelectionMode();
if (!mounted) return;
if (!cancelled) {
BatchProgressDialog.dismiss(context);
}
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.replayGainBatchSuccess(successCount, total),
),
),
);
}
Widget _buildSelectionBottomBar(
BuildContext context,
ColorScheme colorScheme,
@@ -1508,10 +1434,12 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _DownloadedAlbumSelectionActionButton(
LayoutBuilder(
builder: (context, constraints) {
const spacing = 8.0;
final itemWidth = (constraints.maxWidth - spacing) / 2;
final actions = <Widget>[
_DownloadedAlbumSelectionActionButton(
icon: Icons.share_outlined,
label: context.l10n.selectionShareCount(selectedCount),
onPressed: selectedCount > 0
@@ -1519,10 +1447,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
: null,
colorScheme: colorScheme,
),
),
const SizedBox(width: 8),
Expanded(
child: _DownloadedAlbumSelectionActionButton(
_DownloadedAlbumSelectionActionButton(
icon: Icons.swap_horiz,
label: context.l10n.selectionConvertCount(selectedCount),
onPressed: selectedCount > 0
@@ -1530,8 +1455,27 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
: null,
colorScheme: colorScheme,
),
),
],
_DownloadedAlbumSelectionActionButton(
icon: Icons.graphic_eq,
label: context.l10n.selectionReplayGainCount(
selectedCount,
),
onPressed: selectedCount > 0
? () => _runBatchReplayGain(tracks)
: null,
colorScheme: colorScheme,
),
];
return Wrap(
spacing: spacing,
runSpacing: spacing,
children: [
for (final action in actions)
SizedBox(width: itemWidth, child: action),
],
);
},
),
const SizedBox(height: 8),
+3
View File
@@ -6,6 +6,7 @@ import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/screens/artist_screen.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
class FavoriteArtistsScreen extends ConsumerWidget {
@@ -18,6 +19,7 @@ class FavoriteArtistsScreen extends ConsumerWidget {
);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = normalizedHeaderTopPadding(context);
final bottomInset = context.navBarBottomInset;
return Scaffold(
body: CustomScrollView(
@@ -155,6 +157,7 @@ class FavoriteArtistsScreen extends ConsumerWidget {
);
},
),
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
],
),
);
+121 -11
View File
@@ -21,6 +21,7 @@ import 'package:spotiflac_android/services/csv_import_service.dart';
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/screens/playlist_screen.dart';
@@ -31,6 +32,7 @@ import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
part 'home_tab_helpers.dart';
part 'home_tab_widgets.dart';
@@ -46,6 +48,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
final _urlController = TextEditingController();
final FocusNode _searchFocusNode = FocusNode();
String? _lastSearchQuery;
String? _activeSearchInput;
bool _isResettingSearchSurface = false;
late final ProviderSubscription<TrackState> _trackStateSub;
late final ProviderSubscription<bool> _extensionInitSub;
late final ProviderSubscription<bool> _homeFeedExtSub;
@@ -557,9 +561,18 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (text.isEmpty) {
_liveSearchDebounce?.cancel();
_activeSearchInput = null;
_lastSearchQuery = null;
if (!_isResettingSearchSurface) {
_resetSearchSurface(clearText: false);
}
return;
}
if (_activeSearchInput != null && _activeSearchInput != text) {
_activeSearchInput = null;
}
if (_isLiveSearchEnabled() && text.length >= _minLiveSearchChars) {
if (text.startsWith('http') || text.startsWith('spotify:')) return;
@@ -638,10 +651,17 @@ class _HomeTabState extends ConsumerState<HomeTab>
final searchKey =
'${searchProvider ?? 'default'}:$query:${selectedFilter ?? 'all'}';
if (_lastSearchQuery == searchKey) return;
if (_lastSearchQuery == searchKey) {
_activeSearchInput = query;
ref.read(trackProvider.notifier).setSearchText(query.trim().isNotEmpty);
if (mounted) setState(() {});
return;
}
_lastSearchQuery = searchKey;
_activeSearchInput = query;
_searchSortOption = _SearchSortOption.defaultOrder;
_invalidateSearchSortCaches();
ref.read(trackProvider.notifier).setSearchText(query.trim().isNotEmpty);
final isExtensionEnabled =
searchProvider != null &&
@@ -686,12 +706,26 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
Future<void> _clearAndRefresh() async {
_liveSearchDebounce?.cancel();
_pendingLiveSearchQuery = null;
_urlController.clear();
_searchFocusNode.unfocus();
_lastSearchQuery = null;
ref.read(trackProvider.notifier).clear();
_resetSearchSurface();
}
void _resetSearchSurface({bool clearText = true}) {
if (_isResettingSearchSurface) return;
_isResettingSearchSurface = true;
try {
_liveSearchDebounce?.cancel();
_pendingLiveSearchQuery = null;
_lastSearchQuery = null;
_activeSearchInput = null;
FocusManager.instance.primaryFocus?.unfocus();
if (clearText && _urlController.text.isNotEmpty) {
_urlController.clear();
}
ref.read(trackProvider.notifier).clear();
if (mounted) setState(() {});
} finally {
_isResettingSearchSurface = false;
}
}
Future<void> _fetchMetadata() async {
@@ -1114,6 +1148,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
);
final isLoading = ref.watch(trackProvider.select((s) => s.isLoading));
final searchError = ref.watch(trackProvider.select((s) => s.error));
final hasSearchedBefore = ref.watch(
settingsProvider.select((s) => s.hasSearchedBefore),
);
@@ -1153,12 +1188,25 @@ class _HomeTabState extends ConsumerState<HomeTab>
final isSearchFocused = _searchFocusNode.hasFocus;
final hasShortSearchInput =
hasSearchInput && searchText.length < _minLiveSearchChars;
final hasSearchError = hasSearchInput && searchError != null;
final hasActiveSearchSurface =
hasSearchInput &&
(_activeSearchInput == searchText ||
hasActualResults ||
isLoading ||
hasSearchError);
final showEmptySearchResult =
hasActiveSearchSurface &&
!hasActualResults &&
!isLoading &&
searchError == null;
final isShowingRecentAccess = ref.watch(
trackProvider.select((s) => s.isShowingRecentAccess),
);
final mediaQuery = MediaQuery.of(context);
final screenHeight = mediaQuery.size.height;
final topPadding = normalizedHeaderTopPadding(context);
final bottomInset = context.navBarBottomInset;
final hasHistoryItems = ref.watch(
_homeHistoryPreviewProvider.select((items) => items.isNotEmpty),
);
@@ -1166,7 +1214,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
final recentModeRequested = isShowingRecentAccess || isSearchFocused;
final showRecentAccess =
recentModeRequested &&
(!hasSearchInput || hasShortSearchInput || !hasActualResults) &&
(!hasSearchInput ||
hasShortSearchInput ||
(!hasActualResults &&
!hasSearchError &&
!hasActiveSearchSurface)) &&
!isLoading;
final isSearchProviderLoading =
!extensionReadiness.isInitialized && extensionReadiness.error == null;
@@ -1180,6 +1232,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
final showExplore =
!hasActualResults &&
!isLoading &&
!hasActiveSearchSurface &&
!showRecentAccess &&
!homeFeedDisabled &&
(hasHomeFeedExtension || hasExploreContent) &&
@@ -1299,7 +1352,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
if (hasActualResults && !showRecentAccess)
if (hasActiveSearchSurface &&
!showRecentAccess &&
!showEmptySearchResult)
Consumer(
builder: (context, ref, _) {
final currentSearchProvider = ref.watch(
@@ -1466,7 +1521,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
(searchAlbums != null && searchAlbums.isNotEmpty) ||
(searchPlaylists != null && searchPlaylists.isNotEmpty) ||
isLoading ||
error != null;
error != null ||
hasActiveSearchSurface;
return SliverMainAxisGroup(
slivers: _buildSearchResults(
@@ -1478,6 +1534,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
error: error,
colorScheme: colorScheme,
hasResults: hasResults,
showEmptySearchResult: showEmptySearchResult,
searchExtensionId: searchExtensionId,
showLocalLibraryIndicator: showLocalLibraryIndicator,
thumbnailSizesByExtensionId: thumbnailSizesByExtensionId,
@@ -1485,6 +1542,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
},
),
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
],
),
),
@@ -2611,6 +2669,47 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
}
Widget _buildEmptySearchResultWidget(ColorScheme colorScheme) {
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 340),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 86,
height: 86,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.surfaceContainerHighest,
),
child: Icon(
Icons.manage_search,
size: 46,
color: colorScheme.primary,
),
),
const SizedBox(height: 16),
Text(
context.l10n.errorNoTracksFound,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
context.l10n.searchEmptyResultSubtitle,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
),
],
),
);
}
String _sortOptionLabel(_SearchSortOption option) {
switch (option) {
case _SearchSortOption.defaultOrder:
@@ -2888,6 +2987,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
required String? error,
required ColorScheme colorScheme,
required bool hasResults,
required bool showEmptySearchResult,
required String? searchExtensionId,
required bool showLocalLibraryIndicator,
required Map<String, (double, double)> thumbnailSizesByExtensionId,
@@ -2934,6 +3034,16 @@ class _HomeTabState extends ConsumerState<HomeTab>
child: LinearProgressIndicator(),
),
),
if (showEmptySearchResult && !hasActualData)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 96),
child: _buildEmptySearchResultWidget(colorScheme),
),
),
),
];
bool sortButtonShown = false;
@@ -3454,7 +3564,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
decoration: InputDecoration(
hintText: _getSearchHint(),
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
fillColor: settingsGroupColor(context),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(color: colorScheme.outlineVariant),
+3 -3
View File
@@ -347,7 +347,7 @@ class _TrackItemWithStatus extends ConsumerWidget {
vertical: 2,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Row(
@@ -356,7 +356,7 @@ class _TrackItemWithStatus extends ConsumerWidget {
Icon(
Icons.folder_outlined,
size: 10,
color: colorScheme.onTertiaryContainer,
color: colorScheme.onPrimaryContainer,
),
const SizedBox(width: 3),
Text(
@@ -364,7 +364,7 @@ class _TrackItemWithStatus extends ConsumerWidget {
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w500,
color: colorScheme.onTertiaryContainer,
color: colorScheme.onPrimaryContainer,
),
),
],
+6 -6
View File
@@ -10,6 +10,7 @@ import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/widgets/bottom_sheet_option_tile.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
class LibraryPlaylistsScreen extends ConsumerWidget {
const LibraryPlaylistsScreen({super.key});
@@ -21,6 +22,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = normalizedHeaderTopPadding(context);
final bottomInset = context.navBarBottomInset;
return Scaffold(
body: CustomScrollView(
@@ -132,6 +134,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
);
}, childCount: playlists.length * 2 - 1),
),
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
],
),
floatingActionButton: FloatingActionButton.extended(
@@ -358,13 +361,10 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
WidgetRef ref,
String playlistId,
) async {
final result = await FilePicker.pickFiles(
type: FileType.image,
allowMultiple: false,
);
if (result == null || result.files.isEmpty) return;
final picked = await FilePicker.pickFile(type: FileType.image);
if (picked == null) return;
final path = result.files.first.path;
final path = picked.path;
if (path == null || path.isEmpty) return;
await ref
@@ -14,6 +14,7 @@ import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
@@ -322,6 +323,7 @@ class _LibraryTracksFolderScreenState
.maybeWhen(data: (keys) => keys, orElse: () => const <String>{});
final bottomPadding = MediaQuery.of(context).padding.bottom;
final bottomInset = context.navBarBottomInset;
return PopScope(
canPop: !_isSelectionMode,
@@ -379,6 +381,7 @@ class _LibraryTracksFolderScreenState
SliverToBoxAdapter(
child: SizedBox(height: _isSelectionMode ? 200 : 32),
),
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
],
),
@@ -568,13 +571,10 @@ class _LibraryTracksFolderScreenState
final playlistId = widget.playlistId;
if (playlistId == null) return;
final result = await FilePicker.pickFiles(
type: FileType.image,
allowMultiple: false,
);
if (result == null || result.files.isEmpty) return;
final picked = await FilePicker.pickFile(type: FileType.image);
if (picked == null) return;
final path = result.files.first.path;
final path = picked.path;
if (path == null || path.isEmpty) return;
await ref
@@ -1237,7 +1237,7 @@ class _CollectionTrackTile extends ConsumerWidget {
vertical: 2,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Row(
@@ -1246,7 +1246,7 @@ class _CollectionTrackTile extends ConsumerWidget {
Icon(
Icons.folder_outlined,
size: 10,
color: colorScheme.onTertiaryContainer,
color: colorScheme.onPrimaryContainer,
),
const SizedBox(width: 3),
Text(
@@ -1254,7 +1254,7 @@ class _CollectionTrackTile extends ConsumerWidget {
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w500,
color: colorScheme.onTertiaryContainer,
color: colorScheme.onPrimaryContainer,
),
),
],
+161 -191
View File
@@ -12,10 +12,13 @@ import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/image_cache_utils.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/services/replaygain_service.dart';
import 'package:spotiflac_android/services/local_track_redownload_service.dart';
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
import 'package:spotiflac_android/widgets/batch_convert_sheet.dart';
import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
@@ -250,6 +253,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final bottomPadding = MediaQuery.of(context).padding.bottom;
final bottomInset = context.navBarBottomInset;
final tracks = _sortedTracksCache;
if (tracks.isEmpty) {
@@ -286,6 +290,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
SliverToBoxAdapter(
child: SizedBox(height: _isSelectionMode ? 120 : 32),
),
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
],
),
@@ -863,6 +868,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
await _safeDeleteFile(tempPath!);
return false;
}
await writeReEnrichSafSidecarLrc(safUri: safUri, reEnrichResult: result);
}
if (_hasValue(downloadedCoverPath)) {
@@ -875,6 +881,15 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
await _safeDeleteFile(tempPath!);
}
if (ffmpegResult != null) {
// Filesystem .lrc sidecar. SAF sidecar is written only after
// writeTempToSaf succeeds.
await writeReEnrichSidecarLrc(
audioFilePath: item.filePath,
reEnrichResult: result,
);
}
return ffmpegResult != null;
}
@@ -883,12 +898,14 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
List<String>? updateFields,
}) async {
final durationMs = (item.duration ?? 0) * 1000;
final artistTagMode = ref.read(settingsProvider).artistTagMode;
final settings = ref.read(settingsProvider);
final artistTagMode = settings.artistTagMode;
final request = <String, dynamic>{
'file_path': item.filePath,
'cover_url': '',
'max_quality': true,
'embed_lyrics': true,
'embed_lyrics': settings.embedLyrics,
'lyrics_mode': settings.lyricsMode,
'artist_tag_mode': artistTagMode,
'spotify_id': '',
'track_name': item.trackName,
@@ -911,6 +928,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final result = await PlatformBridge.reEnrichFile(request);
final method = result['method'] as String?;
if (method == 'native') {
// Filesystem .lrc sidecar (SAF sidecar handled natively in Kotlin).
await writeReEnrichSidecarLrc(
audioFilePath: item.filePath,
reEnrichResult: result,
);
return true;
}
if (method == 'ffmpeg') {
@@ -1197,18 +1219,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
if (formats.isEmpty) return;
String selectedFormat = formats.first;
bool isLosslessTarget =
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
String defaultBitrateForFormat(String format) {
if (format == 'Opus') return '128k';
if (format == 'AAC') return '256k';
return '320k';
}
String selectedBitrate = isLosslessTarget
? '320k'
: defaultBitrateForFormat(selectedFormat);
final sheetTitle = context.l10n.selectionBatchConvertConfirmTitle;
final sheetConfirmLabel = context.l10n.selectionConvertCount(
_selectedIds.length,
);
showModalBottomSheet<void>(
context: context,
@@ -1216,145 +1230,19 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (sheetContext) {
return StatefulBuilder(
builder: (context, setSheetState) {
final colorScheme = Theme.of(context).colorScheme;
final bitrates = ['128k', '192k', '256k', '320k'];
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.4,
),
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 16),
Text(
context.l10n.selectionBatchConvertConfirmTitle,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 20),
Text(
context.l10n.trackConvertTargetFormat,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: formats.map((format) {
final isSelected = format == selectedFormat;
return ChoiceChip(
label: Text(format),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() {
selectedFormat = format;
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
if (!isLosslessTarget) {
selectedBitrate = defaultBitrateForFormat(
format,
);
}
});
}
},
);
}).toList(),
),
if (!isLosslessTarget) ...[
const SizedBox(height: 16),
Text(
context.l10n.trackConvertBitrate,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: bitrates.map((br) {
final isSelected = br == selectedBitrate;
return ChoiceChip(
label: Text(br),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() => selectedBitrate = br);
}
},
);
}).toList(),
),
],
if (isLosslessTarget) ...[
const SizedBox(height: 16),
Row(
children: [
Icon(
Icons.verified,
size: 16,
color: colorScheme.primary,
),
const SizedBox(width: 6),
Text(
context.l10n.trackConvertLosslessHint,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.primary),
),
],
),
],
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () {
Navigator.pop(context);
_performBatchConversion(
allTracks: allTracks,
targetFormat: selectedFormat,
bitrate: selectedBitrate,
);
},
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
child: Text(
context.l10n.selectionConvertCount(
_selectedIds.length,
),
),
),
),
],
),
),
);
},
);
},
builder: (sheetContext) => BatchConvertSheet(
formats: formats,
title: sheetTitle,
confirmLabel: sheetConfirmLabel,
onConvert: (format, bitrate) {
Navigator.pop(sheetContext);
_performBatchConversion(
allTracks: allTracks,
targetFormat: format,
bitrate: bitrate,
);
},
),
);
}
@@ -1391,7 +1279,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return;
}
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final isLossless = isLosslessConversionTarget(targetFormat);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
@@ -1565,27 +1453,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final baseName = dotIdx > 0
? oldFileName.substring(0, dotIdx)
: oldFileName;
String newExt;
String mimeType;
switch (targetFormat.toLowerCase()) {
case 'opus':
newExt = '.opus';
mimeType = 'audio/opus';
break;
case 'alac':
case 'aac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
case 'flac':
newExt = '.flac';
mimeType = 'audio/flac';
break;
default:
newExt = '.mp3';
mimeType = 'audio/mpeg';
break;
}
final convTarget = convertTargetExtAndMime(targetFormat);
final newExt = convTarget.ext;
final mimeType = convTarget.mime;
final newFileName = '$baseName$newExt';
final safUri = await PlatformBridge.createSafFileFromPath(
@@ -1664,6 +1534,80 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
}
}
Future<void> _runBatchReplayGain(List<LocalLibraryItem> tracks) async {
final tracksById = {for (final t in tracks) t.id: t};
final selected = <LocalLibraryItem>[];
for (final id in _selectedIds) {
final item = tracksById[id];
if (item == null) continue;
selected.add(item);
}
if (selected.isEmpty) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(ctx.l10n.replayGainBatchConfirmTitle),
content: Text(
ctx.l10n.replayGainBatchConfirmMessage(selected.length),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(ctx.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(ctx.l10n.replayGainBatchConfirmTitle),
),
],
),
);
if (confirmed != true || !mounted) return;
var cancelled = false;
int successCount = 0;
final total = selected.length;
BatchProgressDialog.show(
context: context,
title: context.l10n.replayGainBatchAnalyzing,
total: total,
icon: Icons.graphic_eq,
onCancel: () {
cancelled = true;
BatchProgressDialog.dismiss(context);
},
);
for (int i = 0; i < total; i++) {
if (!mounted || cancelled) break;
final item = selected[i];
BatchProgressDialog.update(current: i + 1, detail: item.trackName);
try {
final ok = await ReplayGainService.applyToFile(item.filePath);
if (ok) successCount++;
} catch (_) {}
}
_exitSelectionMode();
if (!mounted) return;
if (!cancelled) {
BatchProgressDialog.dismiss(context);
}
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.replayGainBatchSuccess(successCount, total),
),
),
);
}
Widget _buildSelectionBottomBar(
BuildContext context,
ColorScheme colorScheme,
@@ -1761,22 +1705,26 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
),
const SizedBox(height: 12),
Row(
children: [
if (flacEligibleCount > 0) ...[
Expanded(
child: _LocalAlbumSelectionActionButton(
LayoutBuilder(
builder: (context, constraints) {
const spacing = 8.0;
final itemWidth = (constraints.maxWidth - spacing) / 2;
final actions = <Widget>[];
if (flacEligibleCount > 0) {
actions.add(
_LocalAlbumSelectionActionButton(
icon: Icons.download_for_offline_outlined,
label:
'${context.l10n.queueFlacAction} ($flacEligibleCount)',
onPressed: () => _queueSelectedAsFlac(tracks),
colorScheme: colorScheme,
),
),
const SizedBox(width: 8),
],
Expanded(
child: _LocalAlbumSelectionActionButton(
);
}
actions.add(
_LocalAlbumSelectionActionButton(
icon: Icons.auto_fix_high_outlined,
label: '${context.l10n.trackReEnrich} ($selectedCount)',
onPressed: selectedCount > 0
@@ -1784,10 +1732,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
: null,
colorScheme: colorScheme,
),
),
const SizedBox(width: 8),
Expanded(
child: _LocalAlbumSelectionActionButton(
);
actions.add(
_LocalAlbumSelectionActionButton(
icon: Icons.swap_horiz,
label: context.l10n.selectionConvertCount(selectedCount),
onPressed: selectedCount > 0
@@ -1795,8 +1743,30 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
: null,
colorScheme: colorScheme,
),
),
],
);
actions.add(
_LocalAlbumSelectionActionButton(
icon: Icons.graphic_eq,
label: context.l10n.selectionReplayGainCount(
selectedCount,
),
onPressed: selectedCount > 0
? () => _runBatchReplayGain(tracks)
: null,
colorScheme: colorScheme,
),
);
return Wrap(
spacing: spacing,
runSpacing: spacing,
children: [
for (final action in actions)
SizedBox(width: itemWidth, child: action),
],
);
},
),
const SizedBox(height: 8),
+29 -13
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui' show ImageFilter;
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -23,6 +24,7 @@ import 'package:spotiflac_android/services/update_checker.dart';
import 'package:spotiflac_android/widgets/app_announcement_dialog.dart';
import 'package:spotiflac_android/widgets/update_dialog.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('MainShell');
@@ -548,6 +550,7 @@ class _MainShellState extends ConsumerState<MainShell>
return true;
},
child: Scaffold(
extendBody: true,
body: AnimatedBuilder(
animation: _tabJumpTransitionController,
child: PageView.builder(
@@ -570,20 +573,33 @@ class _MainShellState extends ConsumerState<MainShell>
);
},
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex.clamp(0, maxIndex),
onDestinationSelected: _onNavTap,
animationDuration: const Duration(milliseconds: 500),
backgroundColor: Theme.of(context).brightness == Brightness.dark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.05),
Theme.of(context).colorScheme.surface,
)
: Color.alphaBlend(
Colors.black.withValues(alpha: 0.03),
Theme.of(context).colorScheme.surface,
bottomNavigationBar: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
child: DecoratedBox(
position: DecorationPosition.foreground,
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: Theme.of(
context,
).colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
destinations: destinations,
),
child: NavigationBar(
selectedIndex: _currentIndex.clamp(0, maxIndex),
onDestinationSelected: _onNavTap,
animationDuration: const Duration(milliseconds: 500),
elevation: 0,
height: 64,
backgroundColor: settingsGroupColor(
context,
).withValues(alpha: 0.72),
destinations: destinations,
),
),
),
),
),
);
+151 -87
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'dart:ui' show ImageFilter;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
@@ -9,6 +10,7 @@ import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/image_cache_utils.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
@@ -253,7 +255,9 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
_buildAppBar(context, colorScheme),
_buildInfoCard(context, colorScheme),
_buildTrackList(context, colorScheme),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
SliverToBoxAdapter(
child: SizedBox(height: 32 + context.navBarBottomInset),
),
],
),
);
@@ -296,14 +300,17 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
fit: StackFit.expand,
children: [
if (_coverUrl != null)
CachedCoverImage(
imageUrl: _highResCoverUrl(_coverUrl) ?? _coverUrl!,
fit: BoxFit.cover,
memCacheWidth: cacheWidth,
placeholder: (_, _) =>
Container(color: colorScheme.surface),
errorWidget: (_, _, _) =>
Container(color: colorScheme.surface),
ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32),
child: CachedCoverImage(
imageUrl: _highResCoverUrl(_coverUrl) ?? _coverUrl!,
fit: BoxFit.cover,
memCacheWidth: cacheWidth,
placeholder: (_, _) =>
Container(color: colorScheme.surface),
errorWidget: (_, _, _) =>
Container(color: colorScheme.surface),
),
)
else
Container(
@@ -314,91 +321,148 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
color: colorScheme.onSurfaceVariant,
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
height: expandedHeight * 0.65,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.85),
],
Container(color: Colors.black.withValues(alpha: 0.35)),
if (_coverUrl != null)
Positioned(
left: 0,
right: 0,
bottom: 0,
height: expandedHeight * 0.65,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.6),
],
),
),
),
),
),
Positioned(
left: 20,
right: 20,
bottom: 40,
Positioned.fill(
child: AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: showContent ? 1.0 : 0.0,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(
_playlistName,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
height: 1.2,
),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
if (_tracks.isNotEmpty) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.playlist_play,
size: 14,
color: Colors.white,
),
const SizedBox(width: 4),
Text(
context.l10n.tracksCount(_tracks.length),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
child: Padding(
padding: const EdgeInsets.fromLTRB(
20,
kToolbarHeight + 8,
20,
28,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (_coverUrl != null) ...[
Builder(
builder: (context) {
final coverSize =
(constraints.maxWidth * 0.5).clamp(
140.0,
220.0,
);
return Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(
alpha: 0.45,
),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
),
child: CachedCoverImage(
imageUrl:
_highResCoverUrl(_coverUrl) ??
_coverUrl!,
fit: BoxFit.cover,
memCacheWidth: cacheWidth,
borderRadius: BorderRadius.circular(16),
placeholder: (_, _) => Container(
decoration: BoxDecoration(
color:
colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
),
),
errorWidget: (_, _, _) => Container(
decoration: BoxDecoration(
color:
colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
),
),
),
);
},
),
const SizedBox(height: 20),
],
Text(
_playlistName,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
height: 1.2,
),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
if (_tracks.isNotEmpty) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.playlist_play,
size: 14,
color: Colors.white,
),
const SizedBox(width: 4),
Text(
context.l10n.tracksCount(_tracks.length),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildLoveAllButton(),
const SizedBox(width: 12),
_buildDownloadAllCenterButton(context),
const SizedBox(width: 12),
_buildAddToPlaylistButton(context),
],
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildLoveAllButton(),
const SizedBox(width: 12),
_buildDownloadAllCenterButton(context),
const SizedBox(width: 12),
_buildAddToPlaylistButton(context),
],
),
],
],
],
),
),
),
),
@@ -900,7 +964,7 @@ class _PlaylistTrackItem extends ConsumerWidget {
vertical: 2,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Row(
@@ -909,7 +973,7 @@ class _PlaylistTrackItem extends ConsumerWidget {
Icon(
Icons.folder_outlined,
size: 10,
color: colorScheme.onTertiaryContainer,
color: colorScheme.onPrimaryContainer,
),
const SizedBox(width: 3),
Text(
@@ -917,7 +981,7 @@ class _PlaylistTrackItem extends ConsumerWidget {
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w500,
color: colorScheme.onTertiaryContainer,
color: colorScheme.onPrimaryContainer,
),
),
],

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