Compare commits

...

186 Commits

Author SHA1 Message Date
zarzet 0f327cd1f6 feat: add flat singles folder option (Artist/song.flac without Singles subfolder) 2026-03-26 18:15:37 +07:00
zarzet 4f2e677e8b fix: improve skeleton visibility and artist header for light mode 2026-03-26 17:32:54 +07:00
zarzet 79a69f8f70 chore: clean up codebase 2026-03-26 16:43:56 +07:00
zarzet bf0f4bdf3e fix: store URL input flash on startup and FLAC metadata fallback for mismatched files
Load saved registry URL before first state update to prevent brief
flash of the setup screen when the store tab initializes.

Add Ogg/Opus fallback in readFileMetadata when FLAC parsing fails,
handling files saved with .flac extension that contain opus data.
2026-03-26 16:26:14 +07:00
zarzet 5e1cc3ecb5 refactor: extract YouTube download to ytmusic extension and fix UI issues
Remove built-in YouTube/Cobalt download pipeline from Go backend and
Dart frontend. YouTube downloading now requires the ytmusic-spotiflac
extension (with download_provider capability).

Go backend:
- Delete youtube.go (745 lines) and youtube_quality_test.go
- Remove DownloadFromYouTube, IsYouTubeURLExport,
  ExtractYouTubeVideoIDExport from exports.go
- Remove YouTube routing from DownloadTrack and DownloadByStrategy

Dart frontend:
- Remove YouTube from built-in services, bitrate settings, quality UI
- Remove youtubeOpusBitrate/youtubeMp3Bitrate from settings model
- Add migration 7: default service youtube -> tidal
- Remove YouTube l10n keys from all 14 arb files and regenerate
- Update _determineOutputExt to handle opus_/mp3_ quality strings
- Add SAF opus/mp3 metadata embedding in unified branch
- Fix TweenSequence assertion crash (t outside 0.0-1.0)
- Fix store URL TextField styling consistency

Extension changes (gitignored, in extension/YT-Music-SpotiFLAC/):
- Add download_provider type, qualityOptions, network permissions
- Implement checkAvailability and download via SpotubeDL/Cobalt
2026-03-26 16:17:57 +07:00
zarzet d4b37edc2f feat: add animation utilities and fix regressions in UI refactor
- Add animation_utils.dart with skeleton loaders, staggered list animations,
  animated checkboxes, badge bump, download success overlay, and shared
  page route helper
- Replace CircularProgressIndicator with shimmer skeleton loaders across
  album, artist, playlist, search, store, and extension screens
- Unify page transitions via slidePageRoute (MaterialPageRoute) for
  Android predictive back gesture support
- Extract AnimatedSelectionCheckbox with configurable unselectedColor
  to preserve original transparent/opaque backgrounds per context
- Add swipe-to-dismiss on download queue items with confirmDismiss
  dialog for active downloads to prevent accidental cancellation
- Add Hero animations for cover art transitions between list and detail
- Add AnimatedBadge bump on navigation bar badge count changes
- Add DownloadSuccessOverlay green flash on download completion
- Restore fine-grained ref.watch(.select()) in _CollectionTrackTile
  to avoid full list rebuilds on download history changes
- Fix DownloadSuccessOverlay re-flashing on widget recreation by
  initialising _wasSuccess from initial widget state
- Remove orphan Hero tag in search_screen that had no matching pair
- Chip borderRadius updated from 8 to 20 for consistency
2026-03-26 13:38:07 +07:00
zarzet 9483614bc7 feat: cache audio analysis results and fix total samples metric 2026-03-26 02:17:18 +07:00
zarzet a73f2e1a13 feat: auto-select recommended download service based on content source 2026-03-26 01:44:11 +07:00
zarzet 091e3fadd9 feat: add audio quality analysis widget and fix USLT lyrics detection 2026-03-26 01:11:29 +07:00
zarzet 5340ca7b16 chore: bump version to 4.1.0+117 2026-03-25 23:23:14 +07:00
zarzet 85d3e58a26 fix: hi-res cover art for Tidal/Qobuz and album metadata override 2026-03-25 23:17:45 +07:00
zarzet 1125c757fe fix: remove unintended home reset on tab switch 2026-03-25 22:33:04 +07:00
zarzet 66d714d368 fix: unify search bar, filter chips, tab navigation, and clean up comments 2026-03-25 22:27:22 +07:00
zarzet 49c2501fbc refactor: use pointer returns and unified forceRefresh in ExtensionStore 2026-03-25 21:47:31 +07:00
zarzet e487817f21 feat: add sorting options for search results 2026-03-25 21:40:36 +07:00
zarzet d8bbeb1e67 perf: use Tidal/Qobuz metadata for Deezer track resolution 2026-03-25 21:18:47 +07:00
zarzet 9693616645 fix: route tidal/qobuz items from Recent Access to built-in screens instead of extension screens 2026-03-25 20:50:33 +07:00
zarzet 0423e36d34 chore: bump version to 3.9.1+116 2026-03-25 20:08:53 +07:00
zarzet c8d605fdee fix: add ValueListenableBuilder for embedded cover refresh and localize hardcoded queue strings 2026-03-25 20:05:24 +07:00
zarzet 03fd734048 perf: lazy extension VM init, incremental startup maintenance, and UI optimizations
- Defer extension VM initialization until first use with lockReadyVM() pattern to eliminate TOCTOU races and reduce startup overhead
- Add validateExtensionLoad() to catch JS errors at install time without keeping VM alive
- Teardown VM on extension disable to free resources; re-init lazily on re-enable
- Replace full orphan cleanup with incremental cursor-based pagination across launches
- Batch DB writes (upsertBatch, replaceAll) with transactions for atomicity
- Parse JSON natively on Kotlin side to avoid double-serialization over MethodChannel
- Add identity-based memoization caches for unified items and path match keys in queue tab
- Use ValueListenableBuilder for targeted embedded cover refreshes instead of full setState
- Extract shared widgets (_buildAlbumGridItemCore, _buildFilterButton, _navigateWithUnfocus)
- Use libraryCollectionsProvider selector and MediaQuery.paddingOf for fewer rebuilds
- Simplify supporter chip tiers and localize remaining hardcoded strings
2026-03-25 19:55:02 +07:00
zarzet da9d64ccfd chore: update VirusTotal hash in README 2026-03-25 17:15:36 +07:00
zarzet 02e64b7a3c Merge remote-tracking branch 'origin/main' 2026-03-25 17:12:06 +07:00
zarzet a435009d4d fix(qobuz): skip SongLink when ISRC is already available 2026-03-25 17:09:54 +07:00
github-actions[bot] 9ca73a99a6 chore: update AltStore source to v3.9.0 2026-03-25 09:29:51 +00:00
zarzet 4974284760 fix(l10n): consolidate Crowdin locale files and fix ICU plural warnings
- Replace app_es-ES.arb, app_pt-PT.arb, app_tr-TR.arb (hyphen format)
  with properly named app_es_ES.arb, app_pt_PT.arb, app_tr.arb
- Fix @@locale values to match Flutter filename convention (underscore)
- Fix ICU plural syntax: remove redundant 'one {}' before '=1{...}'
  in es_ES, pt_PT, tr translations
- Regenerate l10n output files
2026-03-25 16:12:37 +07:00
Zarz Eleutherius a0306bd345 Merge pull request #258 from zarzet/l10n_dev
New Crowdin updates
2026-03-25 16:08:16 +07:00
zarzet ea7e594c68 Merge remote-tracking branch 'origin/dev' into l10n_dev
# Conflicts:
#	lib/l10n/arb/app_es-ES.arb
#	lib/l10n/arb/app_id.arb
#	lib/l10n/arb/app_pt-PT.arb
#	lib/l10n/arb/app_tr-TR.arb
2026-03-25 16:08:10 +07:00
Zarz Eleutherius d00a84f1b9 New translations app_en.arb (Indonesian) 2026-03-25 16:02:56 +07:00
Zarz Eleutherius 58b6203681 New translations app_en.arb (Chinese Simplified) 2026-03-25 16:02:54 +07:00
Zarz Eleutherius d299144c47 New translations app_en.arb (Russian) 2026-03-25 16:02:53 +07:00
Zarz Eleutherius 40b224e5a1 New translations app_en.arb (Dutch) 2026-03-25 16:02:51 +07:00
Zarz Eleutherius 7021e5493f New translations app_en.arb (Japanese) 2026-03-25 16:02:49 +07:00
Zarz Eleutherius 68bbc8a259 New translations app_en.arb (German) 2026-03-25 16:02:47 +07:00
zarzet be94a59441 chore: bump version to 3.9.0+115, add new translators
- Bump app version from 3.8.8 to 3.9.0 (build 115)
- Add 4 new Crowdin translators: unkn0wn (Indonesian), lunching1272
  (Chinese Simplified), Сергей Ильченко (Russian), Girl-lass (Chinese
  Simplified)
2026-03-25 15:47:08 +07:00
zarzet 3a73aee1b7 feat: add home feed provider setting, fix Qobuz cover URL propagation
- Add homeFeedProvider field to AppSettings with picker UI in extensions page
- Update explore_provider to respect user's home feed provider preference
- Add normalizeCoverReference() and normalizeRemoteHttpUrl() to filter
  invalid cover URLs (no scheme, no host, protocol-relative)
- Apply cover URL normalization across all screens and providers to
  prevent 'no host specified in URI' errors from Qobuz
- Propagate CoverURL from QobuzDownloadResult through Go backend so
  cover art is available even when request metadata is incomplete
2026-03-25 15:46:22 +07:00
zarzet c91154ea3e feat: add built-in search provider in settings, fix bottom sheet overflow 2026-03-25 15:46:12 +07:00
zarzet 4f365ca7fe feat: add built-in Tidal/Qobuz search with recommended service picker
- Add SearchAll() for Tidal and Qobuz in Go backend (tracks, artists, albums)
- Add searchTidalAll/searchQobuzAll platform routing for Android and iOS
- Add Tidal/Qobuz options to search provider dropdown in home tab
- Show (Recommended) label and auto-select service in download picker
2026-03-25 13:52:57 +07:00
zarzet 98fdc0ed7c feat: restore Tidal HIGH (AAC 320kbps) lossy quality option (closes #242)
Requested by @okinaau in issue #242 — brings back the ability to
download tracks in lossy format for users on low storage devices.

HIGH quality fetches the AAC M4A stream directly from the Tidal server
(no lossless download + re-encode), then converts to MP3 or Opus via
FFmpeg based on the tidalHighFormat setting (mp3_320, opus_256, or
opus_128).

- go_backend/tidal.go: restore outputExt .m4a, filename logic,
  duplicate-check guard, HIGH M4A lyrics/LRC handling, and
  bitDepth=0/sampleRate=44100 for HIGH quality result
- settings.dart + settings.g.dart: re-add tidalHighFormat field
  (default mp3_320) with JSON serialization
- settings_provider.dart: re-add setTidalHighFormat(), remove
  migration that force-migrated HIGH to LOSSLESS
- download_queue_provider.dart: restore HIGH conversion logic for
  both SAF and non-SAF paths using FFmpegService.convertM4aToLossy
- download_settings_page.dart: restore Lossy 320kbps quality tile,
  format sub-picker tile, _getTidalHighFormatLabel helper, and
  _showTidalHighFormatPicker bottom sheet
- l10n: add 10 keys (downloadLossy320, downloadLossyFormat,
  downloadLossy320Format, downloadLossy320FormatDesc, downloadLossyMp3,
  downloadLossyMp3Subtitle, downloadLossyOpus256/Subtitle,
  downloadLossyOpus128/Subtitle) to ARB and all 13 generated files
2026-03-22 23:33:32 +07:00
zarzet 12be560cb8 feat: add M4A metadata/cover embed support across all Flutter screens
Add FFmpegService.embedMetadataToM4a() for writing tags and cover art
into M4A files via FFmpeg. Fix two bugs in the same function:
- Remove '-disposition:v:0 attached_pic' which is only valid for
  Matroska/WebM containers and causes FFmpeg to error on MP4/M4A
- Apply same fix to _convertToAlac which had the identical issue

Add M4A handling (isM4A branch) to all four embed call-sites:
track_metadata_screen (lyrics embed, re-enrich, edit metadata sheet,
format conversion), queue_tab, local_album_screen, and
downloaded_album_screen.

Add 'LYRICS'/'UNSYNCEDLYRICS' to _mapMetadataForTagEmbed so existing
lyrics survive a re-enrich cycle on M4A/MP3/Opus files.

Preserve existing lyrics before overwriting tags in the edit metadata
sheet (best-effort readFileMetadata before FFmpeg pass).

Extract mergePlatformMetadataForTagEmbed() into lyrics_metadata_helper
to deduplicate the identical metadata-mapping loops that existed in
queue_tab, local_album_screen, downloaded_album_screen, and
track_metadata_screen.

Wire ensureLyricsMetadataForConversion into the format conversion path
in track_metadata_screen so lyrics are carried through conversions.

Add ISRC and LABEL/ORGANIZATION mappings to _convertToM4aTags.
2026-03-22 23:01:32 +07:00
zarzet 4cf885a52e feat: populate M4A metadata in ReadFileMetadata and library scan
ReadFileMetadata now fills all tag fields (title, artist, album, ISRC,
lyrics, genre, label, copyright, composer, comment, track/disc number)
for M4A files using the new ReadM4ATags helper, matching the existing
behavior for FLAC, MP3, and Ogg.

scanM4AFile reads tags via ReadM4ATags instead of falling back to the
filename, and applies applyDefaultLibraryMetadata for missing fields
(consistent with FLAC/MP3 scan path).

Remove the '&& ext != ".m4a"' guard in cover cache so M4A cover art
is extracted and cached during library scans.
2026-03-22 23:00:55 +07:00
zarzet c57c8a4267 feat: implement full M4A tag read engine with atom path fallback and freeform fix
Add ReadM4ATags() that parses all standard iTunes atoms (title, artist,
album, album artist, date, genre, composer, comment, copyright, lyrics,
track/disc number) and freeform '----' atoms (ISRC, label, lyrics).

Fix two pre-existing bugs in the M4A atom traversal:
- findM4AIlstAtom: now tries moov>udta>meta>ilst first, then falls back
  to moov>meta>ilst so files from Tidal/Qobuz/Apple Music are handled
- readM4AFreeformValue: 'name' atom payload is raw UTF-8 after 4-byte
  flags, not a nested 'data' atom; fix reads it directly so ISRC/label
  freeform tags are no longer silently dropped

Refactor extractLyricsFromM4A and extractCoverFromM4A to reuse the new
helpers (findM4AIlstAtom, readM4ADataAtomPayload) instead of duplicating
the atom traversal logic. Add extractAnyCoverArtWithHint M4A case that
previously returned a hardcoded 'not yet supported' error.
2026-03-22 23:00:42 +07:00
Zarz Eleutherius 2ca6c737c0 Update README 2026-03-22 22:46:03 +07:00
Zarz Eleutherius 2a451ec2a3 Merge pull request #252 from ShuShuzinhuu/main
docs: Add SpotiFLAC Python Module to Other Projects section
2026-03-22 22:44:56 +07:00
Zarz Eleutherius 346e79b247 Merge pull request #254 from Amonoman/main
Improve README structure and readability
2026-03-22 22:44:40 +07:00
zarzet 497ba342c0 feat: add createPlaylistFolder setting for playlist source folder prefix
When enabled, playlist downloads are placed inside a subfolder named
after the playlist before the normal folder organization structure
(e.g. Playlist/<artist>/<album>/). The setting is a no-op when folder
organization is already set to 'By Playlist'. Includes model field,
JSON serialization, settings notifier, download queue path logic,
UI toggle in download settings, and localizations for all 13 languages.
2026-03-22 22:43:03 +07:00
zarzet aca0bbb819 chore: remove security_hardening_test.go
Tests for sanitizeSensitiveLogText, validateExtensionAuthURL,
validateDomain, and buildStoreExtensionDestPath are no longer
maintained alongside the main source and have been removed.
2026-03-22 22:42:50 +07:00
zarzet 2df8fd6282 feat: add normalizeLooseArtistName with diacritic folding for resilient artist matching
Use Unicode NFD decomposition to strip combining marks so variants like
"Özkent" and "Ozkent" are treated as equivalent. Apply the new helper
in both tidal.go and qobuz.go artistsMatch functions.
2026-03-22 22:42:33 +07:00
Amonoman 999317eba1 Update README 2026-03-20 16:14:03 +01:00
Shu 16991476ed Add SpotiFLAC Python Module section to README
Added a section for the SpotiFLAC Python Module with a link and maintainer information.
2026-03-20 09:22:45 -04:00
github-actions[bot] ba33639818 chore: update AltStore source to v3.8.8 2026-03-18 11:33:08 +00:00
zarzet 23cab16471 feat: enable Tidal ISRC and metadata search 2026-03-18 18:14:01 +07:00
zarzet 0a892011de refactor: migrate lyrics providers to Paxsenix endpoints 2026-03-18 17:11:17 +07:00
zarzet acb1d957d3 feat: add McNuggets Jimmy as supporter 2026-03-18 17:10:44 +07:00
zarzet 4a492aeefc chore: bump version to 3.8.8+114 2026-03-18 01:23:55 +07:00
zarzet eb143a41fc refactor: remove redundant comments and fix setMetadataSource bug
- Fix setMetadataSource always returning 'deezer' regardless of input parameter
- Remove self-evident doc comments that restate method/class names across
  app_theme, dynamic_color_wrapper, cover_cache_manager, history_database,
  library_database, and download_service_picker
- Remove stale migration inline notes (// 12 -> 16, // 20 -> 16, etc.) from app_theme
- Remove trivial section-label comments in queue_tab batch conversion method
- Remove duplicate 'wait up to 5 seconds' comment in main_shell
2026-03-18 01:12:16 +07:00
zarzet 75db2f162b fix: improve extension download reliability and Qobuz API integration
- Add dedicated long-timeout download client (24h) for extension file downloads,
  preventing timeouts on large lossless audio files
- Skip unnecessary SongLink Deezer prelookup when an extension download provider
  handles the track, reducing latency and avoiding spurious API failures
- Prefer native track ID over Spotify ID when a source/provider is set, ensuring
  extension providers receive their own IDs correctly
- Update Qobuz MusicDL API endpoint and switch payload URL to open.qobuz.com
- Extract buildQobuzMusicDLPayload helper and add test coverage
2026-03-18 01:06:22 +07:00
zarzet 855d0e3ffc feat: add zcc09 as supporter (thank you) 2026-03-18 00:19:36 +07:00
zarzet 5ccd06cc68 fix: stabilize library scan IDs, pause queue behavior, and scan race condition
- Generate stable SHA-1 based IDs for SAF-scanned library items to prevent null ID crashes on the Dart side
- Suppress false queue-complete notification when user pauses instead of finishing the queue, and break out of parallel loop immediately when paused with no active downloads
- Use SQLite as the single source of truth for library scan results to fix a race condition where auto-scan could fire before provider state finished loading, dropping unchanged rows
2026-03-17 23:54:49 +07:00
github-actions[bot] b2873378fc chore: update AltStore source to v3.8.7 2026-03-17 08:41:17 +00:00
zarzet 66a89d9e8e fix: properly stop active downloads when pausing the queue 2026-03-17 15:26:51 +07:00
zarzet 814deca19d fix: hide queue-as-FLAC button when all selected tracks are already FLAC 2026-03-17 15:19:46 +07:00
zarzet 3bb6754d9c Merge branch 'main' into dev
# Conflicts:
#	lib/constants/app_info.dart
#	lib/main.dart
#	lib/screens/local_album_screen.dart
#	lib/screens/queue_tab.dart
#	lib/screens/settings/donate_page.dart
#	lib/services/local_track_redownload_service.dart
#	pubspec.yaml
2026-03-17 15:10:04 +07:00
zarzet 7d11d67cd2 chore: bump version to 3.8.7+113 2026-03-17 15:07:05 +07:00
zarzet c0bd10cfca fix: skip already-downloaded tracks in library folder download-all 2026-03-17 15:04:45 +07:00
zarzet e003b15ffd fix: skip tracks already in FLAC from queue-as-FLAC selection and fix local album track list widget identity 2026-03-17 15:02:19 +07:00
zarzet ac1c7d31c9 fix: improve Spotify track availability resolution 2026-03-17 14:45:24 +07:00
zarzet 6fc9ffeb23 fix: upgrade Deezer and Tidal cover art to max quality on Dart side
The Dart-side _upgradeToMaxQualityCover only handled Spotify CDN
URLs, causing Deezer covers to stay at 1000x1000 and Tidal at
1280x1280. Add regex-based Deezer upgrade (1800x1800) and Tidal
origin resolution upgrade to match the Go backend logic.

Closes #237
2026-03-16 22:46:45 +07:00
zarzet 9bebed506b fix: honor local library auto-scan cooldown 2026-03-16 22:35:17 +07:00
github-actions[bot] bffeb55a7a chore: update AltStore source to v3.8.6 2026-03-16 14:10:04 +00:00
zarzet c66d13c9fd bump version to 3.8.6+112 2026-03-16 21:02:16 +07:00
github-actions[bot] 8529985a0e chore: update AltStore source to v3.8.6 2026-03-16 13:54:09 +00:00
zarzet a8a3973225 fix: prevent re-download of tracks converted to a different format
When a file is converted externally (e.g. FLAC to OPUS), the
orphan cleanup would delete the history entry because the original
path no longer exists. Now it checks for sibling files with other
audio extensions and updates the stored path instead of deleting.

Also add extension-stripped keys to path_match_keys so that
paths differing only by audio extension still match during local
library scan exclusion and queue deduplication.
2026-03-16 20:38:51 +07:00
zarzet 6710f90e1e feat: add auto-scan option for local library
Add a new 'Auto Scan' setting under Local Library with four modes:
off, every app open (10min cooldown), daily, and weekly. The app
uses WidgetsBindingObserver to trigger incremental scans on launch
and when resuming from background, respecting the configured
cooldown based on the last scan timestamp.
2026-03-16 20:35:59 +07:00
zarzet 929c5f3249 fix: remove double horizontal padding in store tab extension list
The extension list was wrapped in an extra Padding(horizontal: 16)
on top of SettingsGroup's default 16px margin, resulting in 32px
total inset. Remove the outer wrapper to match settings tab width.
2026-03-16 20:35:59 +07:00
zarzet f170ead7b9 docs: add contributors section to README
Add auto-generated contributor avatars via contrib.rocks with a
link to the GitHub contributors page. Include acknowledgement for
translators and bug reporters.
2026-03-16 20:35:59 +07:00
zarzet e63e366228 feat: add mc nuggets jimmy, CJBGR and michahRicie as supporters
Add new supporters to the donate page. michahRicie is highlighted
as a gold supporter.
2026-03-16 20:35:59 +07:00
zarzet 95e755e54e fix: delay iOS folder picker after sheet dismiss and update Afkar hosts 2026-03-16 20:35:59 +07:00
zarzet c719406425 docs: update readme 2026-03-16 20:35:59 +07:00
zarzet 9627ef66cf fix: verify resolved Tidal/Deezer tracks match the download request before downloading
SongLink can return incorrect track IDs (e.g. a different track from the
same album). Qobuz already had verification via qobuzTrackMatchesRequest.
This adds equivalent verification for Tidal and Deezer using a shared
trackMatchesRequest() helper in title_match_utils.go that checks artist,
title, and duration. Mismatched SongLink/ISRC results are now rejected
so the wrong audio is never embedded with Spotify metadata.
2026-03-16 20:35:59 +07:00
zarzet 15f977d98d fix: skip already-downloaded tracks in Download All for albums and playlists
Album and playlist Download All buttons now check download history and local
library before enqueuing, matching the existing behavior in artist discography
and CSV import. Tracks already in library are skipped with a summary snackbar.
2026-03-16 20:35:59 +07:00
zarzet 5b5f043624 docs: add extension store URL setup guide to README 2026-03-16 20:35:59 +07:00
zarzet 529a920b24 bump version to 3.8.5+111 2026-03-16 20:35:59 +07:00
zarzet 09eb6cf206 fix: use album-level artist for Various Artists albums instead of first track's artist
- Extension: fix extractSchemaOrg to find album-level schema (with numTracks) instead of per-track schema
- Extension: add secondaryText2 fallback in parseDescriptiveRows for VA album track artists
- Extension: use headerPrimaryText as primary album artist source, overriding schema.org
- App: album_screen now uses widget.artistName (album-level) instead of tracks.first.artistName
- App: home_tab _parseTrack now populates albumArtist from track data or album-level artist
- Bump Amazon extension to v2.0.1
2026-03-16 20:35:58 +07:00
zarzet af6fa6ea53 fix: extract cover art from M4A/ALAC files for conversion
Add extractCoverFromM4A() that reads the covr atom from the MP4
box tree (moov/udta/meta/ilst/covr/data). Wire it into
ExtractCoverToFile so ALAC-to-FLAC conversion preserves cover art.
2026-03-16 20:35:58 +07:00
zarzet 280b921755 fix: detect embedded lyrics in M4A/ALAC files
Add extractLyricsFromM4A() that walks the MP4 box tree
(moov/udta/meta/ilst/©lyr) to read lyrics. Wire it into
ExtractLyrics so the Embed Lyrics button is hidden when
lyrics already exist in the file.
2026-03-16 20:35:58 +07:00
zarzet 6ebe0c51ce fix: filter batch convert target formats based on source formats
Exclude same-format and lossy-to-lossless targets from the batch
convert sheet so users cannot pick pointless conversions like
FLAC→FLAC. Also clean up redundant inline comments.
2026-03-16 20:35:58 +07:00
zarzet 47bd24c1bd fix: preserve metadata and cover art in ALAC/M4A to FLAC conversion
- Use -map_metadata 0 instead of -map_metadata -1 so FFmpeg copies and
  auto-remaps source tags (M4A/ID3 → Vorbis comments) as a base
- Add _normalizeToVorbisComments() to filter technical fields (BIT_DEPTH,
  SAMPLE_RATE, DURATION) and normalize key variations to standard Vorbis
  comment names before applying overrides
- Switch cover art embedding from METADATA_BLOCK_PICTURE base64 (unreliable
  on Android due to command-line length limits) to -i cover -map 1:v
  -disposition attached_pic (same proven approach as embedMetadata and
  _convertToAlac)
- Drop zero-value track/disc numbers from override map to prevent
  clobbering source metadata with '0' from Go readFileMetadata
2026-03-16 20:35:58 +07:00
zarzet 2b23678c0d feat: add FLAC/ALAC bidirectional lossless conversion support
- Add _convertToAlac() and _convertToFlac() in ffmpeg_service with
  single-pass FFmpeg encoding, metadata tags, and cover art embedding
- Wire lossless formats (ALAC, FLAC) into single-track convert sheet
  with dynamic format list based on source format, hidden bitrate for
  lossless targets, and lossless hint text
- Add lossless conversion to batch convert UI in downloaded_album,
  local_album, and queue_tab screens with lossy-source filtering
- Fix M4A quality probe in Go backend: increase audio sample entry
  buffer from 24 to 32 bytes, read sample rate from correct offset
  (bytes 28-29) and bit depth from samplesize field (bytes 22-23)
- Add l10n keys for lossless confirm dialogs and hints (en, id)
2026-03-16 20:35:58 +07:00
zarzet e8327545ad feat: improve auto-fill track resolution in Edit Metadata sheet
- Identifier-first resolution (ISRC/Deezer/Spotify) before falling back to text search
- Score-based match selection via _metadataMatchScore instead of provider order
- Pass sourceTrackId from TrackMetadataScreen into _EditMetadataSheet
- Refactor buildDeezerExtendedMetadataResult and buildDeezerISRCSearchResult as testable helpers
- Add unit tests for buildDeezerExtendedMetadataResult and buildDeezerISRCSearchResult
- Propagate copyright through Deezer enrichment chain (exports, extension_providers)
2026-03-16 20:35:58 +07:00
zarzet 89a38af538 fix: resolve all flutter analyze warnings and improve auto-fill enrichment chain
- Fix use_build_context_synchronously in _embedLyrics by capturing l10n
  strings before async gaps (snackbarFailedToWriteStorage,
  snackbarFailedToEmbedLyrics, snackbarUnsupportedAudioFormat)
- Improve auto-fill metadata enrichment to use proper API chain:
  search providers -> convertSpotifyToDeezer (SongLink) for Deezer ID
  -> getDeezerMetadata for ISRC -> getDeezerExtendedMetadata for
  genre/label/copyright. Falls back to ISRC-based Deezer lookup when
  SongLink conversion unavailable.
- flutter analyze now reports 0 issues
2026-03-16 20:35:58 +07:00
zarzet b7f34ec47c feat: selective auto-fill from online in Edit Metadata sheet
Add 'Auto-fill from online' expandable section to the metadata editor
that lets users choose exactly which fields to populate from online
metadata search. Users can select individual fields via filter chips,
use 'All' or 'Empty only' quick-select buttons, then tap 'Fetch & Fill'
to search metadata providers and fill only the selected controllers.

The search uses existing searchTracksWithMetadataProviders API with
ISRC-preferring best-match selection. Extended metadata (genre, label,
copyright) is fetched via Deezer extended metadata API when available.
Cover art is downloaded from the match's cover_url. All results are
previewed in the editor before saving — nothing is written to the file
until the user taps Save.

Add 21 new l10n keys (editMetadata* namespace) for all UI strings.
2026-03-16 20:35:58 +07:00
zarzet 967523bfc6 feat: queue FLAC redownloads for local library tracks
Add LocalTrackRedownloadService with confidence-scored metadata matching
(ISRC, title, artist, album, duration, track/disc number, year) to find
reliable online matches for locally-stored tracks.

Wire up 'Queue FLAC' selection action in both local_album_screen and
queue_tab (library tab). Shows progress snackbar during resolution,
skips ambiguous or low-confidence matches, and reports results.

Add Indonesian (id) translations for all queueFlac l10n keys.
2026-03-16 20:35:58 +07:00
zarzet 29d8a185f9 fix: handle nested legacy iOS Documents path in validation
Detect and recover from stale sandbox container paths embedded inside
the current Documents directory. Extracts helper functions for path
suffix normalization and joining to reduce duplication.
2026-03-16 20:35:57 +07:00
zarzet 4495d4bf4e feat: add Opus 320kbps quality, remove Tidal HIGH tier
- Add YouTubeQualityOpus320 constant and opus_320 parser case in Go backend
- Expand opus supported bitrates to [128, 256, 320] across Go, Dart settings, and UI
- Update default YouTube Opus option from 256 to 320kbps
- Remove Tidal HIGH (lossy 320kbps) quality from Go backend, settings model,
  settings provider, download queue provider (both SAF and non-SAF paths),
  settings UI (quality option, format picker, helper methods), and l10n keys
- Add settings migration v6: auto-migrate users with audioQuality=HIGH to LOSSLESS
- Update and add Go test cases for opus_320 and adjusted max bitrate
- Regenerate l10n files, remove 10 unused downloadLossy* l10n keys
2026-03-16 20:35:57 +07:00
zarzet 67737467e0 ci: auto-update AltStore source (apps.json) on release 2026-03-16 20:35:57 +07:00
renovate[bot] 13845eea04 chore(deps): update dependency flutter to v3.41.4 2026-03-16 20:35:57 +07:00
zarzet 12779778d3 fix(i18n): localize hardcoded strings in bulk playlist download and fix trailing newlines 2026-03-16 20:35:57 +07:00
ViscousPot d4178ad036 feat: add option to download multiple selected playlists 2026-03-16 20:35:57 +07:00
ViscousPot 49ea84384d feat: auto fill playlist name during import 2026-03-16 20:35:57 +07:00
ViscousPot a6d9849468 Update CONTRIBUTING.md 2026-03-16 20:35:57 +07:00
ViscousPot 16100aa0fd add fvm 2026-03-16 20:35:57 +07:00
zarzet 387dd47374 feat: add Qobuz Afkar API provider and prefer request metadata for consistent album grouping 2026-03-16 20:35:57 +07:00
zarzet 6ecb69feae fix: prevent re-download of tracks converted to a different format
When a file is converted externally (e.g. FLAC to OPUS), the
orphan cleanup would delete the history entry because the original
path no longer exists. Now it checks for sibling files with other
audio extensions and updates the stored path instead of deleting.

Also add extension-stripped keys to path_match_keys so that
paths differing only by audio extension still match during local
library scan exclusion and queue deduplication.
2026-03-16 20:28:53 +07:00
zarzet feff985439 feat: add auto-scan option for local library
Add a new 'Auto Scan' setting under Local Library with four modes:
off, every app open (10min cooldown), daily, and weekly. The app
uses WidgetsBindingObserver to trigger incremental scans on launch
and when resuming from background, respecting the configured
cooldown based on the last scan timestamp.
2026-03-16 20:28:45 +07:00
zarzet 2e8fe34824 fix: remove double horizontal padding in store tab extension list
The extension list was wrapped in an extra Padding(horizontal: 16)
on top of SettingsGroup's default 16px margin, resulting in 32px
total inset. Remove the outer wrapper to match settings tab width.
2026-03-16 20:28:37 +07:00
zarzet f58005f406 docs: add contributors section to README
Add auto-generated contributor avatars via contrib.rocks with a
link to the GitHub contributors page. Include acknowledgement for
translators and bug reporters.
2026-03-16 20:28:31 +07:00
zarzet 75abc03a4f feat: add mc nuggets jimmy, CJBGR and michahRicie as supporters
Add new supporters to the donate page. michahRicie is highlighted
as a gold supporter.
2026-03-16 20:28:25 +07:00
zarzet 84381d142a fix: delay iOS folder picker after sheet dismiss and update Afkar hosts 2026-03-16 20:17:37 +07:00
github-actions[bot] f67f52eba9 chore: update AltStore source to v3.8.5 2026-03-15 21:35:25 +00:00
zarzet 3747ffff64 docs: update readme 2026-03-16 04:26:35 +07:00
zarzet ed47efed17 fix: verify resolved Tidal/Deezer tracks match the download request before downloading
SongLink can return incorrect track IDs (e.g. a different track from the
same album). Qobuz already had verification via qobuzTrackMatchesRequest.
This adds equivalent verification for Tidal and Deezer using a shared
trackMatchesRequest() helper in title_match_utils.go that checks artist,
title, and duration. Mismatched SongLink/ISRC results are now rejected
so the wrong audio is never embedded with Spotify metadata.
2026-03-16 04:16:44 +07:00
zarzet c0d72e89d7 fix: skip already-downloaded tracks in Download All for albums and playlists
Album and playlist Download All buttons now check download history and local
library before enqueuing, matching the existing behavior in artist discography
and CSV import. Tracks already in library are skipped with a summary snackbar.
2026-03-16 04:16:44 +07:00
zarzet a4313cfe0f docs: add extension store URL setup guide to README 2026-03-16 04:16:44 +07:00
zarzet c7bef03ee3 bump version to 3.8.5+111 2026-03-16 04:16:44 +07:00
zarzet ce5a9e0cff fix: use album-level artist for Various Artists albums instead of first track's artist
- Extension: fix extractSchemaOrg to find album-level schema (with numTracks) instead of per-track schema
- Extension: add secondaryText2 fallback in parseDescriptiveRows for VA album track artists
- Extension: use headerPrimaryText as primary album artist source, overriding schema.org
- App: album_screen now uses widget.artistName (album-level) instead of tracks.first.artistName
- App: home_tab _parseTrack now populates albumArtist from track data or album-level artist
- Bump Amazon extension to v2.0.1
2026-03-16 04:16:39 +07:00
zarzet 859b823e77 fix: extract cover art from M4A/ALAC files for conversion
Add extractCoverFromM4A() that reads the covr atom from the MP4
box tree (moov/udta/meta/ilst/covr/data). Wire it into
ExtractCoverToFile so ALAC-to-FLAC conversion preserves cover art.
2026-03-16 02:49:48 +07:00
zarzet 7d8cf5f7ca fix: detect embedded lyrics in M4A/ALAC files
Add extractLyricsFromM4A() that walks the MP4 box tree
(moov/udta/meta/ilst/©lyr) to read lyrics. Wire it into
ExtractLyrics so the Embed Lyrics button is hidden when
lyrics already exist in the file.
2026-03-16 02:43:13 +07:00
zarzet 4adaed8da0 fix: filter batch convert target formats based on source formats
Exclude same-format and lossy-to-lossless targets from the batch
convert sheet so users cannot pick pointless conversions like
FLAC→FLAC. Also clean up redundant inline comments.
2026-03-16 02:39:11 +07:00
zarzet 554fe08fcd fix: preserve metadata and cover art in ALAC/M4A to FLAC conversion
- Use -map_metadata 0 instead of -map_metadata -1 so FFmpeg copies and
  auto-remaps source tags (M4A/ID3 → Vorbis comments) as a base
- Add _normalizeToVorbisComments() to filter technical fields (BIT_DEPTH,
  SAMPLE_RATE, DURATION) and normalize key variations to standard Vorbis
  comment names before applying overrides
- Switch cover art embedding from METADATA_BLOCK_PICTURE base64 (unreliable
  on Android due to command-line length limits) to -i cover -map 1:v
  -disposition attached_pic (same proven approach as embedMetadata and
  _convertToAlac)
- Drop zero-value track/disc numbers from override map to prevent
  clobbering source metadata with '0' from Go readFileMetadata
2026-03-16 02:26:53 +07:00
zarzet b8af75bf6e feat: add FLAC/ALAC bidirectional lossless conversion support
- Add _convertToAlac() and _convertToFlac() in ffmpeg_service with
  single-pass FFmpeg encoding, metadata tags, and cover art embedding
- Wire lossless formats (ALAC, FLAC) into single-track convert sheet
  with dynamic format list based on source format, hidden bitrate for
  lossless targets, and lossless hint text
- Add lossless conversion to batch convert UI in downloaded_album,
  local_album, and queue_tab screens with lossy-source filtering
- Fix M4A quality probe in Go backend: increase audio sample entry
  buffer from 24 to 32 bytes, read sample rate from correct offset
  (bytes 28-29) and bit depth from samplesize field (bytes 22-23)
- Add l10n keys for lossless confirm dialogs and hints (en, id)
2026-03-16 02:13:45 +07:00
zarzet 35f2f119db feat: improve auto-fill track resolution in Edit Metadata sheet
- Identifier-first resolution (ISRC/Deezer/Spotify) before falling back to text search
- Score-based match selection via _metadataMatchScore instead of provider order
- Pass sourceTrackId from TrackMetadataScreen into _EditMetadataSheet
- Refactor buildDeezerExtendedMetadataResult and buildDeezerISRCSearchResult as testable helpers
- Add unit tests for buildDeezerExtendedMetadataResult and buildDeezerISRCSearchResult
- Propagate copyright through Deezer enrichment chain (exports, extension_providers)
2026-03-15 21:12:47 +07:00
zarzet f36096e0ac fix: resolve all flutter analyze warnings and improve auto-fill enrichment chain
- Fix use_build_context_synchronously in _embedLyrics by capturing l10n
  strings before async gaps (snackbarFailedToWriteStorage,
  snackbarFailedToEmbedLyrics, snackbarUnsupportedAudioFormat)
- Improve auto-fill metadata enrichment to use proper API chain:
  search providers -> convertSpotifyToDeezer (SongLink) for Deezer ID
  -> getDeezerMetadata for ISRC -> getDeezerExtendedMetadata for
  genre/label/copyright. Falls back to ISRC-based Deezer lookup when
  SongLink conversion unavailable.
- flutter analyze now reports 0 issues
2026-03-15 20:42:22 +07:00
zarzet 1665e4cd57 feat: selective auto-fill from online in Edit Metadata sheet
Add 'Auto-fill from online' expandable section to the metadata editor
that lets users choose exactly which fields to populate from online
metadata search. Users can select individual fields via filter chips,
use 'All' or 'Empty only' quick-select buttons, then tap 'Fetch & Fill'
to search metadata providers and fill only the selected controllers.

The search uses existing searchTracksWithMetadataProviders API with
ISRC-preferring best-match selection. Extended metadata (genre, label,
copyright) is fetched via Deezer extended metadata API when available.
Cover art is downloaded from the match's cover_url. All results are
previewed in the editor before saving — nothing is written to the file
until the user taps Save.

Add 21 new l10n keys (editMetadata* namespace) for all UI strings.
2026-03-15 20:35:42 +07:00
zarzet 42f0267277 feat: queue FLAC redownloads for local library tracks
Add LocalTrackRedownloadService with confidence-scored metadata matching
(ISRC, title, artist, album, duration, track/disc number, year) to find
reliable online matches for locally-stored tracks.

Wire up 'Queue FLAC' selection action in both local_album_screen and
queue_tab (library tab). Shows progress snackbar during resolution,
skips ambiguous or low-confidence matches, and reports results.

Add Indonesian (id) translations for all queueFlac l10n keys.
2026-03-15 20:18:58 +07:00
zarzet 82f59d32b9 fix: handle nested legacy iOS Documents path in validation
Detect and recover from stale sandbox container paths embedded inside
the current Documents directory. Extracts helper functions for path
suffix normalization and joining to reduce duplication.
2026-03-15 20:18:29 +07:00
zarzet 941347b007 feat: add Opus 320kbps quality, remove Tidal HIGH tier
- Add YouTubeQualityOpus320 constant and opus_320 parser case in Go backend
- Expand opus supported bitrates to [128, 256, 320] across Go, Dart settings, and UI
- Update default YouTube Opus option from 256 to 320kbps
- Remove Tidal HIGH (lossy 320kbps) quality from Go backend, settings model,
  settings provider, download queue provider (both SAF and non-SAF paths),
  settings UI (quality option, format picker, helper methods), and l10n keys
- Add settings migration v6: auto-migrate users with audioQuality=HIGH to LOSSLESS
- Update and add Go test cases for opus_320 and adjusted max bitrate
- Regenerate l10n files, remove 10 unused downloadLossy* l10n keys
2026-03-15 20:16:44 +07:00
zarzet 739c89569f Merge branch 'main' into dev 2026-03-15 19:42:31 +07:00
zarzet 18607597e9 fix: correct AltStore icon URL to assets/images/logo.png 2026-03-15 19:41:25 +07:00
zarzet 7bb808cba5 ci: auto-update AltStore source (apps.json) on release 2026-03-15 19:11:29 +07:00
Zarz Eleutherius 78cd396847 Merge pull request #233 from Amonoman/main
Add AltStore source and update README documentation
2026-03-15 19:07:50 +07:00
Zarz Eleutherius bb342c01e2 Merge pull request #232 from zarzet/renovate/flutter-3.x
chore(deps): update dependency flutter to v3.41.4
2026-03-15 19:02:31 +07:00
renovate[bot] 8a5dc0edfe chore(deps): update dependency flutter to v3.41.4 2026-03-15 12:02:29 +00:00
Amonoman 8540da484f Add AltStore source and update README 2026-03-15 13:02:23 +01:00
zarzet 20f789f8e0 fix(i18n): localize hardcoded strings in bulk playlist download and fix trailing newlines 2026-03-15 19:01:45 +07:00
Zarz Eleutherius 3e89326c95 Merge pull request #229 from ViscousPot/feat/bulk-download-library-playlists
Add bulk download option for selected library playlists
2026-03-15 18:57:06 +07:00
Zarz Eleutherius a7ea4de25a Merge pull request #228 from ViscousPot/feat/auto-fill-playlist-name-for-import
Auto-fill playlist name when importing from Spotify
2026-03-15 18:56:58 +07:00
Zarz Eleutherius aabfbf062e Merge pull request #230 from ViscousPot/feat/improve-dev+build-instructions
Add FVM config and improve dev setup instructions
2026-03-15 18:56:52 +07:00
zarzet 7b9ed3ec8e feat: add Qobuz Afkar API provider and prefer request metadata for consistent album grouping 2026-03-15 18:52:41 +07:00
ViscousPot 6dad66d62d Update CONTRIBUTING.md 2026-03-15 04:37:00 +00:00
ViscousPot 31018230ee add fvm 2026-03-15 04:12:32 +00:00
ViscousPot 54ddc1f59c feat: auto fill playlist name during import 2026-03-15 02:54:02 +00:00
ViscousPot c6856bd1a1 feat: add option to download multiple selected playlists 2026-03-15 02:50:20 +00:00
zarzet 8c18c7b8f1 Merge branch 'main' of https://github.com/zarzet/SpotiFLAC-Mobile 2026-03-14 23:12:26 +07:00
zarzet 10c5293f64 chore: update VirusTotal hash for v3.8.0 2026-03-14 23:10:19 +07:00
Zarz Eleutherius c347b6999e Merge pull request #218 from ViscousPot/fix/folder-organization-by-playlist-for-library-playlists
Reviewed and approved. The fix correctly passes playlistName to addMultipleToQueue() for library playlists, consistent with playlist_screen.dart pattern.
2026-03-14 12:57:32 +07:00
ViscousPot adc74741ce Update library_tracks_folder_screen.dart 2026-03-14 01:48:34 +00:00
Zarz Eleutherius cbfa147a12 New translations app_en.arb (Turkish) 2026-03-11 23:43:01 +07:00
Zarz Eleutherius 5b8c953ae6 New translations app_en.arb (Hindi) 2026-03-11 23:43:00 +07:00
Zarz Eleutherius 37a4dc096b New translations app_en.arb (Indonesian) 2026-03-11 23:42:58 +07:00
Zarz Eleutherius b3808645fb New translations app_en.arb (Chinese Traditional) 2026-03-11 23:42:57 +07:00
Zarz Eleutherius 24aa804bf2 New translations app_en.arb (Chinese Simplified) 2026-03-11 23:42:56 +07:00
Zarz Eleutherius 941ffb2bb7 New translations app_en.arb (Russian) 2026-03-11 23:42:54 +07:00
Zarz Eleutherius 59737d6f2b New translations app_en.arb (Portuguese) 2026-03-11 23:42:53 +07:00
Zarz Eleutherius c8ad93ee9b New translations app_en.arb (Dutch) 2026-03-11 23:42:52 +07:00
Zarz Eleutherius 8cb0c037c2 New translations app_en.arb (Korean) 2026-03-11 23:42:50 +07:00
Zarz Eleutherius e30b69397b New translations app_en.arb (Japanese) 2026-03-11 23:42:49 +07:00
Zarz Eleutherius d6e837fd61 New translations app_en.arb (German) 2026-03-11 23:42:47 +07:00
Zarz Eleutherius 5c97d202b9 New translations app_en.arb (Spanish) 2026-03-11 23:42:46 +07:00
Zarz Eleutherius 0f6cfa75bb New translations app_en.arb (French) 2026-03-11 23:42:44 +07:00
Zarz Eleutherius 91bd6d1572 Update source file app_en.arb 2026-03-11 23:42:42 +07:00
Zarz Eleutherius dd05061829 New translations app_en.arb (Turkish) 2026-03-10 23:26:30 +07:00
Zarz Eleutherius 8f6b99c550 New translations app_en.arb (Hindi) 2026-03-10 23:26:29 +07:00
Zarz Eleutherius f54ee86591 New translations app_en.arb (Indonesian) 2026-03-10 23:26:27 +07:00
Zarz Eleutherius 42e0ec2663 New translations app_en.arb (Chinese Traditional) 2026-03-10 23:26:26 +07:00
Zarz Eleutherius 0456a97b35 New translations app_en.arb (Chinese Simplified) 2026-03-10 23:26:24 +07:00
Zarz Eleutherius 07c609cc3a New translations app_en.arb (Russian) 2026-03-10 23:26:23 +07:00
Zarz Eleutherius de5d26403f New translations app_en.arb (Portuguese) 2026-03-10 23:26:22 +07:00
Zarz Eleutherius 73c2d0efac New translations app_en.arb (Dutch) 2026-03-10 23:26:20 +07:00
Zarz Eleutherius d3c1c440cc New translations app_en.arb (Korean) 2026-03-10 23:26:19 +07:00
Zarz Eleutherius 94195c636f New translations app_en.arb (Japanese) 2026-03-10 23:26:17 +07:00
Zarz Eleutherius 9abf492362 New translations app_en.arb (German) 2026-03-10 23:26:16 +07:00
Zarz Eleutherius defc84c216 New translations app_en.arb (Spanish) 2026-03-10 23:26:15 +07:00
Zarz Eleutherius 3c9ae39145 New translations app_en.arb (French) 2026-03-10 23:26:13 +07:00
Zarz Eleutherius 581f43f4c1 New translations app_en.arb (Turkish) 2026-03-09 22:45:36 +07:00
Zarz Eleutherius 221d7e4829 New translations app_en.arb (Hindi) 2026-03-09 22:45:35 +07:00
Zarz Eleutherius 706528f04b New translations app_en.arb (Indonesian) 2026-03-09 22:45:33 +07:00
Zarz Eleutherius f95a96dd1f New translations app_en.arb (Chinese Traditional) 2026-03-09 22:45:32 +07:00
Zarz Eleutherius d85c16ce0f New translations app_en.arb (Chinese Simplified) 2026-03-09 22:45:31 +07:00
Zarz Eleutherius 35afdf4be4 New translations app_en.arb (Russian) 2026-03-09 22:45:30 +07:00
Zarz Eleutherius eb5ed86019 New translations app_en.arb (Portuguese) 2026-03-09 22:45:28 +07:00
Zarz Eleutherius 0cfa6f56be New translations app_en.arb (Dutch) 2026-03-09 22:45:27 +07:00
Zarz Eleutherius 5af88ead33 New translations app_en.arb (Korean) 2026-03-09 22:45:25 +07:00
Zarz Eleutherius 8ec63ee610 New translations app_en.arb (Japanese) 2026-03-09 22:45:24 +07:00
Zarz Eleutherius c8247bf7a0 New translations app_en.arb (German) 2026-03-09 22:45:22 +07:00
Zarz Eleutherius 2f3270c7ff New translations app_en.arb (Spanish) 2026-03-09 22:45:21 +07:00
Zarz Eleutherius 960d60f0bc New translations app_en.arb (French) 2026-03-09 22:45:19 +07:00
136 changed files with 31316 additions and 16480 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"flutter": "3.41.4"
}
+57
View File
@@ -393,6 +393,63 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
update-altstore:
runs-on: ubuntu-latest
needs: [get-version, build-ios, create-release]
if: ${{ needs.get-version.outputs.is_prerelease != 'true' }}
permissions:
contents: write
steps:
- name: Checkout main branch
uses: actions/checkout@v6
with:
ref: main
- name: Download iOS IPA
uses: actions/download-artifact@v7
with:
name: ios-ipa
path: ./release
- name: Update apps.json
run: |
VERSION="${{ needs.get-version.outputs.version }}"
VERSION_NUM="${VERSION#v}"
DATE=$(date -u +%Y-%m-%d)
IPA_FILE=$(find ./release -name "*ios*.ipa" | head -1)
if [ -z "$IPA_FILE" ]; then
echo "WARNING: IPA file not found, skipping apps.json update"
exit 0
fi
IPA_SIZE=$(stat -c%s "$IPA_FILE" 2>/dev/null || stat -f%z "$IPA_FILE")
if [ ! -f apps.json ]; then
echo "WARNING: apps.json not found on main, skipping"
exit 0
fi
jq --arg ver "$VERSION_NUM" \
--arg date "$DATE" \
--arg url "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa" \
--argjson size "$IPA_SIZE" \
'.apps[0].version = $ver | .apps[0].versionDate = $date | .apps[0].downloadURL = $url | .apps[0].size = $size' \
apps.json > apps.json.tmp && mv apps.json.tmp apps.json
echo "Updated apps.json:"
cat apps.json
- name: Commit and push
run: |
VERSION="${{ needs.get-version.outputs.version }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add apps.json
git diff --cached --quiet && echo "No changes to commit" || \
(git commit -m "chore: update AltStore source to ${VERSION}" && git push)
notify-telegram:
runs-on: ubuntu-latest
needs: [get-version, create-release]
+3
View File
@@ -77,3 +77,6 @@ flutter_*.log
# Development tools
tool/
.claude/settings.local.json
# FVM Version Cache
.fvm/
+2 -2
View File
@@ -334,7 +334,7 @@ Thank you for your understanding and continued support. This decision was made t
- Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service
- New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)`
- SpotFetch metadata fallback integration for Spotify-blocked regions
- New backend client for `spotify.afkarxyz.fun/api`
- New backend client for `sp.afkarxyz.qzz.io/api`
- Automatic fallback in Spotify metadata fetch path when primary source fails
- Lyrics extraction now supports MP3 (ID3v2) and Opus/OGG (Vorbis comments) in addition to FLAC
- Includes heuristic detection of lyrics stored in Comment fields
@@ -349,7 +349,7 @@ Thank you for your understanding and continued support. This decision was made t
- Legacy Dart bridge methods (`downloadTrack`, `downloadWithFallback`, `downloadWithExtensions`, `downloadFromYouTube`) are now thin wrappers and marked `@Deprecated`
- Qobuz downloader updated to latest Jumo API contract (`/get` endpoint, required headers)
- Amazon download flow now returns `decryption_key` from Go and performs decryption in Flutter (local file + SAF paths)
- Amazon now uses the new `amazon.afkarxyz.fun` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
- Amazon now uses the new `amzn.afkarxyz.qzz.io` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
- Amazon ASIN extraction rewritten with robust URL/query-param parsing and regex fallback
- Amazon provider re-enabled in download service picker and download settings (alongside Tidal, Qobuz, and YouTube picker flow)
- Track Metadata cover UI now refreshes from the embedded file after Edit Metadata/Re-enrich, so the displayed art matches actual file tags
+17 -3
View File
@@ -86,17 +86,31 @@ Translation files are located in `lib/l10n/arb/`.
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
```
3. **Install dependencies**
3. **Use FVM (Flutter Version: 3.38.1)**
```bash
fvm use
```
4. **Install dependencies**
```bash
flutter pub get
```
4. **Generate code** (for Riverpod, JSON serialization, etc.)
5. **Generate code** (for Riverpod, JSON serialization, etc.)
```bash
dart run build_runner build --delete-conflicting-outputs
```
5. **Run the app**
6. **Set up Go environment (Go Version: 1.25.7)**
```bash
cd go_backend
mkdir -p ../android/app/libs
gomobile init
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
cd ..
```
7. **Run the app**
```bash
flutter run
```
+122 -38
View File
@@ -14,6 +14,17 @@
</div>
<div align="center">
[![GitHub Release](https://img.shields.io/github/v/release/zarzet/SpotiFLAC-Mobile?style=for-the-badge&logo=github)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/cc11355330c76f97548b8d26452b91746db9d9c1edbcfc4c18250133484d1487)
[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
[![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac)
[![Telegram Community](https://img.shields.io/badge/COMMUNITY-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac_chat)
</div>
## Screenshots
<p align="center">
@@ -23,68 +34,141 @@
<img src="assets/images/4.jpg?v=2" width="200" />
</p>
<div align="center">
[![GitHub Release](https://img.shields.io/github/v/release/zarzet/SpotiFLAC-Mobile?style=for-the-badge&logo=github)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/0a2bd2a033551983fc9fcd83f82fd912c83914fd1094cd8d1c7c6a68eb23233f)
[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
[![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac)
[![Telegram Community](https://img.shields.io/badge/COMMUNITY-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac_chat)
</div>
---
## Extensions
Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
Extensions let the community add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
### Installing Extensions
1. Go to **Store** tab in the app
2. Browse and install extensions with one tap
3. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
4. Configure extension settings if needed
5. Set provider priority in **Settings > Extensions > Provider Priority**
1. Open the **Store** tab in the app
2. On first launch, enter an **Extension Repository URL** when prompted
3. Browse and install extensions with one tap
4. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
5. Configure extension settings if needed
6. Set provider priority under **Settings > Extensions > Provider Priority**
### Developing Extensions
Want to create your own extension? Check out the [Extension Development Guide](https://zarzet.github.io/SpotiFLAC-Mobile/docs) for complete documentation.
## Other project
> [!NOTE]
> Want to build your own extension? The [Extension Development Guide](https://zarzet.github.io/SpotiFLAC-Mobile/docs) has everything you need.
---
## Related Projects
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music available for 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).
---
## FAQ
**Q: Why is my download failing with "Song not found"?**
A: The track may not be available on the streaming services. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions like Amazon Music from the Store.
<details>
<summary><b>Why does the Store tab ask me to enter a URL?</b></summary>
<br>
**Q: Why are some tracks downloading in lower quality?**
A: Quality depends on what's available from the streaming service and extensions. Built-in providers: Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Deezer up to 16-bit/44.1kHz.
Starting from version 3.8.0, SpotiFLAC uses a decentralized extension repository system extensions are hosted on GitHub repositories rather than a built-in server, so anyone can create and host their own. Enter a repository URL in the Store tab to browse and install extensions.
**Q: Can I download playlists?**
A: Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
</details>
**Q: Why do I need to grant storage permission?**
A: The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant "All files access" in Settings > Apps > SpotiFLAC > Permissions.
<details>
<summary><b>Why is my download failing with "Song not found"?</b></summary>
<br>
**Q: Is this app safe?**
A: Yes, the app is open source and you can verify the code yourself. Each release is scanned with VirusTotal (see badge at top of README).
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.
**Q: Why is download not working in my country?**
A: Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
</details>
<details>
<summary><b>Why are some tracks downloading in lower quality?</b></summary>
<br>
### Want to support SpotiFLAC-Mobile?
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
_If this software is useful and brings you value, consider supporting the project. Your support helps keep development going._
</details>
[![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/zarzet)
<details>
<summary><b>Can I download playlists?</b></summary>
<br>
Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
</details>
<details>
<summary><b>Why do I need to grant storage permission?</b></summary>
<br>
The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant **All files access** under **Settings > Apps > SpotiFLAC > Permissions**.
</details>
<details>
<summary><b>Is this app safe?</b></summary>
<br>
Yes SpotiFLAC is open source and you can verify the code yourself. Each release is also scanned with VirusTotal (see badge above).
</details>
<details>
<summary><b>Why is downloading not working in my country?</b></summary>
<br>
Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
</details>
<details>
<summary><b>Can I add SpotiFLAC to AltStore or SideStore?</b></summary>
<br>
Yes! Add the official source to receive updates directly within the app. Copy this link:
```
https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/refs/heads/main/apps.json
```
In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link.
</details>
> [!NOTE]
> If SpotiFLAC is useful to you, consider supporting development:
>
> [![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/zarzet)
---
## Contributors
Thanks to everyone who has contributed to SpotiFLAC Mobile!
<a href="https://github.com/zarzet/SpotiFLAC-Mobile/graphs/contributors">
<img src="https://contrib.rocks/image?repo=zarzet/SpotiFLAC-Mobile" />
</a>
We also appreciate everyone who helped with [translations on Crowdin](https://crowdin.com/project/spotiflac-mobile), reported bugs, suggested features, and spread the word.
Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md) to get started!
---
## API Credits
[hifi-api](https://github.com/binimum/hifi-api) · [music.binimum.org](https://music.binimum.org) · [qqdl.site](https://qqdl.site) · [squid.wtf](https://squid.wtf) · [spotisaver.net](https://spotisaver.net) · [dabmusic.xyz](https://dabmusic.xyz) · [AfkarXYZ](https://github.com/afkarxyz) · [LRCLib](https://lrclib.net) · [Paxsenix](https://lyrics.paxsenix.org) · [Cobalt](https://cobalt.tools) · [qwkuns.me](https://qwkuns.me) · [SpotubeDL](https://spotubedl.com) · [Song.link](https://song.link) · [IDHS](https://github.com/sjdonado/idonthavespotify)
| | | | | |
|---|---|---|---|---|
| [hifi-api](https://github.com/binimum/hifi-api) | [music.binimum.org](https://music.binimum.org) | [qqdl.site](https://qqdl.site) | [squid.wtf](https://squid.wtf) | [spotisaver.net](https://spotisaver.net) |
| [dabmusic.xyz](https://dabmusic.xyz) | [AfkarXYZ](https://github.com/afkarxyz) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) |
| [qwkuns.me](https://qwkuns.me) | [SpotubeDL](https://spotubedl.com) | [Song.link](https://song.link) | [IDHS](https://github.com/sjdonado/idonthavespotify) | |
> [!TIP]
>
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
> **Star the repo** to get notified about all new releases directly from GitHub.
@@ -137,14 +137,13 @@ class DownloadService : Service() {
private fun startForegroundService() {
isRunning = true
// Acquire wake lock to prevent CPU sleep
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
WAKELOCK_TAG
).apply {
acquire(60 * 60 * 1000L) // 1 hour max
acquire(60 * 60 * 1000L)
}
val notification = buildNotification(0, 0)
@@ -27,9 +27,11 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import org.json.JSONTokener
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.security.MessageDigest
import java.util.Locale
class MainActivity: FlutterFragmentActivity() {
@@ -111,6 +113,13 @@ class MainActivity: FlutterFragmentActivity() {
}
}
private fun buildStableLibraryId(filePath: String): String {
val digest = MessageDigest.getInstance("SHA-1")
val bytes = digest.digest(filePath.toByteArray(Charsets.UTF_8))
val hex = bytes.joinToString("") { "%02x".format(it) }
return "lib_$hex"
}
data class SafScanProgress(
var totalFiles: Int = 0,
var scannedFiles: Int = 0,
@@ -121,39 +130,35 @@ class MainActivity: FlutterFragmentActivity() {
)
companion object {
// Minimum API level we consider "safe" for Impeller (Android 10+)
private const val SAFE_API_FOR_IMPELLER = 29
// Known problematic GPU patterns (lowercase)
private val PROBLEMATIC_GPU_PATTERNS = listOf(
"adreno (tm) 3", // Adreno 300 series (305, 320, 330, etc.) - old Qualcomm
"adreno (tm) 4", // Adreno 400 series - some have issues
"mali-4", // Mali-400 series - old ARM GPUs
"mali-t6", // Mali-T600 series
"mali-t7", // Mali-T700 series (some)
"powervr sgx", // PowerVR SGX series - old Imagination GPUs
"powervr ge8320", // PowerVR GE8320 - known issues
"gc1000", // Vivante GC1000
"gc2000", // Vivante GC2000
"adreno (tm) 3",
"adreno (tm) 4",
"mali-4",
"mali-t6",
"mali-t7",
"powervr sgx",
"powervr ge8320",
"gc1000",
"gc2000",
)
// Known problematic chipsets/hardware (lowercase)
private val PROBLEMATIC_CHIPSETS = listOf(
"mt6762", // MediaTek Helio P22 with PowerVR GE8320
"mt6765", // MediaTek Helio P35 with PowerVR GE8320
"mt8768", // MediaTek tablet chip
"mp0873", // MediaTek variant
"msm8974", // Snapdragon 800/801 with Adreno 330
"msm8226", // Snapdragon 400 with Adreno 305
"msm8926", // Snapdragon 400 with Adreno 305
"apq8084", // Snapdragon 805 (some issues)
"mt6762",
"mt6765",
"mt8768",
"mp0873",
"msm8974",
"msm8226",
"msm8926",
"apq8084",
)
// Known problematic device models (lowercase)
private val PROBLEMATIC_MODELS = listOf(
"sm-t220", // Samsung Tab A7 Lite
"sm-t225", // Samsung Tab A7 Lite LTE
"hammerhead", // Nexus 5 (Adreno 330)
"sm-t220",
"sm-t225",
"hammerhead",
)
/**
* Check if device should use Skia instead of Impeller.
@@ -165,7 +170,6 @@ class MainActivity: FlutterFragmentActivity() {
val model = Build.MODEL.lowercase(Locale.ROOT)
val device = Build.DEVICE.lowercase(Locale.ROOT)
// 1. Check for explicitly problematic device models
for (problematicModel in PROBLEMATIC_MODELS) {
if (model.contains(problematicModel) || device.contains(problematicModel)) {
android.util.Log.i("SpotiFLAC", "Matched problematic model: $problematicModel")
@@ -173,7 +177,6 @@ class MainActivity: FlutterFragmentActivity() {
}
}
// 2. Check for problematic chipsets
for (chipset in PROBLEMATIC_CHIPSETS) {
if (hardware.contains(chipset) || board.contains(chipset)) {
android.util.Log.i("SpotiFLAC", "Matched problematic chipset: $chipset")
@@ -181,12 +184,9 @@ class MainActivity: FlutterFragmentActivity() {
}
}
// 3. For Android < 10 (API 29), be more aggressive about disabling Impeller
if (Build.VERSION.SDK_INT < SAFE_API_FOR_IMPELLER) {
// For older Android, check GPU renderer if available
val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT)
// Check for known problematic GPUs
for (pattern in PROBLEMATIC_GPU_PATTERNS) {
if (gpuRenderer.contains(pattern)) {
android.util.Log.i("SpotiFLAC", "Matched problematic GPU on old Android: $pattern")
@@ -194,14 +194,12 @@ class MainActivity: FlutterFragmentActivity() {
}
}
// For very old Android (< 8.0), always use Skia as Vulkan support is spotty
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
android.util.Log.i("SpotiFLAC", "Android < 8.0, using Skia for safety")
return true
}
}
// 4. For Android 10+, still check for known problematic GPUs
val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT)
for (pattern in PROBLEMATIC_GPU_PATTERNS) {
if (gpuRenderer.contains(pattern)) {
@@ -219,8 +217,6 @@ class MainActivity: FlutterFragmentActivity() {
*/
private fun getGpuRenderer(): String {
return try {
// This might not work before GL context is created,
// but worth trying for additional detection
android.opengl.GLES20.glGetString(android.opengl.GLES20.GL_RENDERER) ?: ""
} catch (e: Exception) {
""
@@ -405,6 +401,38 @@ class MainActivity: FlutterFragmentActivity() {
}
}
private fun parseJsonValue(value: Any?): Any? {
return when (value) {
null, JSONObject.NULL -> null
is JSONObject -> {
val map = LinkedHashMap<String, Any?>()
val keys = value.keys()
while (keys.hasNext()) {
val key = keys.next()
map[key] = parseJsonValue(value.opt(key))
}
map
}
is JSONArray -> {
val list = ArrayList<Any?>()
for (i in 0 until value.length()) {
list.add(parseJsonValue(value.opt(i)))
}
list
}
is Number, is Boolean, is String -> value
else -> value.toString()
}
}
private fun parseJsonPayload(payload: String): Any {
return try {
parseJsonValue(JSONTokener(payload).nextValue()) ?: payload
} catch (_: Exception) {
payload
}
}
private fun startDownloadProgressStream(sink: EventChannel.EventSink) {
stopDownloadProgressStream()
downloadProgressEventSink = sink
@@ -417,7 +445,7 @@ class MainActivity: FlutterFragmentActivity() {
}
if (payload != lastDownloadProgressPayload) {
lastDownloadProgressPayload = payload
sink.success(payload)
sink.success(parseJsonPayload(payload))
}
} catch (e: Exception) {
android.util.Log.w(
@@ -449,7 +477,7 @@ class MainActivity: FlutterFragmentActivity() {
}
if (payload != lastLibraryScanProgressPayload) {
lastLibraryScanProgressPayload = payload
sink.success(payload)
sink.success(parseJsonPayload(payload))
}
} catch (e: Exception) {
android.util.Log.w(
@@ -591,7 +619,6 @@ class MainActivity: FlutterFragmentActivity() {
* Resolve extension from a MediaStore URI by querying DISPLAY_NAME or MIME_TYPE.
*/
private fun resolveMediaStoreExt(uri: Uri, fallbackExt: String?): String {
// Try DISPLAY_NAME first
try {
contentResolver.query(uri, arrayOf(android.provider.MediaStore.MediaColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
@@ -602,7 +629,6 @@ class MainActivity: FlutterFragmentActivity() {
}
} catch (_: Exception) {}
// Try MIME_TYPE
try {
val mime = contentResolver.getType(uri)
val ext = extFromMimeType(mime)
@@ -828,8 +854,6 @@ class MainActivity: FlutterFragmentActivity() {
val mimeType = mimeTypeForExt(outputExt)
val fileName = buildSafFileName(req, outputExt)
// Check for existing file WITHOUT creating the directory first.
// This prevents empty folders from being created for duplicate downloads.
val existingDir = findDocumentDir(treeUri, relativeDir)
if (existingDir != null) {
val existing = existingDir.findFile(fileName)
@@ -844,7 +868,6 @@ class MainActivity: FlutterFragmentActivity() {
}
}
// Only create the directory now that we know we need to download
val targetDir = ensureDocumentDir(treeUri, relativeDir)
?: return errorJson("Failed to access SAF directory")
@@ -867,7 +890,6 @@ class MainActivity: FlutterFragmentActivity() {
val respObj = JSONObject(response)
if (respObj.optBoolean("success", false)) {
// Extension providers write to a local temp path instead of the SAF FD.
// Copy the local file into the SAF document so it is not empty.
val goFilePath = respObj.optString("file_path", "")
if (goFilePath.isNotEmpty() &&
!goFilePath.startsWith("content://") &&
@@ -916,15 +938,10 @@ class MainActivity: FlutterFragmentActivity() {
try {
val docId = android.provider.DocumentsContract.getDocumentId(childUri)
if (docId.isNullOrEmpty()) return null
// Document IDs typically look like "primary:Music/Album/file.cue"
// Parent would be "primary:Music/Album"
val lastSlash = docId.lastIndexOf('/')
if (lastSlash <= 0) return null
val parentDocId = docId.substring(0, lastSlash)
// Build a tree document URI for the parent so it supports listing/findFile
val treeDocId = android.provider.DocumentsContract.getTreeDocumentId(childUri)
if (treeDocId.isNullOrEmpty()) return null
@@ -949,21 +966,17 @@ class MainActivity: FlutterFragmentActivity() {
val lines = File(cueTempPath).readLines()
for (line in lines) {
val trimmed = line.trim().let { l ->
// Strip BOM
if (l.startsWith("\uFEFF")) l.removePrefix("\uFEFF").trim() else l
}
if (trimmed.uppercase(Locale.ROOT).startsWith("FILE ")) {
val rest = trimmed.substring(5).trim()
// Parse: "filename" TYPE or filename TYPE
val filename = if (rest.startsWith("\"")) {
val endQuote = rest.indexOf('"', 1)
if (endQuote > 0) rest.substring(1, endQuote) else rest
} else {
// Last word is the type, everything else is the filename
val parts = rest.split("\\s+".toRegex())
if (parts.size >= 2) parts.dropLast(1).joinToString(" ") else rest
}
// Return just the filename (strip any path separators)
return filename.substringAfterLast("/").substringAfterLast("\\")
}
}
@@ -1048,7 +1061,6 @@ class MainActivity: FlutterFragmentActivity() {
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
val audioFiles = mutableListOf<Pair<DocumentFile, String>>()
// CUE files: (cueDoc, parentDir) — we need the parent to find sibling audio
val cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
val visitedDirUris = mutableSetOf<String>()
val safChildLookupCache = mutableMapOf<String, Map<String, DocumentFile>>()
@@ -1133,7 +1145,6 @@ class MainActivity: FlutterFragmentActivity() {
var scanned = 0
var errors = traversalErrors
// --- CUE first pass: parse CUE sheets, expand to tracks, track referenced audio ---
val cueReferencedAudioUris = mutableSetOf<String>()
for ((cueDoc, parentDir) in cueFiles) {
@@ -1172,10 +1183,8 @@ class MainActivity: FlutterFragmentActivity() {
continue
}
// Mark this audio file so we skip it in the regular audio pass
cueReferencedAudioUris.add(audioDoc.uri.toString())
// Copy audio to same temp dir so Go can resolve it
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
@@ -1189,7 +1198,6 @@ class MainActivity: FlutterFragmentActivity() {
continue
}
// Rename temp audio to its original name so Go can find it by name
val renamedAudio = File(tempDir, audioName)
val tempAudioFile = File(tempAudioPath)
if (renamedAudio.absolutePath != tempAudioFile.absolutePath) {
@@ -1232,14 +1240,12 @@ class MainActivity: FlutterFragmentActivity() {
}
}
// --- Regular audio file pass: skip files referenced by CUE sheets ---
for ((doc, _) in audioFiles) {
if (safScanCancel) {
updateSafScanProgress { it.isComplete = true }
return "[]"
}
// Skip audio files that are represented by CUE track entries
if (cueReferencedAudioUris.contains(doc.uri.toString())) {
scanned++
val pct = scanned.toDouble() / totalItems.toDouble() * 100.0
@@ -1263,7 +1269,9 @@ class MainActivity: FlutterFragmentActivity() {
} else {
try {
val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L }
metadataObj.put("filePath", doc.uri.toString())
val stableUri = doc.uri.toString()
metadataObj.put("id", buildStableLibraryId(stableUri))
metadataObj.put("filePath", stableUri)
metadataObj.put("fileModTime", lastModified)
results.put(metadataObj)
} catch (_: Exception) {
@@ -1316,7 +1324,6 @@ class MainActivity: FlutterFragmentActivity() {
return result.toString()
}
// Parse existing files map: URI -> lastModified
val existingFiles = mutableMapOf<String, Long>()
try {
val obj = JSONObject(existingFilesJson)
@@ -1335,20 +1342,15 @@ class MainActivity: FlutterFragmentActivity() {
}
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>() // doc, path, lastModified
// CUE files to scan: (cueDoc, parentDir, lastModified)
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>()
val cueFilesToScan = mutableListOf<Triple<DocumentFile, DocumentFile, Long>>()
// Unchanged CUE files: (cueDoc, parentDir) — need to discover audio siblings for skip set
val unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
val currentUris = mutableSetOf<String>()
val visitedDirUris = mutableSetOf<String>()
val safChildLookupCache = mutableMapOf<String, Map<String, DocumentFile>>()
var traversalErrors = 0
// Build a map of CUE base URIs -> existing virtual track URIs from the database.
// Virtual paths look like "content://...album.cue#track01".
// We need this to preserve virtual paths for unchanged CUE files.
val existingCueVirtualPaths = mutableMapOf<String, MutableList<String>>() // cueUri -> [virtualPaths]
val existingCueVirtualPaths = mutableMapOf<String, MutableList<String>>()
for (key in existingFiles.keys) {
val hashIdx = key.indexOf("#track")
if (hashIdx > 0) {
@@ -1357,7 +1359,6 @@ class MainActivity: FlutterFragmentActivity() {
}
}
// Collect all files with lastModified
val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque()
queue.add(root to "")
@@ -1413,8 +1414,6 @@ class MainActivity: FlutterFragmentActivity() {
}
queue.add(child to childPath)
} else if (child.isFile) {
// Mark file as present first so it cannot be mis-classified as removed
// when provider-specific metadata calls (e.g., lastModified) fail.
val uriStr = child.uri.toString()
currentUris.add(uriStr)
@@ -1426,18 +1425,15 @@ class MainActivity: FlutterFragmentActivity() {
child.lastModified()
} catch (_: Exception) { 0L }
// Check if any virtual track from this CUE exists with matching modTime
val virtualPaths = existingCueVirtualPaths[uriStr]
val existingModified = virtualPaths?.firstOrNull()?.let { existingFiles[it] }
if (existingModified != null && existingModified == lastModified) {
// CUE is unchanged — mark virtual paths as current so they aren't removed
unchangedCueFiles.add(child to dir)
for (vp in virtualPaths) {
currentUris.add(vp)
}
} else {
// CUE is new or modified — needs scanning
cueFilesToScan.add(Triple(child, dir, lastModified))
}
} else if (ext.isNotBlank() && supportedAudioExt.contains(".$ext")) {
@@ -1448,7 +1444,6 @@ class MainActivity: FlutterFragmentActivity() {
existingModified ?: 0L
}
// Check if file is new or modified
if (existingModified == null || existingModified != lastModified) {
audioFiles.add(Triple(child, path, lastModified))
}
@@ -1465,7 +1460,6 @@ class MainActivity: FlutterFragmentActivity() {
}
}
// Find removed files (in existing but not in current)
val removedUris = existingFiles.keys.filter { !currentUris.contains(it) }
val totalFiles = currentUris.size
val filesToProcess = audioFiles.size + cueFilesToScan.size
@@ -1493,7 +1487,6 @@ class MainActivity: FlutterFragmentActivity() {
var scanned = 0
var errors = traversalErrors
// --- CUE first pass: parse new/modified CUE sheets ---
val cueReferencedAudioUris = mutableSetOf<String>()
for ((cueDoc, parentDir, cueLastModified) in cueFilesToScan) {
@@ -1514,7 +1507,6 @@ class MainActivity: FlutterFragmentActivity() {
var tempCuePath: String? = null
var tempAudioPath: String? = null
try {
// Copy CUE to temp
tempCuePath = copyUriToTemp(cueDoc.uri, ".cue")
if (tempCuePath == null) {
errors++
@@ -1523,10 +1515,8 @@ class MainActivity: FlutterFragmentActivity() {
continue
}
// Extract the audio filename from the CUE sheet text
val audioFileName = extractCueAudioFileName(tempCuePath)
// Find the referenced audio file as a sibling in the same SAF directory
val audioDoc = resolveCueAudioSibling(
parentDir = parentDir,
cueName = cueName,
@@ -1541,10 +1531,8 @@ class MainActivity: FlutterFragmentActivity() {
continue
}
// Mark this audio file so we skip it in the regular audio pass
cueReferencedAudioUris.add(audioDoc.uri.toString())
// Copy audio to same temp dir so Go can resolve it
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
@@ -1558,7 +1546,6 @@ class MainActivity: FlutterFragmentActivity() {
continue
}
// Rename temp audio to its original name so Go can find it by name
val renamedAudio = File(tempDir, audioName)
val tempAudioFile = File(tempAudioPath)
if (renamedAudio.absolutePath != tempAudioFile.absolutePath) {
@@ -1566,7 +1553,6 @@ class MainActivity: FlutterFragmentActivity() {
tempAudioPath = renamedAudio.absolutePath
}
// Call Go to produce library scan entries for each CUE track
val cueResultsJson = Gobackend.scanCueSheetForLibrary(
tempCuePath,
tempDir,
@@ -1578,7 +1564,6 @@ class MainActivity: FlutterFragmentActivity() {
for (j in 0 until cueArray.length()) {
val trackObj = cueArray.getJSONObject(j)
results.put(trackObj)
// Register each virtual path as current so deletion detection works
val virtualPath = trackObj.optString("filePath", "")
if (virtualPath.isNotBlank()) {
currentUris.add(virtualPath)
@@ -1611,9 +1596,6 @@ class MainActivity: FlutterFragmentActivity() {
}
}
// Discover audio siblings for unchanged CUE files so we skip them
// in the regular audio pass. Copy the .cue to temp (tiny file) to extract
// the audio filename, then find the sibling by name.
for ((cueDoc, parentDir) in unchangedCueFiles) {
var tempCue: String? = null
try {
@@ -1638,7 +1620,6 @@ class MainActivity: FlutterFragmentActivity() {
}
}
// --- Regular audio file pass: skip files referenced by CUE sheets ---
for ((doc, _, lastModified) in audioFiles) {
if (safScanCancel) {
updateSafScanProgress { it.isComplete = true }
@@ -1651,7 +1632,6 @@ class MainActivity: FlutterFragmentActivity() {
return result.toString()
}
// Skip audio files that are represented by CUE track entries
if (cueReferencedAudioUris.contains(doc.uri.toString())) {
scanned++
val processed = skippedCount + scanned
@@ -1680,7 +1660,9 @@ class MainActivity: FlutterFragmentActivity() {
} else {
try {
val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified }
metadataObj.put("filePath", doc.uri.toString())
val stableUri = doc.uri.toString()
metadataObj.put("id", buildStableLibraryId(stableUri))
metadataObj.put("filePath", stableUri)
metadataObj.put("fileModTime", safeLastModified)
metadataObj.put("lastModified", safeLastModified)
results.put(metadataObj)
@@ -1703,7 +1685,6 @@ class MainActivity: FlutterFragmentActivity() {
}
}
// Recalculate removedUris now that CUE virtual paths have been registered
val finalRemovedUris = existingFiles.keys.filter { !currentUris.contains(it) }
updateSafScanProgress {
@@ -1881,7 +1862,6 @@ class MainActivity: FlutterFragmentActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Update the intent so receive_sharing_intent can access the new data
setIntent(intent)
}
@@ -1988,13 +1968,13 @@ class MainActivity: FlutterFragmentActivity() {
val response = withContext(Dispatchers.IO) {
Gobackend.getDownloadProgress()
}
result.success(response)
result.success(parseJsonPayload(response))
}
"getAllDownloadProgress" -> {
val response = withContext(Dispatchers.IO) {
Gobackend.getAllDownloadProgress()
}
result.success(response)
result.success(parseJsonPayload(response))
}
"initItemProgress" -> {
val itemId = call.argument<String>("item_id") ?: ""
@@ -2541,7 +2521,6 @@ class MainActivity: FlutterFragmentActivity() {
val tempPath = copyUriToTemp(uri)
?: return@withContext """{"error":"Failed to copy SAF file to temp"}"""
try {
// Replace file_path with temp path for Go
reqObj.put("file_path", tempPath)
val raw = Gobackend.reEnrichFile(reqObj.toString())
val obj = JSONObject(raw)
@@ -2619,7 +2598,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
// Deezer API methods
"searchDeezerAll" -> {
val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15
@@ -2630,6 +2608,26 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"searchTidalAll" -> {
val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15
val artistLimit = call.argument<Int>("artist_limit") ?: 2
val filter = call.argument<String>("filter") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.searchTidalAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
}
result.success(response)
}
"searchQobuzAll" -> {
val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15
val artistLimit = call.argument<Int>("artist_limit") ?: 2
val filter = call.argument<String>("filter") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.searchQobuzAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
}
result.success(response)
}
"getDeezerRelatedArtists" -> {
val artistId = call.argument<String>("artist_id") ?: ""
val limit = call.argument<Int>("limit") ?: 12
@@ -2749,7 +2747,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
// Log methods
"getLogs" -> {
val response = withContext(Dispatchers.IO) {
Gobackend.getLogs()
@@ -2782,7 +2779,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
// Extension System methods
"initExtensionSystem" -> {
val extensionsDir = call.argument<String>("extensions_dir") ?: ""
val dataDir = call.argument<String>("data_dir") ?: ""
@@ -2927,7 +2923,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
// Extension Auth API methods
"getExtensionPendingAuth" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val response = withContext(Dispatchers.IO) {
@@ -2977,7 +2972,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
// Extension FFmpeg API
"getPendingFFmpegCommand" -> {
val commandId = call.argument<String>("command_id") ?: ""
val response = withContext(Dispatchers.IO) {
@@ -3005,7 +2999,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
// Extension Custom Search API
"customSearchWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val query = call.argument<String>("query") ?: ""
@@ -3021,7 +3014,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
// Extension URL Handler API
"handleURLWithExtension" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
@@ -3066,7 +3058,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
// Extension Post-Processing API
"runPostProcessing" -> {
val filePath = call.argument<String>("file_path") ?: ""
val metadataJson = call.argument<String>("metadata") ?: ""
@@ -3110,7 +3101,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
// Extension Store
"initExtensionStore" -> {
val cacheDir = call.argument<String>("cache_dir") ?: ""
withContext(Dispatchers.IO) {
@@ -3172,7 +3162,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
// Extension Home Feed (Explore)
"getExtensionHomeFeed" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val response = withContext(Dispatchers.IO) {
@@ -3187,7 +3176,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
// Local Library Scanning
"setLibraryCoverCacheDir" -> {
val cacheDir = call.argument<String>("cache_dir") ?: ""
withContext(Dispatchers.IO) {
@@ -3264,7 +3252,7 @@ class MainActivity: FlutterFragmentActivity() {
Gobackend.getLibraryScanProgressJSON()
}
}
result.success(response)
result.success(parseJsonPayload(response))
}
"cancelLibraryScan" -> {
withContext(Dispatchers.IO) {
@@ -3292,7 +3280,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
// CUE Sheet Parsing
"parseCueSheet" -> {
val cuePath = call.argument<String>("cue_path") ?: ""
val audioDir = call.argument<String>("audio_dir") ?: ""
@@ -3304,17 +3291,14 @@ class MainActivity: FlutterFragmentActivity() {
?: return@withContext """{"error":"Failed to copy CUE file to temp"}"""
var tempAudioPath: String? = null
try {
// Extract audio filename from CUE text
val audioFileName = extractCueAudioFileName(tempCuePath)
// Try to find the audio sibling in SAF
var audioDoc: DocumentFile? = null
val parentDir = safParentDir(uri)
if (parentDir != null && !audioFileName.isNullOrBlank()) {
audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null }
}
// Fallback: try common extensions with the CUE base name
if (audioDoc == null && parentDir != null) {
val cueName = try {
DocumentFile.fromSingleUri(this@MainActivity, uri)?.name ?: ""
@@ -3333,7 +3317,6 @@ class MainActivity: FlutterFragmentActivity() {
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
if (audioDoc != null) {
// Copy audio to same temp dir with original name
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
val fallbackExt = if (audioExt.isNotBlank()) ".$audioExt" else null
@@ -3348,15 +3331,11 @@ class MainActivity: FlutterFragmentActivity() {
}
}
// Parse with audio in temp dir; Go will resolve there
val resultJson = Gobackend.parseCueSheet(tempCuePath, tempDir)
// Replace the temp audio_path with the SAF content:// URI
// so Dart knows it's a SAF file and handles it accordingly
if (audioDoc != null) {
val resultObj = JSONObject(resultJson)
resultObj.put("audio_path", audioDoc.uri.toString())
// Also pass the original CUE URI for reference
resultObj.put("cue_path", cuePath)
resultObj.toString()
} else {
+18
View File
@@ -0,0 +1,18 @@
{
"name": "SpotiFLAC Source",
"identifier": "com.zarzet.spotiflac.source",
"subtitle": "FLAC Downloader for iOS",
"apps": [
{
"name": "SpotiFLAC",
"bundleIdentifier": "com.zarzet.spotiflac",
"developerName": "zarzet",
"version": "3.9.0",
"versionDate": "2026-03-25",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.9.0/SpotiFLAC-v3.9.0-ios-unsigned.ipa",
"localizedDescription": "Mobile version of SpotiFLAC 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": 34477323
}
]
}
+20 -2
View File
@@ -498,7 +498,13 @@ func extractUserTextFrame(data []byte) (string, string) {
func isLyricsDescription(description string) bool {
switch strings.ToLower(strings.TrimSpace(description)) {
case "lyrics", "lyric", "unsyncedlyrics", "unsynced lyrics", "lrc":
case
"lyrics",
"lyric",
"unsyncedlyrics",
"unsynced lyrics",
"uslt",
"lrc":
return true
default:
return false
@@ -1594,7 +1600,19 @@ func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, strin
return extractOggCoverArt(filePath)
case ".m4a":
return nil, "", fmt.Errorf("M4A cover extraction not yet supported")
data, err := extractCoverFromM4A(filePath)
if err != nil {
return nil, "", err
}
mimeType := "image/jpeg"
if len(data) >= 8 &&
data[0] == 0x89 &&
data[1] == 0x50 &&
data[2] == 0x4E &&
data[3] == 0x47 {
mimeType = "image/png"
}
return data, mimeType, nil
default:
return nil, "", fmt.Errorf("unsupported format: %s", ext)
+133
View File
@@ -0,0 +1,133 @@
package gobackend
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func ffmpegCommand(args ...string) *exec.Cmd {
if ffmpegPath, err := exec.LookPath("ffmpeg"); err == nil {
return exec.Command(ffmpegPath, args...)
}
return exec.Command("ffmpeg", args...)
}
func runFFmpegTestCommand(t *testing.T, args ...string) {
t.Helper()
cmd := ffmpegCommand(args...)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("ffmpeg failed: %v\n%s", err, string(output))
}
}
func TestExtractLyricsReadsMp3AfterCoverEmbed(t *testing.T) {
if _, err := exec.LookPath("ffmpeg"); err != nil {
t.Skip("ffmpeg not available")
}
tempDir := t.TempDir()
sourceFlac := filepath.Join(tempDir, "source.flac")
baseMp3 := filepath.Join(tempDir, "base.mp3")
finalMp3 := filepath.Join(tempDir, "final.mp3")
coverPath := filepath.Join(tempDir, "cover.jpg")
lyrics := "[ti:Test Song]\n[ar:Test Artist]\n[00:00.00]Hello from embedded lyrics"
runFFmpegTestCommand(
t,
"-y",
"-f",
"lavfi",
"-i",
"sine=frequency=440:duration=1",
"-c:a",
"flac",
sourceFlac,
)
runFFmpegTestCommand(
t,
"-y",
"-f",
"lavfi",
"-i",
"color=c=red:s=32x32:d=1",
"-frames:v",
"1",
coverPath,
)
runFFmpegTestCommand(
t,
"-y",
"-i",
sourceFlac,
"-b:a",
"320k",
"-metadata",
"title=Test Song",
"-metadata",
"artist=Test Artist",
"-metadata",
"lyrics="+lyrics,
baseMp3,
)
runFFmpegTestCommand(
t,
"-y",
"-i",
baseMp3,
"-i",
coverPath,
"-map",
"0:a",
"-map_metadata",
"-1",
"-map",
"1:0",
"-c:v:0",
"copy",
"-id3v2_version",
"3",
"-metadata",
"title=Test Song",
"-metadata",
"artist=Test Artist",
"-metadata",
"lyrics="+lyrics,
"-metadata:s:v",
"title=Album cover",
"-metadata:s:v",
"comment=Cover (front)",
"-c:a",
"copy",
finalMp3,
)
meta, err := ReadID3Tags(finalMp3)
if err != nil {
t.Fatalf("ReadID3Tags failed: %v", err)
}
if meta == nil {
t.Fatalf("ReadID3Tags returned nil metadata")
}
embeddedLyrics, err := ExtractLyrics(finalMp3)
if err != nil {
t.Fatalf("ExtractLyrics failed: %v (metadata=%+v)", err, meta)
}
if !strings.Contains(embeddedLyrics, "Hello from embedded lyrics") {
t.Fatalf("embedded lyrics missing, got %q (metadata=%+v)", embeddedLyrics, meta)
}
if !strings.Contains(meta.Lyrics, "Hello from embedded lyrics") {
t.Fatalf("ReadID3Tags lyrics missing, got %+v", meta)
}
if _, err := os.Stat(finalMp3); err != nil {
t.Fatalf("expected final mp3 to exist: %v", err)
}
}
+34 -4
View File
@@ -17,6 +17,8 @@ const (
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
var tidalSizeRegex = regexp.MustCompile(`/\d+x\d+\.jpg$`)
func convertSmallToMedium(imageURL string) string {
if strings.Contains(imageURL, spotifySize300) {
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
@@ -40,7 +42,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
maxURL := upgradeToMaxQuality(downloadURL)
if maxURL != downloadURL {
downloadURL = maxURL
// Log already printed by upgradeToMaxQuality for Deezer
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
}
@@ -86,16 +87,22 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
}
func upgradeToMaxQuality(coverURL string) string {
// Spotify CDN upgrade
if strings.Contains(coverURL, spotifySize640) {
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
}
// Deezer CDN upgrade
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
return upgradeDeezerCover(coverURL)
}
if strings.Contains(coverURL, "resources.tidal.com") {
return upgradeTidalCover(coverURL)
}
if strings.Contains(coverURL, "static.qobuz.com") {
return upgradeQobuzCover(coverURL)
}
return coverURL
}
@@ -111,12 +118,35 @@ func upgradeDeezerCover(coverURL string) string {
return upgraded
}
func upgradeTidalCover(coverURL string) string {
if !strings.Contains(coverURL, "resources.tidal.com") {
return coverURL
}
upgraded := tidalSizeRegex.ReplaceAllString(coverURL, "/origin.jpg")
if upgraded != coverURL {
GoLog("[Cover] Tidal: upgraded to origin resolution")
}
return upgraded
}
func upgradeQobuzCover(coverURL string) string {
if !strings.Contains(coverURL, "static.qobuz.com") {
return coverURL
}
upgraded := qobuzImageSizeRe.ReplaceAllString(coverURL, "_max.jpg")
if upgraded != coverURL {
GoLog("[Cover] Qobuz: upgraded to max resolution")
}
return upgraded
}
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
if imageURL == "" {
return ""
}
// Always upgrade small to medium first
result := convertSmallToMedium(imageURL)
if maxQuality {
-10
View File
@@ -13,7 +13,6 @@ import (
// CueSheet represents a parsed .cue file
type CueSheet struct {
// Album-level metadata
Performer string `json:"performer"`
Title string `json:"title"`
FileName string `json:"file_name"`
@@ -32,7 +31,6 @@ type CueTrack struct {
Performer string `json:"performer"`
ISRC string `json:"isrc,omitempty"`
Composer string `json:"composer,omitempty"`
// Index positions in seconds (fractional)
StartTime float64 `json:"start_time"` // INDEX 01 in seconds
PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present)
}
@@ -82,7 +80,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
continue
}
// Handle BOM at start of file
if strings.HasPrefix(line, "\xef\xbb\xbf") {
line = strings.TrimPrefix(line, "\xef\xbb\xbf")
line = strings.TrimSpace(line)
@@ -90,7 +87,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
upper := strings.ToUpper(line)
// REM commands (album-level metadata)
if strings.HasPrefix(upper, "REM ") {
matches := reRemCommand.FindStringSubmatch(line)
if len(matches) == 3 {
@@ -136,9 +132,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
if strings.HasPrefix(upper, "FILE ") {
rest := line[len("FILE "):]
// Extract filename and type
// Format: FILE "filename.flac" WAVE
// or: FILE filename.flac WAVE
fname, ftype := parseCueFileLine(rest)
sheet.FileName = fname
sheet.FileType = ftype
@@ -146,7 +139,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
}
if strings.HasPrefix(upper, "TRACK ") {
// Save previous track
if currentTrack != nil {
sheet.Tracks = append(sheet.Tracks, *currentTrack)
}
@@ -184,7 +176,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
continue
}
// SONGWRITER (used as composer sometimes)
if strings.HasPrefix(upper, "SONGWRITER ") {
value := unquoteCue(line[len("SONGWRITER "):])
if currentTrack != nil {
@@ -196,7 +187,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
}
}
// Don't forget the last track
if currentTrack != nil {
sheet.Tracks = append(sheet.Tracks, *currentTrack)
}
+9 -7
View File
@@ -256,6 +256,7 @@ type deezerAlbumFull struct {
NbTracks int `json:"nb_tracks"`
RecordType string `json:"record_type"`
Label string `json:"label"`
Copyright string `json:"copyright"`
Genres struct {
Data []deezerGenre `json:"data"`
} `json:"genres"`
@@ -1084,8 +1085,9 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
}
type AlbumExtendedMetadata struct {
Genre string
Label string
Genre string
Label string
Copyright string
}
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
@@ -1116,8 +1118,9 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
}
result := &AlbumExtendedMetadata{
Genre: strings.Join(genres, ", "),
Label: album.Label,
Genre: strings.Join(genres, ", "),
Label: album.Label,
Copyright: album.Copyright,
}
c.cacheMu.Lock()
@@ -1129,7 +1132,7 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
c.maybeCleanupCachesLocked(now)
c.cacheMu.Unlock()
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label)
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s, Copyright: %s\n", result.Genre, result.Label, result.Copyright)
return result, nil
}
@@ -1178,7 +1181,7 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
for attempt := 0; attempt <= deezerMaxRetries; attempt++ {
if attempt > 0 {
delay := deezerRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff
delay := deezerRetryDelay * time.Duration(1<<(attempt-1))
GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay)
time.Sleep(delay)
}
@@ -1191,7 +1194,6 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
lastErr = err
errStr := err.Error()
// Check if error is retryable
isRetryable := strings.Contains(errStr, "timeout") ||
strings.Contains(errStr, "connection reset") ||
strings.Contains(errStr, "connection refused") ||
+46 -8
View File
@@ -203,29 +203,48 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
}
}
if deezerID != "" {
return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), nil
trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID)
if err := verifyDeezerTrack(req, deezerID); err != nil {
GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err)
// Don't reject direct IDs from request payload — they're presumably correct.
}
return trackURL, nil
}
// Try resolving Deezer ID from Spotify ID via SongLink
// Try SongLink
spotifyID := strings.TrimSpace(req.SpotifyID)
if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) {
songlink := NewSongLinkClient()
availability, err := songlink.CheckTrackAvailability(spotifyID, "")
if err == nil && availability.Deezer && availability.DeezerURL != "" {
return availability.DeezerURL, nil
resolvedID := extractDeezerIDFromURL(availability.DeezerURL)
if resolvedID != "" {
if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil {
GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr)
// Fall through to ISRC search instead of using wrong track.
} else {
return availability.DeezerURL, nil
}
} else {
return availability.DeezerURL, nil
}
}
}
// Try resolving from ISRC
// Try ISRC
isrc := strings.TrimSpace(req.ISRC)
if isrc != "" {
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
defer cancel()
track, err := GetDeezerClient().SearchByISRC(ctx, isrc)
if err == nil && track != nil {
deezerID = songLinkExtractDeezerTrackID(track)
if deezerID != "" {
return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), nil
resolvedID := songLinkExtractDeezerTrackID(track)
if resolvedID != "" {
if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil {
GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr)
return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr)
}
return fmt.Sprintf("https://www.deezer.com/track/%s", resolvedID), nil
}
}
}
@@ -233,6 +252,26 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
return "", fmt.Errorf("could not resolve Deezer track URL")
}
func verifyDeezerTrack(req DownloadRequest, deezerID string) error {
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
defer cancel()
trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID)
if err != nil {
return nil // Can't verify — don't block the download.
}
resolved := resolvedTrackInfo{
Title: trackResp.Track.Name,
ArtistName: trackResp.Track.Artists,
Duration: trackResp.Track.DurationMS / 1000,
}
if !trackMatchesRequest(req, resolved, "Deezer") {
return fmt.Errorf("expected '%s - %s', got '%s - %s'",
req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title)
}
GoLog("[Deezer] Track %s verified: '%s - %s' ✓\n", deezerID, resolved.ArtistName, resolved.Title)
return nil
}
type deezerMusicDLRequest struct {
Platform string `json:"platform"`
URL string `json:"url"`
@@ -280,7 +319,6 @@ func (c *DeezerClient) GetMusicDLDownloadURL(deezerTrackURL string) (string, err
return "", fmt.Errorf("MusicDL error: %s", errMsg)
}
// Try various response fields for download URL
for _, key := range []string{"download_url", "url", "link"} {
if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" {
return strings.TrimSpace(urlVal), nil
+231 -159
View File
@@ -48,7 +48,6 @@ func CheckAvailability(spotifyID, isrc string) (string, error) {
}
// SetSongLinkNetworkOptions is kept for backward compatibility.
// It now applies global network compatibility options for all backend API requests.
func SetSongLinkNetworkOptions(allowHTTP, insecureTLS bool) {
SetNetworkCompatibilityOptions(allowHTTP, insecureTLS)
}
@@ -128,6 +127,7 @@ type DownloadResult struct {
TrackNumber int
DiscNumber int
ISRC string
CoverURL string
Genre string
Label string
Copyright string
@@ -135,6 +135,36 @@ type DownloadResult struct {
DecryptionKey string
}
func preferredReleaseMetadata(
req DownloadRequest,
album string,
releaseDate string,
trackNumber int,
discNumber int,
) (string, string, int, int) {
preferredAlbum := strings.TrimSpace(req.AlbumName)
if preferredAlbum == "" {
preferredAlbum = album
}
preferredReleaseDate := strings.TrimSpace(req.ReleaseDate)
if preferredReleaseDate == "" {
preferredReleaseDate = releaseDate
}
preferredTrackNumber := req.TrackNumber
if preferredTrackNumber == 0 {
preferredTrackNumber = trackNumber
}
preferredDiscNumber := req.DiscNumber
if preferredDiscNumber == 0 {
preferredDiscNumber = discNumber
}
return preferredAlbum, preferredReleaseDate, preferredTrackNumber, preferredDiscNumber
}
func buildDownloadSuccessResponse(
req DownloadRequest,
result DownloadResult,
@@ -153,25 +183,16 @@ func buildDownloadSuccessResponse(
artist = req.ArtistName
}
album := result.Album
if album == "" {
album = req.AlbumName
}
releaseDate := result.ReleaseDate
if releaseDate == "" {
releaseDate = req.ReleaseDate
}
trackNumber := result.TrackNumber
if trackNumber == 0 {
trackNumber = req.TrackNumber
}
discNumber := result.DiscNumber
if discNumber == 0 {
discNumber = req.DiscNumber
}
// Preserve requested release metadata when available so mixed-provider
// fallback downloads from the same source album do not get split into
// different albums just because Tidal/Qobuz report variant titles/dates.
album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
req,
result.Album,
result.ReleaseDate,
result.TrackNumber,
result.DiscNumber,
)
isrc := result.ISRC
if isrc == "" {
@@ -193,6 +214,11 @@ func buildDownloadSuccessResponse(
copyright = req.Copyright
}
coverURL := strings.TrimSpace(result.CoverURL)
if coverURL == "" {
coverURL = strings.TrimSpace(req.CoverURL)
}
return DownloadResponse{
Success: true,
Message: message,
@@ -209,7 +235,7 @@ func buildDownloadSuccessResponse(
TrackNumber: trackNumber,
DiscNumber: discNumber,
ISRC: isrc,
CoverURL: req.CoverURL,
CoverURL: coverURL,
Genre: genre,
Label: label,
Copyright: copyright,
@@ -262,7 +288,7 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) {
return
}
if req.ISRC == "" || (req.Genre != "" && req.Label != "") {
if req.ISRC == "" || (req.Genre != "" && req.Label != "" && req.Copyright != "") {
return
}
@@ -284,8 +310,11 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) {
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
}
if req.Genre != "" || req.Label != "" {
GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s\n", req.Genre, req.Label)
if req.Copyright == "" && extMeta.Copyright != "" {
req.Copyright = extMeta.Copyright
}
if req.Genre != "" || req.Label != "" || req.Copyright != "" {
GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
}
}
@@ -354,6 +383,7 @@ func DownloadTrack(requestJSON string) (string, error) {
TrackNumber: qobuzResult.TrackNumber,
DiscNumber: qobuzResult.DiscNumber,
ISRC: qobuzResult.ISRC,
CoverURL: qobuzResult.CoverURL,
LyricsLRC: qobuzResult.LyricsLRC,
}
}
@@ -376,24 +406,6 @@ func DownloadTrack(requestJSON string) (string, error) {
}
}
err = deezerErr
case "youtube":
youtubeResult, youtubeErr := downloadFromYouTube(req)
if youtubeErr == nil {
result = DownloadResult{
FilePath: youtubeResult.FilePath,
BitDepth: 0,
SampleRate: 0,
Title: youtubeResult.Title,
Artist: youtubeResult.Artist,
Album: youtubeResult.Album,
ReleaseDate: youtubeResult.ReleaseDate,
TrackNumber: youtubeResult.TrackNumber,
DiscNumber: youtubeResult.DiscNumber,
ISRC: youtubeResult.ISRC,
LyricsLRC: youtubeResult.LyricsLRC,
}
}
err = youtubeErr
default:
return errorResponse("Unknown service: " + req.Service)
}
@@ -445,7 +457,7 @@ func DownloadByStrategy(requestJSON string) (string, error) {
serviceNormalized := strings.ToLower(serviceRaw)
normalizedReq := req
if serviceNormalized == "youtube" || isBuiltInProvider(serviceNormalized) {
if isBuiltInProvider(serviceNormalized) {
normalizedReq.Service = serviceNormalized
}
@@ -455,10 +467,6 @@ func DownloadByStrategy(requestJSON string) (string, error) {
}
normalizedJSON := string(normalizedBytes)
if serviceNormalized == "youtube" {
return DownloadFromYouTube(normalizedJSON)
}
if req.UseExtensions {
// Respect strict mode when auto fallback is disabled:
// for built-in providers, route directly to selected service only.
@@ -562,6 +570,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
TrackNumber: qobuzResult.TrackNumber,
DiscNumber: qobuzResult.DiscNumber,
ISRC: qobuzResult.ISRC,
CoverURL: qobuzResult.CoverURL,
LyricsLRC: qobuzResult.LyricsLRC,
}
} else if !errors.Is(qobuzErr, ErrDownloadCancelled) {
@@ -689,32 +698,80 @@ func ReadFileMetadata(filePath string) (string, error) {
if isFlac {
metadata, err := ReadMetadata(filePath)
if err != nil {
return "", fmt.Errorf("failed to read metadata: %w", err)
}
result["title"] = metadata.Title
result["artist"] = metadata.Artist
result["album"] = metadata.Album
result["album_artist"] = metadata.AlbumArtist
result["date"] = metadata.Date
result["track_number"] = metadata.TrackNumber
result["disc_number"] = metadata.DiscNumber
result["isrc"] = metadata.ISRC
result["lyrics"] = metadata.Lyrics
result["genre"] = metadata.Genre
result["label"] = metadata.Label
result["copyright"] = metadata.Copyright
result["composer"] = metadata.Composer
result["comment"] = metadata.Comment
// File may have wrong extension (e.g. opus saved as .flac).
// Try Ogg/Opus parser as fallback before giving up.
GoLog("[ReadFileMetadata] FLAC parse failed for %s, trying Ogg fallback: %v\n", filePath, err)
oggMeta, oggErr := ReadOggVorbisComments(filePath)
if oggErr == nil && oggMeta != nil {
result["title"] = oggMeta.Title
result["artist"] = oggMeta.Artist
result["album"] = oggMeta.Album
result["album_artist"] = oggMeta.AlbumArtist
result["date"] = oggMeta.Date
if oggMeta.Date == "" {
result["date"] = oggMeta.Year
}
result["track_number"] = oggMeta.TrackNumber
result["disc_number"] = oggMeta.DiscNumber
result["isrc"] = oggMeta.ISRC
result["lyrics"] = oggMeta.Lyrics
result["genre"] = oggMeta.Genre
result["composer"] = oggMeta.Composer
result["comment"] = oggMeta.Comment
quality, qualityErr := GetOggQuality(filePath)
if qualityErr == nil {
result["sample_rate"] = quality.SampleRate
result["duration"] = quality.Duration
}
} else {
return "", fmt.Errorf("failed to read metadata: %w", err)
}
} else {
result["title"] = metadata.Title
result["artist"] = metadata.Artist
result["album"] = metadata.Album
result["album_artist"] = metadata.AlbumArtist
result["date"] = metadata.Date
result["track_number"] = metadata.TrackNumber
result["disc_number"] = metadata.DiscNumber
result["isrc"] = metadata.ISRC
result["lyrics"] = metadata.Lyrics
result["genre"] = metadata.Genre
result["label"] = metadata.Label
result["copyright"] = metadata.Copyright
result["composer"] = metadata.Composer
result["comment"] = metadata.Comment
quality, qualityErr := GetAudioQuality(filePath)
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
quality, qualityErr := GetAudioQuality(filePath)
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
}
}
}
} else if isM4A {
meta, err := ReadM4ATags(filePath)
if err == nil && 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["disc_number"] = meta.DiscNumber
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
}
quality, qualityErr := GetM4AQuality(filePath)
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
@@ -858,7 +915,6 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
return string(jsonBytes), nil
}
// MP3/Opus: return metadata for Dart-side FFmpeg embedding
resp := map[string]any{
"success": true,
"method": "ffmpeg",
@@ -1103,6 +1159,36 @@ func SearchDeezerAll(query string, trackLimit, artistLimit int, filter string) (
return string(jsonBytes), nil
}
func SearchTidalAll(query string, trackLimit, artistLimit int, filter string) (string, error) {
downloader := NewTidalDownloader()
results, err := downloader.SearchAll(query, trackLimit, artistLimit, filter)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(results)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func SearchQobuzAll(query string, trackLimit, artistLimit int, filter string) (string, error) {
downloader := NewQobuzDownloader()
results, err := downloader.SearchAll(query, trackLimit, artistLimit, filter)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(results)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func GetDeezerRelatedArtists(artistID string, limit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
@@ -1314,10 +1400,7 @@ func GetDeezerExtendedMetadata(trackID string) (string, error) {
return "", err
}
result := map[string]string{
"genre": metadata.Genre,
"label": metadata.Label,
}
result := buildDeezerExtendedMetadataResult(metadata)
jsonBytes, err := json.Marshal(result)
if err != nil {
@@ -1337,7 +1420,8 @@ func SearchDeezerByISRC(isrc string) (string, error) {
return "", err
}
jsonBytes, err := json.Marshal(track)
result := buildDeezerISRCSearchResult(track)
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
@@ -1345,6 +1429,55 @@ func SearchDeezerByISRC(isrc string) (string, error) {
return string(jsonBytes), nil
}
func buildDeezerExtendedMetadataResult(metadata *AlbumExtendedMetadata) map[string]string {
if metadata == nil {
return map[string]string{
"genre": "",
"label": "",
"copyright": "",
}
}
return map[string]string{
"genre": metadata.Genre,
"label": metadata.Label,
"copyright": metadata.Copyright,
}
}
func buildDeezerISRCSearchResult(track *TrackMetadata) map[string]interface{} {
if track == nil {
return map[string]interface{}{}
}
result := map[string]interface{}{
"spotify_id": track.SpotifyID,
"artists": track.Artists,
"name": track.Name,
"album_name": track.AlbumName,
"album_artist": track.AlbumArtist,
"duration_ms": track.DurationMS,
"images": track.Images,
"release_date": track.ReleaseDate,
"track_number": track.TrackNumber,
"total_tracks": track.TotalTracks,
"disc_number": track.DiscNumber,
"external_urls": track.ExternalURL,
"isrc": track.ISRC,
"album_id": track.AlbumID,
"artist_id": track.ArtistID,
"album_type": track.AlbumType,
}
if deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:")); deezerID != "" {
result["id"] = deezerID
result["track_id"] = deezerID
result["success"] = true
}
return result
}
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -1541,62 +1674,6 @@ func errorResponse(msg string) (string, error) {
return string(jsonBytes), nil
}
func DownloadFromYouTube(requestJSON string) (string, error) {
var req DownloadRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return errorResponse("Invalid request: " + err.Error())
}
applySongLinkRegionFromRequest(&req)
defer closeOwnedOutputFD(req.OutputFD)
req.TrackName = strings.TrimSpace(req.TrackName)
req.ArtistName = strings.TrimSpace(req.ArtistName)
req.AlbumName = strings.TrimSpace(req.AlbumName)
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
req.OutputDir = strings.TrimSpace(req.OutputDir)
req.OutputPath = strings.TrimSpace(req.OutputPath)
req.OutputExt = strings.TrimSpace(req.OutputExt)
if req.OutputPath == "" && req.OutputFD <= 0 && req.OutputDir != "" {
AddAllowedDownloadDir(req.OutputDir)
}
youtubeResult, err := downloadFromYouTube(req)
if err != nil {
return errorResponse(err.Error())
}
resp := DownloadResponse{
Success: true,
Message: "Downloaded from YouTube",
FilePath: youtubeResult.FilePath,
Service: "youtube",
Title: youtubeResult.Title,
Artist: youtubeResult.Artist,
Album: youtubeResult.Album,
ReleaseDate: youtubeResult.ReleaseDate,
TrackNumber: youtubeResult.TrackNumber,
DiscNumber: youtubeResult.DiscNumber,
ISRC: youtubeResult.ISRC,
LyricsLRC: youtubeResult.LyricsLRC,
CoverURL: req.CoverURL,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
func IsYouTubeURLExport(urlStr string) bool {
return IsYouTubeURL(urlStr)
}
func ExtractYouTubeVideoIDExport(urlStr string) (string, error) {
return ExtractYouTubeVideoID(urlStr)
}
func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error {
if coverURL == "" {
return fmt.Errorf("no cover URL provided")
@@ -1623,6 +1700,8 @@ func ExtractCoverToFile(audioPath string, outputPath string) error {
if strings.HasSuffix(lower, ".flac") {
coverData, err = ExtractCoverArt(audioPath)
} else if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") {
coverData, err = extractCoverFromM4A(audioPath)
} else if strings.HasSuffix(lower, ".mp3") {
coverData, _, err = extractMP3CoverArt(audioPath)
} else if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
@@ -1803,8 +1882,8 @@ func ReEnrichFile(requestJSON string) (string, error) {
GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr)
}
// Try to get extended metadata (genre, label) from Deezer if not already set
if found && req.ISRC != "" && (req.Genre == "" || req.Label == "") {
// Try to get extended metadata from Deezer if not already set
if found && req.ISRC != "" && (req.Genre == "" || req.Label == "" || req.Copyright == "") {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
cancel()
@@ -1815,7 +1894,10 @@ func ReEnrichFile(requestJSON string) (string, error) {
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
}
GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s\n", req.Genre, req.Label)
if req.Copyright == "" && extMeta.Copyright != "" {
req.Copyright = extMeta.Copyright
}
GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
}
}
@@ -1824,7 +1906,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
}
}
// Log metadata summary before embedding
GoLog("[ReEnrich] Metadata to embed: title=%s, artist=%s, album=%s, albumArtist=%s\n",
req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist)
GoLog("[ReEnrich] track=%d, disc=%d, date=%s, isrc=%s, genre=%s, label=%s\n",
@@ -1884,8 +1965,15 @@ func ReEnrichFile(requestJSON string) (string, error) {
}
}()
// Fetch lyrics
// Preserve existing lyrics when online enrichment does not return a replacement.
var lyricsLRC string
existingLyrics, existingLyricsErr := ExtractLyrics(req.FilePath)
if existingLyricsErr == nil && strings.TrimSpace(existingLyrics) != "" {
lyricsLRC = existingLyrics
GoLog("[ReEnrich] Preserving existing embedded/sidecar lyrics\n")
}
// Fetch lyrics
if req.EmbedLyrics {
client := NewLyricsClient()
durationSec := float64(req.DurationMs) / 1000.0
@@ -1900,7 +1988,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
}
}
// Build enriched metadata response for Dart (includes online search results)
enrichedMeta := map[string]interface{}{
"track_name": req.TrackName,
"artist_name": req.ArtistName,
@@ -1966,7 +2053,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
return string(jsonBytes), nil
}
// MP3/Opus: return metadata map for Dart to use FFmpeg
// Don't cleanup cover temp — Dart needs it for FFmpeg embed
cleanupCover = false
result := map[string]interface{}{
@@ -2047,12 +2133,6 @@ func LoadExtensionFromPath(filePath string) (string, error) {
return "", err
}
settingsStore := GetExtensionSettingsStore()
settings := settingsStore.GetAll(ext.ID)
if len(settings) > 0 {
manager.InitializeExtension(ext.ID, settings)
}
result := map[string]interface{}{
"id": ext.ID,
"name": ext.Manifest.Name,
@@ -2086,12 +2166,6 @@ func UpgradeExtensionFromPath(filePath string) (string, error) {
return "", err
}
settingsStore := GetExtensionSettingsStore()
settings := settingsStore.GetAll(ext.ID)
if len(settings) > 0 {
manager.InitializeExtension(ext.ID, settings)
}
result := map[string]interface{}{
"id": ext.ID,
"display_name": ext.Manifest.DisplayName,
@@ -3086,11 +3160,7 @@ func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
return "", fmt.Errorf("extension store not initialized")
}
if forceRefresh {
store.FetchRegistry(true)
}
extensions, err := store.GetExtensionsWithStatus()
extensions, err := store.getExtensionsWithStatus(forceRefresh)
if err != nil {
return "", err
}
@@ -3184,12 +3254,14 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du
if !ext.Enabled {
return "", fmt.Errorf("extension '%s' is disabled", extensionID)
}
vm, err := ext.lockReadyVM()
if err != nil {
return "", err
}
defer ext.VMMu.Unlock()
// Goja runtime is not thread-safe; guard direct extension.*() calls with VMMu
// to avoid races with other provider calls (e.g. getAlbum/getPlaylist).
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
@@ -3199,7 +3271,7 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du
})()
`, functionName, functionName)
result, err := RunWithTimeoutAndRecover(ext.VM, script, timeout)
result, err := RunWithTimeoutAndRecover(vm, script, timeout)
if err != nil {
return "", fmt.Errorf("%s failed: %w", functionName, err)
}
@@ -0,0 +1,59 @@
package gobackend
import "testing"
func TestBuildDeezerExtendedMetadataResultHandlesNil(t *testing.T) {
result := buildDeezerExtendedMetadataResult(nil)
if result["genre"] != "" {
t.Fatalf("expected empty genre, got %q", result["genre"])
}
if result["label"] != "" {
t.Fatalf("expected empty label, got %q", result["label"])
}
if result["copyright"] != "" {
t.Fatalf("expected empty copyright, got %q", result["copyright"])
}
}
func TestBuildDeezerExtendedMetadataResultIncludesCopyright(t *testing.T) {
result := buildDeezerExtendedMetadataResult(&AlbumExtendedMetadata{
Genre: "Rock",
Label: "EMI",
Copyright: "(C) Queen",
})
if result["genre"] != "Rock" {
t.Fatalf("unexpected genre: %q", result["genre"])
}
if result["label"] != "EMI" {
t.Fatalf("unexpected label: %q", result["label"])
}
if result["copyright"] != "(C) Queen" {
t.Fatalf("unexpected copyright: %q", result["copyright"])
}
}
func TestBuildDeezerISRCSearchResultAddsCompatibilityIDs(t *testing.T) {
result := buildDeezerISRCSearchResult(&TrackMetadata{
SpotifyID: "deezer:3135556",
Name: "Love Of My Life",
Artists: "Queen",
AlbumName: "A Night at the Opera",
ISRC: "GBUM71029604",
ReleaseDate: "1975-11-21",
})
if result["spotify_id"] != "deezer:3135556" {
t.Fatalf("unexpected spotify_id: %v", result["spotify_id"])
}
if result["id"] != "3135556" {
t.Fatalf("unexpected id: %v", result["id"])
}
if result["track_id"] != "3135556" {
t.Fatalf("unexpected track_id: %v", result["track_id"])
}
if result["success"] != true {
t.Fatalf("expected success=true, got %v", result["success"])
}
}
+115
View File
@@ -0,0 +1,115 @@
package gobackend
import "testing"
func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) {
req := DownloadRequest{
TrackName: "Bonus Track",
ArtistName: "Artist",
AlbumName: "Album (Deluxe)",
AlbumArtist: "Artist",
ReleaseDate: "2024-01-01",
TrackNumber: 14,
DiscNumber: 1,
ISRC: "REQ123",
CoverURL: "https://example.com/cover.jpg",
Genre: "Pop",
Label: "Label",
Copyright: "Copyright",
}
result := DownloadResult{
Title: "Bonus Track",
Artist: "Artist",
Album: "Album",
ReleaseDate: "2023-12-01",
TrackNumber: 2,
DiscNumber: 9,
ISRC: "RES456",
}
resp := buildDownloadSuccessResponse(
req,
result,
"tidal",
"ok",
"/tmp/test.flac",
false,
)
if resp.Album != req.AlbumName {
t.Fatalf("album = %q, want %q", resp.Album, req.AlbumName)
}
if resp.ReleaseDate != req.ReleaseDate {
t.Fatalf("release date = %q, want %q", resp.ReleaseDate, req.ReleaseDate)
}
if resp.TrackNumber != req.TrackNumber {
t.Fatalf("track number = %d, want %d", resp.TrackNumber, req.TrackNumber)
}
if resp.DiscNumber != req.DiscNumber {
t.Fatalf("disc number = %d, want %d", resp.DiscNumber, req.DiscNumber)
}
if resp.Artist != result.Artist {
t.Fatalf("artist = %q, want provider artist %q", resp.Artist, result.Artist)
}
if resp.ISRC != result.ISRC {
t.Fatalf("isrc = %q, want provider isrc %q", resp.ISRC, result.ISRC)
}
}
func TestPreferredReleaseMetadataPrefersRequestValues(t *testing.T) {
album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
DownloadRequest{
AlbumName: "Album (Deluxe Edition)",
ReleaseDate: "2024-01-01",
TrackNumber: 13,
DiscNumber: 2,
},
"Album",
"2023-01-01",
3,
1,
)
if album != "Album (Deluxe Edition)" {
t.Fatalf("album = %q", album)
}
if releaseDate != "2024-01-01" {
t.Fatalf("release date = %q", releaseDate)
}
if trackNumber != 13 {
t.Fatalf("track number = %d", trackNumber)
}
if discNumber != 2 {
t.Fatalf("disc number = %d", discNumber)
}
}
func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) {
req := DownloadRequest{
TrackName: "Track",
ArtistName: "Artist",
AlbumName: "Album",
AlbumArtist: "Artist",
}
result := DownloadResult{
Title: "Track",
Artist: "Artist",
Album: "Album",
CoverURL: "https://cdn.qobuz.test/cover.jpg",
}
resp := buildDownloadSuccessResponse(
req,
result,
"qobuz",
"ok",
"/tmp/test.flac",
false,
)
if resp.CoverURL != result.CoverURL {
t.Fatalf("cover url = %q, want %q", resp.CoverURL, result.CoverURL)
}
}
+241 -114
View File
@@ -44,16 +44,76 @@ func compareVersions(v1, v2 string) int {
}
type LoadedExtension struct {
ID string `json:"id"`
Manifest *ExtensionManifest `json:"manifest"`
VM *goja.Runtime `json:"-"`
VMMu sync.Mutex `json:"-"`
runtime *ExtensionRuntime
Enabled bool `json:"enabled"`
Error string `json:"error,omitempty"`
DataDir string `json:"data_dir"`
SourceDir string `json:"source_dir"`
IconPath string `json:"icon_path"`
ID string `json:"id"`
Manifest *ExtensionManifest `json:"manifest"`
VM *goja.Runtime `json:"-"`
VMMu sync.Mutex `json:"-"`
runtime *ExtensionRuntime
initialized bool
Enabled bool `json:"enabled"`
Error string `json:"error,omitempty"`
DataDir string `json:"data_dir"`
SourceDir string `json:"source_dir"`
IconPath string `json:"icon_path"`
}
func getExtensionInitSettings(extensionID string) map[string]interface{} {
settings := GetExtensionSettingsStore().GetAll(extensionID)
if len(settings) == 0 {
return settings
}
filtered := make(map[string]interface{}, len(settings))
for key, value := range settings {
if strings.HasPrefix(key, "_") {
continue
}
filtered[key] = value
}
return filtered
}
func ensureRuntimeReadyLocked(ext *LoadedExtension, applyStoredSettings bool) error {
if ext.VM == nil || ext.runtime == nil {
if err := initializeVMLocked(ext); err != nil {
ext.Error = err.Error()
ext.Enabled = false
return err
}
}
if applyStoredSettings && !ext.initialized {
settings := getExtensionInitSettings(ext.ID)
if len(settings) > 0 {
if err := initializeExtensionWithSettingsLocked(ext, settings); err != nil {
teardownVMLocked(ext)
ext.Error = err.Error()
ext.Enabled = false
return err
}
} else {
ext.initialized = true
}
}
ext.Error = ""
return nil
}
func (ext *LoadedExtension) ensureRuntimeReady() error {
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
return ensureRuntimeReadyLocked(ext, true)
}
func (ext *LoadedExtension) lockReadyVM() (*goja.Runtime, error) {
ext.VMMu.Lock()
if err := ensureRuntimeReadyLocked(ext, true); err != nil {
ext.VMMu.Unlock()
return nil, err
}
return ext.VM, nil
}
type ExtensionManager struct {
@@ -220,10 +280,10 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
SourceDir: extDir,
}
if err := m.initializeVM(ext); err != nil {
if err := validateExtensionLoad(ext); err != nil {
ext.Error = err.Error()
ext.Enabled = false
GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err)
GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err)
}
m.extensions[manifest.Name] = ext
@@ -232,7 +292,10 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
return ext, nil
}
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
func initializeVMLocked(ext *LoadedExtension) error {
ext.VM = nil
ext.runtime = nil
ext.initialized = false
vm := goja.New()
ext.VM = vm
@@ -279,6 +342,136 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
return nil
}
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
return initializeVMLocked(ext)
}
func initializeExtensionWithSettingsLocked(
ext *LoadedExtension,
settings map[string]interface{},
) error {
if ext.VM == nil {
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
}
settingsJSON, err := json.Marshal(settings)
if err != nil {
return fmt.Errorf("Failed to save settings")
}
script := fmt.Sprintf(`
(function() {
var settings = %s;
if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') {
try {
extension.initialize(settings);
return { success: true };
} catch (e) {
return { success: false, error: e.toString() };
}
}
return { success: true, message: 'no initialize function' };
})()
`, string(settingsJSON))
result, err := ext.VM.RunString(script)
if err != nil {
ext.Error = fmt.Sprintf("initialize failed: %v", err)
ext.Enabled = false
GoLog("[Extension] Initialize error for %s: %v\n", ext.ID, err)
return err
}
if result != nil && !goja.IsUndefined(result) {
exported := result.Export()
if resultMap, ok := exported.(map[string]interface{}); ok {
if success, ok := resultMap["success"].(bool); ok && !success {
errMsg := "unknown error"
if e, ok := resultMap["error"].(string); ok {
errMsg = e
}
ext.Error = errMsg
ext.Enabled = false
GoLog("[Extension] Initialize failed for %s: %s\n", ext.ID, errMsg)
return fmt.Errorf("initialize failed: %s", errMsg)
}
}
}
ext.initialized = true
GoLog("[Extension] Initialized %s\n", ext.ID)
return nil
}
func runCleanupLocked(ext *LoadedExtension) error {
if ext.VM != nil {
script := `
(function() {
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
try {
extension.cleanup();
return { success: true };
} catch (e) {
return { success: false, error: e.toString() };
}
}
return { success: true, message: 'no cleanup function' };
})()
`
result, err := ext.VM.RunString(script)
if err != nil {
return err
}
if result != nil && !goja.IsUndefined(result) {
exported := result.Export()
if resultMap, ok := exported.(map[string]interface{}); ok {
if success, ok := resultMap["success"].(bool); ok && !success {
errMsg := "unknown error"
if e, ok := resultMap["error"].(string); ok {
errMsg = e
}
return fmt.Errorf("cleanup failed: %s", errMsg)
}
}
}
if result != nil && !goja.IsUndefined(result) && !goja.IsNull(result) {
GoLog("[Extension] Cleanup called for %s\n", ext.ID)
}
}
return nil
}
func teardownVMLocked(ext *LoadedExtension) {
if err := runCleanupLocked(ext); err != nil {
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
}
if ext.runtime != nil {
if err := ext.runtime.flushStorageNow(); err != nil {
GoLog("[Extension] Failed to flush storage for %s: %v\n", ext.ID, err)
}
ext.runtime.closeStorageFlusher()
}
ext.runtime = nil
ext.VM = nil
ext.initialized = false
}
func validateExtensionLoad(ext *LoadedExtension) error {
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
if err := initializeVMLocked(ext); err != nil {
return err
}
teardownVMLocked(ext)
return nil
}
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -288,21 +481,9 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
return fmt.Errorf("Extension not found")
}
if ext.VM != nil {
cleanup, err := ext.VM.RunString("typeof extension !== 'undefined' && typeof extension.cleanup === 'function' ? extension.cleanup() : null")
if err != nil {
GoLog("[Extension] Error calling cleanup for %s: %v\n", extensionID, err)
} else if cleanup != nil && !goja.IsUndefined(cleanup) && !goja.IsNull(cleanup) {
GoLog("[Extension] Cleanup called for %s\n", extensionID)
}
}
if ext.runtime != nil {
if err := ext.runtime.flushStorageNow(); err != nil {
GoLog("[Extension] Failed to flush storage for %s: %v\n", extensionID, err)
}
ext.runtime.closeStorageFlusher()
ext.runtime = nil
}
ext.VMMu.Lock()
teardownVMLocked(ext)
ext.VMMu.Unlock()
delete(m.extensions, extensionID)
GoLog("[Extension] Unloaded extension: %s\n", extensionID)
@@ -341,7 +522,21 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
return fmt.Errorf("Extension not found")
}
ext.Enabled = enabled
if enabled {
ext.Enabled = true
if err := ext.ensureRuntimeReady(); err != nil {
store := GetExtensionSettingsStore()
ext.Enabled = false
_ = store.Set(extensionID, "_enabled", false)
return err
}
} else {
ext.Enabled = false
ext.Error = ""
ext.VMMu.Lock()
teardownVMLocked(ext)
ext.VMMu.Unlock()
}
GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled])
store := GetExtensionSettingsStore()
@@ -436,10 +631,10 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
}
}
if err := m.initializeVM(ext); err != nil {
if err := validateExtensionLoad(ext); err != nil {
ext.Error = err.Error()
ext.Enabled = false
GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err)
GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err)
}
m.extensions[manifest.Name] = ext
@@ -590,10 +785,14 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
SourceDir: extDir,
}
if err := m.initializeVM(ext); err != nil {
if wasEnabled {
if err := ext.ensureRuntimeReady(); err != nil {
GoLog("[Extension] Failed to initialize upgraded extension %s: %v\n", newManifest.Name, err)
}
} else if err := validateExtensionLoad(ext); err != nil {
ext.Error = err.Error()
ext.Enabled = false
GoLog("[Extension] Failed to initialize VM for %s: %v\n", newManifest.Name, err)
GoLog("[Extension] Failed to validate upgraded extension %s: %v\n", newManifest.Name, err)
}
m.mu.Lock()
@@ -790,56 +989,13 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
return fmt.Errorf("Extension not found")
}
if ext.VM == nil {
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
}
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
settingsJSON, err := json.Marshal(settings)
if err != nil {
return fmt.Errorf("Failed to save settings")
}
script := fmt.Sprintf(`
(function() {
var settings = %s;
if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') {
try {
extension.initialize(settings);
return { success: true };
} catch (e) {
return { success: false, error: e.toString() };
}
}
return { success: true, message: 'no initialize function' };
})()
`, string(settingsJSON))
result, err := ext.VM.RunString(script)
if err != nil {
ext.Error = fmt.Sprintf("initialize failed: %v", err)
ext.Enabled = false
GoLog("[Extension] Initialize error for %s: %v\n", extensionID, err)
if err := ensureRuntimeReadyLocked(ext, false); err != nil {
return err
}
if result != nil && !goja.IsUndefined(result) {
exported := result.Export()
if resultMap, ok := exported.(map[string]interface{}); ok {
if success, ok := resultMap["success"].(bool); ok && !success {
errMsg := "unknown error"
if e, ok := resultMap["error"].(string); ok {
errMsg = e
}
ext.Error = errMsg
ext.Enabled = false
GoLog("[Extension] Initialize failed for %s: %s\n", extensionID, errMsg)
return fmt.Errorf("initialize failed: %s", errMsg)
}
}
}
GoLog("[Extension] Initialized %s\n", extensionID)
return nil
return initializeExtensionWithSettingsLocked(ext, settings)
}
func (m *ExtensionManager) CleanupExtension(extensionID string) error {
@@ -854,41 +1010,12 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
if ext.VM == nil {
return nil
}
script := `
(function() {
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
try {
extension.cleanup();
return { success: true };
} catch (e) {
return { success: false, error: e.toString() };
}
}
return { success: true, message: 'no cleanup function' };
})()
`
result, err := ext.VM.RunString(script)
if err != nil {
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
if err := runCleanupLocked(ext); err != nil {
GoLog("[Extension] Cleanup error for %s: %v\n", extensionID, err)
return err
}
if result != nil && !goja.IsUndefined(result) {
exported := result.Export()
if resultMap, ok := exported.(map[string]interface{}); ok {
if success, ok := resultMap["success"].(bool); ok && !success {
errMsg := "unknown error"
if e, ok := resultMap["error"].(string); ok {
errMsg = e
}
GoLog("[Extension] Cleanup failed for %s: %s\n", extensionID, errMsg)
return fmt.Errorf("cleanup failed: %s", errMsg)
}
}
}
GoLog("[Extension] Cleaned up %s\n", extensionID)
return nil
}
@@ -917,8 +1044,8 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
return nil, fmt.Errorf("extension not found: %s", extensionID)
}
if ext.VM == nil {
return nil, fmt.Errorf("extension VM not initialized")
if err := ext.ensureRuntimeReady(); err != nil {
return nil, err
}
if !ext.Enabled {
+71 -32
View File
@@ -125,6 +125,15 @@ func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper
}
}
func (p *ExtensionProviderWrapper) lockReadyVM() error {
vm, err := p.extension.lockReadyVM()
if err != nil {
return err
}
p.vm = vm
return nil
}
func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) {
if !p.extension.Manifest.IsMetadataProvider() {
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
@@ -133,8 +142,9 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
p.extension.VMMu.Lock()
if err := p.lockReadyVM(); err != nil {
return nil, err
}
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
@@ -192,8 +202,9 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
p.extension.VMMu.Lock()
if err := p.lockReadyVM(); err != nil {
return nil, err
}
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
@@ -240,8 +251,9 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
p.extension.VMMu.Lock()
if err := p.lockReadyVM(); err != nil {
return nil, err
}
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
@@ -291,8 +303,9 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
p.extension.VMMu.Lock()
if err := p.lockReadyVM(); err != nil {
return nil, err
}
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
@@ -345,8 +358,10 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
if !p.extension.Enabled {
return track, nil
}
p.extension.VMMu.Lock()
if err := p.lockReadyVM(); err != nil {
GoLog("[Extension] EnrichTrack init error for %s: %v\n", p.extension.ID, err)
return track, nil
}
defer p.extension.VMMu.Unlock()
trackJSON, err := json.Marshal(track)
@@ -405,8 +420,9 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
p.extension.VMMu.Lock()
if err := p.lockReadyVM(); err != nil {
return nil, err
}
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
@@ -452,8 +468,9 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
p.extension.VMMu.Lock()
if err := p.lockReadyVM(); err != nil {
return nil, err
}
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
@@ -501,8 +518,13 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
p.extension.VMMu.Lock()
if err := p.lockReadyVM(); err != nil {
return &ExtDownloadResult{
Success: false,
ErrorMessage: err.Error(),
ErrorType: "init_error",
}, nil
}
defer p.extension.VMMu.Unlock()
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
@@ -1065,8 +1087,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] Metadata provider search failed (non-fatal): %v\n", searchErr)
}
// Try Deezer extended metadata for genre/label if we have ISRC
if req.ISRC != "" && (req.Genre == "" || req.Label == "") {
// Try Deezer extended metadata if we have ISRC
if req.ISRC != "" &&
(req.Genre == "" || req.Label == "" || req.Copyright == "") {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
extMeta, err := GetDeezerClient().GetExtendedMetadataByISRC(ctx, req.ISRC)
cancel()
@@ -1077,7 +1100,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
}
GoLog("[DownloadWithExtensionFallback] Extended metadata from Deezer: genre=%s, label=%s\n", req.Genre, req.Label)
if req.Copyright == "" && extMeta.Copyright != "" {
req.Copyright = extMeta.Copyright
}
GoLog("[DownloadWithExtensionFallback] Extended metadata from Deezer: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
}
}
}
@@ -1249,7 +1275,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
if isBuiltInProvider(providerIDNormalized) {
if (req.Genre == "" || req.Label == "") && req.ISRC != "" {
if (req.Genre == "" || req.Label == "" || req.Copyright == "") &&
req.ISRC != "" {
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
deezerClient := GetDeezerClient()
@@ -1264,6 +1291,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
req.Label = extMeta.Label
GoLog("[DownloadWithExtensionFallback] Label from Deezer: %s\n", req.Label)
}
if req.Copyright == "" && extMeta.Copyright != "" {
req.Copyright = extMeta.Copyright
GoLog("[DownloadWithExtensionFallback] Copyright from Deezer: %s\n", req.Copyright)
}
} else if err != nil {
GoLog("[DownloadWithExtensionFallback] Failed to get extended metadata from Deezer: %v\n", err)
}
@@ -1471,6 +1502,7 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
TrackNumber: qobuzResult.TrackNumber,
DiscNumber: qobuzResult.DiscNumber,
ISRC: qobuzResult.ISRC,
CoverURL: qobuzResult.CoverURL,
}
}
err = qobuzErr
@@ -1513,6 +1545,7 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
TrackNumber: result.TrackNumber,
DiscNumber: result.DiscNumber,
ISRC: result.ISRC,
CoverURL: result.CoverURL,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
@@ -1615,8 +1648,9 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
p.extension.VMMu.Lock()
if err := p.lockReadyVM(); err != nil {
return nil, err
}
defer p.extension.VMMu.Unlock()
if options == nil {
@@ -1696,8 +1730,9 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
p.extension.VMMu.Lock()
if err := p.lockReadyVM(); err != nil {
return nil, err
}
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
@@ -1781,8 +1816,9 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
p.extension.VMMu.Lock()
if err := p.lockReadyVM(); err != nil {
return nil, err
}
defer p.extension.VMMu.Unlock()
sourceJSON, _ := json.Marshal(sourceTrack)
@@ -1851,8 +1887,9 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
p.extension.VMMu.Lock()
if err := p.lockReadyVM(); err != nil {
return &PostProcessResult{Success: false, Error: err.Error()}, nil
}
defer p.extension.VMMu.Unlock()
metadataJSON, _ := json.Marshal(metadata)
@@ -1913,8 +1950,9 @@ func (p *ExtensionProviderWrapper) PostProcessV2(input PostProcessInput, metadat
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
p.extension.VMMu.Lock()
if err := p.lockReadyVM(); err != nil {
return &PostProcessResult{Success: false, Error: err.Error()}, nil
}
defer p.extension.VMMu.Unlock()
metadataJSON, _ := json.Marshal(metadata)
@@ -2171,8 +2209,9 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
p.extension.VMMu.Lock()
if err := p.lockReadyVM(); err != nil {
return nil, err
}
defer p.extension.VMMu.Unlock()
// Use global variables to avoid JS injection issues with special characters in track/artist names
+17 -11
View File
@@ -81,13 +81,14 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
}
type ExtensionRuntime struct {
extensionID string
manifest *ExtensionManifest
settings map[string]interface{}
httpClient *http.Client
cookieJar http.CookieJar
dataDir string
vm *goja.Runtime
extensionID string
manifest *ExtensionManifest
settings map[string]interface{}
httpClient *http.Client
downloadClient *http.Client
cookieJar http.CookieJar
dataDir string
vm *goja.Runtime
storageMu sync.RWMutex
storageCache map[string]interface{}
@@ -132,13 +133,20 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
storageFlushDelay: defaultStorageFlushDelay,
}
runtime.httpClient = newExtensionHTTPClient(ext, jar, 30*time.Second)
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout)
return runtime
}
func newExtensionHTTPClient(ext *LoadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
// Extension sandbox enforces HTTPS-only domains. Do not apply global
// allow_http scheme downgrade here, because some extension APIs (e.g.
// spotify-web) will redirect http -> https and can end up in 301 loops.
// We still reuse sharedTransport so insecure TLS compatibility mode remains effective.
client := &http.Client{
Transport: sharedTransport,
Timeout: 30 * time.Second,
Timeout: timeout,
Jar: jar,
}
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
@@ -165,9 +173,7 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
}
return nil
}
runtime.httpClient = client
return runtime
return client
}
type RedirectBlockedError struct {
+6 -1
View File
@@ -174,7 +174,12 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
}
resp, err := r.httpClient.Do(req)
client := r.downloadClient
if client == nil {
client = r.httpClient
}
resp, err := client.Do(req)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
+17 -13
View File
@@ -97,8 +97,8 @@ type StoreExtensionResponse struct {
HasUpdate bool `json:"has_update"`
}
func (e *StoreExtension) ToResponse() StoreExtensionResponse {
return StoreExtensionResponse{
func (e *StoreExtension) ToResponse() *StoreExtensionResponse {
return &StoreExtensionResponse{
ID: e.ID,
Name: e.Name,
DisplayName: e.getDisplayName(),
@@ -236,7 +236,6 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
// Check if a registry URL has been configured
if s.registryURL == "" {
return nil, fmt.Errorf("no registry URL configured. Please add a repository URL first")
}
@@ -289,8 +288,8 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
return &registry, nil
}
func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) {
registry, err := s.FetchRegistry(false)
func (s *ExtensionStore) getExtensionsWithStatus(forceRefresh bool) ([]*StoreExtensionResponse, error) {
registry, err := s.FetchRegistry(forceRefresh)
if err != nil {
return nil, err
}
@@ -304,22 +303,29 @@ func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, er
}
}
result := make([]StoreExtensionResponse, len(registry.Extensions))
for i, ext := range registry.Extensions {
resp := ext.ToResponse()
LogDebug("ExtensionStore", "Building store response for %d registry extensions (%d installed)", len(registry.Extensions), len(installed))
result := make([]*StoreExtensionResponse, 0, len(registry.Extensions))
for i := range registry.Extensions {
ext := &registry.Extensions[i]
resp := ext.ToResponse()
if installedVersion, ok := installed[ext.ID]; ok {
resp.IsInstalled = true
resp.InstalledVersion = installedVersion
resp.HasUpdate = compareVersions(ext.Version, installedVersion) > 0
}
result[i] = resp
result = append(result, resp)
}
LogDebug("ExtensionStore", "Built store response payload for %d extensions", len(result))
return result, nil
}
func (s *ExtensionStore) GetExtensionsWithStatus() ([]*StoreExtensionResponse, error) {
return s.getExtensionsWithStatus(false)
}
func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error {
registry, err := s.FetchRegistry(false)
if err != nil {
@@ -389,7 +395,6 @@ func ResolveRegistryURL(input string) (string, error) {
return input, nil
}
// Try to match https://github.com/<owner>/<repo>[/...]
const ghPrefix = "https://github.com/"
if !strings.HasPrefix(input, ghPrefix) {
// Also accept http:// and upgrade silently.
@@ -470,7 +475,7 @@ func (s *ExtensionStore) GetCategories() []string {
}
}
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) {
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]*StoreExtensionResponse, error) {
extensions, err := s.GetExtensionsWithStatus()
if err != nil {
return nil, err
@@ -480,7 +485,7 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
return extensions, nil
}
var result []StoreExtensionResponse
result := make([]*StoreExtensionResponse, 0, len(extensions))
queryLower := toLower(query)
for _, ext := range extensions {
@@ -493,7 +498,6 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
!containsIgnoreCase(ext.DisplayName, queryLower) &&
!containsIgnoreCase(ext.Description, queryLower) &&
!containsIgnoreCase(ext.Author, queryLower) {
// Check tags
found := false
for _, tag := range ext.Tags {
if containsIgnoreCase(tag, queryLower) {
+1 -1
View File
@@ -12,6 +12,7 @@ require (
github.com/refraction-networking/utls v1.8.2
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864
golang.org/x/net v0.50.0
golang.org/x/text v0.34.0
)
require (
@@ -24,6 +25,5 @@ require (
golang.org/x/mod v0.33.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.42.0 // indirect
)
-18
View File
@@ -6,8 +6,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 h1:QckvTXtu55YMopmVeDrPQ/r+T6xjw8KMCmE3UgUldkw=
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
@@ -30,36 +28,20 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4=
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg=
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af h1:VqXrZNyqFISxo0rNDFZQlRDRIp7RXSJDeh/LbrK+W1k=
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af/go.mod h1:tbwefIr7RlQD1OpZ0KEZ9nux/uiihAOGdafgZfJkmII=
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 h1:cTVynMSsMYgbUrtia2HB1jrhdUwQNtQti91vUCyjMp4=
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+4 -8
View File
@@ -300,14 +300,11 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
continue
}
// Check for ISP blocking via HTTP status codes
// Some ISPs return 403 or 451 when blocking content
if resp.StatusCode == 403 || resp.StatusCode == 451 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
bodyStr := strings.ToLower(string(body))
// Check if response looks like ISP blocking page
ispBlockingIndicators := []string{
"blocked", "forbidden", "access denied", "not available in your",
"restricted", "censored", "unavailable for legal", "blocked by",
@@ -346,11 +343,12 @@ func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Dur
return min(nextDelay, config.MaxDelay)
}
// Returns 60 seconds as default if header is missing or invalid
// Returns 0 if the header is missing or invalid so callers can keep their
// normal exponential backoff instead of stalling for an arbitrary minute.
func getRetryAfterDuration(resp *http.Response) time.Duration {
retryAfter := resp.Header.Get("Retry-After")
if retryAfter == "" {
return 60 * time.Second
return 0
}
if seconds, err := strconv.Atoi(retryAfter); err == nil {
@@ -364,7 +362,7 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
}
}
return 60 * time.Second
return 0
}
func ReadResponseBody(resp *http.Response) ([]byte, error) {
@@ -517,7 +515,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
return nil
}
// Returns true if ISP blocking was detected
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
ispErr := IsISPBlocking(err, requestURL)
if ispErr != nil {
@@ -552,7 +549,6 @@ func extractDomain(rawURL string) string {
return "unknown"
}
// If ISP blocking is detected, returns a more descriptive error
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
if err == nil {
return nil
-2
View File
@@ -112,7 +112,6 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
resp, err := sharedClient.Do(req)
if err == nil {
// Check for Cloudflare challenge page (403 with specific markers)
if resp.StatusCode == 403 || resp.StatusCode == 503 {
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
@@ -154,7 +153,6 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
return resp, nil
}
// Check if error might be TLS-related (Cloudflare blocking)
errStr := strings.ToLower(err.Error())
tlsRelated := strings.Contains(errStr, "tls") ||
strings.Contains(errStr, "handshake") ||
+20 -17
View File
@@ -234,8 +234,6 @@ func ScanLibraryFolder(folderPath string) (string, error) {
continue
}
// Skip audio files that are referenced by a .cue sheet
// (they will be represented by the cue sheet's track entries instead)
if cueReferencedAudioFiles[filePath] {
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
continue
@@ -293,7 +291,7 @@ func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scan
libraryCoverCacheMu.RLock()
coverCacheDir := libraryCoverCacheDir
libraryCoverCacheMu.RUnlock()
if coverCacheDir != "" && ext != ".m4a" {
if coverCacheDir != "" {
coverPath, err := SaveCoverToCacheWithHint(filePath, displayNameHint, coverCacheDir)
if err == nil && coverPath != "" {
result.CoverPath = coverPath
@@ -373,13 +371,30 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul
}
func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
metadata, err := ReadM4ATags(filePath)
if err == nil && metadata != nil {
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.DiscNumber = metadata.DiscNumber
result.ReleaseDate = metadata.Date
if result.ReleaseDate == "" {
result.ReleaseDate = metadata.Year
}
result.Genre = metadata.Genre
}
quality, err := GetM4AQuality(filePath)
if err == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
}
return scanFromFilename(filePath, "", result)
applyDefaultLibraryMetadata(filePath, "", result)
return result, nil
}
func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
@@ -540,9 +555,6 @@ func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string,
return string(jsonBytes), nil
}
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
// Only files that are new or have changed modification time will be scanned
func loadExistingFilesSnapshot(snapshotPath string) (map[string]int64, error) {
existingFiles := make(map[string]int64)
if snapshotPath == "" {
@@ -620,7 +632,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
libraryScanProgress.TotalFiles = totalFiles
libraryScanProgressMu.Unlock()
// Find files to scan (new or modified)
var filesToScan []libraryAudioFileInfo
skippedCount := 0
existingCueTrackModTimes := make(map[string]int64)
@@ -636,10 +647,8 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
for _, f := range currentFiles {
existingModTime, exists := existingFiles[f.path]
if !exists {
// For .cue files, also check if any virtual path entries exist
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
if cueTrackModTime, hasCueTracks := existingCueTrackModTimes[f.path]; hasCueTracks {
// CUE file exists in DB via virtual paths; check if modTime changed
if f.modTime == cueTrackModTime {
skippedCount++
} else {
@@ -658,14 +667,11 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
var deletedPaths []string
for existingPath := range existingFiles {
// For CUE virtual paths (e.g. "/path/album.cue#track01"),
// check if the base .cue file still exists on disk
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
baseCuePath := existingPath[:idx]
if currentPathSet[baseCuePath] {
continue // Base .cue file still exists, not deleted
continue
}
// Base CUE file is gone, mark virtual path as deleted
deletedPaths = append(deletedPaths, existingPath)
} else if !currentPathSet[existingPath] {
deletedPaths = append(deletedPaths, existingPath)
@@ -696,7 +702,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
scanTime := time.Now().UTC().Format(time.RFC3339)
errorCount := 0
// Track audio files referenced by .cue sheets to avoid duplicates (incremental)
cueReferencedAudioFilesInc := make(map[string]bool)
parsedCueFiles := make(map[string]scannedCueFileInfo)
for _, f := range filesToScan {
@@ -731,7 +736,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
ext := strings.ToLower(filepath.Ext(f.path))
// Handle .cue files: produce multiple track results
if ext == ".cue" {
var cueResults []LibraryScanResult
cueInfo, ok := parsedCueFiles[f.path]
@@ -756,7 +760,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
continue
}
// Skip audio files referenced by .cue sheets
if cueReferencedAudioFilesInc[f.path] {
continue
}
+107 -70
View File
@@ -3,6 +3,7 @@ package gobackend
import (
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
@@ -82,7 +83,6 @@ func SetLyricsProviderOrder(providers []string) {
return
}
// Validate provider names
validNames := map[string]bool{
LyricsProviderSpotifyAPI: true,
LyricsProviderLRCLIB: true,
@@ -104,7 +104,6 @@ func SetLyricsProviderOrder(providers []string) {
GoLog("[Lyrics] Provider order set to: %v\n", valid)
}
// GetLyricsProviderOrder returns the current lyrics provider order.
func GetLyricsProviderOrder() []string {
lyricsProvidersMu.RLock()
defer lyricsProvidersMu.RUnlock()
@@ -118,15 +117,14 @@ func GetLyricsProviderOrder() []string {
return result
}
// GetAvailableLyricsProviders returns metadata about all available providers.
func GetAvailableLyricsProviders() []map[string]interface{} {
return []map[string]interface{}{
{"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced synced lyrics via community API"},
{"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced lyrics via Paxsenix"},
{"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"},
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": false, "description": "NetEase Cloud Music (good for Asian songs)"},
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Largest lyrics database (multi-language)"},
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Word-by-word synced lyrics"},
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics (good for Chinese songs)"},
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics via Paxsenix"},
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics via Paxsenix"},
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics via Paxsenix"},
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics via Paxsenix"},
}
}
@@ -139,7 +137,6 @@ func normalizeLyricsFetchOptions(opts LyricsFetchOptions) LyricsFetchOptions {
return opts
}
// SetLyricsFetchOptions sets provider-specific lyric fetch behavior.
func SetLyricsFetchOptions(opts LyricsFetchOptions) {
normalized := normalizeLyricsFetchOptions(opts)
@@ -155,7 +152,6 @@ func SetLyricsFetchOptions(opts LyricsFetchOptions) {
)
}
// GetLyricsFetchOptions returns current provider-specific lyric fetch behavior.
func GetLyricsFetchOptions() LyricsFetchOptions {
lyricsFetchOptionsMu.RLock()
defer lyricsFetchOptionsMu.RUnlock()
@@ -431,6 +427,99 @@ func parseSpotifyRetryAfter(retryAfter string, now time.Time) time.Time {
return now.Add(10 * time.Minute)
}
func buildSpotifyLyricsResponse(lines []LyricsLine, syncType, plainLyrics string) (*LyricsResponse, error) {
if len(lines) == 0 {
return nil, fmt.Errorf("Spotify Lyrics API returned empty lines")
}
if syncType == "" {
if len(lines) > 0 && lines[0].StartTimeMs > 0 {
syncType = "LINE_SYNCED"
} else {
syncType = "UNSYNCED"
}
}
return &LyricsResponse{
Lines: lines,
SyncType: syncType,
Instrumental: false,
PlainLyrics: plainLyrics,
Provider: "Spotify Lyrics API",
Source: "Spotify Lyrics API",
}, nil
}
func plainLyricsFromTimedLines(lines []LyricsLine) string {
parts := make([]string, 0, len(lines))
for _, line := range lines {
words := strings.TrimSpace(line.Words)
if words == "" {
continue
}
parts = append(parts, words)
}
return strings.Join(parts, "\n")
}
func parseSpotifyLyricsResponseBody(body []byte) (*LyricsResponse, error) {
var lrcPayload string
if err := json.Unmarshal(body, &lrcPayload); err == nil {
trimmed := strings.TrimSpace(lrcPayload)
if trimmed == "" {
return nil, fmt.Errorf("Spotify Lyrics API returned empty payload")
}
lines := parseSyncedLyrics(trimmed)
if len(lines) > 0 {
return buildSpotifyLyricsResponse(lines, "LINE_SYNCED", plainLyricsFromTimedLines(lines))
}
plainLines := plainTextLyricsLines(trimmed)
return buildSpotifyLyricsResponse(plainLines, "UNSYNCED", trimmed)
}
var apiResp SpotifyLyricsAPIResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err)
}
if apiResp.Error {
msg := strings.TrimSpace(apiResp.Message)
if msg == "" {
msg = "Spotify Lyrics API returned error"
}
return nil, fmt.Errorf("%s", msg)
}
lines := make([]LyricsLine, 0, len(apiResp.Lines))
for _, line := range apiResp.Lines {
words := strings.TrimSpace(line.Words)
if words == "" {
continue
}
startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag)
lines = append(lines, LyricsLine{
StartTimeMs: startMs,
Words: words,
EndTimeMs: 0,
})
}
for i := 0; i < len(lines)-1; i++ {
nextStart := lines[i+1].StartTimeMs
if nextStart > lines[i].StartTimeMs {
lines[i].EndTimeMs = nextStart
}
}
if len(lines) > 0 {
last := len(lines) - 1
if lines[last].EndTimeMs == 0 {
lines[last].EndTimeMs = lines[last].StartTimeMs + 5000
}
}
return buildSpotifyLyricsResponse(lines, apiResp.SyncType, plainLyricsFromTimedLines(lines))
}
func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsResponse, error) {
now := time.Now()
if limitedUntil := getSpotifyLyricsRateLimitUntil(); limitedUntil.After(now) {
@@ -449,7 +538,7 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo
spotifyID = parsed.ID
}
apiURL := fmt.Sprintf("https://spotify-lyrics-api-pi.vercel.app/?trackid=%s&format=lrc", url.QueryEscape(spotifyID))
apiURL := fmt.Sprintf("https://lyrics.paxsenix.org/spotify/lyrics?id=%s", url.QueryEscape(spotifyID))
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
@@ -462,13 +551,18 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read Spotify Lyrics API response: %w", err)
}
if resp.StatusCode != 200 {
if resp.StatusCode == http.StatusTooManyRequests {
retryUntil := parseSpotifyRetryAfter(resp.Header.Get("Retry-After"), now)
setSpotifyLyricsRateLimitUntil(retryUntil)
}
var payload map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&payload); err == nil {
if err := json.Unmarshal(bodyBytes, &payload); err == nil {
if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" {
return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg))
}
@@ -479,63 +573,7 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo
return nil, fmt.Errorf("Spotify Lyrics API returned status %d", resp.StatusCode)
}
var apiResp SpotifyLyricsAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err)
}
if apiResp.Error {
msg := strings.TrimSpace(apiResp.Message)
if msg == "" {
msg = "Spotify Lyrics API returned error"
}
return nil, fmt.Errorf("%s", msg)
}
result := &LyricsResponse{
Lines: make([]LyricsLine, 0, len(apiResp.Lines)),
SyncType: apiResp.SyncType,
Instrumental: false,
PlainLyrics: "",
Provider: "Spotify Lyrics API",
Source: "Spotify Lyrics API",
}
for _, line := range apiResp.Lines {
words := strings.TrimSpace(line.Words)
if words == "" {
continue
}
startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag)
result.Lines = append(result.Lines, LyricsLine{
StartTimeMs: startMs,
Words: words,
EndTimeMs: 0,
})
}
if len(result.Lines) > 1 {
for i := 0; i < len(result.Lines)-1; i++ {
nextStart := result.Lines[i+1].StartTimeMs
if nextStart > result.Lines[i].StartTimeMs {
result.Lines[i].EndTimeMs = nextStart
}
}
last := len(result.Lines) - 1
if result.Lines[last].EndTimeMs == 0 {
result.Lines[last].EndTimeMs = result.Lines[last].StartTimeMs + 5000
}
}
if len(result.Lines) == 0 {
return nil, fmt.Errorf("Spotify Lyrics API returned empty lines")
}
if result.SyncType == "" {
result.SyncType = "LINE_SYNCED"
}
return result, nil
return parseSpotifyLyricsResponseBody(bodyBytes)
}
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
@@ -624,7 +662,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder)
// Cascade through all configured built-in providers
for _, providerName := range providerOrder {
GoLog("[Lyrics] Trying provider: %s\n", providerName)
+65 -126
View File
@@ -4,121 +4,25 @@ import (
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
)
// AppleMusicClient fetches lyrics from Apple Music.
// Uses a scraped JWT token for search and a proxy for lyrics.
// Uses Paxsenix endpoints for search and lyrics.
type AppleMusicClient struct {
httpClient *http.Client
}
// Apple Music token manager — singleton with mutex for thread safety
type appleTokenManager struct {
mu sync.Mutex
token string
}
var globalAppleTokenManager = &appleTokenManager{}
func (m *appleTokenManager) getToken(client *http.Client) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.token != "" {
return m.token, nil
}
// Step 1: Fetch the Apple Music beta page
req, err := http.NewRequest("GET", "https://beta.music.apple.com", nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to fetch Apple Music page: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read Apple Music page: %w", err)
}
// Step 2: Find the index JS file URL
indexJsRegex := regexp.MustCompile(`/assets/index~[^/]+\.js`)
match := indexJsRegex.Find(body)
if match == nil {
return "", fmt.Errorf("could not find index JS script URL on Apple Music page")
}
indexJsURL := "https://beta.music.apple.com" + string(match)
// Step 3: Fetch the JS file
jsReq, err := http.NewRequest("GET", indexJsURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create JS request: %w", err)
}
jsReq.Header.Set("User-Agent", getRandomUserAgent())
jsResp, err := client.Do(jsReq)
if err != nil {
return "", fmt.Errorf("failed to fetch Apple Music JS: %w", err)
}
defer jsResp.Body.Close()
jsBody, err := io.ReadAll(jsResp.Body)
if err != nil {
return "", fmt.Errorf("failed to read Apple Music JS: %w", err)
}
// Step 4: Extract JWT token (starts with eyJh)
tokenRegex := regexp.MustCompile(`eyJh[^"]*`)
tokenMatch := tokenRegex.Find(jsBody)
if tokenMatch == nil {
return "", fmt.Errorf("could not find JWT token in Apple Music JS")
}
m.token = string(tokenMatch)
GoLog("[AppleMusic] Token obtained successfully (length: %d)\n", len(m.token))
return m.token, nil
}
func (m *appleTokenManager) clearToken() {
m.mu.Lock()
defer m.mu.Unlock()
m.token = ""
}
type appleMusicSearchResponse struct {
Results struct {
Songs *struct {
Data []struct {
ID string `json:"id"`
Type string `json:"type"`
} `json:"data"`
} `json:"songs"`
} `json:"results"`
Resources *struct {
Songs map[string]struct {
Attributes struct {
Name string `json:"name"`
ArtistName string `json:"artistName"`
AlbumName string `json:"albumName"`
URL string `json:"url"`
Artwork struct {
URL string `json:"url"`
} `json:"artwork"`
} `json:"attributes"`
} `json:"songs"`
} `json:"resources"`
type appleMusicSearchResult struct {
ID string `json:"id"`
SongName string `json:"songName"`
ArtistName string `json:"artistName"`
AlbumName string `json:"albumName"`
Duration int `json:"duration"`
}
// PaxResponse represents the lyrics proxy response for word-by-word / line lyrics
@@ -149,32 +53,71 @@ func NewAppleMusicClient() *AppleMusicClient {
}
}
func selectBestAppleMusicSearchResult(results []appleMusicSearchResult, trackName, artistName string, durationSec float64) *appleMusicSearchResult {
if len(results) == 0 {
return nil
}
normalizedTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(trackName)))
normalizedArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(artistName)))
if normalizedArtist == "" {
normalizedArtist = strings.ToLower(strings.TrimSpace(artistName))
}
bestIndex := 0
bestScore := -1
for i := range results {
result := &results[i]
score := 0
candidateTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(result.SongName)))
candidateArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(result.ArtistName)))
switch {
case candidateTrack == normalizedTrack:
score += 50
case strings.Contains(candidateTrack, normalizedTrack) || strings.Contains(normalizedTrack, candidateTrack):
score += 25
}
switch {
case candidateArtist == normalizedArtist:
score += 60
case strings.Contains(candidateArtist, normalizedArtist) || strings.Contains(normalizedArtist, candidateArtist):
score += 30
}
if durationSec > 0 && result.Duration > 0 {
diff := math.Abs(float64(result.Duration)/1000.0 - durationSec)
if diff <= durationToleranceSec {
score += 20
}
}
if score > bestScore {
bestScore = score
bestIndex = i
}
}
return &results[bestIndex]
}
// SearchSong searches for a song on Apple Music and returns its ID.
func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, error) {
func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
query := trackName + " " + artistName
if strings.TrimSpace(query) == "" {
return "", fmt.Errorf("empty search query")
}
token, err := globalAppleTokenManager.getToken(c.httpClient)
if err != nil {
return "", fmt.Errorf("apple music token error: %w", err)
}
encodedQuery := url.QueryEscape(query)
searchURL := fmt.Sprintf(
"https://amp-api.music.apple.com/v1/catalog/us/search?term=%s&types=songs&limit=5&l=en-US&platform=web&format[resources]=map&include[songs]=artists&extend=artistUrl",
encodedQuery,
)
searchURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/search?q=%s", encodedQuery)
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Origin", "https://music.apple.com")
req.Header.Set("Referer", "https://music.apple.com/")
req.Header.Set("User-Agent", getRandomUserAgent())
req.Header.Set("Accept", "application/json")
@@ -184,25 +127,21 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, err
}
defer resp.Body.Close()
if resp.StatusCode == 401 {
globalAppleTokenManager.clearToken()
return "", fmt.Errorf("apple music token expired")
}
if resp.StatusCode != 200 {
return "", fmt.Errorf("apple music search returned HTTP %d", resp.StatusCode)
}
var searchResp appleMusicSearchResponse
var searchResp []appleMusicSearchResult
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return "", fmt.Errorf("failed to decode apple music response: %w", err)
}
if searchResp.Results.Songs == nil || len(searchResp.Results.Songs.Data) == 0 {
best := selectBestAppleMusicSearchResult(searchResp, trackName, artistName, durationSec)
if best == nil || strings.TrimSpace(best.ID) == "" {
return "", fmt.Errorf("no songs found on apple music")
}
return searchResp.Results.Songs.Data[0].ID, nil
return strings.TrimSpace(best.ID), nil
}
// FetchLyricsByID fetches lyrics from the paxsenix proxy using Apple Music song ID.
@@ -320,7 +259,7 @@ func (c *AppleMusicClient) FetchLyrics(
durationSec float64,
multiPersonWordByWord bool,
) (*LyricsResponse, error) {
songID, err := c.SearchSong(trackName, artistName)
songID, err := c.SearchSong(trackName, artistName, durationSec)
if err != nil {
return nil, err
}
+92 -89
View File
@@ -3,6 +3,8 @@ package gobackend
import (
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strings"
@@ -45,100 +47,105 @@ type musixmatchLyricsResponse struct {
func NewMusixmatchClient() *MusixmatchClient {
return &MusixmatchClient{
httpClient: NewMetadataHTTPClient(15 * time.Second),
baseURL: "http://158.180.60.95",
baseURL: "https://lyrics.paxsenix.org/musixmatch/lyrics",
}
}
// searchAndGetLyrics searches for a song and retrieves its lyrics in one call.
// The Musixmatch proxy returns both search result and lyrics in a single response.
func (c *MusixmatchClient) searchAndGetLyrics(trackName, artistName string) (*musixmatchSearchResponse, error) {
func (c *MusixmatchClient) fetchLyricsPayload(trackName, artistName string, durationSec float64, lyricsType, language string) (string, error) {
if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" {
return nil, fmt.Errorf("empty track or artist name")
return "", fmt.Errorf("empty track or artist name")
}
encodedArtist := url.QueryEscape(artistName)
encodedTrack := url.QueryEscape(trackName)
fullURL := fmt.Sprintf("%s/v2/full?artist=%s&track=%s", c.baseURL, encodedArtist, encodedTrack)
params := url.Values{}
params.Set("t", trackName)
params.Set("a", artistName)
params.Set("type", lyricsType)
params.Set("format", "lrc")
if durationSec > 0 {
params.Set("d", fmt.Sprintf("%d", int(math.Round(durationSec))))
}
if strings.TrimSpace(language) != "" {
params.Set("l", strings.ToLower(strings.TrimSpace(language)))
}
fullURL := c.baseURL + "?" + params.Encode()
req, err := http.NewRequest("GET", fullURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("musixmatch search failed: %w", err)
return "", fmt.Errorf("musixmatch request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read musixmatch response: %w", err)
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode)
trimmed := strings.TrimSpace(string(body))
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
return "", fmt.Errorf("musixmatch proxy returned HTTP %d: %s", resp.StatusCode, errMsg)
}
return "", fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode)
}
var result musixmatchSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode musixmatch response: %w", err)
var lrcPayload string
if err := json.Unmarshal(body, &lrcPayload); err == nil {
lrcPayload = strings.TrimSpace(lrcPayload)
if lrcPayload == "" {
return "", fmt.Errorf("empty musixmatch lyrics payload")
}
return lrcPayload, nil
}
return &result, nil
trimmed := strings.TrimSpace(string(body))
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
return "", fmt.Errorf("%s", errMsg)
}
if trimmed != "" && !strings.HasPrefix(trimmed, "{") {
return trimmed, nil
}
return "", fmt.Errorf("failed to decode musixmatch response")
}
// FetchLyricsInLanguage retrieves lyrics from Musixmatch for a specific language code.
func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string) (*LyricsResponse, error) {
func (c *MusixmatchClient) FetchLyricsInLanguage(trackName, artistName string, durationSec float64, language string) (*LyricsResponse, error) {
lang := strings.ToLower(strings.TrimSpace(language))
if songID <= 0 || lang == "" {
return nil, fmt.Errorf("invalid song id or language")
if lang == "" {
return nil, fmt.Errorf("invalid language")
}
fullURL := fmt.Sprintf("%s/v2/full?id=%d&lang=%s", c.baseURL, songID, url.QueryEscape(lang))
req, err := http.NewRequest("GET", fullURL, nil)
lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "translate", lang)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("musixmatch language fetch failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("musixmatch language endpoint returned HTTP %d", resp.StatusCode)
return nil, err
}
var result musixmatchSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode musixmatch language response: %w", err)
lines := parseSyncedLyrics(lrcText)
if len(lines) > 0 {
return &LyricsResponse{
Lines: lines,
SyncType: "LINE_SYNCED",
PlainLyrics: plainLyricsFromTimedLines(lines),
Provider: "Musixmatch",
Source: fmt.Sprintf("Musixmatch (%s)", lang),
}, nil
}
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
if len(lines) > 0 {
return &LyricsResponse{
Lines: lines,
SyncType: "LINE_SYNCED",
Provider: "Musixmatch",
Source: fmt.Sprintf("Musixmatch (%s)", lang),
}, nil
}
}
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
if len(lines) > 0 {
return &LyricsResponse{
Lines: lines,
SyncType: "UNSYNCED",
PlainLyrics: result.UnsyncedLyrics.Lyrics,
Provider: "Musixmatch",
Source: fmt.Sprintf("Musixmatch (%s)", lang),
}, nil
}
plainLines := plainTextLyricsLines(lrcText)
if len(plainLines) > 0 {
return &LyricsResponse{
Lines: plainLines,
SyncType: "UNSYNCED",
PlainLyrics: lrcText,
Provider: "Musixmatch",
Source: fmt.Sprintf("Musixmatch (%s)", lang),
}, nil
}
return nil, fmt.Errorf("no lyrics found on musixmatch for language %s", lang)
@@ -146,43 +153,39 @@ func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string)
// FetchLyrics searches Musixmatch and returns parsed LyricsResponse.
func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec float64, preferredLanguage string) (*LyricsResponse, error) {
result, err := c.searchAndGetLyrics(trackName, artistName)
if err != nil {
return nil, err
}
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" && result.ID > 0 {
localized, localizedErr := c.FetchLyricsInLanguage(result.ID, preferred)
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" {
localized, localizedErr := c.FetchLyricsInLanguage(trackName, artistName, durationSec, preferred)
if localizedErr == nil {
return localized, nil
}
GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr)
}
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
if len(lines) > 0 {
return &LyricsResponse{
Lines: lines,
SyncType: "LINE_SYNCED",
Provider: "Musixmatch",
Source: "Musixmatch",
}, nil
}
lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "word", "")
if err != nil {
return nil, err
}
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
lines := parseSyncedLyrics(lrcText)
if len(lines) > 0 {
return &LyricsResponse{
Lines: lines,
SyncType: "LINE_SYNCED",
PlainLyrics: plainLyricsFromTimedLines(lines),
Provider: "Musixmatch",
Source: "Musixmatch",
}, nil
}
if len(lines) > 0 {
return &LyricsResponse{
Lines: lines,
SyncType: "UNSYNCED",
PlainLyrics: result.UnsyncedLyrics.Lyrics,
Provider: "Musixmatch",
Source: "Musixmatch",
}, nil
}
plainLines := plainTextLyricsLines(lrcText)
if len(plainLines) > 0 {
return &LyricsResponse{
Lines: plainLines,
SyncType: "UNSYNCED",
PlainLyrics: lrcText,
Provider: "Musixmatch",
Source: "Musixmatch",
}, nil
}
return nil, fmt.Errorf("no lyrics found on musixmatch")
+4 -11
View File
@@ -9,8 +9,7 @@ import (
"time"
)
// NeteaseClient fetches lyrics from NetEase Cloud Music (music.163.com).
// This is a direct public API — no proxy dependency.
// NeteaseClient fetches lyrics through Paxsenix's NetEase endpoints.
type NeteaseClient struct {
httpClient *http.Client
}
@@ -59,12 +58,9 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
return 0, fmt.Errorf("empty search query")
}
searchURL := "http://music.163.com/api/search/pc"
searchURL := "https://lyrics.paxsenix.org/netease/search"
params := url.Values{}
params.Set("s", query)
params.Set("type", "1")
params.Set("limit", "1")
params.Set("offset", "0")
params.Set("q", query)
fullURL := searchURL + "?" + params.Encode()
@@ -102,12 +98,9 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
// FetchLyricsByID fetches synced lyrics for a given Netease song ID.
func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includeRomanization bool) (string, error) {
lyricsURL := "http://music.163.com/api/song/lyric"
lyricsURL := "https://lyrics.paxsenix.org/netease/lyrics"
params := url.Values{}
params.Set("id", fmt.Sprintf("%d", songID))
params.Set("lv", "1")
params.Set("tv", "1")
params.Set("rv", "1")
fullURL := lyricsURL + "?" + params.Encode()
+39 -95
View File
@@ -1,45 +1,31 @@
package gobackend
import (
"bytes"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strings"
"time"
)
// QQMusicClient fetches lyrics from QQ Music.
// Search uses public QQ Music API, lyrics use the paxsenix proxy.
// Uses Paxsenix metadata lookup for lyrics.
type QQMusicClient struct {
httpClient *http.Client
}
type qqMusicSearchResponse struct {
Data struct {
Song struct {
List []struct {
Title string `json:"title"`
Singer []struct {
Name string `json:"name"`
} `json:"singer"`
Album struct {
Name string `json:"name"`
} `json:"album"`
ID int64 `json:"id"`
} `json:"list"`
} `json:"song"`
} `json:"data"`
type qqLyricsMetadataRequest struct {
Artist []string `json:"artist"`
Album string `json:"album,omitempty"`
SongID int64 `json:"songid,omitempty"`
Title string `json:"title"`
Duration int64 `json:"duration,omitempty"`
}
// QQ Music lyrics request payload for paxsenix proxy
type qqLyricsPayload struct {
Artist []string `json:"artist"`
Album string `json:"album"`
ID int64 `json:"id"`
Title string `json:"title"`
type qqLyricsMetadataResponse struct {
Lyrics []paxLyrics `json:"lyrics"`
}
func NewQQMusicClient() *QQMusicClient {
@@ -48,79 +34,29 @@ func NewQQMusicClient() *QQMusicClient {
}
}
// searchSong searches QQ Music and returns the song info needed for lyrics fetch.
func (c *QQMusicClient) searchSong(trackName, artistName string) (*qqLyricsPayload, error) {
query := trackName + " " + artistName
if strings.TrimSpace(query) == "" {
return nil, fmt.Errorf("empty search query")
// fetchLyricsByMetadata asks Paxsenix to resolve and return QQ lyrics using track metadata.
func (c *QQMusicClient) fetchLyricsByMetadata(trackName, artistName string, durationSec float64) (string, error) {
payload := qqLyricsMetadataRequest{
Artist: []string{artistName},
Title: trackName,
}
if durationSec > 0 {
payload.Duration = int64(math.Round(durationSec))
}
searchURL := "https://c.y.qq.com/soso/fcgi-bin/client_search_cp"
params := url.Values{}
params.Set("format", "json")
params.Set("inCharset", "utf8")
params.Set("outCharset", "utf8")
params.Set("platform", "yqq.json")
params.Set("new_json", "1")
params.Set("w", query)
fullURL := searchURL + "?" + params.Encode()
req, err := http.NewRequest("GET", fullURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("qqmusic search failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("qqmusic search returned HTTP %d", resp.StatusCode)
}
var searchResp qqMusicSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return nil, fmt.Errorf("failed to decode qqmusic response: %w", err)
}
if len(searchResp.Data.Song.List) == 0 {
return nil, fmt.Errorf("no songs found on qqmusic")
}
song := searchResp.Data.Song.List[0]
var artists []string
for _, singer := range song.Singer {
artists = append(artists, singer.Name)
}
return &qqLyricsPayload{
Artist: artists,
Album: song.Album.Name,
ID: song.ID,
Title: song.Title,
}, nil
}
// fetchLyricsByPayload fetches lyrics from the paxsenix proxy using QQ Music song info.
func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string, error) {
lyricsURL := "https://paxsenix.alwaysdata.net/getQQLyrics.php"
lyricsURL := "https://lyrics.paxsenix.org/qq/lyrics-metadata"
payloadBytes, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("failed to marshal payload: %w", err)
}
req, err := http.NewRequest("POST", lyricsURL, bytes.NewReader(payloadBytes))
req, err := http.NewRequest("POST", lyricsURL, strings.NewReader(string(payloadBytes)))
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
@@ -146,6 +82,17 @@ func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string,
return bodyStr, nil
}
func formatQQLyricsMetadataToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
var response qqLyricsMetadataResponse
if err := json.Unmarshal([]byte(rawJSON), &response); err != nil {
return "", fmt.Errorf("failed to parse qq metadata lyrics response")
}
if len(response.Lyrics) == 0 {
return "", fmt.Errorf("qq metadata lyrics response was empty")
}
return formatPaxContent("Syllable", response.Lyrics, multiPersonWordByWord), nil
}
// FetchLyrics searches QQ Music and returns parsed LyricsResponse.
func (c *QQMusicClient) FetchLyrics(
trackName,
@@ -153,12 +100,7 @@ func (c *QQMusicClient) FetchLyrics(
durationSec float64,
multiPersonWordByWord bool,
) (*LyricsResponse, error) {
payload, err := c.searchSong(trackName, artistName)
if err != nil {
return nil, err
}
rawLyrics, err := c.fetchLyricsByPayload(payload)
rawLyrics, err := c.fetchLyricsByMetadata(trackName, artistName, durationSec)
if err != nil {
return nil, err
}
@@ -166,11 +108,13 @@ func (c *QQMusicClient) FetchLyrics(
return nil, fmt.Errorf("qqmusic proxy returned non-lyric payload: %s", errMsg)
}
// Try to parse as pax format (word-by-word or line)
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
lrcText, err := formatQQLyricsMetadataToLRC(rawLyrics, multiPersonWordByWord)
if err != nil {
// If pax parsing fails, try to use as direct LRC text
lrcText = rawLyrics
if fallback, fallbackErr := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord); fallbackErr == nil {
lrcText = fallback
} else {
lrcText = rawLyrics
}
}
lines := parseSyncedLyrics(lrcText)
+320 -6
View File
@@ -552,6 +552,14 @@ func ExtractLyrics(filePath string) (string, error) {
return extractLyricsFromSidecarLRC(filePath)
}
if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") {
lyrics, err := extractLyricsFromM4A(filePath)
if err == nil && strings.TrimSpace(lyrics) != "" {
return lyrics, nil
}
return extractLyricsFromSidecarLRC(filePath)
}
if strings.HasSuffix(lower, ".mp3") {
meta, err := ReadID3Tags(filePath)
if err == nil && meta != nil {
@@ -581,6 +589,299 @@ func ExtractLyrics(filePath string) (string, error) {
return extractLyricsFromSidecarLRC(filePath)
}
func ReadM4ATags(filePath string) (*AudioMetadata, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
return nil, err
}
ilst, err := findM4AIlstAtom(f, fi.Size())
if err != nil {
return nil, err
}
metadata := &AudioMetadata{}
start := ilst.offset + ilst.headerSize
end := ilst.offset + ilst.size
for pos := start; pos+8 <= end; {
header, err := readAtomHeaderAt(f, pos, fi.Size())
if err != nil {
return nil, err
}
if header.size == 0 {
header.size = end - pos
}
if header.size < header.headerSize {
return nil, fmt.Errorf("invalid atom size for %s", header.typ)
}
switch header.typ {
case "\xa9nam":
metadata.Title, _ = readM4ATextValue(f, header, fi.Size())
case "\xa9ART":
metadata.Artist, _ = readM4ATextValue(f, header, fi.Size())
case "\xa9alb":
metadata.Album, _ = readM4ATextValue(f, header, fi.Size())
case "aART":
metadata.AlbumArtist, _ = readM4ATextValue(f, header, fi.Size())
case "\xa9day":
metadata.Date, _ = readM4ATextValue(f, header, fi.Size())
metadata.Year = metadata.Date
case "\xa9gen":
metadata.Genre, _ = readM4ATextValue(f, header, fi.Size())
case "\xa9wrt":
metadata.Composer, _ = readM4ATextValue(f, header, fi.Size())
case "\xa9cmt":
metadata.Comment, _ = readM4ATextValue(f, header, fi.Size())
case "cprt":
metadata.Copyright, _ = readM4ATextValue(f, header, fi.Size())
case "\xa9lyr":
metadata.Lyrics, _ = readM4ATextValue(f, header, fi.Size())
case "trkn":
metadata.TrackNumber, _ = readM4AIndexValue(f, header, fi.Size())
case "disk":
metadata.DiscNumber, _ = readM4AIndexValue(f, header, fi.Size())
case "----":
name, value, freeformErr := readM4AFreeformValue(f, header, fi.Size())
if freeformErr == nil {
switch strings.ToUpper(strings.TrimSpace(name)) {
case "ISRC":
metadata.ISRC = value
case "LABEL", "ORGANIZATION":
metadata.Label = value
case "COMMENT":
if metadata.Comment == "" {
metadata.Comment = value
}
case "COMPOSER":
if metadata.Composer == "" {
metadata.Composer = value
}
case "COPYRIGHT":
if metadata.Copyright == "" {
metadata.Copyright = value
}
case "LYRICS", "UNSYNCEDLYRICS":
if metadata.Lyrics == "" {
metadata.Lyrics = value
}
}
}
}
pos += header.size
}
if metadata.Title == "" &&
metadata.Artist == "" &&
metadata.Album == "" &&
metadata.AlbumArtist == "" &&
metadata.Lyrics == "" &&
metadata.TrackNumber == 0 &&
metadata.DiscNumber == 0 {
return nil, fmt.Errorf("no M4A tags found")
}
return metadata, nil
}
func extractLyricsFromM4A(filePath string) (string, error) {
metadata, err := ReadM4ATags(filePath)
if err != nil {
return "", err
}
if metadata == nil || strings.TrimSpace(metadata.Lyrics) == "" {
return "", fmt.Errorf("no lyrics found in file")
}
return metadata.Lyrics, nil
}
func extractCoverFromM4A(filePath string) ([]byte, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
return nil, err
}
fileSize := fi.Size()
ilst, err := findM4AIlstAtom(f, fileSize)
if err != nil {
return nil, err
}
bodyStart := ilst.offset + ilst.headerSize
bodySize := ilst.size - ilst.headerSize
covr, found, err := findAtomInRange(f, bodyStart, bodySize, "covr", fileSize)
if err != nil || !found {
return nil, fmt.Errorf("cover atom not found")
}
dataStart := covr.offset + covr.headerSize
dataSize := covr.size - covr.headerSize
dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize)
if err != nil || !found {
return nil, fmt.Errorf("data atom not found in cover")
}
// data atom: header + 4 bytes type indicator + 4 bytes locale
imgStart := dataAtom.offset + dataAtom.headerSize + 8
imgLen := dataAtom.size - dataAtom.headerSize - 8
if imgLen <= 0 {
return nil, fmt.Errorf("empty cover data")
}
buf := make([]byte, imgLen)
if _, err := f.ReadAt(buf, imgStart); err != nil {
return nil, err
}
return buf, nil
}
// findM4AIlstAtom locates the ilst atom that holds all iTunes-style tags.
// It tries two common layouts:
// 1. moov > udta > meta > ilst (iTunes, FFmpeg default)
// 2. moov > meta > ilst (some encoders omit the udta wrapper)
func findM4AIlstAtom(f *os.File, fileSize int64) (atomHeader, error) {
moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
if err != nil || !found {
return atomHeader{}, fmt.Errorf("moov not found")
}
moovBodyStart := moov.offset + moov.headerSize
moovBodySize := moov.size - moov.headerSize
// Path 1: moov > udta > meta > ilst
if udta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "udta", fileSize); ok {
udtaBodyStart := udta.offset + udta.headerSize
udtaBodySize := udta.size - udta.headerSize
if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 {
metaBodyStart := meta.offset + meta.headerSize + 4
metaBodySize := meta.size - meta.headerSize - 4
if ilst, ok3, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok3 {
return ilst, nil
}
}
}
// Path 2: moov > meta > ilst (no udta wrapper)
if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok {
metaBodyStart := meta.offset + meta.headerSize + 4
metaBodySize := meta.size - meta.headerSize - 4
if ilst, ok2, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok2 {
return ilst, nil
}
}
return atomHeader{}, fmt.Errorf("ilst not found (tried moov>udta>meta>ilst and moov>meta>ilst)")
}
func readM4ADataAtomPayload(f *os.File, dataAtom atomHeader) ([]byte, error) {
payloadStart := dataAtom.offset + dataAtom.headerSize + 8
payloadLen := dataAtom.size - dataAtom.headerSize - 8
if payloadLen <= 0 {
return nil, fmt.Errorf("empty data atom in %s", dataAtom.typ)
}
buf := make([]byte, payloadLen)
if _, err := f.ReadAt(buf, payloadStart); err != nil {
return nil, err
}
return buf, nil
}
func readM4ADataPayload(f *os.File, parent atomHeader, fileSize int64) ([]byte, error) {
dataStart := parent.offset + parent.headerSize
dataSize := parent.size - parent.headerSize
dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize)
if err != nil || !found {
return nil, fmt.Errorf("data atom not found in %s", parent.typ)
}
return readM4ADataAtomPayload(f, dataAtom)
}
func readM4ATextValue(f *os.File, parent atomHeader, fileSize int64) (string, error) {
payload, err := readM4ADataPayload(f, parent, fileSize)
if err != nil {
return "", err
}
return strings.TrimSpace(strings.TrimRight(string(payload), "\x00")), nil
}
func readM4AIndexValue(f *os.File, parent atomHeader, fileSize int64) (int, error) {
payload, err := readM4ADataPayload(f, parent, fileSize)
if err != nil {
return 0, err
}
if len(payload) < 4 {
return 0, fmt.Errorf("index payload too short in %s", parent.typ)
}
return int(binary.BigEndian.Uint16(payload[2:4])), nil
}
func readM4AFreeformValue(f *os.File, parent atomHeader, fileSize int64) (string, string, error) {
start := parent.offset + parent.headerSize
end := parent.offset + parent.size
var nameValue string
var dataValue string
for pos := start; pos+8 <= end; {
header, err := readAtomHeaderAt(f, pos, fileSize)
if err != nil {
return "", "", err
}
if header.size == 0 {
header.size = end - pos
}
if header.size < header.headerSize {
return "", "", fmt.Errorf("invalid atom size for %s", header.typ)
}
switch header.typ {
case "mean":
// Domain qualifier (e.g. "com.apple.iTunes") — not needed, skip.
case "name":
// The "name" atom payload is: 4-byte version/flags, then raw UTF-8 text.
// It does NOT contain a nested "data" atom, so read the payload directly.
payloadStart := header.offset + header.headerSize + 4
payloadLen := header.size - header.headerSize - 4
if payloadLen > 0 {
buf := make([]byte, payloadLen)
if _, readErr := f.ReadAt(buf, payloadStart); readErr == nil {
nameValue = strings.TrimSpace(strings.TrimRight(string(buf), "\x00"))
}
}
case "data":
payload, payloadErr := readM4ADataAtomPayload(f, header)
if payloadErr == nil {
dataValue = strings.TrimSpace(strings.TrimRight(string(payload), "\x00"))
}
}
pos += header.size
}
if nameValue == "" || dataValue == "" {
return "", "", fmt.Errorf("freeform M4A tag incomplete")
}
return nameValue, dataValue, nil
}
func extractLyricsFromSidecarLRC(filePath string) (string, error) {
ext := filepath.Ext(filePath)
base := strings.TrimSuffix(filePath, ext)
@@ -743,15 +1044,28 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
return AudioQuality{}, err
}
buf := make([]byte, 24)
buf := make([]byte, 32)
if _, err := f.ReadAt(buf, sampleOffset); err != nil {
return AudioQuality{}, fmt.Errorf("failed to read audio sample entry: %w", err)
}
sampleRate := int(buf[22])<<8 | int(buf[23])
bitDepth := 16
if atomType == "alac" {
bitDepth = 24
// AudioSampleEntry layout from the box type field:
// [0:4] type ("mp4a"/"alac")
// [4:10] SampleEntry.reserved
// [10:12] data_reference_index
// [12:20] reserved[8]
// [20:22] channelcount
// [22:24] samplesize (bit depth)
// [24:26] pre_defined
// [26:28] reserved
// [28:32] samplerate (16.16 fixed-point)
sampleRate := int(buf[28])<<8 | int(buf[29])
bitDepth := int(buf[22])<<8 | int(buf[23])
if bitDepth <= 0 {
bitDepth = 16
if atomType == "alac" {
bitDepth = 24
}
}
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
@@ -874,7 +1188,7 @@ func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string
if bestIdx >= 0 {
absolute := readPos - int64(len(tail)) + int64(bestIdx)
if absolute+24 > fileSize {
if absolute+32 > fileSize {
return 0, "", fmt.Errorf("audio info not found in M4A file")
}
return absolute, bestType, nil
+199 -24
View File
@@ -49,11 +49,13 @@ const (
qobuzArtistGetBaseURL = "https://www.qobuz.com/api.json/0.2/artist/get?artist_id="
qobuzPlaylistGetBaseURL = "https://www.qobuz.com/api.json/0.2/playlist/get?playlist_id="
qobuzStoreSearchBaseURL = "https://www.qobuz.com/us-en/search/tracks/"
qobuzTrackOpenBaseURL = "https://open.qobuz.com/track/"
qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/"
qobuzStoreBaseURL = "https://www.qobuz.com/us-en"
qobuzDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download"
qobuzDownloadAPIURL = "https://dl.musicdl.me/qobuz/download"
qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId="
qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId="
qobuzAfkarAPIURL = "https://qbz.afkarxyz.qzz.io/api/track/"
qobuzSquidAPIURL = "https://qobuz.squid.wtf/api/download-music?country=US&track_id="
qobuzDebugKeyXORMask = byte(0x5A)
)
@@ -260,26 +262,35 @@ func qobuzTrackDisplayTitle(track *QobuzTrack) string {
return fmt.Sprintf("%s (%s)", title, version)
}
var qobuzImageSizeRe = regexp.MustCompile(`_\d+\.jpg$`)
func qobuzUpscaleImageURL(url string) string {
if url == "" {
return ""
}
return qobuzImageSizeRe.ReplaceAllString(url, "_max.jpg")
}
func qobuzTrackAlbumImage(track *QobuzTrack) string {
if track == nil {
return ""
}
return qobuzFirstNonEmpty(
return qobuzUpscaleImageURL(qobuzFirstNonEmpty(
track.Album.Image.Large,
track.Album.Image.Small,
track.Album.Image.Thumbnail,
)
))
}
func qobuzAlbumImage(album *qobuzAlbumDetails) string {
if album == nil {
return ""
}
return qobuzFirstNonEmpty(
return qobuzUpscaleImageURL(qobuzFirstNonEmpty(
album.Image.Large,
album.Image.Small,
album.Image.Thumbnail,
)
))
}
func qobuzTrackArtistID(track *QobuzTrack) string {
@@ -477,8 +488,8 @@ func parseQobuzURL(input string) (string, string, error) {
}
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
normExpected := normalizeLooseArtistName(expectedArtist)
normFound := normalizeLooseArtistName(foundArtist)
if normExpected == normFound {
return true
@@ -934,7 +945,17 @@ func (q *QobuzDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Items))
for i := range album.Tracks.Items {
tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(&album.Tracks.Items[i]))
track := &album.Tracks.Items[i]
track.Album.ID = album.ID
track.Album.Title = album.Title
track.Album.ReleaseDate = album.ReleaseDateOriginal
track.Album.Image = qobuzImageSet{
Thumbnail: album.Image.Thumbnail,
Small: album.Image.Small,
Large: album.Image.Large,
}
track.Album.TracksCount = album.TracksCount
tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(track))
}
return &AlbumResponsePayload{
@@ -1019,6 +1040,10 @@ func (q *QobuzDownloader) GetArtistMetadata(resourceID string) (*ArtistResponseP
func (q *QobuzDownloader) GetAvailableAPIs() []string {
return []string{
qobuzDownloadAPIURL,
qobuzDabMusicAPIURL,
qobuzDeebAPIURL,
qobuzAfkarAPIURL,
qobuzSquidAPIURL,
}
}
@@ -1039,6 +1064,8 @@ func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider {
{Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard},
// "deeb" is mapped from the legacy reference fallback endpoint.
{Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard},
// "qbz" comes from the desktop reference app and uses /api/track/{id}?quality=...
{Name: "qbz", URL: qobuzAfkarAPIURL, Kind: qobuzAPIKindStandard},
{Name: "squid", URL: qobuzSquidAPIURL, Kind: qobuzAPIKindStandard},
}
}
@@ -1299,6 +1326,134 @@ func (q *QobuzDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetad
return results, nil
}
// SearchAll searches Qobuz for tracks, artists, and albums matching the query.
// Returns results in the same SearchAllResult format as Deezer's SearchAll.
func (q *QobuzDownloader) SearchAll(query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) {
GoLog("[Qobuz] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter)
cleanQuery := strings.TrimSpace(query)
if cleanQuery == "" {
return nil, fmt.Errorf("empty qobuz search query")
}
albumLimit := 5
if filter != "" {
switch filter {
case "track":
trackLimit = 50
artistLimit = 0
albumLimit = 0
case "artist":
trackLimit = 0
artistLimit = 20
albumLimit = 0
case "album":
trackLimit = 0
artistLimit = 0
albumLimit = 20
}
}
result := &SearchAllResult{
Tracks: make([]TrackMetadata, 0, trackLimit),
Artists: make([]SearchArtistResult, 0, artistLimit),
Albums: make([]SearchAlbumResult, 0, albumLimit),
Playlists: make([]SearchPlaylistResult, 0),
}
if trackLimit > 0 {
tracks, err := q.searchQobuzTracksWithFallback(cleanQuery, trackLimit)
if err != nil {
GoLog("[Qobuz] Track search failed: %v\n", err)
return nil, fmt.Errorf("qobuz track search failed: %w", err)
}
GoLog("[Qobuz] Got %d tracks from API\n", len(tracks))
for i := range tracks {
result.Tracks = append(result.Tracks, qobuzTrackToTrackMetadata(&tracks[i]))
}
}
if artistLimit > 0 {
searchURL := fmt.Sprintf("https://www.qobuz.com/api.json/0.2/artist/search?query=%s&limit=%d&app_id=%s",
url.QueryEscape(cleanQuery), artistLimit, q.appID)
req, err := http.NewRequest("GET", searchURL, nil)
if err == nil {
resp, reqErr := DoRequestWithUserAgent(q.client, req)
if reqErr == nil {
defer resp.Body.Close()
if resp.StatusCode == 200 {
var artistResp struct {
Artists struct {
Items []struct {
ID int64 `json:"id"`
Name string `json:"name"`
Image qobuzImageSet `json:"image"`
} `json:"items"`
} `json:"artists"`
}
if decErr := json.NewDecoder(resp.Body).Decode(&artistResp); decErr == nil {
GoLog("[Qobuz] Got %d artists from API\n", len(artistResp.Artists.Items))
for _, artist := range artistResp.Artists.Items {
imageURL := qobuzFirstNonEmpty(artist.Image.Large, artist.Image.Small, artist.Image.Thumbnail)
result.Artists = append(result.Artists, SearchArtistResult{
ID: qobuzPrefixedNumericID(artist.ID),
Name: strings.TrimSpace(artist.Name),
Images: imageURL,
})
}
} else {
GoLog("[Qobuz] Artist search decode failed: %v\n", decErr)
}
}
} else {
GoLog("[Qobuz] Artist search request failed: %v\n", reqErr)
}
}
}
if albumLimit > 0 {
searchURL := fmt.Sprintf("https://www.qobuz.com/api.json/0.2/album/search?query=%s&limit=%d&app_id=%s",
url.QueryEscape(cleanQuery), albumLimit, q.appID)
req, err := http.NewRequest("GET", searchURL, nil)
if err == nil {
resp, reqErr := DoRequestWithUserAgent(q.client, req)
if reqErr == nil {
defer resp.Body.Close()
if resp.StatusCode == 200 {
var albumResp struct {
Albums struct {
Items []qobuzAlbumDetails `json:"items"`
} `json:"albums"`
}
if decErr := json.NewDecoder(resp.Body).Decode(&albumResp); decErr == nil {
GoLog("[Qobuz] Got %d albums from API\n", len(albumResp.Albums.Items))
for i := range albumResp.Albums.Items {
album := &albumResp.Albums.Items[i]
result.Albums = append(result.Albums, SearchAlbumResult{
ID: qobuzPrefixedID(album.ID),
Name: strings.TrimSpace(album.Title),
Artists: qobuzArtistsDisplayName(album.Artists, album.Artist.Name),
Images: qobuzAlbumImage(album),
ReleaseDate: qobuzNormalizeReleaseDate(album.ReleaseDateOriginal),
TotalTracks: album.TracksCount,
AlbumType: qobuzNormalizeAlbumType(album.ReleaseType, album.ProductType, album.TracksCount),
})
}
} else {
GoLog("[Qobuz] Album search decode failed: %v\n", decErr)
}
}
} else {
GoLog("[Qobuz] Album search request failed: %v\n", reqErr)
}
}
}
GoLog("[Qobuz] SearchAll complete: %d tracks, %d artists, %d albums\n", len(result.Tracks), len(result.Artists), len(result.Albums))
return result, nil
}
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
queries := []string{}
@@ -1624,19 +1779,23 @@ func fetchQobuzURLWithRetry(provider qobuzAPIProvider, trackID int64, quality st
return fetchQobuzURLSingleAttempt(provider, trackID, quality, timeout, "")
}
func buildQobuzMusicDLPayload(trackID int64, quality string) ([]byte, error) {
requestQuality := mapQobuzQualityCodeToAPI(quality)
payload := map[string]any{
"quality": requestQuality,
"upload_to_r2": false,
"url": fmt.Sprintf("%s%d", qobuzTrackOpenBaseURL, trackID),
}
return json.Marshal(payload)
}
func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, quality string, timeout time.Duration, country string) (qobuzDownloadInfo, error) {
var lastErr error
retryDelay := qobuzRetryDelay
var payloadBytes []byte
if provider.Kind == qobuzAPIKindMusicDL {
requestQuality := mapQobuzQualityCodeToAPI(quality)
payload := map[string]any{
"quality": requestQuality,
"upload_to_r2": false,
"url": fmt.Sprintf("%s%d", qobuzTrackPlayBaseURL, trackID),
}
var err error
payloadBytes, err = json.Marshal(payload)
payloadBytes, err = buildQobuzMusicDLPayload(trackID, quality)
if err != nil {
return qobuzDownloadInfo{}, fmt.Errorf("failed to encode qobuz request: %w", err)
}
@@ -1681,7 +1840,6 @@ func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, qualit
}
if provider.Kind == qobuzAPIKindMusicDL {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Debug-Key", getQobuzDebugKey())
}
resp, err := DoRequestWithUserAgent(client, req)
@@ -1928,6 +2086,7 @@ type QobuzDownloadResult struct {
TrackNumber int
DiscNumber int
ISRC string
CoverURL string
LyricsLRC string
}
@@ -1989,8 +2148,8 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
}
}
// Strategy 3: Try to get QobuzID from SongLink if we have SpotifyID
if track == nil && req.SpotifyID != "" && req.QobuzID == "" {
// Strategy 3: Try to get QobuzID from SongLink if we have SpotifyID but no ISRC
if track == nil && req.SpotifyID != "" && req.QobuzID == "" && req.ISRC == "" {
GoLog("[%s] Trying to get Qobuz ID from SongLink for Spotify ID: %s\n", logPrefix, req.SpotifyID)
songLinkClient := NewSongLinkClient()
availability, slErr := songLinkCheckTrackAvailabilityFunc(songLinkClient, req.SpotifyID, req.ISRC)
@@ -2121,7 +2280,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
parallelDone := make(chan struct{})
go func() {
defer close(parallelDone)
coverURL := req.CoverURL
coverURL := strings.TrimSpace(req.CoverURL)
if coverURL == "" {
coverURL = strings.TrimSpace(qobuzTrackAlbumImage(track))
}
embedLyrics := req.EmbedLyrics
if !req.EmbedMetadata {
coverURL = ""
@@ -2156,6 +2318,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
if req.AlbumName != "" {
albumName = req.AlbumName
}
releaseDate := track.Album.ReleaseDate
if req.ReleaseDate != "" {
releaseDate = req.ReleaseDate
}
actualTrackNumber := req.TrackNumber
if actualTrackNumber == 0 {
@@ -2167,7 +2333,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
Artist: track.Performer.Name,
Album: albumName,
AlbumArtist: req.AlbumArtist,
Date: track.Album.ReleaseDate,
Date: releaseDate,
TrackNumber: actualTrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber,
@@ -2231,17 +2397,26 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
lyricsLRC = parallelResult.LyricsLRC
}
resultAlbum, resultReleaseDate, resultTrackNumber, resultDiscNumber := preferredReleaseMetadata(
req,
track.Album.Title,
track.Album.ReleaseDate,
actualTrackNumber,
req.DiscNumber,
)
return QobuzDownloadResult{
FilePath: outputPath,
BitDepth: actualBitDepth,
SampleRate: actualSampleRate,
Title: track.Title,
Artist: track.Performer.Name,
Album: track.Album.Title,
ReleaseDate: track.Album.ReleaseDate,
TrackNumber: actualTrackNumber,
DiscNumber: req.DiscNumber,
Album: resultAlbum,
ReleaseDate: resultReleaseDate,
TrackNumber: resultTrackNumber,
DiscNumber: resultDiscNumber,
ISRC: track.ISRC,
CoverURL: strings.TrimSpace(qobuzTrackAlbumImage(track)),
LyricsLRC: lyricsLRC,
}, nil
}
+30 -3
View File
@@ -1,6 +1,9 @@
package gobackend
import "testing"
import (
"encoding/json"
"testing"
)
func TestParseQobuzURL(t *testing.T) {
tests := []struct {
@@ -195,6 +198,28 @@ func TestGetQobuzDebugKey(t *testing.T) {
}
}
func TestBuildQobuzMusicDLPayloadUsesOpenTrackURL(t *testing.T) {
payloadBytes, err := buildQobuzMusicDLPayload(374610875, "7")
if err != nil {
t.Fatalf("buildQobuzMusicDLPayload returned error: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
t.Fatalf("payload is not valid JSON: %v", err)
}
if got := payload["url"]; got != "https://open.qobuz.com/track/374610875" {
t.Fatalf("payload url = %v, want open.qobuz.com track URL", got)
}
if got := payload["quality"]; got != "hi-res" {
t.Fatalf("payload quality = %v, want hi-res", got)
}
if got := payload["upload_to_r2"]; got != false {
t.Fatalf("payload upload_to_r2 = %v, want false", got)
}
}
func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) {
body := []byte(`
<button data-itemtype="album" data-itemId="0886446451985"></button>
@@ -213,14 +238,16 @@ func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) {
func TestQobuzAvailableProviders(t *testing.T) {
providers := NewQobuzDownloader().GetAvailableProviders()
if len(providers) != 3 {
t.Fatalf("expected 3 Qobuz providers, got %d", len(providers))
if len(providers) != 5 {
t.Fatalf("expected 5 Qobuz providers, got %d", len(providers))
}
want := map[string]string{
"musicdl": qobuzAPIKindMusicDL,
"dabmusic": qobuzAPIKindStandard,
"deeb": qobuzAPIKindStandard,
"qbz": qobuzAPIKindStandard,
"squid": qobuzAPIKindStandard,
}
for _, provider := range providers {
-80
View File
@@ -1,80 +0,0 @@
package gobackend
import (
"path/filepath"
"strings"
"testing"
)
func TestSanitizeSensitiveLogText(t *testing.T) {
input := "access_token=abc123 Authorization:Bearer xyz456 https://api.example.com/cb?refresh_token=zzz"
redacted := sanitizeSensitiveLogText(input)
if strings.Contains(redacted, "abc123") || strings.Contains(redacted, "xyz456") || strings.Contains(redacted, "zzz") {
t.Fatalf("expected sensitive values to be redacted, got: %s", redacted)
}
if !strings.Contains(redacted, "[REDACTED]") {
t.Fatalf("expected redaction marker in output, got: %s", redacted)
}
}
func TestValidateExtensionAuthURL(t *testing.T) {
if err := validateExtensionAuthURL("https://accounts.example.com/oauth/authorize"); err != nil {
t.Fatalf("expected valid auth URL, got error: %v", err)
}
blocked := []string{
"http://accounts.example.com/oauth/authorize",
"https://user:pass@accounts.example.com/oauth/authorize",
"https://localhost/oauth/authorize",
}
for _, rawURL := range blocked {
if err := validateExtensionAuthURL(rawURL); err == nil {
t.Fatalf("expected URL to be blocked: %s", rawURL)
}
}
}
func TestValidateDomainRejectsEmbeddedCredentials(t *testing.T) {
ext := &LoadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
Permissions: ExtensionPermissions{
Network: []string{"api.example.com"},
},
},
DataDir: t.TempDir(),
}
runtime := NewExtensionRuntime(ext)
if err := runtime.validateDomain("https://user:pass@api.example.com/resource"); err == nil {
t.Fatal("expected embedded URL credentials to be rejected")
}
}
func TestBuildStoreExtensionDestPath(t *testing.T) {
baseDir := t.TempDir()
destPath, err := buildStoreExtensionDestPath(baseDir, "../evil/name")
if err != nil {
t.Fatalf("expected sanitized path to be generated, got error: %v", err)
}
if !isPathWithinBase(baseDir, destPath) {
t.Fatalf("expected destination path to remain under base dir: %s", destPath)
}
baseName := filepath.Base(destPath)
if strings.Contains(baseName, "/") || strings.Contains(baseName, `\`) {
t.Fatalf("expected filename to be sanitized, got: %s", baseName)
}
if !strings.HasSuffix(baseName, ".spotiflac-ext") {
t.Fatalf("expected .spotiflac-ext suffix, got: %s", baseName)
}
if _, err := buildStoreExtensionDestPath(baseDir, " "); err == nil {
t.Fatal("expected empty extension id to be rejected")
}
}
+144 -43
View File
@@ -1,6 +1,7 @@
package gobackend
import (
"bytes"
"context"
"encoding/json"
"fmt"
@@ -14,6 +15,10 @@ type SongLinkClient struct {
client *http.Client
}
type songLinkPlatformLink struct {
URL string `json:"url"`
}
type TrackAvailability struct {
SpotifyID string `json:"spotify_id"`
Tidal bool `json:"tidal"`
@@ -43,6 +48,7 @@ var (
songLinkCheckAvailabilityFromDeezer = func(s *SongLinkClient, deezerTrackID string) (*TrackAvailability, error) {
return s.CheckAvailabilityFromDeezer(deezerTrackID)
}
songLinkRetryConfig = DefaultRetryConfig
)
func NewSongLinkClient() *SongLinkClient {
@@ -130,7 +136,14 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
}
func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
songLinkRateLimiter.WaitForSlot()
availability, pageErr := s.checkTrackAvailabilityFromSpotifyPage(spotifyTrackID)
if pageErr == nil {
return availability, nil
}
if !songLinkRateLimiter.TryAcquire() {
return nil, fmt.Errorf("song.link page lookup failed: %w (SongLink local rate limit exceeded)", pageErr)
}
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
apiURL := buildSongLinkURLFromTarget(spotifyURL, "")
@@ -140,10 +153,10 @@ func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string
return nil, fmt.Errorf("failed to create request: %w", err)
}
retryConfig := DefaultRetryConfig()
retryConfig := songLinkRetryConfig()
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil {
return nil, fmt.Errorf("failed to check availability: %w", err)
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API lookup failed: %w", pageErr, err)
}
defer resp.Body.Close()
@@ -154,10 +167,10 @@ func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string
return nil, fmt.Errorf("track not found on any streaming platform")
}
if resp.StatusCode == 429 {
return nil, fmt.Errorf("SongLink rate limit exceeded")
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API rate limit exceeded", pageErr)
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API returned status %d", pageErr, resp.StatusCode)
}
body, err := ReadResponseBody(resp)
@@ -166,59 +179,102 @@ func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string
}
var songLinkResp struct {
LinksByPlatform map[string]struct {
URL string `json:"url"`
} `json:"linksByPlatform"`
LinksByPlatform map[string]songLinkPlatformLink `json:"linksByPlatform"`
}
if err := json.Unmarshal(body, &songLinkResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
availability := &TrackAvailability{
SpotifyID: spotifyTrackID,
LogWarn("SongLink", "Spotify %s resolved via SongLink API after song.link page failure: %v", spotifyTrackID, pageErr)
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, songLinkResp.LinksByPlatform), nil
}
func (s *SongLinkClient) checkTrackAvailabilityFromSpotifyPage(spotifyTrackID string) (*TrackAvailability, error) {
pageURL := fmt.Sprintf("https://song.link/s/%s", spotifyTrackID)
req, err := http.NewRequest("GET", pageURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create song.link page request: %w", err)
}
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
req.Header.Set("Accept", "text/html,application/xhtml+xml")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch song.link page: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == 404 {
return nil, fmt.Errorf("track not found on song.link page")
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("song.link page returned status %d", resp.StatusCode)
}
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
availability.AmazonURL = amazonLink.URL
body, err := ReadResponseBody(resp)
if err != nil {
return nil, fmt.Errorf("failed to read song.link page: %w", err)
}
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true
availability.DeezerURL = deezerLink.URL
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
nextDataJSON, err := extractSongLinkNextDataJSON(body)
if err != nil {
return nil, err
}
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
availability.Qobuz = true
availability.QobuzURL = qobuzLink.URL
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
var pageData struct {
Props struct {
PageProps struct {
PageData struct {
Sections []struct {
Links []struct {
Platform string `json:"platform"`
URL string `json:"url"`
Show bool `json:"show"`
} `json:"links"`
} `json:"sections"`
} `json:"pageData"`
} `json:"pageProps"`
} `json:"props"`
}
if err := json.Unmarshal(nextDataJSON, &pageData); err != nil {
return nil, fmt.Errorf("failed to decode song.link page data: %w", err)
}
// Prefer youtubeMusic URLs — they bypass Cobalt login requirements
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
}
// Fallback to regular youtube if youtubeMusic not available
if !availability.YouTube {
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
linksByPlatform := make(map[string]songLinkPlatformLink)
for _, section := range pageData.Props.PageProps.PageData.Sections {
for _, link := range section.Links {
if !link.Show || strings.TrimSpace(link.URL) == "" {
continue
}
linksByPlatform[link.Platform] = songLinkPlatformLink{URL: link.URL}
}
}
return availability, nil
if len(linksByPlatform) == 0 {
return nil, fmt.Errorf("song.link page contained no usable platform links")
}
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, linksByPlatform), nil
}
func extractSongLinkNextDataJSON(body []byte) ([]byte, error) {
const startMarker = `<script id="__NEXT_DATA__" type="application/json">`
const endMarker = `</script>`
start := bytes.Index(body, []byte(startMarker))
if start < 0 {
return nil, fmt.Errorf("song.link page missing __NEXT_DATA__")
}
start += len(startMarker)
end := bytes.Index(body[start:], []byte(endMarker))
if end < 0 {
return nil, fmt.Errorf("song.link page has unterminated __NEXT_DATA__")
}
return body[start : start+end], nil
}
func (s *SongLinkClient) checkTrackAvailabilityFromISRC(isrc string) (*TrackAvailability, error) {
@@ -459,7 +515,7 @@ func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAv
return nil, fmt.Errorf("failed to create request: %w", err)
}
retryConfig := DefaultRetryConfig()
retryConfig := songLinkRetryConfig()
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil {
return nil, fmt.Errorf("failed to check album availability: %w", err)
@@ -542,7 +598,7 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin
return nil, fmt.Errorf("failed to create request: %w", err)
}
retryConfig := DefaultRetryConfig()
retryConfig := songLinkRetryConfig()
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil {
return nil, fmt.Errorf("failed to check availability: %w", err)
@@ -647,7 +703,7 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
return nil, fmt.Errorf("failed to create request: %w", err)
}
retryConfig := DefaultRetryConfig()
retryConfig := songLinkRetryConfig()
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil {
return nil, fmt.Errorf("failed to check availability: %w", err)
@@ -728,6 +784,51 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
return availability, nil
}
func buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID string, links map[string]songLinkPlatformLink) *TrackAvailability {
availability := &TrackAvailability{
SpotifyID: spotifyTrackID,
}
if availability.SpotifyID == "" {
if spotifyLink, ok := links["spotify"]; ok && spotifyLink.URL != "" {
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
}
}
if tidalLink, ok := links["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
}
if amazonLink, ok := links["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
availability.AmazonURL = amazonLink.URL
}
if qobuzLink, ok := links["qobuz"]; ok && qobuzLink.URL != "" {
availability.Qobuz = true
availability.QobuzURL = qobuzLink.URL
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
}
if deezerLink, ok := links["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true
availability.DeezerURL = deezerLink.URL
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
}
if ytMusicLink, ok := links["youtubeMusic"]; ok && ytMusicLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
}
if !availability.YouTube {
if youtubeLink, ok := links["youtube"]; ok && youtubeLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
}
}
return availability
}
func extractSpotifyIDFromURL(spotifyURL string) string {
parts := strings.Split(spotifyURL, "/track/")
if len(parts) > 1 {
@@ -802,7 +903,7 @@ func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvaila
return nil, fmt.Errorf("failed to create request: %w", err)
}
retryConfig := DefaultRetryConfig()
retryConfig := songLinkRetryConfig()
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil {
return nil, fmt.Errorf("failed to check availability: %w", err)
+127
View File
@@ -0,0 +1,127 @@
package gobackend
import (
"io"
"net/http"
"strings"
"testing"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
func TestGetRetryAfterDurationMissingHeaderReturnsZero(t *testing.T) {
resp := &http.Response{
Header: make(http.Header),
}
if got := getRetryAfterDuration(resp); got != 0 {
t.Fatalf("getRetryAfterDuration() = %v, want 0", got)
}
}
func TestCheckTrackAvailabilityFromSpotifyPrefersSongLinkPage(t *testing.T) {
client := &SongLinkClient{
client: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case req.URL.Host == "api.song.link":
t.Fatalf("api.song.link should not be called when song.link page succeeds")
return nil, nil
case req.URL.Host == "song.link" && req.URL.Path == "/s/testspotifyid":
body := `<!DOCTYPE html><html><body><script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"pageData":{"sections":[{"displayName":"Listen","links":[{"platform":"spotify","url":"https://open.spotify.com/track/testspotifyid","show":true},{"platform":"deezer","url":"https://www.deezer.com/track/908604612","show":true},{"platform":"amazonMusic","url":"https://music.amazon.com/albums/B086Q2QNLH?trackAsin=B086Q41M9C","show":true},{"platform":"tidal","url":"https://listen.tidal.com/track/134858527","show":true},{"platform":"qobuz","url":"https://open.qobuz.com/track/195125822","show":true},{"platform":"youtubeMusic","url":"https://music.youtube.com/watch?v=testvideoid1","show":true}]}]}}}}</script></body></html>`
return &http.Response{
StatusCode: 200,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
default:
t.Fatalf("unexpected request: %s", req.URL.String())
return nil, nil
}
}),
},
}
availability, err := client.CheckTrackAvailability("testspotifyid", "")
if err != nil {
t.Fatalf("CheckTrackAvailability() error = %v", err)
}
if availability.SpotifyID != "testspotifyid" {
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testspotifyid")
}
if !availability.Deezer || availability.DeezerID != "908604612" {
t.Fatalf("Deezer availability = %+v, want DeezerID 908604612", availability)
}
if !availability.Amazon || !availability.Tidal || !availability.Qobuz || !availability.YouTube {
t.Fatalf("availability flags = %+v, want Amazon/Tidal/Qobuz/YouTube true", availability)
}
if availability.YouTubeID != "testvideoid1" {
t.Fatalf("YouTubeID = %q, want %q", availability.YouTubeID, "testvideoid1")
}
}
func TestCheckTrackAvailabilityFromSpotifyFallsBackToAPIWhenPageFails(t *testing.T) {
origRetryConfig := songLinkRetryConfig
songLinkRetryConfig = func() RetryConfig {
return RetryConfig{
MaxRetries: 0,
InitialDelay: 0,
MaxDelay: 0,
BackoffFactor: 1,
}
}
defer func() {
songLinkRetryConfig = origRetryConfig
}()
client := &SongLinkClient{
client: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case req.URL.Host == "song.link" && req.URL.Path == "/s/testspotifyid":
return &http.Response{
StatusCode: 500,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("page failure")),
Request: req,
}, nil
case req.URL.Host == "api.song.link":
body := `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/testspotifyid"},"deezer":{"url":"https://www.deezer.com/track/908604612"},"amazonMusic":{"url":"https://music.amazon.com/albums/B086Q2QNLH?trackAsin=B086Q41M9C"},"tidal":{"url":"https://listen.tidal.com/track/134858527"},"qobuz":{"url":"https://open.qobuz.com/track/195125822"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=testvideoid1"}}}`
return &http.Response{
StatusCode: 200,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
default:
t.Fatalf("unexpected request: %s", req.URL.String())
return nil, nil
}
}),
},
}
availability, err := client.CheckTrackAvailability("testspotifyid", "")
if err != nil {
t.Fatalf("CheckTrackAvailability() error = %v", err)
}
if availability.SpotifyID != "testspotifyid" {
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testspotifyid")
}
if !availability.Deezer || availability.DeezerID != "908604612" {
t.Fatalf("Deezer availability = %+v, want DeezerID 908604612", availability)
}
if !availability.Amazon || !availability.Tidal || !availability.Qobuz || !availability.YouTube {
t.Fatalf("availability flags = %+v, want Amazon/Tidal/Qobuz/YouTube true", availability)
}
if availability.YouTubeID != "testvideoid1" {
t.Fatalf("YouTubeID = %q, want %q", availability.YouTubeID, "testvideoid1")
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"time"
)
const DefaultSpotFetchAPIBaseURL = "https://spotify.afkarxyz.fun/api"
const DefaultSpotFetchAPIBaseURL = "https://sp.afkarxyz.qzz.io/api"
// GetSpotifyDataWithAPI fetches Spotify metadata through SpotFetch-compatible API.
// This is used as a fallback when direct Spotify API access is blocked/limited.
+288 -19
View File
@@ -26,8 +26,14 @@ type TidalDownloader struct {
}
var (
globalTidalDownloader *TidalDownloader
tidalDownloaderOnce sync.Once
globalTidalDownloader *TidalDownloader
tidalDownloaderOnce sync.Once
tidalGetTrackSearchPageFunc = func(t *TidalDownloader, query string, limit int) (*tidalPublicTrackSearchResponse, error) {
return t.getTrackSearchPage(query, limit)
}
tidalGetPublicTrackFunc = func(t *TidalDownloader, resourceID string) (*TidalTrack, error) {
return t.getPublicTrack(resourceID)
}
)
const (
@@ -758,15 +764,101 @@ func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) {
}
func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
return nil, fmt.Errorf("tidal ISRC search API disabled: no client credentials mode")
normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc))
if normalizedISRC == "" {
return nil, fmt.Errorf("empty tidal ISRC")
}
page, err := tidalGetTrackSearchPageFunc(t, normalizedISRC, 20)
if err != nil {
return nil, err
}
for i := range page.Items {
if strings.EqualFold(strings.TrimSpace(page.Items[i].ISRC), normalizedISRC) {
return &page.Items[i], nil
}
}
return nil, fmt.Errorf("no exact tidal ISRC match found for %s", normalizedISRC)
}
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, albumName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode")
queryParts := make([]string, 0, 3)
if trimmed := strings.TrimSpace(trackName); trimmed != "" {
queryParts = append(queryParts, trimmed)
}
if trimmed := strings.TrimSpace(artistName); trimmed != "" {
queryParts = append(queryParts, trimmed)
}
if len(queryParts) == 0 {
return nil, fmt.Errorf("tidal metadata search requires track or artist name")
}
queries := []string{strings.Join(queryParts, " ")}
if trimmedAlbum := strings.TrimSpace(albumName); trimmedAlbum != "" {
queries = append(queries, strings.Join(append(queryParts, trimmedAlbum), " "))
}
req := DownloadRequest{
TrackName: strings.TrimSpace(trackName),
ArtistName: strings.TrimSpace(artistName),
AlbumName: strings.TrimSpace(albumName),
ISRC: strings.ToUpper(strings.TrimSpace(spotifyISRC)),
DurationMS: expectedDuration * 1000,
}
seenQueries := make(map[string]struct{}, len(queries))
for _, query := range queries {
if _, seen := seenQueries[query]; seen {
continue
}
seenQueries[query] = struct{}{}
page, err := tidalGetTrackSearchPageFunc(t, query, 20)
if err != nil {
return nil, err
}
var candidates []*TidalTrack
for i := range page.Items {
track := &page.Items[i]
if req.ISRC != "" && !strings.EqualFold(strings.TrimSpace(track.ISRC), req.ISRC) {
continue
}
resolved := resolvedTrackInfo{
Title: strings.TrimSpace(track.Title),
ArtistName: tidalTrackArtistsDisplay(track),
Duration: track.Duration,
}
if trackMatchesRequest(req, resolved, "Tidal search") {
candidates = append(candidates, track)
}
}
if len(candidates) == 0 {
continue
}
if req.AlbumName != "" {
for _, candidate := range candidates {
if titlesMatch(req.AlbumName, candidate.Album.Title) {
return candidate, nil
}
}
}
return candidates[0], nil
}
if req.ISRC != "" {
return nil, fmt.Errorf("no tidal metadata match found for exact ISRC %s", req.ISRC)
}
return nil, fmt.Errorf("no tidal metadata match found")
}
func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*TidalTrack, error) {
return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode")
return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", "", 0)
}
func (t *TidalDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetadata, error) {
@@ -782,6 +874,121 @@ func (t *TidalDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetad
return results, nil
}
// SearchAll searches Tidal for tracks, artists, and albums matching the query.
// Returns results in the same SearchAllResult format as Deezer's SearchAll.
func (t *TidalDownloader) SearchAll(query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) {
GoLog("[Tidal] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter)
cleanQuery := strings.TrimSpace(query)
if cleanQuery == "" {
return nil, fmt.Errorf("empty tidal search query")
}
albumLimit := 5
if filter != "" {
switch filter {
case "track":
trackLimit = 50
artistLimit = 0
albumLimit = 0
case "artist":
trackLimit = 0
artistLimit = 20
albumLimit = 0
case "album":
trackLimit = 0
artistLimit = 0
albumLimit = 20
}
}
result := &SearchAllResult{
Tracks: make([]TrackMetadata, 0, trackLimit),
Artists: make([]SearchArtistResult, 0, artistLimit),
Albums: make([]SearchAlbumResult, 0, albumLimit),
Playlists: make([]SearchPlaylistResult, 0),
}
if trackLimit > 0 {
page, err := t.getTrackSearchPage(cleanQuery, trackLimit)
if err != nil {
GoLog("[Tidal] Track search failed: %v\n", err)
return nil, fmt.Errorf("tidal track search failed: %w", err)
}
GoLog("[Tidal] Got %d tracks from API\n", len(page.Items))
for i := range page.Items {
result.Tracks = append(result.Tracks, tidalTrackToTrackMetadata(&page.Items[i]))
}
}
if artistLimit > 0 {
requestURL := tidalBuildMetadataURL("search/artists", url.Values{
"query": {cleanQuery},
"limit": {strconv.Itoa(artistLimit)},
"offset": {"0"},
})
var artistResp struct {
Items []struct {
ID int64 `json:"id"`
Name string `json:"name"`
Picture string `json:"picture"`
Popularity int `json:"popularity"`
URL string `json:"url"`
} `json:"items"`
}
if err := t.getTidalMetadataJSON(requestURL, &artistResp); err == nil {
GoLog("[Tidal] Got %d artists from API\n", len(artistResp.Items))
for _, artist := range artistResp.Items {
result.Artists = append(result.Artists, SearchArtistResult{
ID: tidalPrefixedNumericID(artist.ID),
Name: strings.TrimSpace(artist.Name),
Images: tidalImageURL(artist.Picture, "750x750"),
Followers: 0,
Popularity: artist.Popularity,
})
}
} else {
GoLog("[Tidal] Artist search failed: %v\n", err)
}
}
if albumLimit > 0 {
requestURL := tidalBuildMetadataURL("search/albums", url.Values{
"query": {cleanQuery},
"limit": {strconv.Itoa(albumLimit)},
"offset": {"0"},
})
var albumResp struct {
Items []tidalPublicAlbum `json:"items"`
}
if err := t.getTidalMetadataJSON(requestURL, &albumResp); err == nil {
GoLog("[Tidal] Got %d albums from API\n", len(albumResp.Items))
for i := range albumResp.Items {
album := &albumResp.Items[i]
albumType := strings.ToLower(strings.TrimSpace(album.Type))
if albumType == "" {
albumType = "album"
}
result.Albums = append(result.Albums, SearchAlbumResult{
ID: tidalPrefixedNumericID(album.ID),
Name: strings.TrimSpace(album.Title),
Artists: tidalAlbumArtistsDisplay(album),
Images: tidalImageURL(album.Cover, "1280x1280"),
ReleaseDate: strings.TrimSpace(album.ReleaseDate),
TotalTracks: album.NumberOfTracks,
AlbumType: albumType,
})
}
} else {
GoLog("[Tidal] Album search failed: %v\n", err)
}
}
GoLog("[Tidal] SearchAll complete: %d tracks, %d artists, %d albums\n", len(result.Tracks), len(result.Artists), len(result.Albums))
return result, nil
}
func (t *TidalDownloader) GetTrackMetadata(resourceID string) (*TrackResponse, error) {
track, err := t.getPublicTrack(resourceID)
if err != nil {
@@ -808,13 +1015,11 @@ func (t *TidalDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
tracks := make([]AlbumTrackMetadata, 0, len(itemsModule.PagedList.Items))
for _, item := range itemsModule.PagedList.Items {
track := item.Item
if track.Album.ID == 0 {
track.Album.ID = headerModule.Album.ID
track.Album.Title = headerModule.Album.Title
track.Album.Cover = headerModule.Album.Cover
track.Album.ReleaseDate = headerModule.Album.ReleaseDate
track.Album.URL = headerModule.Album.URL
}
track.Album.ID = headerModule.Album.ID
track.Album.Title = headerModule.Album.Title
track.Album.Cover = headerModule.Album.Cover
track.Album.ReleaseDate = headerModule.Album.ReleaseDate
track.Album.URL = headerModule.Album.URL
tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&track))
}
@@ -1537,8 +1742,8 @@ type TidalDownloadResult struct {
}
func artistsMatch(spotifyArtist, tidalArtist string) bool {
normSpotify := strings.ToLower(strings.TrimSpace(spotifyArtist))
normTidal := strings.ToLower(strings.TrimSpace(tidalArtist))
normSpotify := normalizeLooseArtistName(spotifyArtist)
normTidal := normalizeLooseArtistName(tidalArtist)
if normSpotify == normTidal {
return true
@@ -1847,6 +2052,36 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
}
}
if !gotTidalID && req.ISRC != "" {
GoLog("[%s] Trying direct Tidal ISRC search: %s\n", logPrefix, req.ISRC)
directTrack, directErr := downloader.SearchTrackByISRC(req.ISRC)
if directErr == nil && directTrack != nil && directTrack.ID > 0 {
trackID = directTrack.ID
gotTidalID = true
GoLog("[%s] Got Tidal ID %d from direct ISRC search\n", logPrefix, trackID)
} else if directErr != nil {
GoLog("[%s] Direct Tidal ISRC search failed: %v\n", logPrefix, directErr)
}
}
if !gotTidalID && req.ISRC != "" && req.TrackName != "" && req.ArtistName != "" {
GoLog("[%s] Trying Tidal public metadata search with ISRC\n", logPrefix)
searchTrack, searchErr := downloader.SearchTrackByMetadataWithISRC(
req.TrackName,
req.ArtistName,
req.AlbumName,
req.ISRC,
expectedDurationSec,
)
if searchErr == nil && searchTrack != nil && searchTrack.ID > 0 {
trackID = searchTrack.ID
gotTidalID = true
GoLog("[%s] Got Tidal ID %d from public metadata search\n", logPrefix, trackID)
} else if searchErr != nil {
GoLog("[%s] Tidal public metadata search failed: %v\n", logPrefix, searchErr)
}
}
if !gotTidalID && (req.SpotifyID != "" || req.DeezerID != "") {
GoLog("[%s] Trying SongLink for Tidal ID...\n", logPrefix)
@@ -1911,6 +2146,32 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
return nil, fmt.Errorf("failed to find tidal track id from request/cache/songlink")
}
// Verify the resolved track matches the request.
actualTrack, fetchErr := tidalGetPublicTrackFunc(downloader, strconv.FormatInt(trackID, 10))
if fetchErr != nil {
GoLog("[%s] Warning: could not fetch Tidal track %d for verification: %v\n", logPrefix, trackID, fetchErr)
// Continue without verification — better than failing entirely.
} else {
providerArtist := actualTrack.Artist.Name
if providerArtist == "" && len(actualTrack.Artists) > 0 {
providerArtist = actualTrack.Artists[0].Name
}
resolved := resolvedTrackInfo{
Title: actualTrack.Title,
ArtistName: providerArtist,
Duration: actualTrack.Duration,
}
if !trackMatchesRequest(req, resolved, logPrefix) {
// Invalidate the cached ID so future requests don't reuse it.
if req.ISRC != "" {
GetTrackIDCache().SetTidal(req.ISRC, 0)
}
return nil, fmt.Errorf("tidal track %d does not match request: expected '%s - %s', got '%s - %s'",
trackID, req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title)
}
GoLog("[%s] Track %d verified: '%s - %s' ✓\n", logPrefix, trackID, resolved.ArtistName, resolved.Title)
}
track := &TidalTrack{
ID: trackID,
Title: strings.TrimSpace(req.TrackName),
@@ -2180,25 +2441,33 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
bitDepth := downloadInfo.BitDepth
sampleRate := downloadInfo.SampleRate
lyricsLRC := ""
if quality == "HIGH" {
bitDepth = 0
sampleRate = 44100
}
lyricsLRC := ""
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsLRC = parallelResult.LyricsLRC
}
resultAlbum, resultReleaseDate, resultTrackNumber, resultDiscNumber := preferredReleaseMetadata(
req,
track.Album.Title,
track.Album.ReleaseDate,
actualTrackNumber,
actualDiscNumber,
)
return TidalDownloadResult{
FilePath: actualOutputPath,
BitDepth: bitDepth,
SampleRate: sampleRate,
Title: track.Title,
Artist: track.Artist.Name,
Album: track.Album.Title,
ReleaseDate: track.Album.ReleaseDate,
TrackNumber: actualTrackNumber,
DiscNumber: actualDiscNumber,
Album: resultAlbum,
ReleaseDate: resultReleaseDate,
TrackNumber: resultTrackNumber,
DiscNumber: resultDiscNumber,
ISRC: track.ISRC,
LyricsLRC: lyricsLRC,
}, nil
+72 -2
View File
@@ -3,6 +3,8 @@ package gobackend
import (
"strings"
"unicode"
"golang.org/x/text/unicode/norm"
)
// normalizeLooseTitle collapses separators/punctuation so titles like
@@ -22,11 +24,39 @@ func normalizeLooseTitle(title string) string {
b.WriteRune(r)
case unicode.IsSpace(r):
b.WriteByte(' ')
// Treat common separators as spaces.
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
b.WriteByte(' ')
default:
// Drop other punctuation/symbols (including emoji) for loose matching.
}
}
return strings.Join(strings.Fields(b.String()), " ")
}
// normalizeLooseArtistName folds diacritics and common separators so artist
// verification is resilient to variants like "Özkent" vs "Ozkent".
func normalizeLooseArtistName(name string) string {
trimmed := strings.TrimSpace(strings.ToLower(name))
if trimmed == "" {
return ""
}
decomposed := norm.NFD.String(trimmed)
var b strings.Builder
b.Grow(len(decomposed))
for _, r := range decomposed {
switch {
case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Mc, r), unicode.Is(unicode.Me, r):
continue
case unicode.IsLetter(r), unicode.IsNumber(r):
b.WriteRune(r)
case unicode.IsSpace(r):
b.WriteByte(' ')
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
b.WriteByte(' ')
default:
}
}
@@ -68,3 +98,43 @@ func normalizeSymbolOnlyTitle(title string) string {
return b.String()
}
// resolvedTrackInfo holds the metadata fetched from a provider for verification.
type resolvedTrackInfo struct {
Title string
ArtistName string
Duration int
}
// trackMatchesRequest checks whether a resolved track from a provider matches
// the original download request. Returns true if the track is a plausible match.
func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool {
if req.ArtistName != "" && resolved.ArtistName != "" &&
!artistsMatch(req.ArtistName, resolved.ArtistName) {
GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n",
logPrefix, req.ArtistName, resolved.ArtistName)
return false
}
if req.TrackName != "" && resolved.Title != "" &&
!titlesMatch(req.TrackName, resolved.Title) {
GoLog("[%s] Verification failed: title mismatch — expected '%s', got '%s'\n",
logPrefix, req.TrackName, resolved.Title)
return false
}
expectedDurationSec := req.DurationMS / 1000
if expectedDurationSec > 0 && resolved.Duration > 0 {
diff := expectedDurationSec - resolved.Duration
if diff < 0 {
diff = -diff
}
if diff > 10 {
GoLog("[%s] Verification failed: duration mismatch — expected %ds, got %ds\n",
logPrefix, expectedDurationSec, resolved.Duration)
return false
}
}
return true
}
-747
View File
@@ -1,747 +0,0 @@
package gobackend
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
)
type YouTubeDownloader struct {
client *http.Client
apiURL string
mu sync.Mutex
}
const spotubeBaseURL = "https://spotubedl.com"
var (
globalYouTubeDownloader *YouTubeDownloader
youtubeDownloaderOnce sync.Once
)
type YouTubeQuality string
const (
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
YouTubeQualityOpus128 YouTubeQuality = "opus_128"
YouTubeQualityMP3128 YouTubeQuality = "mp3_128"
YouTubeQualityMP3256 YouTubeQuality = "mp3_256"
YouTubeQualityMP3320 YouTubeQuality = "mp3_320"
)
var (
youtubeOpusSupportedBitrates = []int{128, 256}
youtubeMp3SupportedBitrates = []int{128, 256, 320}
)
type CobaltRequest struct {
URL string `json:"url"`
AudioBitrate string `json:"audioBitrate,omitempty"`
AudioFormat string `json:"audioFormat,omitempty"`
DownloadMode string `json:"downloadMode,omitempty"`
FilenameStyle string `json:"filenameStyle,omitempty"`
DisableMetadata bool `json:"disableMetadata,omitempty"`
}
type CobaltResponse struct {
Status string `json:"status"`
URL string `json:"url,omitempty"`
Filename string `json:"filename,omitempty"`
Error *struct {
Code string `json:"code"`
Context *struct {
Service string `json:"service,omitempty"`
Limit int `json:"limit,omitempty"`
} `json:"context,omitempty"`
} `json:"error,omitempty"`
}
type YouTubeDownloadResult struct {
FilePath string
Title string
Artist string
Album string
ReleaseDate string
TrackNumber int
DiscNumber int
ISRC string
Format string // "opus" or "mp3"
Bitrate int
LyricsLRC string
CoverData []byte
}
func NewYouTubeDownloader() *YouTubeDownloader {
youtubeDownloaderOnce.Do(func() {
globalYouTubeDownloader = &YouTubeDownloader{
client: NewHTTPClientWithTimeout(DownloadTimeout),
apiURL: "https://api.qwkuns.me",
}
})
return globalYouTubeDownloader
}
func extractBitrateFromQuality(raw string, defaultBitrate int) int {
parts := strings.FieldsFunc(raw, func(r rune) bool {
return (r < '0' || r > '9')
})
for i := len(parts) - 1; i >= 0; i-- {
part := parts[i]
if part == "" {
continue
}
if parsed, err := strconv.Atoi(part); err == nil {
return parsed
}
}
return defaultBitrate
}
func nearestSupportedBitrate(value int, supported []int) int {
nearest := supported[0]
nearestDistance := absInt(value - nearest)
for _, option := range supported[1:] {
distance := absInt(value - option)
// On tie prefer higher quality.
if distance < nearestDistance || (distance == nearestDistance && option > nearest) {
nearest = option
nearestDistance = distance
}
}
return nearest
}
func absInt(value int) int {
if value < 0 {
return -value
}
return value
}
func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalized YouTubeQuality) {
normalizedRaw := strings.ToLower(strings.TrimSpace(raw))
if strings.HasPrefix(normalizedRaw, "opus") {
parsed := extractBitrateFromQuality(normalizedRaw, 256)
finalBitrate := nearestSupportedBitrate(parsed, youtubeOpusSupportedBitrates)
return "opus", finalBitrate, YouTubeQuality(fmt.Sprintf("opus_%d", finalBitrate))
}
if strings.HasPrefix(normalizedRaw, "mp3") {
parsed := extractBitrateFromQuality(normalizedRaw, 320)
finalBitrate := nearestSupportedBitrate(parsed, youtubeMp3SupportedBitrates)
return "mp3", finalBitrate, YouTubeQuality(fmt.Sprintf("mp3_%d", finalBitrate))
}
// Backward compatibility for legacy symbolic values.
switch normalizedRaw {
case "opus_256", "opus256", "opus":
return "opus", 256, YouTubeQualityOpus256
case "opus_128", "opus128":
return "opus", 128, YouTubeQualityOpus128
case "mp3_320", "mp3320", "mp3", "":
return "mp3", 320, YouTubeQualityMP3320
case "mp3_256", "mp3256":
return "mp3", 256, YouTubeQualityMP3256
case "mp3_128", "mp3128":
return "mp3", 128, YouTubeQualityMP3128
default:
return "mp3", 320, YouTubeQualityMP3320
}
}
func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) {
query := fmt.Sprintf("%s %s", artistName, trackName)
searchQuery := url.QueryEscape(query)
GoLog("[YouTube] Search query: %s\n", query)
youtubeMusicURL := fmt.Sprintf("https://music.youtube.com/search?q=%s", searchQuery)
return youtubeMusicURL, nil
}
func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQuality) (*CobaltResponse, error) {
y.mu.Lock()
defer y.mu.Unlock()
audioFormat, bitrate, _ := parseYouTubeQualityInput(string(quality))
audioBitrate := strconv.Itoa(bitrate)
// Try SpotubeDL first (primary)
var spotubeErr error
videoID, extractErr := ExtractYouTubeVideoID(youtubeURL)
if extractErr == nil {
GoLog("[YouTube] Requesting from SpotubeDL: videoID=%s (format: %s, bitrate: %s)\n",
videoID, audioFormat, audioBitrate)
resp, err := y.requestSpotubeDL(videoID, audioFormat, audioBitrate)
if err == nil {
return resp, nil
}
spotubeErr = err
GoLog("[YouTube] SpotubeDL failed: %v, trying Cobalt fallback...\n", err)
} else {
GoLog("[YouTube] Could not extract video ID: %v, skipping SpotubeDL\n", extractErr)
}
// Fallback: direct Cobalt API (api.qwkuns.me)
cobaltURL := toYouTubeMusicURL(youtubeURL)
GoLog("[YouTube] Requesting from Cobalt API: %s (format: %s, bitrate: %s)\n",
cobaltURL, audioFormat, audioBitrate)
resp, err := y.requestCobaltDirect(cobaltURL, audioFormat, audioBitrate)
if err != nil {
if spotubeErr != nil {
return nil, fmt.Errorf("all download methods failed: spotubedl: %v, cobalt: %v", spotubeErr, err)
}
return nil, fmt.Errorf("all download methods failed: spotubedl: extractErr=%v, cobalt: %v", extractErr, err)
}
return resp, nil
}
func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitrate string) (*CobaltResponse, error) {
reqBody := CobaltRequest{
URL: videoURL,
AudioFormat: audioFormat,
AudioBitrate: audioBitrate,
DownloadMode: "audio",
FilenameStyle: "basic",
DisableMetadata: true,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequest("POST", y.apiURL, strings.NewReader(string(jsonData)))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := DoRequestWithUserAgent(y.client, req)
if err != nil {
return nil, fmt.Errorf("cobalt API request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
GoLog("[YouTube] Cobalt API response status: %d\n", resp.StatusCode)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("cobalt API returned status %d: %s", resp.StatusCode, string(body))
}
var cobaltResp CobaltResponse
if err := json.Unmarshal(body, &cobaltResp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
if cobaltResp.Status == "error" && cobaltResp.Error != nil {
return nil, fmt.Errorf("cobalt error: %s", cobaltResp.Error.Code)
}
if cobaltResp.Status != "tunnel" && cobaltResp.Status != "redirect" {
return nil, fmt.Errorf("unexpected cobalt status: %s", cobaltResp.Status)
}
if cobaltResp.URL == "" {
return nil, fmt.Errorf("no download URL in response")
}
GoLog("[YouTube] Got download URL from Cobalt (status: %s)\n", cobaltResp.Status)
return &cobaltResp, nil
}
// requestSpotubeDL uses SpotubeDL as a Cobalt proxy (they handle auth to yt-dl.click instances).
// Engines v3/v2 are MP3-oriented outputs, so we only use them for MP3 requests.
func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate string) (*CobaltResponse, error) {
engines := []string{"v1"}
if strings.EqualFold(audioFormat, "mp3") {
engines = append(engines, "v3", "v2")
}
var lastErr error
for _, engine := range engines {
resp, err := y.requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine)
if err == nil {
return resp, nil
}
lastErr = err
GoLog("[YouTube] SpotubeDL (%s) failed: %v\n", engine, err)
}
if lastErr == nil {
lastErr = fmt.Errorf("no SpotubeDL engine available")
}
return nil, lastErr
}
func (y *YouTubeDownloader) requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine string) (*CobaltResponse, error) {
apiURL := fmt.Sprintf("%s/api/download/%s?engine=%s&format=%s&quality=%s",
spotubeBaseURL, videoID, url.QueryEscape(engine), url.QueryEscape(audioFormat), url.QueryEscape(audioBitrate))
GoLog("[YouTube] Requesting from SpotubeDL (%s): %s\n", engine, apiURL)
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := DoRequestWithUserAgent(y.client, req)
if err != nil {
return nil, fmt.Errorf("spotubedl request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
GoLog("[YouTube] SpotubeDL (%s) response status: %d\n", engine, resp.StatusCode)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("spotubedl(%s) returned status %d: %s", engine, resp.StatusCode, string(body))
}
var result struct {
URL string `json:"url"`
Status string `json:"status"`
Error string `json:"error"`
Message string `json:"message"`
Filename string `json:"filename"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to parse spotubedl response: %w", err)
}
downloadURL := strings.TrimSpace(result.URL)
if downloadURL == "" {
if result.Error != "" {
return nil, fmt.Errorf("spotubedl(%s) error: %s", engine, result.Error)
}
if result.Message != "" {
return nil, fmt.Errorf("spotubedl(%s) message: %s", engine, result.Message)
}
return nil, fmt.Errorf("no download URL from spotubedl(%s)", engine)
}
if strings.HasPrefix(downloadURL, "/") {
downloadURL = spotubeBaseURL + downloadURL
}
if !strings.HasPrefix(downloadURL, "http://") && !strings.HasPrefix(downloadURL, "https://") {
return nil, fmt.Errorf("invalid download URL from spotubedl(%s): %s", engine, downloadURL)
}
filename := strings.TrimSpace(result.Filename)
if filename == "" {
if parsedURL, parseErr := url.Parse(downloadURL); parseErr == nil {
if queryFilename := strings.TrimSpace(parsedURL.Query().Get("filename")); queryFilename != "" {
if decodedFilename, decodeErr := url.QueryUnescape(queryFilename); decodeErr == nil {
filename = decodedFilename
} else {
filename = queryFilename
}
}
}
}
GoLog("[YouTube] Got download URL from SpotubeDL (%s)\n", engine)
return &CobaltResponse{
Status: "tunnel",
URL: downloadURL,
Filename: filename,
}, nil
}
func (y *YouTubeDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
ctx := context.Background()
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
ctx = initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
}
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := DoRequestWithUserAgent(y.client, req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
}
expectedSize := resp.ContentLength
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
out, err := openOutputForWrite(outputPath, outputFD)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
bufWriter := bufio.NewWriterSize(out, 256*1024)
var written int64
if itemID != "" {
progressWriter := NewItemProgressWriter(bufWriter, itemID)
written, err = io.Copy(progressWriter, resp.Body)
} else {
written, err = io.Copy(bufWriter, resp.Body)
}
flushErr := bufWriter.Flush()
closeErr := out.Close()
if err != nil {
cleanupOutputOnError(outputPath, outputFD)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to flush buffer: %w", flushErr)
}
if closeErr != nil {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to close file: %w", closeErr)
}
if expectedSize > 0 && written != expectedSize {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
}
GoLog("[YouTube] Download completed: %d bytes written\n", written)
return nil
}
func BuildYouTubeSearchURL(trackName, artistName string) string {
query := fmt.Sprintf("%s %s official audio", artistName, trackName)
return fmt.Sprintf("https://music.youtube.com/search?q=%s", url.QueryEscape(query))
}
func BuildYouTubeWatchURL(videoID string) string {
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
}
func isYouTubeVideoID(s string) bool {
if len(s) != 11 {
return false
}
for _, c := range s {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
return false
}
}
return true
}
func IsYouTubeURL(urlStr string) bool {
lower := strings.ToLower(urlStr)
return strings.Contains(lower, "youtube.com") ||
strings.Contains(lower, "youtu.be") ||
strings.Contains(lower, "music.youtube.com")
}
// toYouTubeMusicURL converts any YouTube URL to music.youtube.com format.
// YouTube Music URLs bypass the login requirement that affects regular YouTube videos on Cobalt.
func toYouTubeMusicURL(rawURL string) string {
videoID, err := ExtractYouTubeVideoID(rawURL)
if err != nil {
return rawURL
}
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
}
func ExtractYouTubeVideoID(urlStr string) (string, error) {
if strings.Contains(urlStr, "youtu.be/") {
parts := strings.Split(urlStr, "youtu.be/")
if len(parts) >= 2 {
videoID := strings.Split(parts[1], "?")[0]
videoID = strings.Split(videoID, "&")[0]
return strings.TrimSpace(videoID), nil
}
}
parsed, err := url.Parse(urlStr)
if err != nil {
return "", fmt.Errorf("invalid URL: %w", err)
}
if v := parsed.Query().Get("v"); v != "" {
return v, nil
}
if strings.Contains(parsed.Path, "/embed/") {
parts := strings.Split(parsed.Path, "/embed/")
if len(parts) >= 2 {
return strings.Split(parts[1], "/")[0], nil
}
}
if strings.Contains(parsed.Path, "/v/") {
parts := strings.Split(parsed.Path, "/v/")
if len(parts) >= 2 {
return strings.Split(parts[1], "/")[0], nil
}
}
return "", fmt.Errorf("could not extract video ID from URL")
}
// searchYouTubeMusicViaExtension uses the YT Music extension's customSearch
// to find a track by artist + title. It filters for tracks only (not videos,
// albums, or playlists) and returns the YouTube Music watch URL for the first
// matching track, or "" if nothing was found.
func searchYouTubeMusicViaExtension(artistName, trackName string) string {
extManager := GetExtensionManager()
searchProviders := extManager.GetSearchProviders()
// Find the ytmusic-spotiflac extension
var ytProvider *ExtensionProviderWrapper
for _, p := range searchProviders {
if p.extension.ID == "ytmusic-spotiflac" {
ytProvider = p
break
}
}
if ytProvider == nil {
GoLog("[YouTube] YT Music extension not found or not enabled, skipping fallback\n")
return ""
}
query := strings.TrimSpace(artistName + " " + trackName)
if query == "" {
return ""
}
GoLog("[YouTube] Searching YT Music extension for: %s\n", query)
results, err := ytProvider.CustomSearch(query, map[string]interface{}{
"filter": "tracks",
})
if err != nil {
GoLog("[YouTube] YT Music extension search failed: %v\n", err)
return ""
}
// Find the first track result (item_type == "track" with a valid video ID)
for _, track := range results {
if track.ItemType != "" && track.ItemType != "track" {
continue
}
videoID := strings.TrimSpace(track.ID)
if videoID == "" {
continue
}
if isYouTubeVideoID(videoID) {
return BuildYouTubeWatchURL(videoID)
}
}
GoLog("[YouTube] YT Music extension returned no matching tracks for: %s\n", query)
return ""
}
func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
downloader := NewYouTubeDownloader()
format, bitrate, quality := parseYouTubeQualityInput(req.Quality)
// URL lookup priority: YouTube video ID > YT Music extension > SongLink (Spotify/Deezer/ISRC)
var youtubeURL string
var lookupErr error
// SpotifyID might actually be a YouTube video ID (from YT Music extension)
if req.SpotifyID != "" && isYouTubeVideoID(req.SpotifyID) {
youtubeURL = BuildYouTubeWatchURL(req.SpotifyID)
GoLog("[YouTube] SpotifyID appears to be YouTube video ID, using directly: %s\n", youtubeURL)
}
// Try YT Music extension search first (if installed) - more accurate, tracks only
if youtubeURL == "" && (req.TrackName != "" || req.ArtistName != "") {
youtubeURL = searchYouTubeMusicViaExtension(req.ArtistName, req.TrackName)
if youtubeURL != "" {
GoLog("[YouTube] Found YouTube URL via YT Music extension: %s\n", youtubeURL)
}
}
// Fallback: Try Spotify ID via SongLink
if youtubeURL == "" && req.SpotifyID != "" && !isYouTubeVideoID(req.SpotifyID) {
GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID)
songlink := NewSongLinkClient()
youtubeURL, lookupErr = songlink.GetYouTubeURLFromSpotify(req.SpotifyID)
if lookupErr != nil {
GoLog("[YouTube] SongLink Spotify lookup failed: %v\n", lookupErr)
} else {
GoLog("[YouTube] Found YouTube URL via SongLink (Spotify): %s\n", youtubeURL)
}
}
// Fallback: Try Deezer ID via SongLink
if youtubeURL == "" && req.DeezerID != "" {
GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID)
songlink := NewSongLinkClient()
youtubeURL, lookupErr = songlink.GetYouTubeURLFromDeezer(req.DeezerID)
if lookupErr != nil {
GoLog("[YouTube] SongLink Deezer lookup failed: %v\n", lookupErr)
} else {
GoLog("[YouTube] Found YouTube URL via SongLink (Deezer): %s\n", youtubeURL)
}
}
// Fallback: Try ISRC via SongLink
if youtubeURL == "" && req.ISRC != "" {
GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC)
songlink := NewSongLinkClient()
availability, isrcErr := songlink.CheckTrackAvailability("", req.ISRC)
if isrcErr == nil && availability.YouTube && availability.YouTubeURL != "" {
youtubeURL = availability.YouTubeURL
GoLog("[YouTube] Found YouTube URL via SongLink (ISRC): %s\n", youtubeURL)
} else if isrcErr != nil {
GoLog("[YouTube] SongLink ISRC lookup failed: %v\n", isrcErr)
}
}
// Cobalt requires direct video URLs, not search URLs
if youtubeURL == "" {
return YouTubeDownloadResult{}, fmt.Errorf("could not find YouTube URL for track: %s - %s (no Spotify/Deezer ID available or track not on YouTube)", req.ArtistName, req.TrackName)
}
GoLog("[YouTube] Requesting download from Cobalt for: %s\n", youtubeURL)
cobaltResp, err := downloader.GetDownloadURL(youtubeURL, quality)
if err != nil {
return YouTubeDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
}
ext := ".mp3"
if format == "opus" {
ext = ".opus"
}
// Some SpotubeDL engines may return a different output container than requested.
// Respect the provider-reported filename to avoid saving MP3 bytes with .opus extension.
if cobaltResp != nil && cobaltResp.Filename != "" {
lowerName := strings.ToLower(strings.TrimSpace(cobaltResp.Filename))
switch {
case strings.HasSuffix(lowerName, ".mp3"):
ext = ".mp3"
format = "mp3"
case strings.HasSuffix(lowerName, ".opus"), strings.HasSuffix(lowerName, ".ogg"):
ext = ".opus"
format = "opus"
}
}
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
"title": req.TrackName,
"artist": req.ArtistName,
"album": req.AlbumName,
"track": req.TrackNumber,
"year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate,
"disc": req.DiscNumber,
})
filename = sanitizeFilename(filename) + ext
var outputPath string
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
if isSafOutput {
outputPath = strings.TrimSpace(req.OutputPath)
if outputPath == "" && isFDOutput(req.OutputFD) {
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
}
} else {
outputPath = req.OutputDir + "/" + filename
}
GoLog("[YouTube] Downloading to: %s\n", outputPath)
var parallelResult *ParallelDownloadResult
if req.EmbedLyrics || req.CoverURL != "" {
GoLog("[YouTube] Starting parallel fetch for cover and lyrics...\n")
parallelResult = FetchCoverAndLyricsParallel(
req.CoverURL,
req.EmbedMaxQualityCover,
req.SpotifyID,
req.TrackName,
req.ArtistName,
req.EmbedLyrics,
int64(req.DurationMS),
)
}
if err := downloader.DownloadFile(cobaltResp.URL, outputPath, req.OutputFD, req.ItemID); err != nil {
return YouTubeDownloadResult{}, fmt.Errorf("download failed: %w", err)
}
lyricsLRC := ""
var coverData []byte
if parallelResult != nil {
if parallelResult.LyricsLRC != "" {
lyricsLRC = parallelResult.LyricsLRC
GoLog("[YouTube] Got lyrics from lrclib (%d lines)\n", len(parallelResult.LyricsData.Lines))
}
if parallelResult.CoverData != nil {
coverData = parallelResult.CoverData
GoLog("[YouTube] Got cover art (%d bytes)\n", len(coverData))
}
}
return YouTubeDownloadResult{
FilePath: outputPath,
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
ReleaseDate: req.ReleaseDate,
TrackNumber: req.TrackNumber,
DiscNumber: req.DiscNumber,
ISRC: req.ISRC,
Format: format,
Bitrate: bitrate,
LyricsLRC: lyricsLRC,
CoverData: coverData,
}, nil
}
-41
View File
@@ -1,41 +0,0 @@
package gobackend
import "testing"
func TestParseYouTubeQualityInput_OpusNormalizesToSupportedBitrates(t *testing.T) {
format, bitrate, normalized := parseYouTubeQualityInput("opus_160")
if format != "opus" {
t.Fatalf("expected opus format, got %s", format)
}
if bitrate != 128 {
t.Fatalf("expected 128 bitrate, got %d", bitrate)
}
if normalized != YouTubeQualityOpus128 {
t.Fatalf("expected %s normalized, got %s", YouTubeQualityOpus128, normalized)
}
}
func TestParseYouTubeQualityInput_Mp3NormalizesToSupportedBitrates(t *testing.T) {
format, bitrate, normalized := parseYouTubeQualityInput("mp3_192")
if format != "mp3" {
t.Fatalf("expected mp3 format, got %s", format)
}
if bitrate != 256 {
t.Fatalf("expected 256 bitrate, got %d", bitrate)
}
if normalized != YouTubeQualityMP3256 {
t.Fatalf("expected %s normalized, got %s", YouTubeQualityMP3256, normalized)
}
}
func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) {
_, opusBitrate, _ := parseYouTubeQualityInput("opus_999")
if opusBitrate != 256 {
t.Fatalf("expected opus normalization to 256, got %d", opusBitrate)
}
_, mp3Bitrate, _ := parseYouTubeQualityInput("mp3_1")
if mp3Bitrate != 128 {
t.Fatalf("expected mp3 normalization to 128, got %d", mp3Bitrate)
}
}
+20
View File
@@ -367,6 +367,26 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "searchTidalAll":
let args = call.arguments as! [String: Any]
let query = args["query"] as! String
let trackLimit = args["track_limit"] as? Int ?? 15
let artistLimit = args["artist_limit"] as? Int ?? 3
let filter = args["filter"] as? String ?? ""
let response = GobackendSearchTidalAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
if let error = error { throw error }
return response
case "searchQobuzAll":
let args = call.arguments as! [String: Any]
let query = args["query"] as! String
let trackLimit = args["track_limit"] as? Int ?? 15
let artistLimit = args["artist_limit"] as? Int ?? 3
let filter = args["filter"] as? String ?? ""
let response = GobackendSearchQobuzAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
if let error = error { throw error }
return response
case "getDeezerRelatedArtists":
let args = call.arguments as! [String: Any]
let artistId = args["artist_id"] as! String
+9 -9
View File
@@ -3,24 +3,24 @@ import 'package:flutter/foundation.dart';
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '3.8.0';
static const String buildNumber = '106';
static const String version = '4.1.0';
static const String buildNumber = '117';
static const String fullVersion = '$version+$buildNumber';
/// Shows "Internal" in debug builds, actual version in release.
static String get displayVersion => kDebugMode ? 'Internal' : version;
static const String appName = 'SpotiFLAC';
static const String appName = 'SpotiFLAC Mobile';
static const String copyright = '© 2026 SpotiFLAC';
static const String mobileAuthor = 'zarzet';
static const String originalAuthor = 'afkarxyz';
static const String githubRepo = 'zarzet/SpotiFLAC-Mobile';
static const String githubUrl = 'https://github.com/$githubRepo';
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
static const String originalGithubUrl =
'https://github.com/afkarxyz/SpotiFLAC';
static const String kofiUrl = 'https://ko-fi.com/zarzet';
static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/';
}
+570 -87
View File
@@ -1066,6 +1066,12 @@ abstract class AppLocalizations {
/// **'Import'**
String get dialogImport;
/// Confirm button in Download All dialog
///
/// In en, this message translates to:
/// **'Download'**
String get dialogDownload;
/// Dialog button - discard changes
///
/// In en, this message translates to:
@@ -1426,6 +1432,66 @@ abstract class AppLocalizations {
/// **'Playlists'**
String get searchPlaylists;
/// Bottom sheet title for search sort options
///
/// In en, this message translates to:
/// **'Sort Results'**
String get searchSortTitle;
/// Sort option - default API order
///
/// In en, this message translates to:
/// **'Default'**
String get searchSortDefault;
/// Sort option - title ascending
///
/// In en, this message translates to:
/// **'Title (A-Z)'**
String get searchSortTitleAZ;
/// Sort option - title descending
///
/// In en, this message translates to:
/// **'Title (Z-A)'**
String get searchSortTitleZA;
/// Sort option - artist ascending
///
/// In en, this message translates to:
/// **'Artist (A-Z)'**
String get searchSortArtistAZ;
/// Sort option - artist descending
///
/// In en, this message translates to:
/// **'Artist (Z-A)'**
String get searchSortArtistZA;
/// Sort option - shortest duration first
///
/// In en, this message translates to:
/// **'Duration (Shortest)'**
String get searchSortDurationShort;
/// Sort option - longest duration first
///
/// In en, this message translates to:
/// **'Duration (Longest)'**
String get searchSortDurationLong;
/// Sort option - oldest release first
///
/// In en, this message translates to:
/// **'Release Date (Oldest)'**
String get searchSortDateOldest;
/// Sort option - newest release first
///
/// In en, this message translates to:
/// **'Release Date (Newest)'**
String get searchSortDateNewest;
/// Tooltip - play button
///
/// In en, this message translates to:
@@ -2317,7 +2383,7 @@ abstract class AppLocalizations {
/// Default search provider option
///
/// In en, this message translates to:
/// **'Default (Deezer/Spotify)'**
/// **'Default (Deezer)'**
String get extensionDefaultProvider;
/// Subtitle for default provider
@@ -2590,30 +2656,72 @@ abstract class AppLocalizations {
/// **'24-bit / up to 192kHz'**
String get qualityHiResFlacMaxSubtitle;
/// Quality option label for Tidal lossy 320kbps
///
/// In en, this message translates to:
/// **'Lossy 320kbps'**
String get downloadLossy320;
/// Setting title to pick output format for Tidal lossy downloads
///
/// In en, this message translates to:
/// **'Lossy Format'**
String get downloadLossyFormat;
/// Title of the Tidal lossy format picker bottom sheet
///
/// In en, this message translates to:
/// **'Lossy 320kbps Format'**
String get downloadLossy320Format;
/// Description in the Tidal 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.'**
String get downloadLossy320FormatDesc;
/// Tidal lossy format option - MP3 320kbps
///
/// In en, this message translates to:
/// **'MP3 320kbps'**
String get downloadLossyMp3;
/// Subtitle for MP3 320kbps Tidal lossy option
///
/// In en, this message translates to:
/// **'Best compatibility, ~10MB per track'**
String get downloadLossyMp3Subtitle;
/// Tidal lossy format option - Opus 256kbps
///
/// In en, this message translates to:
/// **'Opus 256kbps'**
String get downloadLossyOpus256;
/// Subtitle for Opus 256kbps Tidal lossy option
///
/// In en, this message translates to:
/// **'Best quality Opus, ~8MB per track'**
String get downloadLossyOpus256Subtitle;
/// Tidal lossy format option - Opus 128kbps
///
/// In en, this message translates to:
/// **'Opus 128kbps'**
String get downloadLossyOpus128;
/// Subtitle for Opus 128kbps Tidal lossy option
///
/// In en, this message translates to:
/// **'Smallest size, ~4MB per track'**
String get downloadLossyOpus128Subtitle;
/// Note about quality availability
///
/// In en, this message translates to:
/// **'Actual quality depends on track availability from the service'**
String get qualityNote;
/// Note for YouTube service explaining lossy-only quality
///
/// In en, this message translates to:
/// **'YouTube provides lossy audio only. Not part of lossless fallback.'**
String get youtubeQualityNote;
/// Title for YouTube Opus bitrate setting
///
/// In en, this message translates to:
/// **'YouTube Opus Bitrate'**
String get youtubeOpusBitrateTitle;
/// Title for YouTube MP3 bitrate setting
///
/// In en, this message translates to:
/// **'YouTube MP3 Bitrate'**
String get youtubeMp3BitrateTitle;
/// Setting - show quality picker
///
/// In en, this message translates to:
@@ -2794,6 +2902,18 @@ abstract class AppLocalizations {
/// **'Artist/Album/ and Artist/Singles/'**
String get albumFolderArtistAlbumSinglesSubtitle;
/// Album folder option with singles directly in artist folder
///
/// In en, this message translates to:
/// **'Artist / Album (Singles flat)'**
String get albumFolderArtistAlbumFlat;
/// Folder structure example for flat singles
///
/// In en, this message translates to:
/// **'Artist/Album/ and Artist/song.flac'**
String get albumFolderArtistAlbumFlatSubtitle;
/// Button - delete selected tracks
///
/// In en, this message translates to:
@@ -3100,6 +3220,42 @@ abstract class AppLocalizations {
/// **'Show when searching for existing tracks'**
String get libraryShowDuplicateIndicatorSubtitle;
/// Setting for automatic library scanning
///
/// In en, this message translates to:
/// **'Auto Scan'**
String get libraryAutoScan;
/// Subtitle for auto scan setting
///
/// In en, this message translates to:
/// **'Automatically scan your library for new files'**
String get libraryAutoScanSubtitle;
/// Auto scan disabled
///
/// In en, this message translates to:
/// **'Off'**
String get libraryAutoScanOff;
/// Auto scan when app opens
///
/// In en, this message translates to:
/// **'Every app open'**
String get libraryAutoScanOnOpen;
/// Auto scan once per day
///
/// In en, this message translates to:
/// **'Daily'**
String get libraryAutoScanDaily;
/// Auto scan once per week
///
/// In en, this message translates to:
/// **'Weekly'**
String get libraryAutoScanWeekly;
/// Section header for library actions
///
/// In en, this message translates to:
@@ -3832,6 +3988,36 @@ abstract class AppLocalizations {
/// **'FFmpeg metadata embed failed'**
String get trackReEnrichFfmpegFailed;
/// Action/button label for queueing FLAC redownloads for local tracks
///
/// In en, this message translates to:
/// **'Queue FLAC'**
String get queueFlacAction;
/// Confirmation dialog body before queueing FLAC redownloads for local tracks
///
/// In en, this message translates to:
/// **'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n{count} selected'**
String queueFlacConfirmMessage(int count);
/// Snackbar while resolving remote matches for local FLAC redownloads
///
/// In en, this message translates to:
/// **'Finding FLAC matches... ({current}/{total})'**
String queueFlacFindingProgress(int current, int total);
/// Snackbar when no safe FLAC redownload matches were found
///
/// In en, this message translates to:
/// **'No reliable online matches found for the selection'**
String get queueFlacNoReliableMatches;
/// Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped
///
/// In en, this message translates to:
/// **'Added {addedCount} tracks to queue, skipped {skippedCount}'**
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount);
/// Snackbar when save operation fails
///
/// In en, this message translates to:
@@ -3847,7 +4033,7 @@ abstract class AppLocalizations {
/// Subtitle for convert format menu item
///
/// In en, this message translates to:
/// **'Convert to MP3 or Opus'**
/// **'Convert to MP3, Opus, ALAC, or FLAC'**
String get trackConvertFormatSubtitle;
/// Title of convert bottom sheet
@@ -3884,6 +4070,21 @@ abstract class AppLocalizations {
String bitrate,
);
/// Confirmation dialog message for lossless-to-lossless conversion
///
/// In en, this message translates to:
/// **'Convert from {sourceFormat} to {targetFormat}? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'**
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
);
/// Hint shown when converting between lossless formats
///
/// In en, this message translates to:
/// **'Lossless conversion — no quality loss'**
String get trackConvertLosslessHint;
/// Snackbar while converting
///
/// In en, this message translates to:
@@ -4254,6 +4455,12 @@ abstract class AppLocalizations {
String bitrate,
);
/// Confirmation dialog message for lossless batch conversion
///
/// In en, this message translates to:
/// **'Convert {count} {count, plural, =1{track} other{tracks}} to {format}? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'**
String selectionBatchConvertConfirmMessageLossless(int count, String format);
/// Snackbar during batch conversion progress
///
/// In en, this message translates to:
@@ -4434,7 +4641,7 @@ abstract class AppLocalizations {
/// **'Added {count} tracks to Loved'**
String snackbarAddedTracksToLoved(int count);
/// Title of the Download All confirmation dialog
/// Dialog title for bulk download confirmation
///
/// In en, this message translates to:
/// **'Download All'**
@@ -4446,12 +4653,6 @@ abstract class AppLocalizations {
/// **'Download {count} tracks?'**
String dialogDownloadAllMessage(int count);
/// Confirm button in Download All dialog
///
/// In en, this message translates to:
/// **'Download'**
String get dialogDownload;
/// Checkbox label in import dialog to skip already-downloaded songs
///
/// In en, this message translates to:
@@ -4572,6 +4773,30 @@ abstract class AppLocalizations {
/// **'Artist Name Filters'**
String get downloadArtistNameFilters;
/// Setting title for adding a playlist folder prefix before the normal organization structure
///
/// In en, this message translates to:
/// **'Create playlist source folder'**
String get downloadCreatePlaylistSourceFolder;
/// Subtitle when playlist source folder prefix is enabled
///
/// In en, this message translates to:
/// **'Playlist downloads use Playlist/ plus your normal folder structure.'**
String get downloadCreatePlaylistSourceFolderEnabled;
/// Subtitle when playlist source folder prefix is disabled
///
/// In en, this message translates to:
/// **'Playlist downloads use the normal folder structure only.'**
String get downloadCreatePlaylistSourceFolderDisabled;
/// Subtitle when playlist folder prefix setting is redundant because folder organization is already by playlist
///
/// In en, this message translates to:
/// **'By Playlist already places downloads inside a playlist folder.'**
String get downloadCreatePlaylistSourceFolderRedundant;
/// Setting title for SongLink country region
///
/// In en, this message translates to:
@@ -4602,18 +4827,6 @@ abstract class AppLocalizations {
/// **'Select a built-in service to enable'**
String get downloadSelectServiceToEnable;
/// Quality option label for Tidal lossy 320kbps
///
/// In en, this message translates to:
/// **'Lossy 320kbps'**
String get downloadLossy320;
/// Setting title to pick output format for Tidal lossy downloads
///
/// In en, this message translates to:
/// **'Lossy Format'**
String get downloadLossyFormat;
/// Info hint when non-Tidal/Qobuz service is selected
///
/// In en, this message translates to:
@@ -4740,54 +4953,6 @@ abstract class AppLocalizations {
/// **'Auto'**
String get downloadMusixmatchAuto;
/// Title of the Tidal lossy format picker bottom sheet
///
/// In en, this message translates to:
/// **'Lossy 320kbps Format'**
String get downloadLossy320Format;
/// Description in the Tidal 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.'**
String get downloadLossy320FormatDesc;
/// Tidal lossy format option - MP3 320kbps
///
/// In en, this message translates to:
/// **'MP3 320kbps'**
String get downloadLossyMp3;
/// Subtitle for MP3 320kbps option
///
/// In en, this message translates to:
/// **'Best compatibility, ~10MB per track'**
String get downloadLossyMp3Subtitle;
/// Tidal lossy format option - Opus 256kbps
///
/// In en, this message translates to:
/// **'Opus 256kbps'**
String get downloadLossyOpus256;
/// Subtitle for Opus 256kbps option
///
/// In en, this message translates to:
/// **'Best quality Opus, ~8MB per track'**
String get downloadLossyOpus256Subtitle;
/// Tidal lossy format option - Opus 128kbps
///
/// In en, this message translates to:
/// **'Opus 128kbps'**
String get downloadLossyOpus128;
/// Subtitle for Opus 128kbps option
///
/// In en, this message translates to:
/// **'Smallest size, ~4MB per track'**
String get downloadLossyOpus128Subtitle;
/// Subtitle for 'Any' network mode option
///
/// In en, this message translates to:
@@ -4817,6 +4982,324 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Refresh'**
String get cacheRefresh;
/// Dialog message for bulk playlist download confirmation
///
/// In en, this message translates to:
/// **'Download {trackCount} {trackCount, plural, =1{track} other{tracks}} from {playlistCount} {playlistCount, plural, =1{playlist} other{playlists}}?'**
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount);
/// Button label for bulk downloading selected playlists
///
/// In en, this message translates to:
/// **'Download {count} {count, plural, =1{playlist} other{playlists}}'**
String bulkDownloadPlaylistsButton(int count);
/// Button label when no playlists are selected for download
///
/// In en, this message translates to:
/// **'Select playlists to download'**
String get bulkDownloadSelectPlaylists;
/// Snackbar when selected playlists contain no tracks
///
/// In en, this message translates to:
/// **'Selected playlists have no tracks'**
String get snackbarSelectedPlaylistsEmpty;
/// Playlist count display
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 playlist} other{{count} playlists}}'**
String playlistsCount(int count);
/// Section title for selective online metadata auto-fill in the edit metadata sheet
///
/// In en, this message translates to:
/// **'Auto-fill from online'**
String get editMetadataAutoFill;
/// Description for the auto-fill section
///
/// In en, this message translates to:
/// **'Select fields to fill automatically from online metadata'**
String get editMetadataAutoFillDesc;
/// Button label to fetch online metadata and fill selected fields
///
/// In en, this message translates to:
/// **'Fetch & Fill'**
String get editMetadataAutoFillFetch;
/// Snackbar shown while searching for online metadata
///
/// In en, this message translates to:
/// **'Searching online...'**
String get editMetadataAutoFillSearching;
/// Snackbar when online metadata search returns no results
///
/// In en, this message translates to:
/// **'No matching metadata found online'**
String get editMetadataAutoFillNoResults;
/// Snackbar confirming how many fields were auto-filled
///
/// In en, this message translates to:
/// **'Filled {count} {count, plural, =1{field} other{fields}} from online metadata'**
String editMetadataAutoFillDone(int count);
/// Snackbar when user taps Fetch without selecting any fields
///
/// In en, this message translates to:
/// **'Select at least one field to auto-fill'**
String get editMetadataAutoFillNoneSelected;
/// Chip label for title field in auto-fill selector
///
/// In en, this message translates to:
/// **'Title'**
String get editMetadataFieldTitle;
/// Chip label for artist field in auto-fill selector
///
/// In en, this message translates to:
/// **'Artist'**
String get editMetadataFieldArtist;
/// Chip label for album field in auto-fill selector
///
/// In en, this message translates to:
/// **'Album'**
String get editMetadataFieldAlbum;
/// Chip label for album artist field in auto-fill selector
///
/// In en, this message translates to:
/// **'Album Artist'**
String get editMetadataFieldAlbumArtist;
/// Chip label for date field in auto-fill selector
///
/// In en, this message translates to:
/// **'Date'**
String get editMetadataFieldDate;
/// Chip label for track number field in auto-fill selector
///
/// In en, this message translates to:
/// **'Track #'**
String get editMetadataFieldTrackNum;
/// Chip label for disc number field in auto-fill selector
///
/// In en, this message translates to:
/// **'Disc #'**
String get editMetadataFieldDiscNum;
/// Chip label for genre field in auto-fill selector
///
/// In en, this message translates to:
/// **'Genre'**
String get editMetadataFieldGenre;
/// Chip label for ISRC field in auto-fill selector
///
/// In en, this message translates to:
/// **'ISRC'**
String get editMetadataFieldIsrc;
/// Chip label for label field in auto-fill selector
///
/// In en, this message translates to:
/// **'Label'**
String get editMetadataFieldLabel;
/// Chip label for copyright field in auto-fill selector
///
/// In en, this message translates to:
/// **'Copyright'**
String get editMetadataFieldCopyright;
/// Chip label for cover art field in auto-fill selector
///
/// In en, this message translates to:
/// **'Cover Art'**
String get editMetadataFieldCover;
/// Button to select all fields for auto-fill
///
/// In en, this message translates to:
/// **'All'**
String get editMetadataSelectAll;
/// Button to select only fields that are currently empty
///
/// In en, this message translates to:
/// **'Empty only'**
String get editMetadataSelectEmpty;
/// Header for active downloads section with count
///
/// In en, this message translates to:
/// **'Downloading ({count})'**
String queueDownloadingCount(int count);
/// Header label for downloaded items section in library
///
/// In en, this message translates to:
/// **'Downloaded'**
String get queueDownloadedHeader;
/// Shown while filter results are being computed
///
/// In en, this message translates to:
/// **'Filtering...'**
String get queueFilteringIndicator;
/// Track count label with plural support
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 track} other{{count} tracks}}'**
String queueTrackCount(int count);
/// Album count label with plural support
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 album} other{{count} albums}}'**
String queueAlbumCount(int count);
/// Empty state title when no album downloads exist
///
/// In en, this message translates to:
/// **'No album downloads'**
String get queueEmptyAlbums;
/// Empty state subtitle for album downloads
///
/// In en, this message translates to:
/// **'Download multiple tracks from an album to see them here'**
String get queueEmptyAlbumsSubtitle;
/// Empty state title when no single track downloads exist
///
/// In en, this message translates to:
/// **'No single downloads'**
String get queueEmptySingles;
/// Empty state subtitle for single track downloads
///
/// In en, this message translates to:
/// **'Single track downloads will appear here'**
String get queueEmptySinglesSubtitle;
/// Empty state title when download history is empty
///
/// In en, this message translates to:
/// **'No download history'**
String get queueEmptyHistory;
/// Empty state subtitle for download history
///
/// In en, this message translates to:
/// **'Downloaded tracks will appear here'**
String get queueEmptyHistorySubtitle;
/// Shown when all playlists are selected in selection mode
///
/// In en, this message translates to:
/// **'All playlists selected'**
String get selectionAllPlaylistsSelected;
/// Hint shown in playlist selection mode
///
/// In en, this message translates to:
/// **'Tap playlists to select'**
String get selectionTapPlaylistsToSelect;
/// Hint shown when no playlists are selected for deletion
///
/// In en, this message translates to:
/// **'Select playlists to delete'**
String get selectionSelectPlaylistsToDelete;
/// Title for audio analysis section
///
/// In en, this message translates to:
/// **'Audio Quality Analysis'**
String get audioAnalysisTitle;
/// Description for audio analysis tap-to-analyze prompt
///
/// In en, this message translates to:
/// **'Verify lossless quality with spectrum analysis'**
String get audioAnalysisDescription;
/// Loading text while analyzing audio
///
/// In en, this message translates to:
/// **'Analyzing audio...'**
String get audioAnalysisAnalyzing;
/// Sample rate metric label
///
/// In en, this message translates to:
/// **'Sample Rate'**
String get audioAnalysisSampleRate;
/// Bit depth metric label
///
/// In en, this message translates to:
/// **'Bit Depth'**
String get audioAnalysisBitDepth;
/// Channels metric label
///
/// In en, this message translates to:
/// **'Channels'**
String get audioAnalysisChannels;
/// Duration metric label
///
/// In en, this message translates to:
/// **'Duration'**
String get audioAnalysisDuration;
/// Nyquist frequency metric label
///
/// In en, this message translates to:
/// **'Nyquist'**
String get audioAnalysisNyquist;
/// File size metric label
///
/// In en, this message translates to:
/// **'Size'**
String get audioAnalysisFileSize;
/// Dynamic range metric label
///
/// In en, this message translates to:
/// **'Dynamic Range'**
String get audioAnalysisDynamicRange;
/// Peak amplitude metric label
///
/// In en, this message translates to:
/// **'Peak'**
String get audioAnalysisPeak;
/// RMS level metric label
///
/// In en, this message translates to:
/// **'RMS'**
String get audioAnalysisRms;
/// Total samples metric label
///
/// In en, this message translates to:
/// **'Samples'**
String get audioAnalysisSamples;
}
class _AppLocalizationsDelegate
+433 -99
View File
@@ -365,7 +365,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get aboutAppDescription =>
'Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.';
'Lade Spotify-Titel in verlustfreier Qualität von Tidal und Qobuz herunter.';
@override
String get artistAlbums => 'Alben';
@@ -441,7 +441,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get setupDownloadLocationIosMessage =>
'Auf iOS werden Downloads im Dokumentenverzeichnis der App gespeichert. Du kannst sie über die Datei-App aufrufen.';
'Auf iOS werden Downloads im Dokumentenordner der App gespeichert. Du kannst sie über die Datei-App aufrufen.';
@override
String get setupAppDocumentsFolder => 'App-Dokumentenordner';
@@ -536,6 +536,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get dialogImport => 'Importieren';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Verwerfen';
@@ -702,15 +705,15 @@ class AppLocalizationsDe extends AppLocalizations {
String get errorNoTracksFound => 'Keine Titel gefunden';
@override
String get errorUrlNotRecognized => 'Link not recognized';
String get errorUrlNotRecognized => 'Link wurde nicht erkannt';
@override
String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
'Dieser Link ist inkompatibel. Prüfe die URL und stelle sicher, dass eine kompatible Erweiterung installiert ist.';
@override
String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.';
'Laden fehlgeschlagen. Bitte erneut versuchen.';
@override
String errorMissingExtensionSource(String item) {
@@ -747,7 +750,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get selectionAllSelected => 'Alle Titel sind ausgewählt';
@override
String get selectionSelectToDelete => 'Titel zum Löschen auswählen';
String get selectionSelectToDelete => 'Titel zum Löschen wählen';
@override
String progressFetchingMetadata(int current, int total) {
@@ -764,11 +767,41 @@ class AppLocalizationsDe extends AppLocalizations {
String get searchArtists => 'Künstler';
@override
String get searchAlbums => 'Albums';
String get searchAlbums => 'Alben';
@override
String get searchPlaylists => 'Playlisten';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override
String get tooltipPlay => 'Abspielen';
@@ -786,11 +819,11 @@ class AppLocalizationsDe extends AppLocalizations {
String get folderOrganizationNone => 'Keine Organisation';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
String get folderOrganizationByPlaylist => 'Nach Playlist';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
'Ordner für jede Playlist trennen';
@override
String get folderOrganizationByArtist => 'Nach Künstler';
@@ -807,7 +840,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get folderOrganizationNoneSubtitle =>
'Alle Dateien im Download-Verzeichnis';
'Alle Dateien im Download-Ordner';
@override
String get folderOrganizationByArtistSubtitle =>
@@ -1410,37 +1443,60 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-Bit / bis 192kHz';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get qualityNote =>
'Die eigentliche Qualität hängt von der Verfügbarkeit des Dienstes ab';
@override
String get youtubeQualityNote =>
'YouTube bietet nur verlustbehaftete Audioqualität. Deswegen ist es kein Teil des verlustfreien Fallbacks.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override
String get downloadAskBeforeDownload => 'Qualität vor Download fragen';
@override
String get downloadDirectory => 'Downloadverzeichnis';
String get downloadDirectory => 'Download-Ordner';
@override
String get downloadSeparateSinglesFolder => 'Singles Ordner trennen';
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
String get downloadAlbumFolderStructure => 'Album-Ordnerstruktur';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
String get downloadUseAlbumArtistForFolders =>
'Album-Künstler für Ordner verwenden';
@override
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
String get downloadUsePrimaryArtistOnly => 'Primärer Künstler nur für Ordner';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
@@ -1448,7 +1504,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
'Vollständiger Künstler für Ordnername';
@override
String get downloadSelectQuality => 'Qualität wählen';
@@ -1470,7 +1526,8 @@ class AppLocalizationsDe extends AppLocalizations {
'Bist du dir sicher, dass du alle Downloads löschen möchten?';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
String get settingsAutoExportFailed =>
'Auto-Export fehlgeschlagener Downloads';
@override
String get settingsAutoExportFailedSubtitle =>
@@ -1493,14 +1550,14 @@ class AppLocalizationsDe extends AppLocalizations {
String get albumFolderArtistAlbum => 'Künstler/Album';
@override
String get albumFolderArtistAlbumSubtitle => 'Albums/Artist Name/Album Name/';
String get albumFolderArtistAlbumSubtitle => 'Alben/Künster Name/Album Name/';
@override
String get albumFolderArtistYearAlbum => 'Artist / [Year] Album';
String get albumFolderArtistYearAlbum => 'Künstler / [Year] Album';
@override
String get albumFolderArtistYearAlbumSubtitle =>
'Albums/Künster Name/[2005] Album Name/';
'Alben/Künster Name/[2005] Album Name/';
@override
String get albumFolderAlbumOnly => 'Nur Alben';
@@ -1512,14 +1569,21 @@ class AppLocalizationsDe extends AppLocalizations {
String get albumFolderYearAlbum => '[Year] Album';
@override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
String get albumFolderYearAlbumSubtitle => 'Alben/[2005] Album Name/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
String get albumFolderArtistAlbumSingles => 'Künstler / Album + Singles';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
'Künstler/Album/ und Künstler/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => 'Ausgewählte löschen';
@@ -1558,7 +1622,7 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
String get downloadedAlbumSelectToDelete => 'Titel zum Löschen wählen';
@override
String downloadedAlbumDiscHeader(int discNumber) {
@@ -1604,7 +1668,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count Titel von $albumCount Albums';
return '$count Titel aus $albumCount Alben';
}
@override
@@ -1620,14 +1684,14 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get discographySelectAlbumsSubtitle =>
'Choose specific albums or singles';
'Wähle bestimmte Alben oder Singles';
@override
String get discographyFetchingTracks => 'Lade Titel...';
@override
String discographyFetchingAlbum(int current, int total) {
return 'Fetching $current of $total...';
return 'Lade $current von $total...';
}
@override
@@ -1640,7 +1704,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
return '$count Titel zur Warteschlange hinzugefügt';
}
@override
@@ -1652,7 +1716,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get discographyNoAlbums => 'Es sind keine Alben verfügbar';
@override
String get discographyFailedToFetch => 'Failed to fetch some albums';
String get discographyFailedToFetch => 'Fehler beim Abrufen einiger Alben';
@override
String get sectionStorageAccess => 'Speicherzugriff';
@@ -1661,14 +1725,14 @@ class AppLocalizationsDe extends AppLocalizations {
String get allFilesAccess => 'Zugriff auf alle Dateien';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
String get allFilesAccessEnabledSubtitle => 'Darf in jeden Ordner schreiben';
@override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
String get allFilesAccessDisabledSubtitle => 'Nur auf Medienordner begrenzt';
@override
String get allFilesAccessDescription =>
'Aktiviere die Option, wenn beim Speichern in benutzerdefinierten Ordnern Schreibfehler auftreten. Weil Android 13+ standardmäßig den Zugriff auf bestimmte Verzeichnisse einschränkt.';
'Option bei Schreibfehlern bitte aktivieren (erforderlich ab Android 13).';
@override
String get allFilesAccessDeniedMessage =>
@@ -1682,13 +1746,15 @@ class AppLocalizationsDe extends AppLocalizations {
String get settingsLocalLibrary => 'Lokale Bibliothek';
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
String get settingsLocalLibrarySubtitle =>
'Musik scannen & Duplikate erkennen';
@override
String get settingsCache => 'Speicher & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
String get settingsCacheSubtitle =>
'Größe anzeigen und Daten im Cache leeren';
@override
String get libraryTitle => 'Lokale Bibliothek';
@@ -1701,7 +1767,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get libraryEnableLocalLibrarySubtitle =>
'Scan and track your existing music';
'Scan und verfolge deine bestehende Musik';
@override
String get libraryFolder => 'Bibliotheksordner';
@@ -1710,12 +1776,31 @@ class AppLocalizationsDe extends AppLocalizations {
String get libraryFolderHint => 'Tippe um Ordner auszuwählen';
@override
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
String get libraryShowDuplicateIndicator => 'Duplikat Indikator anzeigen';
@override
String get libraryShowDuplicateIndicatorSubtitle =>
'Bei der Suche nach vorhandenen Titeln anzeigen';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Aktionen';
@@ -1892,7 +1977,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Hole dir FLAC Audio von Tidal, Qobuz oder Amazon Musik';
'Hole dir FLAC Audio von Tidal, Qobuz oder Deezer';
@override
String get tutorialWelcomeTip3 =>
@@ -1959,7 +2044,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get tutorialSettingsTip1 =>
'Downloadverzeichnis und Ordnerorganisation ändern';
'Download-Ordner und Ordner-Organisation ändern';
@override
String get tutorialSettingsTip2 =>
@@ -2017,14 +2102,14 @@ class AppLocalizationsDe extends AppLocalizations {
String get cacheSectionMaintenance => 'Wartung';
@override
String get cacheAppDirectory => 'App-Cache Verzeichnis';
String get cacheAppDirectory => 'App-Cache Ordner';
@override
String get cacheAppDirectoryDesc =>
'HTTP-Antworten, WebView Daten und andere temporäre App-Daten.';
@override
String get cacheTempDirectory => 'Temporäres Verzeichnis';
String get cacheTempDirectory => 'Temporärer Ordner';
@override
String get cacheTempDirectoryDesc =>
@@ -2145,11 +2230,11 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
return 'Cover in $fileName gespeichert';
}
@override
String get trackCoverNoSource => 'No cover art source available';
String get trackCoverNoSource => 'Keine Cover Quelle vorhanden';
@override
String trackLyricsSaved(String fileName) {
@@ -2169,6 +2254,28 @@ class AppLocalizationsDe extends AppLocalizations {
String get trackReEnrichFfmpegFailed =>
'FFmpeg Metadaten-Einbettung fehlgeschlagen';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Fehler: $error';
@@ -2201,6 +2308,18 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Konvertieren von $sourceFormat in $targetFormat bei $bitrate?\n\nDie Originaldatei wird nach der Konvertierung gelöscht.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Konvertiere Audio...';
@@ -2213,10 +2332,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get trackConvertFailed => 'Konvertierung fehlgeschlagen';
@override
String get cueSplitTitle => 'Split CUE Sheet';
String get cueSplitTitle => 'CUE-Sheet aufteilen';
@override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
String get cueSplitSubtitle => 'CUE+FLAC in einzelne Titel aufteilen';
@override
String cueSplitAlbum(String album) {
@@ -2225,40 +2344,41 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String cueSplitArtist(String artist) {
return 'Artist: $artist';
return 'Künstler: $artist';
}
@override
String cueSplitTrackCount(int count) {
return '$count tracks';
return '$count Titel';
}
@override
String get cueSplitConfirmTitle => 'Split CUE Album';
String get cueSplitConfirmTitle => 'CUE-Album aufteilen';
@override
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
return 'Soll „$album in $count einzelne FLAC-Dateien aufgeteilt werden?\n\nDie Dateien werden im selben Ordner gespeichert.';
}
@override
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
return 'CUE-Sheet wird geteilt... ($current/$total)';
}
@override
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
return '$count Titel erfolgreich aufgeteilt';
}
@override
String get cueSplitFailed => 'CUE split failed';
String get cueSplitFailed => 'CUE-Aufteilung fehlgeschlagen';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
String get cueSplitNoAudioFile =>
'Audiodatei für dieses CUE-Sheet nicht gefunden';
@override
String get cueSplitButton => 'Split into Tracks';
String get cueSplitButton => 'In Titel aufteilen';
@override
String get actionCreate => 'Erstellen';
@@ -2455,6 +2575,17 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Konvertiere $count $format $_temp0 zu $bitrate?\n\nOriginaldateien werden nach der Konvertierung gelöscht.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Konvertiere $current von $total...';
@@ -2472,11 +2603,11 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Künstlerordner verwenden den Album-Interpreten, wenn verfügbar';
'Interpret-Ordner verwenden Album-Interpret, sofern vorhanden';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
'Künstler-Ordner nur für Titel-Künstler';
@override
String get lyricsProvidersTitle => 'Lyrics Providers';
@@ -2580,9 +2711,6 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2648,6 +2776,22 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
@override
String get downloadSongLinkRegion => 'SongLink Region';
@@ -2666,12 +2810,6 @@ class AppLocalizationsDe extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2748,32 +2886,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2790,4 +2902,226 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
}
+377 -47
View File
@@ -525,6 +525,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get dialogImport => 'Import';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Discard';
@@ -756,6 +759,36 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get searchPlaylists => 'Playlists';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override
String get tooltipPlay => 'Play';
@@ -1237,7 +1270,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
String get extensionDefaultProvider => 'Default (Deezer)';
@override
String get extensionDefaultProviderSubtitle => 'Use built-in search';
@@ -1386,20 +1419,42 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1497,6 +1552,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -1692,6 +1754,25 @@ class AppLocalizationsEn extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Actions';
@@ -2142,6 +2223,28 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2151,7 +2254,8 @@ class AppLocalizationsEn extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2174,6 +2278,18 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2427,6 +2543,17 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2552,9 +2679,6 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2620,6 +2744,22 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
@override
String get downloadSongLinkRegion => 'SongLink Region';
@@ -2638,12 +2778,6 @@ class AppLocalizationsEn extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2720,32 +2854,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2762,4 +2870,226 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
}
File diff suppressed because it is too large Load Diff
+376 -47
View File
@@ -358,7 +358,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
@override
String get artistAlbums => 'Albums';
@@ -527,6 +527,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get dialogImport => 'Import';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Discard';
@@ -758,6 +761,36 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get searchPlaylists => 'Playlists';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override
String get tooltipPlay => 'Play';
@@ -1388,20 +1421,42 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1499,6 +1554,13 @@ class AppLocalizationsFr extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -1694,6 +1756,25 @@ class AppLocalizationsFr extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Actions';
@@ -1870,7 +1951,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
@override
String get tutorialWelcomeTip3 =>
@@ -2144,6 +2225,28 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2176,6 +2279,18 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2429,6 +2544,17 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2554,9 +2680,6 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2622,6 +2745,22 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
@override
String get downloadSongLinkRegion => 'SongLink Region';
@@ -2640,12 +2779,6 @@ class AppLocalizationsFr extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2722,32 +2855,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2764,4 +2871,226 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
}
+376 -47
View File
@@ -356,7 +356,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
@override
String get artistAlbums => 'Albums';
@@ -525,6 +525,9 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get dialogImport => 'Import';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Discard';
@@ -756,6 +759,36 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get searchPlaylists => 'Playlists';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override
String get tooltipPlay => 'Play';
@@ -1386,20 +1419,42 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1497,6 +1552,13 @@ class AppLocalizationsHi extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -1692,6 +1754,25 @@ class AppLocalizationsHi extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Actions';
@@ -1868,7 +1949,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
@override
String get tutorialWelcomeTip3 =>
@@ -2142,6 +2223,28 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2174,6 +2277,18 @@ class AppLocalizationsHi extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2427,6 +2542,17 @@ class AppLocalizationsHi extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2552,9 +2678,6 @@ class AppLocalizationsHi extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2620,6 +2743,22 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
@override
String get downloadSongLinkRegion => 'SongLink Region';
@@ -2638,12 +2777,6 @@ class AppLocalizationsHi extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2720,32 +2853,6 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2762,4 +2869,226 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
}
+430 -98
View File
@@ -359,7 +359,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get aboutAppDescription =>
'Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.';
'Unduh lagu-lagu Spotify dalam kualitas lossless dari Tidal dan Qobuz.';
@override
String get artistAlbums => 'Album';
@@ -528,6 +528,9 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get dialogImport => 'Impor';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Buang';
@@ -759,6 +762,36 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get searchPlaylists => 'Playlist';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override
String get tooltipPlay => 'Putar';
@@ -766,21 +799,21 @@ class AppLocalizationsId extends AppLocalizations {
String get filenameFormat => 'Format Nama File';
@override
String get filenameShowAdvancedTags => 'Show advanced tags';
String get filenameShowAdvancedTags => 'Tampilkan tag lanjutan';
@override
String get filenameShowAdvancedTagsDescription =>
'Enable formatted tags for track padding and date patterns';
'Aktifkan tag yang diformat untuk padding trek dan pola tanggal';
@override
String get folderOrganizationNone => 'Tidak ada';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
String get folderOrganizationByPlaylist => 'Berdasarkan Daftar Putar';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
'Setiap daftar putar memerlukan folder terpisah';
@override
String get folderOrganizationByArtist => 'Berdasarkan Artis';
@@ -936,13 +969,13 @@ class AppLocalizationsId extends AppLocalizations {
'Masukkan Client ID dan Secret Anda untuk menggunakan kuota aplikasi Spotify Anda sendiri.';
@override
String get credentialsClientId => 'Client ID';
String get credentialsClientId => 'ID Klien';
@override
String get credentialsClientIdHint => 'Tempel Client ID';
@override
String get credentialsClientSecret => 'Client Secret';
String get credentialsClientSecret => 'Rahasia Klien';
@override
String get credentialsClientSecretHint => 'Tempel Client Secret';
@@ -951,7 +984,7 @@ class AppLocalizationsId extends AppLocalizations {
String get channelStable => 'Stabil';
@override
String get channelPreview => 'Preview';
String get channelPreview => 'Pratinjau';
@override
String get sectionSearchSource => 'Sumber Pencarian';
@@ -981,33 +1014,34 @@ class AppLocalizationsId extends AppLocalizations {
String get sectionFileSettings => 'Pengaturan File';
@override
String get sectionLyrics => 'Lyrics';
String get sectionLyrics => 'Lirik';
@override
String get lyricsMode => 'Lyrics Mode';
String get lyricsMode => 'Mode Lirik';
@override
String get lyricsModeDescription =>
'Choose how lyrics are saved with your downloads';
'Pilih cara lirik disimpan bersama unduhan Anda';
@override
String get lyricsModeEmbed => 'Embed in file';
String get lyricsModeEmbed => 'Sematkan dalam file';
@override
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
String get lyricsModeEmbedSubtitle =>
'Lirik tersimpan di dalam metadata FLAC';
@override
String get lyricsModeExternal => 'External .lrc file';
String get lyricsModeExternal => 'File .lrc eksternal';
@override
String get lyricsModeExternalSubtitle =>
'Separate .lrc file for players like Samsung Music';
'File .lrc terpisah untuk pemutar musik seperti Samsung Music';
@override
String get lyricsModeBoth => 'Both';
String get lyricsModeBoth => 'Keduanya';
@override
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
String get lyricsModeBothSubtitle => 'Sematkan dan simpan file .lrc';
@override
String get sectionColor => 'Warna';
@@ -1119,10 +1153,10 @@ class AppLocalizationsId extends AppLocalizations {
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
String get trackLabel => 'Lebel';
@override
String get trackCopyright => 'Copyright';
String get trackCopyright => 'Hak cipta';
@override
String get trackDownloaded => 'Diunduh';
@@ -1140,13 +1174,13 @@ class AppLocalizationsId extends AppLocalizations {
String get trackLyricsLoadFailed => 'Gagal memuat lirik';
@override
String get trackEmbedLyrics => 'Embed Lyrics';
String get trackEmbedLyrics => 'Sematkan Lirik';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
String get trackLyricsEmbedded => 'Lirik berhasil disematkan';
@override
String get trackInstrumental => 'Instrumental track';
String get trackInstrumental => 'Lagu instrumental';
@override
String get trackCopiedToClipboard => 'Disalin ke clipboard';
@@ -1242,7 +1276,7 @@ class AppLocalizationsId extends AppLocalizations {
String get storeEmptyNoResults => 'No extensions found';
@override
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
String get extensionDefaultProvider => 'Bawaan (Deezer/Spotify)';
@override
String get extensionDefaultProviderSubtitle => 'Gunakan pencarian bawaan';
@@ -1254,7 +1288,7 @@ class AppLocalizationsId extends AppLocalizations {
String get extensionId => 'ID';
@override
String get extensionError => 'Error';
String get extensionError => 'Terjadi kesalahan';
@override
String get extensionCapabilities => 'Kemampuan';
@@ -1393,20 +1427,42 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / hingga 192kHz';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get qualityNote =>
'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
@@ -1420,18 +1476,19 @@ class AppLocalizationsId extends AppLocalizations {
String get downloadAlbumFolderStructure => 'Struktur Folder Album';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
String get downloadUseAlbumArtistForFolders =>
'Gunakan Artis Album untuk folder';
@override
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
String get downloadUsePrimaryArtistOnly => 'Hanya artis utama untuk folder';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
'Artis unggulan dihapus dari nama folder (misalnya Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
'Nama lengkap artis digunakan untuk nama folder';
@override
String get downloadSelectQuality => 'Pilih Kualitas';
@@ -1453,24 +1510,24 @@ class AppLocalizationsId extends AppLocalizations {
'Apakah Anda yakin ingin menghapus semua unduhan?';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
String get settingsAutoExportFailed => 'Unduhan yang gagal diekspor otomatis';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
'Simpan unduhan yang gagal ke file TXT secara otomatis';
@override
String get settingsDownloadNetwork => 'Download Network';
String get settingsDownloadNetwork => 'Jaringan Unduhan';
@override
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
String get settingsDownloadNetworkAny => 'WiFi + Data Seluler';
@override
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
String get settingsDownloadNetworkWifiOnly => 'Hanya WiFi';
@override
String get settingsDownloadNetworkSubtitle =>
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
'Pilih jaringan mana yang akan digunakan untuk mengunduh. Jika diatur ke Hanya WiFi, unduhan akan berhenti sementara dan menggunakan data seluler.';
@override
String get albumFolderArtistAlbum => 'Artis / Album';
@@ -1498,11 +1555,18 @@ class AppLocalizationsId extends AppLocalizations {
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Nama Album/';
@override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
String get albumFolderArtistAlbumSingles => 'Artis / Album + Singel';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
'Artis/Album/ dan Artis/Single/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih';
@@ -1558,21 +1622,21 @@ class AppLocalizationsId extends AppLocalizations {
String get recentTypeSong => 'Lagu';
@override
String get recentTypePlaylist => 'Playlist';
String get recentTypePlaylist => 'Daftar putar';
@override
String get recentEmpty => 'No recent items yet';
String get recentEmpty => 'Belum ada item terbaru';
@override
String get recentShowAllDownloads => 'Show All Downloads';
String get recentShowAllDownloads => 'Tampilkan Semua Unduhan';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
return 'Daftar Putar: $name';
}
@override
String get discographyDownload => 'Download Discography';
String get discographyDownload => 'Unduh Diskografi';
@override
String get discographyDownloadAll => 'Unduh Semua';
@@ -1699,6 +1763,25 @@ class AppLocalizationsId extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Actions';
@@ -1863,44 +1946,44 @@ class AppLocalizationsId extends AppLocalizations {
}
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
String get tutorialWelcomeTitle => 'Selamat Datang di SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
'Mari kita pelajari cara mengunduh musik favorit Anda dalam kualitas lossless. Tutorial singkat ini akan menunjukkan dasar-dasarnya.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
'Unduh musik dari Spotify, Deezer, atau tempel URL yang didukung';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
'Dapatkan audio berkualitas FLAC dari Tidal, Qobuz, atau Deezer';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
'Penyematan metadata, sampul album, dan lirik secara otomatis';
@override
String get tutorialSearchTitle => 'Finding Music';
String get tutorialSearchTitle => 'Menemukan Musik';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
'Ada dua cara mudah untuk menemukan musik yang ingin Anda unduh.';
@override
String get tutorialDownloadTitle => 'Downloading Music';
String get tutorialDownloadTitle => 'Mengunduh Musik';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
'Mengunduh musik itu mudah dan cepat. Begini cara kerjanya.';
@override
String get tutorialLibraryTitle => 'Your Library';
String get tutorialLibraryTitle => 'Perpustakaan Anda';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
'Semua musik yang Anda unduh tersusun rapi di tab Perpustakaan.';
@override
String get tutorialLibraryTip1 =>
@@ -2149,6 +2232,28 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Antrekan FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Cari kecocokan online untuk track yang dipilih lalu antrekan download FLAC.\n\nFile yang sudah ada tidak akan diubah atau dihapus.\n\nHanya kecocokan dengan keyakinan tinggi yang akan diantrikan otomatis.\n\n$count dipilih';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Mencari kecocokan FLAC... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'Tidak ada kecocokan online yang cukup meyakinkan untuk pilihan ini';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Menambahkan $addedCount track ke antrean, melewati $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2158,7 +2263,8 @@ class AppLocalizationsId extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
String get trackConvertFormatSubtitle =>
'Konversi ke MP3, Opus, ALAC, atau FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2181,6 +2287,18 @@ class AppLocalizationsId extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Konversi dari $sourceFormat ke $targetFormat? (Lossless — tanpa kehilangan kualitas)\n\nFile asli akan dihapus setelah konversi.';
}
@override
String get trackConvertLosslessHint =>
'Konversi lossless — tanpa kehilangan kualitas';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2434,6 +2552,17 @@ class AppLocalizationsId extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2559,9 +2688,6 @@ class AppLocalizationsId extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2627,6 +2753,22 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Buat folder sumber playlist';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Unduhan dari playlist memakai Playlist/ lalu struktur folder normal Anda.';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Unduhan dari playlist hanya memakai struktur folder normal.';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'Mode Berdasarkan Playlist sudah menaruh unduhan ke dalam folder playlist.';
@override
String get downloadSongLinkRegion => 'SongLink Region';
@@ -2645,12 +2787,6 @@ class AppLocalizationsId extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2727,32 +2863,6 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2769,4 +2879,226 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
}
+388 -59
View File
@@ -352,7 +352,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get aboutAppDescription =>
'Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。';
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
@override
String get artistAlbums => 'アルバム';
@@ -521,6 +521,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get dialogImport => 'インポート';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => '破棄';
@@ -751,6 +754,36 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get searchPlaylists => 'プレイリスト';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override
String get tooltipPlay => '再生';
@@ -758,7 +791,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get filenameFormat => 'ファイル名の形式';
@override
String get filenameShowAdvancedTags => 'Show advanced tags';
String get filenameShowAdvancedTags => '高度なタグを表示';
@override
String get filenameShowAdvancedTagsDescription =>
@@ -1135,7 +1168,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@override
String get trackInstrumental => 'Instrumental track';
String get trackInstrumental => 'インストゥルメンタルのトラック';
@override
String get trackCopiedToClipboard => 'クリップボードにコピーしました';
@@ -1376,19 +1409,41 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / 最大 192kHz';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus のビットレート';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 のビットレート';
@override
String get downloadAskBeforeDownload => 'ダウンロード前に確認する';
@@ -1484,6 +1539,13 @@ class AppLocalizationsJa extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => '選択済みを削除';
@@ -1679,6 +1741,25 @@ class AppLocalizationsJa extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'アクション';
@@ -1855,7 +1936,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
@override
String get tutorialWelcomeTip3 =>
@@ -2129,6 +2210,28 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return '失敗: $error';
@@ -2161,6 +2264,18 @@ class AppLocalizationsJa extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'オーディオを変換中...';
@@ -2173,7 +2288,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get trackConvertFailed => '変換に失敗しました';
@override
String get cueSplitTitle => 'Split CUE Sheet';
String get cueSplitTitle => '分割 CUE シート';
@override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@@ -2323,7 +2438,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get collectionRemoveFromPlaylist => 'Remove from playlist';
@override
String get collectionRemoveFromFolder => 'Remove from folder';
String get collectionRemoveFromFolder => 'フォルダから削除';
@override
String collectionRemoved(String trackName) {
@@ -2357,26 +2472,26 @@ class AppLocalizationsJa extends AppLocalizations {
String get trackOptionRemoveFromLoved => 'Remove from Loved';
@override
String get trackOptionAddToWishlist => 'Add to Wishlist';
String get trackOptionAddToWishlist => 'ウィッシュリストに追加';
@override
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
String get trackOptionRemoveFromWishlist => 'ウィッシュから削除';
@override
String get collectionPlaylistChangeCover => 'Change cover image';
String get collectionPlaylistChangeCover => 'カバー画像を変更';
@override
String get collectionPlaylistRemoveCover => 'Remove cover image';
String get collectionPlaylistRemoveCover => 'カバー画像を削除';
@override
String selectionShareCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
other: '個のトラック',
one: '個のトラック',
);
return 'Share $count $_temp0';
return '$count $_temp0を共有';
}
@override
@@ -2397,7 +2512,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get selectionConvertNoConvertible => 'No convertible tracks selected';
@override
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
String get selectionBatchConvertConfirmTitle => '一括変換';
@override
String selectionBatchConvertConfirmMessage(
@@ -2414,6 +2529,17 @@ class AppLocalizationsJa extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2539,9 +2665,6 @@ class AppLocalizationsJa extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2607,6 +2730,22 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
@override
String get downloadSongLinkRegion => 'SongLink Region';
@@ -2625,12 +2764,6 @@ class AppLocalizationsJa extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2707,32 +2840,6 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2749,4 +2856,226 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
}
+376 -47
View File
@@ -344,7 +344,7 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get aboutAppDescription =>
'Tidal, Qobuz, Amazon Music에서 Spotify 트랙을 무손실 음질로 다운로드하세요.';
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
@override
String get artistAlbums => '앨범';
@@ -510,6 +510,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get dialogImport => '불러오기';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => '취소';
@@ -738,6 +741,36 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get searchPlaylists => '재생목록들';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override
String get tooltipPlay => '재생';
@@ -1366,20 +1399,42 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1477,6 +1532,13 @@ class AppLocalizationsKo extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -1672,6 +1734,25 @@ class AppLocalizationsKo extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Actions';
@@ -1848,7 +1929,7 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
@override
String get tutorialWelcomeTip3 =>
@@ -2122,6 +2203,28 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2154,6 +2257,18 @@ class AppLocalizationsKo extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2407,6 +2522,17 @@ class AppLocalizationsKo extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2532,9 +2658,6 @@ class AppLocalizationsKo extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2600,6 +2723,22 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
@override
String get downloadSongLinkRegion => 'SongLink Region';
@@ -2618,12 +2757,6 @@ class AppLocalizationsKo extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2700,32 +2833,6 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2742,4 +2849,226 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
}
+380 -51
View File
@@ -158,16 +158,16 @@ class AppLocalizationsNl extends AppLocalizations {
String get optionsConcurrentDownloads => 'Concurrent Downloads';
@override
String get optionsConcurrentSequential => 'Sequential (1 at a time)';
String get optionsConcurrentSequential => 'Sequentiële (1 per keer)';
@override
String optionsConcurrentParallel(int count) {
return '$count parallel downloads';
return '';
}
@override
String get optionsConcurrentWarning =>
'Parallel downloads may trigger rate limiting';
'Parallel downloaden kan leiden tot rate-limiting';
@override
String get optionsExtensionStore => 'Extension Store';
@@ -271,7 +271,7 @@ class AppLocalizationsNl extends AppLocalizations {
String get aboutContributors => 'Contributors';
@override
String get aboutMobileDeveloper => 'Mobile version developer';
String get aboutMobileDeveloper => '';
@override
String get aboutOriginalCreator => 'Creator of the original SpotiFLAC';
@@ -356,7 +356,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
@override
String get artistAlbums => 'Albums';
@@ -525,6 +525,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get dialogImport => 'Import';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Discard';
@@ -756,6 +759,36 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get searchPlaylists => 'Playlists';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override
String get tooltipPlay => 'Play';
@@ -1386,20 +1419,42 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1497,6 +1552,13 @@ class AppLocalizationsNl extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -1692,6 +1754,25 @@ class AppLocalizationsNl extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Actions';
@@ -1868,7 +1949,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
@override
String get tutorialWelcomeTip3 =>
@@ -2142,6 +2223,28 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2174,6 +2277,18 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2427,6 +2542,17 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2552,9 +2678,6 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2620,6 +2743,22 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
@override
String get downloadSongLinkRegion => 'SongLink Region';
@@ -2638,12 +2777,6 @@ class AppLocalizationsNl extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2720,32 +2853,6 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2762,4 +2869,226 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
}
File diff suppressed because it is too large Load Diff
+401 -71
View File
@@ -363,7 +363,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get aboutAppDescription =>
'Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.';
'Скачивайте треки Spotify в lossless качестве с Tidal и Qobuz.';
@override
String get artistAlbums => 'Альбомы';
@@ -534,6 +534,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get dialogImport => 'Импорт';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Отменить';
@@ -703,15 +706,15 @@ class AppLocalizationsRu extends AppLocalizations {
String get errorNoTracksFound => 'Треки не найдены';
@override
String get errorUrlNotRecognized => 'Link not recognized';
String get errorUrlNotRecognized => 'Ссылка не распознана';
@override
String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
'Эта ссылка не поддерживается. Убедитесь, что URL-адрес указан правильно и установлено совместимое расширение.';
@override
String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.';
'Не удалось загрузить контент по этой ссылке. Пожалуйста, попробуйте еще раз.';
@override
String errorMissingExtensionSource(String item) {
@@ -770,6 +773,36 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get searchPlaylists => 'Плейлисты';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override
String get tooltipPlay => 'Воспроизвести';
@@ -787,11 +820,11 @@ class AppLocalizationsRu extends AppLocalizations {
String get folderOrganizationNone => 'Без организации';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
String get folderOrganizationByPlaylist => 'По плейлисту';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
'Отдельная папка для каждого плейлиста';
@override
String get folderOrganizationByArtist => 'По исполнителю';
@@ -1411,20 +1444,42 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-бит / до 192кГц';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get qualityNote =>
'Фактическое качество зависит от доступности треков в сервисе';
@override
String get youtubeQualityNote =>
'YouTube обеспечивает только звук с потерями(Lossy).';
@override
String get youtubeOpusBitrateTitle => 'Битрейт YouTube Opus';
@override
String get youtubeMp3BitrateTitle => 'Битрейт YouTube MP3';
@override
String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием';
@@ -1447,7 +1502,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
'Список исполнителей, чьи работы были удалены из названия папки (например, Джастин Бибер, Quavo → Джастин Бибер)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
@@ -1526,6 +1581,13 @@ class AppLocalizationsRu extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Исполнитель/Альбом и Исполнитель/Сингл/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => 'Удалить выбранные';
@@ -1728,6 +1790,25 @@ class AppLocalizationsRu extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Показать при поиске существующих треков';
@override
String get libraryAutoScan => 'Auto Scan';
@override
String get libraryAutoScanSubtitle =>
'Automatically scan your library for new files';
@override
String get libraryAutoScanOff => 'Off';
@override
String get libraryAutoScanOnOpen => 'Every app open';
@override
String get libraryAutoScanDaily => 'Daily';
@override
String get libraryAutoScanWeekly => 'Weekly';
@override
String get libraryActions => 'Действия';
@@ -1918,7 +1999,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
'Скачайте FLAC с Tidal, Qobuz или Amazon Music';
'Получите аудио в качестве FLAC от Tidal, Qobuz или Deezer';
@override
String get tutorialWelcomeTip3 =>
@@ -2014,7 +2095,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history';
return 'Удалено $count утерянных записей из истории';
}
@override
@@ -2039,7 +2120,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get cacheSectionStorage => 'Кэшированные данные';
@override
String get cacheSectionMaintenance => 'Maintenance';
String get cacheSectionMaintenance => 'Обслуживание';
@override
String get cacheAppDirectory => 'Папка кэша приложения';
@@ -2085,7 +2166,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
'Удалить записи из истории загрузок и библиотеки, которые остались без файлов.';
@override
String get cacheNoData => 'Нет кэшированных данных';
@@ -2133,7 +2214,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
'Удалить историю загрузок, оставшихся без просмотра, и отсутствующие записи в библиотеке';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
@@ -2195,6 +2276,28 @@ class AppLocalizationsRu extends AppLocalizations {
String get trackReEnrichFfmpegFailed =>
'Ошибка встраивания метаданных FFmpeg';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Ошибка: $error';
@@ -2227,6 +2330,18 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Конвертировать из $sourceFormat в $targetFormat $bitrate?\n\nОригинальный файл будет удален после конвертации.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Конвертация аудио...';
@@ -2239,52 +2354,52 @@ class AppLocalizationsRu extends AppLocalizations {
String get trackConvertFailed => 'Ошибка конвертации';
@override
String get cueSplitTitle => 'Split CUE Sheet';
String get cueSplitTitle => 'Разделить CUE Sheet';
@override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
String get cueSplitSubtitle => 'Разделить файл CUE+FLAC на отдельные треки';
@override
String cueSplitAlbum(String album) {
return 'Album: $album';
return 'Альбом: $album';
}
@override
String cueSplitArtist(String artist) {
return 'Artist: $artist';
return 'Артист: $artist';
}
@override
String cueSplitTrackCount(int count) {
return '$count tracks';
return '$count треков';
}
@override
String get cueSplitConfirmTitle => 'Split CUE Album';
String get cueSplitConfirmTitle => 'Разделенный CUE-альбом';
@override
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
return 'Разбить \"$album\" на $count отдельных FLAC-файлов?';
}
@override
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
return 'Разделение CUE sheet... ($current/$total)';
}
@override
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
return 'Успешно разделено на $count треков';
}
@override
String get cueSplitFailed => 'CUE split failed';
String get cueSplitFailed => 'Разделение CUE не удалось';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
String get cueSplitNoAudioFile => 'Аудиофайл для этого CUE sheet не найден';
@override
String get cueSplitButton => 'Split into Tracks';
String get cueSplitButton => 'Разделить на Треки';
@override
String get actionCreate => 'Создать';
@@ -2450,7 +2565,8 @@ class AppLocalizationsRu extends AppLocalizations {
}
@override
String get selectionShareNoFiles => 'No shareable files found';
String get selectionShareNoFiles =>
'Файлы, доступные для совместного доступа, не найдены';
@override
String selectionConvertCount(int count) {
@@ -2483,7 +2599,18 @@ class AppLocalizationsRu extends AppLocalizations {
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
return 'Преобразовать $count $_temp0 в $format с $bitrate?';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
@@ -2611,9 +2738,6 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2679,6 +2803,22 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
@override
String get downloadSongLinkRegion => 'SongLink Region';
@@ -2697,12 +2837,6 @@ class AppLocalizationsRu extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2779,32 +2913,6 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2821,4 +2929,226 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+143 -51
View File
@@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.",
"aboutAppDescription": "Lade Spotify-Titel in verlustfreier Qualität von Tidal und Qobuz herunter.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -555,7 +555,7 @@
"@setupDownloadLocationTitle": {
"description": "Download location dialog title"
},
"setupDownloadLocationIosMessage": "Auf iOS werden Downloads im Dokumentenverzeichnis der App gespeichert. Du kannst sie über die Datei-App aufrufen.",
"setupDownloadLocationIosMessage": "Auf iOS werden Downloads im Dokumentenordner der App gespeichert. Du kannst sie über die Datei-App aufrufen.",
"@setupDownloadLocationIosMessage": {
"description": "iOS-specific folder info"
},
@@ -897,6 +897,18 @@
"@errorNoTracksFound": {
"description": "Error - search returned no results"
},
"errorUrlNotRecognized": "Link wurde nicht erkannt",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
},
"errorUrlNotRecognizedMessage": "Dieser Link ist inkompatibel. Prüfe die URL und stelle sicher, dass eine kompatible Erweiterung installiert ist.",
"@errorUrlNotRecognizedMessage": {
"description": "Error message - URL not recognized explanation"
},
"errorUrlFetchFailed": "Laden fehlgeschlagen. Bitte erneut versuchen.",
"@errorUrlFetchFailed": {
"description": "Error message - generic URL fetch failure"
},
"errorMissingExtensionSource": "Kann {item} nicht lade wegen fehlender Erweiterungsquelle",
"@errorMissingExtensionSource": {
"description": "Error - extension source not available",
@@ -947,7 +959,7 @@
"@selectionAllSelected": {
"description": "Status - all items selected"
},
"selectionSelectToDelete": "Titel zum Löschen auswählen",
"selectionSelectToDelete": "Titel zum Löschen wählen",
"@selectionSelectToDelete": {
"description": "Placeholder when nothing selected"
},
@@ -975,7 +987,7 @@
"@searchArtists": {
"description": "Search result category - artists"
},
"searchAlbums": "Albums",
"searchAlbums": "Alben",
"@searchAlbums": {
"description": "Search result category - albums"
},
@@ -1003,6 +1015,14 @@
"@folderOrganizationNone": {
"description": "Folder option - flat structure"
},
"folderOrganizationByPlaylist": "Nach Playlist",
"@folderOrganizationByPlaylist": {
"description": "Folder option - playlist folders"
},
"folderOrganizationByPlaylistSubtitle": "Ordner für jede Playlist trennen",
"@folderOrganizationByPlaylistSubtitle": {
"description": "Subtitle for playlist folder option"
},
"folderOrganizationByArtist": "Nach Künstler",
"@folderOrganizationByArtist": {
"description": "Folder option - artist folders"
@@ -1019,7 +1039,7 @@
"@folderOrganizationDescription": {
"description": "Folder organization sheet description"
},
"folderOrganizationNoneSubtitle": "Alle Dateien im Download-Verzeichnis",
"folderOrganizationNoneSubtitle": "Alle Dateien im Download-Ordner",
"@folderOrganizationNoneSubtitle": {
"description": "Subtitle for no organization option"
},
@@ -1097,7 +1117,7 @@
},
"providerBuiltIn": "Integriert",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Erweiterung",
"@providerExtension": {
@@ -1753,23 +1773,11 @@
"@qualityNote": {
"description": "Note about quality availability"
},
"youtubeQualityNote": "YouTube bietet nur verlustbehaftete Audioqualität. Deswegen ist es kein Teil des verlustfreien Fallbacks.",
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Qualität vor Download fragen",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
},
"downloadDirectory": "Downloadverzeichnis",
"downloadDirectory": "Download-Ordner",
"@downloadDirectory": {
"description": "Setting - download folder"
},
@@ -1777,15 +1785,15 @@
"@downloadSeparateSinglesFolder": {
"description": "Setting - separate folder for singles"
},
"downloadAlbumFolderStructure": "Album Folder Structure",
"downloadAlbumFolderStructure": "Album-Ordnerstruktur",
"@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization"
},
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
"downloadUseAlbumArtistForFolders": "Album-Künstler für Ordner verwenden",
"@downloadUseAlbumArtistForFolders": {
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
},
"downloadUsePrimaryArtistOnly": "Primary artist only for folders",
"downloadUsePrimaryArtistOnly": "Primärer Künstler nur für Ordner",
"@downloadUsePrimaryArtistOnly": {
"description": "Setting - strip featured artists from folder name"
},
@@ -1793,7 +1801,7 @@
"@downloadUsePrimaryArtistOnlyEnabled": {
"description": "Subtitle when primary artist only is enabled"
},
"downloadUsePrimaryArtistOnlyDisabled": "Full artist string used for folder name",
"downloadUsePrimaryArtistOnlyDisabled": "Vollständiger Künstler für Ordnername",
"@downloadUsePrimaryArtistOnlyDisabled": {
"description": "Subtitle when primary artist only is disabled"
},
@@ -1821,7 +1829,7 @@
"@queueClearAllMessage": {
"description": "Clear queue confirmation"
},
"settingsAutoExportFailed": "Auto-export failed downloads",
"settingsAutoExportFailed": "Auto-Export fehlgeschlagener Downloads",
"@settingsAutoExportFailed": {
"description": "Setting toggle for auto-export"
},
@@ -1849,15 +1857,15 @@
"@albumFolderArtistAlbum": {
"description": "Album folder option"
},
"albumFolderArtistAlbumSubtitle": "Albums/Artist Name/Album Name/",
"albumFolderArtistAlbumSubtitle": "Alben/Künster Name/Album Name/",
"@albumFolderArtistAlbumSubtitle": {
"description": "Folder structure example"
},
"albumFolderArtistYearAlbum": "Artist / [Year] Album",
"albumFolderArtistYearAlbum": "Künstler / [Year] Album",
"@albumFolderArtistYearAlbum": {
"description": "Album folder option with year"
},
"albumFolderArtistYearAlbumSubtitle": "Albums/Künster Name/[2005] Album Name/",
"albumFolderArtistYearAlbumSubtitle": "Alben/Künster Name/[2005] Album Name/",
"@albumFolderArtistYearAlbumSubtitle": {
"description": "Folder structure example"
},
@@ -1873,15 +1881,15 @@
"@albumFolderYearAlbum": {
"description": "Album folder option with year"
},
"albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/",
"albumFolderYearAlbumSubtitle": "Alben/[2005] Album Name/",
"@albumFolderYearAlbumSubtitle": {
"description": "Folder structure example"
},
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
"albumFolderArtistAlbumSingles": "Künstler / Album + Singles",
"@albumFolderArtistAlbumSingles": {
"description": "Album folder option with singles inside artist"
},
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
"albumFolderArtistAlbumSinglesSubtitle": "Künstler/Album/ und Künstler/Singles/",
"@albumFolderArtistAlbumSinglesSubtitle": {
"description": "Folder structure example"
},
@@ -1924,7 +1932,7 @@
}
}
},
"downloadedAlbumSelectToDelete": "Select tracks to delete",
"downloadedAlbumSelectToDelete": "Titel zum Löschen wählen",
"@downloadedAlbumSelectToDelete": {
"description": "Placeholder when nothing selected"
},
@@ -1996,7 +2004,7 @@
"@discographyAlbumsOnly": {
"description": "Option - download only albums"
},
"discographyAlbumsOnlySubtitle": "{count} Titel von {albumCount} Albums",
"discographyAlbumsOnlySubtitle": "{count} Titel aus {albumCount} Alben",
"@discographyAlbumsOnlySubtitle": {
"description": "Subtitle showing album tracks count",
"placeholders": {
@@ -2028,7 +2036,7 @@
"@discographySelectAlbums": {
"description": "Option - manually select albums to download"
},
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
"discographySelectAlbumsSubtitle": "Wähle bestimmte Alben oder Singles",
"@discographySelectAlbumsSubtitle": {
"description": "Subtitle for select albums option"
},
@@ -2036,7 +2044,7 @@
"@discographyFetchingTracks": {
"description": "Progress - fetching album tracks"
},
"discographyFetchingAlbum": "Fetching {current} of {total}...",
"discographyFetchingAlbum": "Lade {current} von {total}...",
"@discographyFetchingAlbum": {
"description": "Progress - fetching specific album",
"placeholders": {
@@ -2061,7 +2069,7 @@
"@discographyDownloadSelected": {
"description": "Button - download selected albums"
},
"discographyAddedToQueue": "Added {count} tracks to queue",
"discographyAddedToQueue": "{count} Titel zur Warteschlange hinzugefügt",
"@discographyAddedToQueue": {
"description": "Snackbar - tracks added from discography",
"placeholders": {
@@ -2086,7 +2094,7 @@
"@discographyNoAlbums": {
"description": "Error - no albums found for artist"
},
"discographyFailedToFetch": "Failed to fetch some albums",
"discographyFailedToFetch": "Fehler beim Abrufen einiger Alben",
"@discographyFailedToFetch": {
"description": "Error - some albums failed to load"
},
@@ -2098,15 +2106,15 @@
"@allFilesAccess": {
"description": "Toggle for MANAGE_EXTERNAL_STORAGE permission"
},
"allFilesAccessEnabledSubtitle": "Can write to any folder",
"allFilesAccessEnabledSubtitle": "Darf in jeden Ordner schreiben",
"@allFilesAccessEnabledSubtitle": {
"description": "Subtitle when all files access is enabled"
},
"allFilesAccessDisabledSubtitle": "Limited to media folders only",
"allFilesAccessDisabledSubtitle": "Nur auf Medienordner begrenzt",
"@allFilesAccessDisabledSubtitle": {
"description": "Subtitle when all files access is disabled"
},
"allFilesAccessDescription": "Aktiviere die Option, wenn beim Speichern in benutzerdefinierten Ordnern Schreibfehler auftreten. Weil Android 13+ standardmäßig den Zugriff auf bestimmte Verzeichnisse einschränkt.",
"allFilesAccessDescription": "Option bei Schreibfehlern bitte aktivieren (erforderlich ab Android 13).",
"@allFilesAccessDescription": {
"description": "Description explaining when to enable all files access"
},
@@ -2122,7 +2130,7 @@
"@settingsLocalLibrary": {
"description": "Settings menu item - local library"
},
"settingsLocalLibrarySubtitle": "Scan music & detect duplicates",
"settingsLocalLibrarySubtitle": "Musik scannen & Duplikate erkennen",
"@settingsLocalLibrarySubtitle": {
"description": "Subtitle for local library settings"
},
@@ -2130,7 +2138,7 @@
"@settingsCache": {
"description": "Settings menu item - cache management"
},
"settingsCacheSubtitle": "View size and clear cached data",
"settingsCacheSubtitle": "Größe anzeigen und Daten im Cache leeren",
"@settingsCacheSubtitle": {
"description": "Subtitle for cache management menu"
},
@@ -2146,7 +2154,7 @@
"@libraryEnableLocalLibrary": {
"description": "Toggle to enable library scanning"
},
"libraryEnableLocalLibrarySubtitle": "Scan and track your existing music",
"libraryEnableLocalLibrarySubtitle": "Scan und verfolge deine bestehende Musik",
"@libraryEnableLocalLibrarySubtitle": {
"description": "Subtitle for enable toggle"
},
@@ -2158,7 +2166,7 @@
"@libraryFolderHint": {
"description": "Placeholder when no folder selected"
},
"libraryShowDuplicateIndicator": "Show Duplicate Indicator",
"libraryShowDuplicateIndicator": "Duplikat Indikator anzeigen",
"@libraryShowDuplicateIndicator": {
"description": "Toggle for duplicate indicator in search"
},
@@ -2383,7 +2391,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Hole dir FLAC Audio von Tidal, Qobuz oder Amazon Musik",
"tutorialWelcomeTip2": "Hole dir FLAC Audio von Tidal, Qobuz oder Deezer",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},
@@ -2455,7 +2463,7 @@
"@tutorialSettingsDesc": {
"description": "Tutorial settings page description"
},
"tutorialSettingsTip1": "Downloadverzeichnis und Ordnerorganisation ändern",
"tutorialSettingsTip1": "Download-Ordner und Ordner-Organisation ändern",
"@tutorialSettingsTip1": {
"description": "Tutorial settings tip 1"
},
@@ -2529,7 +2537,7 @@
"@cacheSectionMaintenance": {
"description": "Section header for cleanup actions"
},
"cacheAppDirectory": "App-Cache Verzeichnis",
"cacheAppDirectory": "App-Cache Ordner",
"@cacheAppDirectory": {
"description": "Cache item title for app cache directory"
},
@@ -2537,7 +2545,7 @@
"@cacheAppDirectoryDesc": {
"description": "Description of what app cache directory contains"
},
"cacheTempDirectory": "Temporäres Verzeichnis",
"cacheTempDirectory": "Temporärer Ordner",
"@cacheTempDirectory": {
"description": "Cache item title for temporary files directory"
},
@@ -2705,7 +2713,7 @@
"@trackEditMetadata": {
"description": "Menu action - edit embedded metadata"
},
"trackCoverSaved": "Cover art saved to {fileName}",
"trackCoverSaved": "Cover in {fileName} gespeichert",
"@trackCoverSaved": {
"description": "Snackbar after cover art saved",
"placeholders": {
@@ -2714,7 +2722,7 @@
}
}
},
"trackCoverNoSource": "No cover art source available",
"trackCoverNoSource": "Keine Cover Quelle vorhanden",
"@trackCoverNoSource": {
"description": "Snackbar when no cover art URL or embedded cover"
},
@@ -2808,6 +2816,90 @@
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
},
"cueSplitTitle": "CUE-Sheet aufteilen",
"@cueSplitTitle": {
"description": "Title for CUE split bottom sheet"
},
"cueSplitSubtitle": "CUE+FLAC in einzelne Titel aufteilen",
"@cueSplitSubtitle": {
"description": "Subtitle for CUE split menu item"
},
"cueSplitAlbum": "Album: {album}",
"@cueSplitAlbum": {
"description": "Album name in CUE split sheet",
"placeholders": {
"album": {
"type": "String"
}
}
},
"cueSplitArtist": "Künstler: {artist}",
"@cueSplitArtist": {
"description": "Artist name in CUE split sheet",
"placeholders": {
"artist": {
"type": "String"
}
}
},
"cueSplitTrackCount": "{count} Titel",
"@cueSplitTrackCount": {
"description": "Number of tracks in CUE sheet",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitConfirmTitle": "CUE-Album aufteilen",
"@cueSplitConfirmTitle": {
"description": "CUE split confirmation dialog title"
},
"cueSplitConfirmMessage": "Soll „{album}“ in {count} einzelne FLAC-Dateien aufgeteilt werden?\n\nDie Dateien werden im selben Ordner gespeichert.",
"@cueSplitConfirmMessage": {
"description": "CUE split confirmation dialog message",
"placeholders": {
"album": {
"type": "String"
},
"count": {
"type": "int"
}
}
},
"cueSplitSplitting": "CUE-Sheet wird geteilt... ({current}/{total})",
"@cueSplitSplitting": {
"description": "Snackbar while splitting CUE",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"cueSplitSuccess": "{count} Titel erfolgreich aufgeteilt",
"@cueSplitSuccess": {
"description": "Snackbar after successful CUE split",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitFailed": "CUE-Aufteilung fehlgeschlagen",
"@cueSplitFailed": {
"description": "Snackbar when CUE split fails"
},
"cueSplitNoAudioFile": "Audiodatei für dieses CUE-Sheet nicht gefunden",
"@cueSplitNoAudioFile": {
"description": "Error when CUE audio file is missing"
},
"cueSplitButton": "In Titel aufteilen",
"@cueSplitButton": {
"description": "Button text to start CUE splitting"
},
"actionCreate": "Erstellen",
"@actionCreate": {
"description": "Generic action button - create"
@@ -3094,11 +3186,11 @@
}
}
},
"downloadUseAlbumArtistForFoldersAlbumSubtitle": "Künstlerordner verwenden den Album-Interpreten, wenn verfügbar",
"downloadUseAlbumArtistForFoldersAlbumSubtitle": "Interpret-Ordner verwenden Album-Interpret, sofern vorhanden",
"@downloadUseAlbumArtistForFoldersAlbumSubtitle": {
"description": "Subtitle when Album Artist is used for folder naming"
},
"downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only",
"downloadUseAlbumArtistForFoldersTrackSubtitle": "Künstler-Ordner nur für Titel-Künstler",
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming"
}
+460 -54
View File
@@ -671,6 +671,10 @@
"@dialogImport": {
"description": "Dialog button - import data"
},
"dialogDownload": "Download",
"@dialogDownload": {
"description": "Dialog button - download action"
},
"dialogDiscard": "Discard",
"@dialogDiscard": {
"description": "Dialog button - discard changes"
@@ -995,6 +999,46 @@
"@searchPlaylists": {
"description": "Search result category - playlists"
},
"searchSortTitle": "Sort Results",
"@searchSortTitle": {
"description": "Bottom sheet title for search sort options"
},
"searchSortDefault": "Default",
"@searchSortDefault": {
"description": "Sort option - default API order"
},
"searchSortTitleAZ": "Title (A-Z)",
"@searchSortTitleAZ": {
"description": "Sort option - title ascending"
},
"searchSortTitleZA": "Title (Z-A)",
"@searchSortTitleZA": {
"description": "Sort option - title descending"
},
"searchSortArtistAZ": "Artist (A-Z)",
"@searchSortArtistAZ": {
"description": "Sort option - artist ascending"
},
"searchSortArtistZA": "Artist (Z-A)",
"@searchSortArtistZA": {
"description": "Sort option - artist descending"
},
"searchSortDurationShort": "Duration (Shortest)",
"@searchSortDurationShort": {
"description": "Sort option - shortest duration first"
},
"searchSortDurationLong": "Duration (Longest)",
"@searchSortDurationLong": {
"description": "Sort option - longest duration first"
},
"searchSortDateOldest": "Release Date (Oldest)",
"@searchSortDateOldest": {
"description": "Sort option - oldest release first"
},
"searchSortDateNewest": "Release Date (Newest)",
"@searchSortDateNewest": {
"description": "Sort option - newest release first"
},
"tooltipPlay": "Play",
"@tooltipPlay": {
"description": "Tooltip - play button"
@@ -1622,7 +1666,7 @@
"@storeEmptyNoResults": {
"description": "Message when search/filter returns no results"
},
"extensionDefaultProvider": "Default (Deezer/Spotify)",
"extensionDefaultProvider": "Default (Deezer)",
"@extensionDefaultProvider": {
"description": "Default search provider option"
},
@@ -1821,22 +1865,50 @@
"@qualityHiResFlacMaxSubtitle": {
"description": "Technical spec for hi-res max"
},
"downloadLossy320": "Lossy 320kbps",
"@downloadLossy320": {
"description": "Quality option label for Tidal lossy 320kbps"
},
"downloadLossyFormat": "Lossy Format",
"@downloadLossyFormat": {
"description": "Setting title to pick output format for Tidal lossy downloads"
},
"downloadLossy320Format": "Lossy 320kbps Format",
"@downloadLossy320Format": {
"description": "Title of the Tidal 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": {
"description": "Description in the Tidal lossy format picker"
},
"downloadLossyMp3": "MP3 320kbps",
"@downloadLossyMp3": {
"description": "Tidal lossy format option - MP3 320kbps"
},
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
"@downloadLossyMp3Subtitle": {
"description": "Subtitle for MP3 320kbps Tidal lossy option"
},
"downloadLossyOpus256": "Opus 256kbps",
"@downloadLossyOpus256": {
"description": "Tidal lossy format option - Opus 256kbps"
},
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
"@downloadLossyOpus256Subtitle": {
"description": "Subtitle for Opus 256kbps Tidal lossy option"
},
"downloadLossyOpus128": "Opus 128kbps",
"@downloadLossyOpus128": {
"description": "Tidal lossy format option - Opus 128kbps"
},
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
"@downloadLossyOpus128Subtitle": {
"description": "Subtitle for Opus 128kbps Tidal lossy option"
},
"qualityNote": "Actual quality depends on track availability from the service",
"@qualityNote": {
"description": "Note about quality availability"
},
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
@@ -1957,6 +2029,14 @@
"@albumFolderArtistAlbumSinglesSubtitle": {
"description": "Folder structure example"
},
"albumFolderArtistAlbumFlat": "Artist / Album (Singles flat)",
"@albumFolderArtistAlbumFlat": {
"description": "Album folder option with singles directly in artist folder"
},
"albumFolderArtistAlbumFlatSubtitle": "Artist/Album/ and Artist/song.flac",
"@albumFolderArtistAlbumFlatSubtitle": {
"description": "Folder structure example for flat singles"
},
"downloadedAlbumDeleteSelected": "Delete Selected",
"@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks"
@@ -2238,6 +2318,30 @@
"@libraryShowDuplicateIndicatorSubtitle": {
"description": "Subtitle for duplicate indicator toggle"
},
"libraryAutoScan": "Auto Scan",
"@libraryAutoScan": {
"description": "Setting for automatic library scanning"
},
"libraryAutoScanSubtitle": "Automatically scan your library for new files",
"@libraryAutoScanSubtitle": {
"description": "Subtitle for auto scan setting"
},
"libraryAutoScanOff": "Off",
"@libraryAutoScanOff": {
"description": "Auto scan disabled"
},
"libraryAutoScanOnOpen": "Every app open",
"@libraryAutoScanOnOpen": {
"description": "Auto scan when app opens"
},
"libraryAutoScanDaily": "Daily",
"@libraryAutoScanDaily": {
"description": "Auto scan once per day"
},
"libraryAutoScanWeekly": "Weekly",
"@libraryAutoScanWeekly": {
"description": "Auto scan once per week"
},
"libraryActions": "Actions",
"@libraryActions": {
"description": "Section header for library actions"
@@ -2815,6 +2919,47 @@
"@trackReEnrichFfmpegFailed": {
"description": "Snackbar when FFmpeg embed fails for MP3/Opus"
},
"queueFlacAction": "Queue FLAC",
"@queueFlacAction": {
"description": "Action/button label for queueing FLAC redownloads for local tracks"
},
"queueFlacConfirmMessage": "Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n{count} selected",
"@queueFlacConfirmMessage": {
"description": "Confirmation dialog body before queueing FLAC redownloads for local tracks",
"placeholders": {
"count": {
"type": "int"
}
}
},
"queueFlacFindingProgress": "Finding FLAC matches... ({current}/{total})",
"@queueFlacFindingProgress": {
"description": "Snackbar while resolving remote matches for local FLAC redownloads",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"queueFlacNoReliableMatches": "No reliable online matches found for the selection",
"@queueFlacNoReliableMatches": {
"description": "Snackbar when no safe FLAC redownload matches were found"
},
"queueFlacQueuedWithSkipped": "Added {addedCount} tracks to queue, skipped {skippedCount}",
"@queueFlacQueuedWithSkipped": {
"description": "Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped",
"placeholders": {
"addedCount": {
"type": "int"
},
"skippedCount": {
"type": "int"
}
}
},
"trackSaveFailed": "Failed: {error}",
"@trackSaveFailed": {
"description": "Snackbar when save operation fails",
@@ -2828,7 +2973,7 @@
"@trackConvertFormat": {
"description": "Menu item - convert audio format"
},
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
"trackConvertFormatSubtitle": "Convert to MP3, Opus, ALAC, or FLAC",
"@trackConvertFormatSubtitle": {
"description": "Subtitle for convert format menu item"
},
@@ -2863,6 +3008,22 @@
}
}
},
"trackConvertConfirmMessageLossless": "Convert from {sourceFormat} to {targetFormat}? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.",
"@trackConvertConfirmMessageLossless": {
"description": "Confirmation dialog message for lossless-to-lossless conversion",
"placeholders": {
"sourceFormat": {
"type": "String"
},
"targetFormat": {
"type": "String"
}
}
},
"trackConvertLosslessHint": "Lossless conversion — no quality loss",
"@trackConvertLosslessHint": {
"description": "Hint shown when converting between lossless formats"
},
"trackConvertConverting": "Converting audio...",
"@trackConvertConverting": {
"description": "Snackbar while converting"
@@ -3214,6 +3375,18 @@
}
}
},
"selectionBatchConvertConfirmMessageLossless": "Convert {count} {count, plural, =1{track} other{tracks}} to {format}? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.",
"@selectionBatchConvertConfirmMessageLossless": {
"description": "Confirmation dialog message for lossless batch conversion",
"placeholders": {
"count": {
"type": "int"
},
"format": {
"type": "String"
}
}
},
"selectionBatchConvertProgress": "Converting {current} of {total}...",
"@selectionBatchConvertProgress": {
"description": "Snackbar during batch conversion progress",
@@ -3489,6 +3662,22 @@
"@downloadArtistNameFilters": {
"description": "Setting title for artist folder filter options"
},
"downloadCreatePlaylistSourceFolder": "Create playlist source folder",
"@downloadCreatePlaylistSourceFolder": {
"description": "Setting title for adding a playlist folder prefix before the normal organization structure"
},
"downloadCreatePlaylistSourceFolderEnabled": "Playlist downloads use Playlist/ plus your normal folder structure.",
"@downloadCreatePlaylistSourceFolderEnabled": {
"description": "Subtitle when playlist source folder prefix is enabled"
},
"downloadCreatePlaylistSourceFolderDisabled": "Playlist downloads use the normal folder structure only.",
"@downloadCreatePlaylistSourceFolderDisabled": {
"description": "Subtitle when playlist source folder prefix is disabled"
},
"downloadCreatePlaylistSourceFolderRedundant": "By Playlist already places downloads inside a playlist folder.",
"@downloadCreatePlaylistSourceFolderRedundant": {
"description": "Subtitle when playlist folder prefix setting is redundant because folder organization is already by playlist"
},
"downloadSongLinkRegion": "SongLink Region",
"@downloadSongLinkRegion": {
"description": "Setting title for SongLink country region"
@@ -3509,14 +3698,7 @@
"@downloadSelectServiceToEnable": {
"description": "Hint shown instead of Ask-quality subtitle when no built-in service selected"
},
"downloadLossy320": "Lossy 320kbps",
"@downloadLossy320": {
"description": "Quality option label for Tidal lossy 320kbps"
},
"downloadLossyFormat": "Lossy Format",
"@downloadLossyFormat": {
"description": "Setting title to pick output format for Tidal lossy downloads"
},
"downloadSelectTidalQobuz": "Select Tidal or Qobuz above to configure quality",
"@downloadSelectTidalQobuz": {
"description": "Info hint when non-Tidal/Qobuz service is selected"
@@ -3602,38 +3784,7 @@
"@downloadMusixmatchAuto": {
"description": "Button to reset Musixmatch language to automatic"
},
"downloadLossy320Format": "Lossy 320kbps Format",
"@downloadLossy320Format": {
"description": "Title of the Tidal 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": {
"description": "Description in the Tidal lossy format picker"
},
"downloadLossyMp3": "MP3 320kbps",
"@downloadLossyMp3": {
"description": "Tidal lossy format option - MP3 320kbps"
},
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
"@downloadLossyMp3Subtitle": {
"description": "Subtitle for MP3 320kbps option"
},
"downloadLossyOpus256": "Opus 256kbps",
"@downloadLossyOpus256": {
"description": "Tidal lossy format option - Opus 256kbps"
},
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
"@downloadLossyOpus256Subtitle": {
"description": "Subtitle for Opus 256kbps option"
},
"downloadLossyOpus128": "Opus 128kbps",
"@downloadLossyOpus128": {
"description": "Tidal lossy format option - Opus 128kbps"
},
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
"@downloadLossyOpus128Subtitle": {
"description": "Subtitle for Opus 128kbps option"
},
"downloadNetworkAnySubtitle": "WiFi + Mobile Data",
"@downloadNetworkAnySubtitle": {
"description": "Subtitle for 'Any' network mode option"
@@ -3657,5 +3808,260 @@
"cacheRefresh": "Refresh",
"@cacheRefresh": {
"description": "Tooltip for refresh button on cache management page"
},
"dialogDownloadAllTitle": "Download All",
"@dialogDownloadAllTitle": {
"description": "Dialog title for bulk download confirmation"
},
"dialogDownloadPlaylistsMessage": "Download {trackCount} {trackCount, plural, =1{track} other{tracks}} from {playlistCount} {playlistCount, plural, =1{playlist} other{playlists}}?",
"@dialogDownloadPlaylistsMessage": {
"description": "Dialog message for bulk playlist download confirmation",
"placeholders": {
"trackCount": {
"type": "int"
},
"playlistCount": {
"type": "int"
}
}
},
"bulkDownloadPlaylistsButton": "Download {count} {count, plural, =1{playlist} other{playlists}}",
"@bulkDownloadPlaylistsButton": {
"description": "Button label for bulk downloading selected playlists",
"placeholders": {
"count": {
"type": "int"
}
}
},
"bulkDownloadSelectPlaylists": "Select playlists to download",
"@bulkDownloadSelectPlaylists": {
"description": "Button label when no playlists are selected for download"
},
"snackbarSelectedPlaylistsEmpty": "Selected playlists have no tracks",
"@snackbarSelectedPlaylistsEmpty": {
"description": "Snackbar when selected playlists contain no tracks"
},
"playlistsCount": "{count, plural, =1{1 playlist} other{{count} playlists}}",
"@playlistsCount": {
"description": "Playlist count display",
"placeholders": {
"count": {
"type": "int"
}
}
},
"editMetadataAutoFill": "Auto-fill from online",
"@editMetadataAutoFill": {
"description": "Section title for selective online metadata auto-fill in the edit metadata sheet"
},
"editMetadataAutoFillDesc": "Select fields to fill automatically from online metadata",
"@editMetadataAutoFillDesc": {
"description": "Description for the auto-fill section"
},
"editMetadataAutoFillFetch": "Fetch & Fill",
"@editMetadataAutoFillFetch": {
"description": "Button label to fetch online metadata and fill selected fields"
},
"editMetadataAutoFillSearching": "Searching online...",
"@editMetadataAutoFillSearching": {
"description": "Snackbar shown while searching for online metadata"
},
"editMetadataAutoFillNoResults": "No matching metadata found online",
"@editMetadataAutoFillNoResults": {
"description": "Snackbar when online metadata search returns no results"
},
"editMetadataAutoFillDone": "Filled {count} {count, plural, =1{field} other{fields}} from online metadata",
"@editMetadataAutoFillDone": {
"description": "Snackbar confirming how many fields were auto-filled",
"placeholders": {
"count": {
"type": "int"
}
}
},
"editMetadataAutoFillNoneSelected": "Select at least one field to auto-fill",
"@editMetadataAutoFillNoneSelected": {
"description": "Snackbar when user taps Fetch without selecting any fields"
},
"editMetadataFieldTitle": "Title",
"@editMetadataFieldTitle": {
"description": "Chip label for title field in auto-fill selector"
},
"editMetadataFieldArtist": "Artist",
"@editMetadataFieldArtist": {
"description": "Chip label for artist field in auto-fill selector"
},
"editMetadataFieldAlbum": "Album",
"@editMetadataFieldAlbum": {
"description": "Chip label for album field in auto-fill selector"
},
"editMetadataFieldAlbumArtist": "Album Artist",
"@editMetadataFieldAlbumArtist": {
"description": "Chip label for album artist field in auto-fill selector"
},
"editMetadataFieldDate": "Date",
"@editMetadataFieldDate": {
"description": "Chip label for date field in auto-fill selector"
},
"editMetadataFieldTrackNum": "Track #",
"@editMetadataFieldTrackNum": {
"description": "Chip label for track number field in auto-fill selector"
},
"editMetadataFieldDiscNum": "Disc #",
"@editMetadataFieldDiscNum": {
"description": "Chip label for disc number field in auto-fill selector"
},
"editMetadataFieldGenre": "Genre",
"@editMetadataFieldGenre": {
"description": "Chip label for genre field in auto-fill selector"
},
"editMetadataFieldIsrc": "ISRC",
"@editMetadataFieldIsrc": {
"description": "Chip label for ISRC field in auto-fill selector"
},
"editMetadataFieldLabel": "Label",
"@editMetadataFieldLabel": {
"description": "Chip label for label field in auto-fill selector"
},
"editMetadataFieldCopyright": "Copyright",
"@editMetadataFieldCopyright": {
"description": "Chip label for copyright field in auto-fill selector"
},
"editMetadataFieldCover": "Cover Art",
"@editMetadataFieldCover": {
"description": "Chip label for cover art field in auto-fill selector"
},
"editMetadataSelectAll": "All",
"@editMetadataSelectAll": {
"description": "Button to select all fields for auto-fill"
},
"editMetadataSelectEmpty": "Empty only",
"@editMetadataSelectEmpty": {
"description": "Button to select only fields that are currently empty"
},
"queueDownloadingCount": "Downloading ({count})",
"@queueDownloadingCount": {
"description": "Header for active downloads section with count",
"placeholders": {
"count": {
"type": "int"
}
}
},
"queueDownloadedHeader": "Downloaded",
"@queueDownloadedHeader": {
"description": "Header label for downloaded items section in library"
},
"queueFilteringIndicator": "Filtering...",
"@queueFilteringIndicator": {
"description": "Shown while filter results are being computed"
},
"queueTrackCount": "{count, plural, =1{1 track} other{{count} tracks}}",
"@queueTrackCount": {
"description": "Track count label with plural support",
"placeholders": {
"count": {
"type": "int"
}
}
},
"queueAlbumCount": "{count, plural, =1{1 album} other{{count} albums}}",
"@queueAlbumCount": {
"description": "Album count label with plural support",
"placeholders": {
"count": {
"type": "int"
}
}
},
"queueEmptyAlbums": "No album downloads",
"@queueEmptyAlbums": {
"description": "Empty state title when no album downloads exist"
},
"queueEmptyAlbumsSubtitle": "Download multiple tracks from an album to see them here",
"@queueEmptyAlbumsSubtitle": {
"description": "Empty state subtitle for album downloads"
},
"queueEmptySingles": "No single downloads",
"@queueEmptySingles": {
"description": "Empty state title when no single track downloads exist"
},
"queueEmptySinglesSubtitle": "Single track downloads will appear here",
"@queueEmptySinglesSubtitle": {
"description": "Empty state subtitle for single track downloads"
},
"queueEmptyHistory": "No download history",
"@queueEmptyHistory": {
"description": "Empty state title when download history is empty"
},
"queueEmptyHistorySubtitle": "Downloaded tracks will appear here",
"@queueEmptyHistorySubtitle": {
"description": "Empty state subtitle for download history"
},
"selectionAllPlaylistsSelected": "All playlists selected",
"@selectionAllPlaylistsSelected": {
"description": "Shown when all playlists are selected in selection mode"
},
"selectionTapPlaylistsToSelect": "Tap playlists to select",
"@selectionTapPlaylistsToSelect": {
"description": "Hint shown in playlist selection mode"
},
"selectionSelectPlaylistsToDelete": "Select playlists to delete",
"@selectionSelectPlaylistsToDelete": {
"description": "Hint shown when no playlists are selected for deletion"
},
"audioAnalysisTitle": "Audio Quality Analysis",
"@audioAnalysisTitle": {
"description": "Title for audio analysis section"
},
"audioAnalysisDescription": "Verify lossless quality with spectrum analysis",
"@audioAnalysisDescription": {
"description": "Description for audio analysis tap-to-analyze prompt"
},
"audioAnalysisAnalyzing": "Analyzing audio...",
"@audioAnalysisAnalyzing": {
"description": "Loading text while analyzing audio"
},
"audioAnalysisSampleRate": "Sample Rate",
"@audioAnalysisSampleRate": {
"description": "Sample rate metric label"
},
"audioAnalysisBitDepth": "Bit Depth",
"@audioAnalysisBitDepth": {
"description": "Bit depth metric label"
},
"audioAnalysisChannels": "Channels",
"@audioAnalysisChannels": {
"description": "Channels metric label"
},
"audioAnalysisDuration": "Duration",
"@audioAnalysisDuration": {
"description": "Duration metric label"
},
"audioAnalysisNyquist": "Nyquist",
"@audioAnalysisNyquist": {
"description": "Nyquist frequency metric label"
},
"audioAnalysisFileSize": "Size",
"@audioAnalysisFileSize": {
"description": "File size metric label"
},
"audioAnalysisDynamicRange": "Dynamic Range",
"@audioAnalysisDynamicRange": {
"description": "Dynamic range metric label"
},
"audioAnalysisPeak": "Peak",
"@audioAnalysisPeak": {
"description": "Peak amplitude metric label"
},
"audioAnalysisRms": "RMS",
"@audioAnalysisRms": {
"description": "RMS level metric label"
},
"audioAnalysisSamples": "Samples",
"@audioAnalysisSamples": {
"description": "Total samples metric label"
}
}
+401 -7
View File
@@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Descarga pistas de Spotify con calidad sin pérdida de Tidal y Qobuz.",
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -897,6 +897,18 @@
"@errorNoTracksFound": {
"description": "Error - search returned no results"
},
"errorUrlNotRecognized": "Link not recognized",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
},
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
"@errorUrlNotRecognizedMessage": {
"description": "Error message - URL not recognized explanation"
},
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
"@errorUrlFetchFailed": {
"description": "Error message - generic URL fetch failure"
},
"errorMissingExtensionSource": "No se puede cargar {item}: falta una fuente de extensión",
"@errorMissingExtensionSource": {
"description": "Error - extension source not available",
@@ -991,10 +1003,26 @@
"@filenameFormat": {
"description": "Setting title - filename pattern"
},
"filenameShowAdvancedTags": "Show advanced tags",
"@filenameShowAdvancedTags": {
"description": "Toggle label for showing advanced filename tags"
},
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
"@filenameShowAdvancedTagsDescription": {
"description": "Description for advanced filename tag toggle"
},
"folderOrganizationNone": "Ninguna organización",
"@folderOrganizationNone": {
"description": "Folder option - flat structure"
},
"folderOrganizationByPlaylist": "By Playlist",
"@folderOrganizationByPlaylist": {
"description": "Folder option - playlist folders"
},
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
"@folderOrganizationByPlaylistSubtitle": {
"description": "Subtitle for playlist folder option"
},
"folderOrganizationByArtist": "Por Artista",
"@folderOrganizationByArtist": {
"description": "Folder option - artist folders"
@@ -1745,10 +1773,6 @@
"@qualityNote": {
"description": "Note about quality availability"
},
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"downloadAskBeforeDownload": "Preguntar antes de descargar",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
@@ -2198,6 +2222,15 @@
"@libraryAboutDescription": {
"description": "Description of local library feature"
},
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
"@libraryTracksUnit": {
"description": "Unit label for tracks count (without the number itself)",
"placeholders": {
"count": {
"type": "int"
}
}
},
"libraryLastScanned": "Last scanned: {time}",
"@libraryLastScanned": {
"description": "Last scan time display",
@@ -2358,7 +2391,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Obtén audio en calidad FLAC de Tidal, Qobuz o Deezer",
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},
@@ -2783,6 +2816,367 @@
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
},
"cueSplitTitle": "Split CUE Sheet",
"@cueSplitTitle": {
"description": "Title for CUE split bottom sheet"
},
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
"@cueSplitSubtitle": {
"description": "Subtitle for CUE split menu item"
},
"cueSplitAlbum": "Album: {album}",
"@cueSplitAlbum": {
"description": "Album name in CUE split sheet",
"placeholders": {
"album": {
"type": "String"
}
}
},
"cueSplitArtist": "Artist: {artist}",
"@cueSplitArtist": {
"description": "Artist name in CUE split sheet",
"placeholders": {
"artist": {
"type": "String"
}
}
},
"cueSplitTrackCount": "{count} tracks",
"@cueSplitTrackCount": {
"description": "Number of tracks in CUE sheet",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitConfirmTitle": "Split CUE Album",
"@cueSplitConfirmTitle": {
"description": "CUE split confirmation dialog title"
},
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
"@cueSplitConfirmMessage": {
"description": "CUE split confirmation dialog message",
"placeholders": {
"album": {
"type": "String"
},
"count": {
"type": "int"
}
}
},
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
"@cueSplitSplitting": {
"description": "Snackbar while splitting CUE",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"cueSplitSuccess": "Split into {count} tracks successfully",
"@cueSplitSuccess": {
"description": "Snackbar after successful CUE split",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitFailed": "CUE split failed",
"@cueSplitFailed": {
"description": "Snackbar when CUE split fails"
},
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
"@cueSplitNoAudioFile": {
"description": "Error when CUE audio file is missing"
},
"cueSplitButton": "Split into Tracks",
"@cueSplitButton": {
"description": "Button text to start CUE splitting"
},
"actionCreate": "Create",
"@actionCreate": {
"description": "Generic action button - create"
},
"collectionFoldersTitle": "My folders",
"@collectionFoldersTitle": {
"description": "Library section title for custom folders"
},
"collectionWishlist": "Wishlist",
"@collectionWishlist": {
"description": "Custom folder for saved tracks to download later"
},
"collectionLoved": "Loved",
"@collectionLoved": {
"description": "Custom folder for favorite tracks"
},
"collectionPlaylists": "Playlists",
"@collectionPlaylists": {
"description": "Custom user playlists folder"
},
"collectionPlaylist": "Playlist",
"@collectionPlaylist": {
"description": "Single playlist label"
},
"collectionAddToPlaylist": "Add to playlist",
"@collectionAddToPlaylist": {
"description": "Action to add a track to user playlist"
},
"collectionCreatePlaylist": "Create playlist",
"@collectionCreatePlaylist": {
"description": "Action to create a new playlist"
},
"collectionNoPlaylistsYet": "No playlists yet",
"@collectionNoPlaylistsYet": {
"description": "Empty state title when user has no playlists"
},
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
"@collectionNoPlaylistsSubtitle": {
"description": "Empty state subtitle when user has no playlists"
},
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
"@collectionPlaylistTracks": {
"description": "Track count label for custom playlists",
"placeholders": {
"count": {
"type": "int"
}
}
},
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
"@collectionAddedToPlaylist": {
"description": "Snackbar after adding track to playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
"@collectionAlreadyInPlaylist": {
"description": "Snackbar when track already exists in playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistCreated": "Playlist created",
"@collectionPlaylistCreated": {
"description": "Snackbar after creating playlist"
},
"collectionPlaylistNameHint": "Playlist name",
"@collectionPlaylistNameHint": {
"description": "Hint text for playlist name input"
},
"collectionPlaylistNameRequired": "Playlist name is required",
"@collectionPlaylistNameRequired": {
"description": "Validation error for empty playlist name"
},
"collectionRenamePlaylist": "Rename playlist",
"@collectionRenamePlaylist": {
"description": "Action to rename playlist"
},
"collectionDeletePlaylist": "Delete playlist",
"@collectionDeletePlaylist": {
"description": "Action to delete playlist"
},
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
"@collectionDeletePlaylistMessage": {
"description": "Confirmation message for deleting playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistDeleted": "Playlist deleted",
"@collectionPlaylistDeleted": {
"description": "Snackbar after deleting playlist"
},
"collectionPlaylistRenamed": "Playlist renamed",
"@collectionPlaylistRenamed": {
"description": "Snackbar after renaming playlist"
},
"collectionWishlistEmptyTitle": "Wishlist is empty",
"@collectionWishlistEmptyTitle": {
"description": "Wishlist empty state title"
},
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
"@collectionWishlistEmptySubtitle": {
"description": "Wishlist empty state subtitle"
},
"collectionLovedEmptyTitle": "Loved folder is empty",
"@collectionLovedEmptyTitle": {
"description": "Loved empty state title"
},
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
"@collectionLovedEmptySubtitle": {
"description": "Loved empty state subtitle"
},
"collectionPlaylistEmptyTitle": "Playlist is empty",
"@collectionPlaylistEmptyTitle": {
"description": "Playlist empty state title"
},
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
"@collectionPlaylistEmptySubtitle": {
"description": "Playlist empty state subtitle"
},
"collectionRemoveFromPlaylist": "Remove from playlist",
"@collectionRemoveFromPlaylist": {
"description": "Tooltip for removing track from playlist"
},
"collectionRemoveFromFolder": "Remove from folder",
"@collectionRemoveFromFolder": {
"description": "Tooltip for removing track from wishlist/loved folder"
},
"collectionRemoved": "\"{trackName}\" removed",
"@collectionRemoved": {
"description": "Snackbar after removing a track from a collection",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
"@collectionAddedToLoved": {
"description": "Snackbar after adding track to loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
"@collectionRemovedFromLoved": {
"description": "Snackbar after removing track from loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
"@collectionAddedToWishlist": {
"description": "Snackbar after adding track to wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
"@collectionRemovedFromWishlist": {
"description": "Snackbar after removing track from wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"trackOptionAddToLoved": "Add to Loved",
"@trackOptionAddToLoved": {
"description": "Bottom sheet action label - add track to loved folder"
},
"trackOptionRemoveFromLoved": "Remove from Loved",
"@trackOptionRemoveFromLoved": {
"description": "Bottom sheet action label - remove track from loved folder"
},
"trackOptionAddToWishlist": "Add to Wishlist",
"@trackOptionAddToWishlist": {
"description": "Bottom sheet action label - add track to wishlist"
},
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
"@trackOptionRemoveFromWishlist": {
"description": "Bottom sheet action label - remove track from wishlist"
},
"collectionPlaylistChangeCover": "Change cover image",
"@collectionPlaylistChangeCover": {
"description": "Bottom sheet action to pick a custom cover image for a playlist"
},
"collectionPlaylistRemoveCover": "Remove cover image",
"@collectionPlaylistRemoveCover": {
"description": "Bottom sheet action to remove custom cover image from a playlist"
},
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
"@selectionShareCount": {
"description": "Share button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionShareNoFiles": "No shareable files found",
"@selectionShareNoFiles": {
"description": "Snackbar when no selected files exist on disk"
},
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
"@selectionConvertCount": {
"description": "Convert button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionConvertNoConvertible": "No convertible tracks selected",
"@selectionConvertNoConvertible": {
"description": "Snackbar when no selected tracks support conversion"
},
"selectionBatchConvertConfirmTitle": "Batch Convert",
"@selectionBatchConvertConfirmTitle": {
"description": "Confirmation dialog title for batch conversion"
},
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
"@selectionBatchConvertConfirmMessage": {
"description": "Confirmation dialog message for batch conversion",
"placeholders": {
"count": {
"type": "int"
},
"format": {
"type": "String"
},
"bitrate": {
"type": "String"
}
}
},
"selectionBatchConvertProgress": "Converting {current} of {total}...",
"@selectionBatchConvertProgress": {
"description": "Snackbar during batch conversion progress",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
"@selectionBatchConvertSuccess": {
"description": "Snackbar after batch conversion completes",
"placeholders": {
"success": {
"type": "int"
},
"total": {
"type": "int"
},
"format": {
"type": "String"
}
}
},
"downloadedAlbumDownloadedCount": "{count} descargado",
"@downloadedAlbumDownloadedCount": {
"description": "Downloaded tracks count badge",
@@ -2800,4 +3194,4 @@
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming"
}
}
}
+107 -15
View File
@@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -897,6 +897,18 @@
"@errorNoTracksFound": {
"description": "Error - search returned no results"
},
"errorUrlNotRecognized": "Link not recognized",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
},
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
"@errorUrlNotRecognizedMessage": {
"description": "Error message - URL not recognized explanation"
},
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
"@errorUrlFetchFailed": {
"description": "Error message - generic URL fetch failure"
},
"errorMissingExtensionSource": "Cannot load {item}: missing extension source",
"@errorMissingExtensionSource": {
"description": "Error - extension source not available",
@@ -1003,6 +1015,14 @@
"@folderOrganizationNone": {
"description": "Folder option - flat structure"
},
"folderOrganizationByPlaylist": "By Playlist",
"@folderOrganizationByPlaylist": {
"description": "Folder option - playlist folders"
},
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
"@folderOrganizationByPlaylistSubtitle": {
"description": "Subtitle for playlist folder option"
},
"folderOrganizationByArtist": "By Artist",
"@folderOrganizationByArtist": {
"description": "Folder option - artist folders"
@@ -1097,7 +1117,7 @@
},
"providerBuiltIn": "Built-in",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Extension",
"@providerExtension": {
@@ -1753,18 +1773,6 @@
"@qualityNote": {
"description": "Note about quality availability"
},
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
@@ -2383,7 +2391,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},
@@ -2808,6 +2816,90 @@
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
},
"cueSplitTitle": "Split CUE Sheet",
"@cueSplitTitle": {
"description": "Title for CUE split bottom sheet"
},
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
"@cueSplitSubtitle": {
"description": "Subtitle for CUE split menu item"
},
"cueSplitAlbum": "Album: {album}",
"@cueSplitAlbum": {
"description": "Album name in CUE split sheet",
"placeholders": {
"album": {
"type": "String"
}
}
},
"cueSplitArtist": "Artist: {artist}",
"@cueSplitArtist": {
"description": "Artist name in CUE split sheet",
"placeholders": {
"artist": {
"type": "String"
}
}
},
"cueSplitTrackCount": "{count} tracks",
"@cueSplitTrackCount": {
"description": "Number of tracks in CUE sheet",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitConfirmTitle": "Split CUE Album",
"@cueSplitConfirmTitle": {
"description": "CUE split confirmation dialog title"
},
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
"@cueSplitConfirmMessage": {
"description": "CUE split confirmation dialog message",
"placeholders": {
"album": {
"type": "String"
},
"count": {
"type": "int"
}
}
},
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
"@cueSplitSplitting": {
"description": "Snackbar while splitting CUE",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"cueSplitSuccess": "Split into {count} tracks successfully",
"@cueSplitSuccess": {
"description": "Snackbar after successful CUE split",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitFailed": "CUE split failed",
"@cueSplitFailed": {
"description": "Snackbar when CUE split fails"
},
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
"@cueSplitNoAudioFile": {
"description": "Error when CUE audio file is missing"
},
"cueSplitButton": "Split into Tracks",
"@cueSplitButton": {
"description": "Button text to start CUE splitting"
},
"actionCreate": "Create",
"@actionCreate": {
"description": "Generic action button - create"
+107 -15
View File
@@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -897,6 +897,18 @@
"@errorNoTracksFound": {
"description": "Error - search returned no results"
},
"errorUrlNotRecognized": "Link not recognized",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
},
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
"@errorUrlNotRecognizedMessage": {
"description": "Error message - URL not recognized explanation"
},
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
"@errorUrlFetchFailed": {
"description": "Error message - generic URL fetch failure"
},
"errorMissingExtensionSource": "Cannot load {item}: missing extension source",
"@errorMissingExtensionSource": {
"description": "Error - extension source not available",
@@ -1003,6 +1015,14 @@
"@folderOrganizationNone": {
"description": "Folder option - flat structure"
},
"folderOrganizationByPlaylist": "By Playlist",
"@folderOrganizationByPlaylist": {
"description": "Folder option - playlist folders"
},
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
"@folderOrganizationByPlaylistSubtitle": {
"description": "Subtitle for playlist folder option"
},
"folderOrganizationByArtist": "By Artist",
"@folderOrganizationByArtist": {
"description": "Folder option - artist folders"
@@ -1097,7 +1117,7 @@
},
"providerBuiltIn": "Built-in",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Extension",
"@providerExtension": {
@@ -1753,18 +1773,6 @@
"@qualityNote": {
"description": "Note about quality availability"
},
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
@@ -2383,7 +2391,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},
@@ -2808,6 +2816,90 @@
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
},
"cueSplitTitle": "Split CUE Sheet",
"@cueSplitTitle": {
"description": "Title for CUE split bottom sheet"
},
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
"@cueSplitSubtitle": {
"description": "Subtitle for CUE split menu item"
},
"cueSplitAlbum": "Album: {album}",
"@cueSplitAlbum": {
"description": "Album name in CUE split sheet",
"placeholders": {
"album": {
"type": "String"
}
}
},
"cueSplitArtist": "Artist: {artist}",
"@cueSplitArtist": {
"description": "Artist name in CUE split sheet",
"placeholders": {
"artist": {
"type": "String"
}
}
},
"cueSplitTrackCount": "{count} tracks",
"@cueSplitTrackCount": {
"description": "Number of tracks in CUE sheet",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitConfirmTitle": "Split CUE Album",
"@cueSplitConfirmTitle": {
"description": "CUE split confirmation dialog title"
},
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
"@cueSplitConfirmMessage": {
"description": "CUE split confirmation dialog message",
"placeholders": {
"album": {
"type": "String"
},
"count": {
"type": "int"
}
}
},
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
"@cueSplitSplitting": {
"description": "Snackbar while splitting CUE",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"cueSplitSuccess": "Split into {count} tracks successfully",
"@cueSplitSuccess": {
"description": "Snackbar after successful CUE split",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitFailed": "CUE split failed",
"@cueSplitFailed": {
"description": "Snackbar when CUE split fails"
},
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
"@cueSplitNoAudioFile": {
"description": "Error when CUE audio file is missing"
},
"cueSplitButton": "Split into Tracks",
"@cueSplitButton": {
"description": "Button text to start CUE splitting"
},
"actionCreate": "Create",
"@actionCreate": {
"description": "Generic action button - create"
+218 -65
View File
@@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.",
"aboutAppDescription": "Unduh lagu-lagu Spotify dalam kualitas lossless dari Tidal dan Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -1003,11 +1003,11 @@
"@filenameFormat": {
"description": "Setting title - filename pattern"
},
"filenameShowAdvancedTags": "Show advanced tags",
"filenameShowAdvancedTags": "Tampilkan tag lanjutan",
"@filenameShowAdvancedTags": {
"description": "Toggle label for showing advanced filename tags"
},
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
"filenameShowAdvancedTagsDescription": "Aktifkan tag yang diformat untuk padding trek dan pola tanggal",
"@filenameShowAdvancedTagsDescription": {
"description": "Description for advanced filename tag toggle"
},
@@ -1015,6 +1015,14 @@
"@folderOrganizationNone": {
"description": "Folder option - flat structure"
},
"folderOrganizationByPlaylist": "Berdasarkan Daftar Putar",
"@folderOrganizationByPlaylist": {
"description": "Folder option - playlist folders"
},
"folderOrganizationByPlaylistSubtitle": "Setiap daftar putar memerlukan folder terpisah",
"@folderOrganizationByPlaylistSubtitle": {
"description": "Subtitle for playlist folder option"
},
"folderOrganizationByArtist": "Berdasarkan Artis",
"@folderOrganizationByArtist": {
"description": "Folder option - artist folders"
@@ -1109,7 +1117,7 @@
},
"providerBuiltIn": "Bawaan",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Ekstensi",
"@providerExtension": {
@@ -1209,7 +1217,7 @@
"@credentialsDescription": {
"description": "Credentials dialog explanation"
},
"credentialsClientId": "Client ID",
"credentialsClientId": "ID Klien",
"@credentialsClientId": {
"description": "Client ID field label - DO NOT TRANSLATE"
},
@@ -1217,7 +1225,7 @@
"@credentialsClientIdHint": {
"description": "Client ID placeholder"
},
"credentialsClientSecret": "Client Secret",
"credentialsClientSecret": "Rahasia Klien",
"@credentialsClientSecret": {
"description": "Client Secret field label - DO NOT TRANSLATE"
},
@@ -1229,7 +1237,7 @@
"@channelStable": {
"description": "Update channel - stable releases"
},
"channelPreview": "Preview",
"channelPreview": "Pratinjau",
"@channelPreview": {
"description": "Update channel - beta/preview releases"
},
@@ -1269,39 +1277,39 @@
"@sectionFileSettings": {
"description": "Settings section header"
},
"sectionLyrics": "Lyrics",
"sectionLyrics": "Lirik",
"@sectionLyrics": {
"description": "Settings section header"
},
"lyricsMode": "Lyrics Mode",
"lyricsMode": "Mode Lirik",
"@lyricsMode": {
"description": "Setting - how to save lyrics"
},
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
"lyricsModeDescription": "Pilih cara lirik disimpan bersama unduhan Anda",
"@lyricsModeDescription": {
"description": "Lyrics mode picker description"
},
"lyricsModeEmbed": "Embed in file",
"lyricsModeEmbed": "Sematkan dalam file",
"@lyricsModeEmbed": {
"description": "Lyrics mode option - embed in audio file"
},
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
"lyricsModeEmbedSubtitle": "Lirik tersimpan di dalam metadata FLAC",
"@lyricsModeEmbedSubtitle": {
"description": "Subtitle for embed option"
},
"lyricsModeExternal": "External .lrc file",
"lyricsModeExternal": "File .lrc eksternal",
"@lyricsModeExternal": {
"description": "Lyrics mode option - separate LRC file"
},
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
"lyricsModeExternalSubtitle": "File .lrc terpisah untuk pemutar musik seperti Samsung Music",
"@lyricsModeExternalSubtitle": {
"description": "Subtitle for external option"
},
"lyricsModeBoth": "Both",
"lyricsModeBoth": "Keduanya",
"@lyricsModeBoth": {
"description": "Lyrics mode option - embed and external"
},
"lyricsModeBothSubtitle": "Embed and save .lrc file",
"lyricsModeBothSubtitle": "Sematkan dan simpan file .lrc",
"@lyricsModeBothSubtitle": {
"description": "Subtitle for both option"
},
@@ -1447,11 +1455,11 @@
"@trackGenre": {
"description": "Metadata label - music genre"
},
"trackLabel": "Label",
"trackLabel": "Lebel",
"@trackLabel": {
"description": "Metadata label - record label"
},
"trackCopyright": "Copyright",
"trackCopyright": "Hak cipta",
"@trackCopyright": {
"description": "Metadata label - copyright information"
},
@@ -1475,15 +1483,15 @@
"@trackLyricsLoadFailed": {
"description": "Message when lyrics loading fails"
},
"trackEmbedLyrics": "Embed Lyrics",
"trackEmbedLyrics": "Sematkan Lirik",
"@trackEmbedLyrics": {
"description": "Action - embed lyrics into audio file"
},
"trackLyricsEmbedded": "Lyrics embedded successfully",
"trackLyricsEmbedded": "Lirik berhasil disematkan",
"@trackLyricsEmbedded": {
"description": "Snackbar - lyrics saved to file"
},
"trackInstrumental": "Instrumental track",
"trackInstrumental": "Lagu instrumental",
"@trackInstrumental": {
"description": "Message when track is instrumental (no lyrics)"
},
@@ -1562,7 +1570,7 @@
"@storeClearFilters": {
"description": "Button to clear all filters"
},
"extensionDefaultProvider": "Default (Deezer/Spotify)",
"extensionDefaultProvider": "Bawaan (Deezer/Spotify)",
"@extensionDefaultProvider": {
"description": "Default search provider option"
},
@@ -1578,7 +1586,7 @@
"@extensionId": {
"description": "Extension detail - unique ID"
},
"extensionError": "Error",
"extensionError": "Terjadi kesalahan",
"@extensionError": {
"description": "Extension detail - error message"
},
@@ -1765,18 +1773,6 @@
"@qualityNote": {
"description": "Note about quality availability"
},
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Tanya Sebelum Unduh",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
@@ -1793,19 +1789,35 @@
"@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization"
},
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
"downloadUseAlbumArtistForFolders": "Gunakan Artis Album untuk folder",
"@downloadUseAlbumArtistForFolders": {
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
},
"downloadUsePrimaryArtistOnly": "Primary artist only for folders",
"downloadCreatePlaylistSourceFolder": "Buat folder sumber playlist",
"@downloadCreatePlaylistSourceFolder": {
"description": "Setting title for adding a playlist folder prefix before the normal organization structure"
},
"downloadCreatePlaylistSourceFolderEnabled": "Unduhan dari playlist memakai Playlist/ lalu struktur folder normal Anda.",
"@downloadCreatePlaylistSourceFolderEnabled": {
"description": "Subtitle when playlist source folder prefix is enabled"
},
"downloadCreatePlaylistSourceFolderDisabled": "Unduhan dari playlist hanya memakai struktur folder normal.",
"@downloadCreatePlaylistSourceFolderDisabled": {
"description": "Subtitle when playlist source folder prefix is disabled"
},
"downloadCreatePlaylistSourceFolderRedundant": "Mode Berdasarkan Playlist sudah menaruh unduhan ke dalam folder playlist.",
"@downloadCreatePlaylistSourceFolderRedundant": {
"description": "Subtitle when playlist folder prefix setting is redundant because folder organization is already by playlist"
},
"downloadUsePrimaryArtistOnly": "Hanya artis utama untuk folder",
"@downloadUsePrimaryArtistOnly": {
"description": "Setting - strip featured artists from folder name"
},
"downloadUsePrimaryArtistOnlyEnabled": "Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)",
"downloadUsePrimaryArtistOnlyEnabled": "Artis unggulan dihapus dari nama folder (misalnya Justin Bieber, Quavo → Justin Bieber)",
"@downloadUsePrimaryArtistOnlyEnabled": {
"description": "Subtitle when primary artist only is enabled"
},
"downloadUsePrimaryArtistOnlyDisabled": "Full artist string used for folder name",
"downloadUsePrimaryArtistOnlyDisabled": "Nama lengkap artis digunakan untuk nama folder",
"@downloadUsePrimaryArtistOnlyDisabled": {
"description": "Subtitle when primary artist only is disabled"
},
@@ -1833,27 +1845,27 @@
"@queueClearAllMessage": {
"description": "Clear queue confirmation"
},
"settingsAutoExportFailed": "Auto-export failed downloads",
"settingsAutoExportFailed": "Unduhan yang gagal diekspor otomatis",
"@settingsAutoExportFailed": {
"description": "Setting toggle for auto-export"
},
"settingsAutoExportFailedSubtitle": "Save failed downloads to TXT file automatically",
"settingsAutoExportFailedSubtitle": "Simpan unduhan yang gagal ke file TXT secara otomatis",
"@settingsAutoExportFailedSubtitle": {
"description": "Subtitle for auto-export setting"
},
"settingsDownloadNetwork": "Download Network",
"settingsDownloadNetwork": "Jaringan Unduhan",
"@settingsDownloadNetwork": {
"description": "Setting for network type preference"
},
"settingsDownloadNetworkAny": "WiFi + Mobile Data",
"settingsDownloadNetworkAny": "WiFi + Data Seluler",
"@settingsDownloadNetworkAny": {
"description": "Network option - use any connection"
},
"settingsDownloadNetworkWifiOnly": "WiFi Only",
"settingsDownloadNetworkWifiOnly": "Hanya WiFi",
"@settingsDownloadNetworkWifiOnly": {
"description": "Network option - only use WiFi"
},
"settingsDownloadNetworkSubtitle": "Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.",
"settingsDownloadNetworkSubtitle": "Pilih jaringan mana yang akan digunakan untuk mengunduh. Jika diatur ke Hanya WiFi, unduhan akan berhenti sementara dan menggunakan data seluler.",
"@settingsDownloadNetworkSubtitle": {
"description": "Subtitle explaining network preference"
},
@@ -1889,11 +1901,11 @@
"@albumFolderYearAlbumSubtitle": {
"description": "Folder structure example"
},
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
"albumFolderArtistAlbumSingles": "Artis / Album + Singel",
"@albumFolderArtistAlbumSingles": {
"description": "Album folder option with singles inside artist"
},
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
"albumFolderArtistAlbumSinglesSubtitle": "Artis/Album/ dan Artis/Single/",
"@albumFolderArtistAlbumSinglesSubtitle": {
"description": "Folder structure example"
},
@@ -1962,19 +1974,19 @@
"@recentTypeSong": {
"description": "Recent access item type - song/track"
},
"recentTypePlaylist": "Playlist",
"recentTypePlaylist": "Daftar putar",
"@recentTypePlaylist": {
"description": "Recent access item type - playlist"
},
"recentEmpty": "No recent items yet",
"recentEmpty": "Belum ada item terbaru",
"@recentEmpty": {
"description": "Empty state text for recent access list"
},
"recentShowAllDownloads": "Show All Downloads",
"recentShowAllDownloads": "Tampilkan Semua Unduhan",
"@recentShowAllDownloads": {
"description": "Button label to unhide hidden downloads in recent access"
},
"recentPlaylistInfo": "Playlist: {name}",
"recentPlaylistInfo": "Daftar Putar: {name}",
"@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access",
"placeholders": {
@@ -1984,7 +1996,7 @@
}
}
},
"discographyDownload": "Download Discography",
"discographyDownload": "Unduh Diskografi",
"@discographyDownload": {
"description": "Button - download artist discography"
},
@@ -2383,47 +2395,47 @@
}
}
},
"tutorialWelcomeTitle": "Welcome to SpotiFLAC!",
"tutorialWelcomeTitle": "Selamat Datang di SpotiFLAC!",
"@tutorialWelcomeTitle": {
"description": "Tutorial welcome page title"
},
"tutorialWelcomeDesc": "Let's learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.",
"tutorialWelcomeDesc": "Mari kita pelajari cara mengunduh musik favorit Anda dalam kualitas lossless. Tutorial singkat ini akan menunjukkan dasar-dasarnya.",
"@tutorialWelcomeDesc": {
"description": "Tutorial welcome page description"
},
"tutorialWelcomeTip1": "Download music from Spotify, Deezer, or paste any supported URL",
"tutorialWelcomeTip1": "Unduh musik dari Spotify, Deezer, atau tempel URL yang didukung",
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
"tutorialWelcomeTip2": "Dapatkan audio berkualitas FLAC dari Tidal, Qobuz, atau Deezer",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},
"tutorialWelcomeTip3": "Automatic metadata, cover art, and lyrics embedding",
"tutorialWelcomeTip3": "Penyematan metadata, sampul album, dan lirik secara otomatis",
"@tutorialWelcomeTip3": {
"description": "Tutorial welcome tip 3"
},
"tutorialSearchTitle": "Finding Music",
"tutorialSearchTitle": "Menemukan Musik",
"@tutorialSearchTitle": {
"description": "Tutorial search page title"
},
"tutorialSearchDesc": "There are two easy ways to find music you want to download.",
"tutorialSearchDesc": "Ada dua cara mudah untuk menemukan musik yang ingin Anda unduh.",
"@tutorialSearchDesc": {
"description": "Tutorial search page description"
},
"tutorialDownloadTitle": "Downloading Music",
"tutorialDownloadTitle": "Mengunduh Musik",
"@tutorialDownloadTitle": {
"description": "Tutorial download page title"
},
"tutorialDownloadDesc": "Downloading music is simple and fast. Here's how it works.",
"tutorialDownloadDesc": "Mengunduh musik itu mudah dan cepat. Begini cara kerjanya.",
"@tutorialDownloadDesc": {
"description": "Tutorial download page description"
},
"tutorialLibraryTitle": "Your Library",
"tutorialLibraryTitle": "Perpustakaan Anda",
"@tutorialLibraryTitle": {
"description": "Tutorial library page title"
},
"tutorialLibraryDesc": "All your downloaded music is organized in the Library tab.",
"tutorialLibraryDesc": "Semua musik yang Anda unduh tersusun rapi di tab Perpustakaan.",
"@tutorialLibraryDesc": {
"description": "Tutorial library page description"
},
@@ -2755,6 +2767,47 @@
"@trackReEnrichFfmpegFailed": {
"description": "Snackbar when FFmpeg embed fails for MP3/Opus"
},
"queueFlacAction": "Antrekan FLAC",
"@queueFlacAction": {
"description": "Action/button label for queueing FLAC redownloads for local tracks"
},
"queueFlacConfirmMessage": "Cari kecocokan online untuk track yang dipilih lalu antrekan download FLAC.\n\nFile yang sudah ada tidak akan diubah atau dihapus.\n\nHanya kecocokan dengan keyakinan tinggi yang akan diantrikan otomatis.\n\n{count} dipilih",
"@queueFlacConfirmMessage": {
"description": "Confirmation dialog body before queueing FLAC redownloads for local tracks",
"placeholders": {
"count": {
"type": "int"
}
}
},
"queueFlacFindingProgress": "Mencari kecocokan FLAC... ({current}/{total})",
"@queueFlacFindingProgress": {
"description": "Snackbar while resolving remote matches for local FLAC redownloads",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"queueFlacNoReliableMatches": "Tidak ada kecocokan online yang cukup meyakinkan untuk pilihan ini",
"@queueFlacNoReliableMatches": {
"description": "Snackbar when no safe FLAC redownload matches were found"
},
"queueFlacQueuedWithSkipped": "Menambahkan {addedCount} track ke antrean, melewati {skippedCount}",
"@queueFlacQueuedWithSkipped": {
"description": "Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped",
"placeholders": {
"addedCount": {
"type": "int"
},
"skippedCount": {
"type": "int"
}
}
},
"trackSaveFailed": "Failed: {error}",
"@trackSaveFailed": {
"description": "Snackbar when save operation fails",
@@ -2768,7 +2821,7 @@
"@trackConvertFormat": {
"description": "Menu item - convert audio format"
},
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
"trackConvertFormatSubtitle": "Konversi ke MP3, Opus, ALAC, atau FLAC",
"@trackConvertFormatSubtitle": {
"description": "Subtitle for convert format menu item"
},
@@ -2803,6 +2856,22 @@
}
}
},
"trackConvertConfirmMessageLossless": "Konversi dari {sourceFormat} ke {targetFormat}? (Lossless — tanpa kehilangan kualitas)\n\nFile asli akan dihapus setelah konversi.",
"@trackConvertConfirmMessageLossless": {
"description": "Confirmation dialog message for lossless-to-lossless conversion",
"placeholders": {
"sourceFormat": {
"type": "String"
},
"targetFormat": {
"type": "String"
}
}
},
"trackConvertLosslessHint": "Konversi lossless — tanpa kehilangan kualitas",
"@trackConvertLosslessHint": {
"description": "Hint shown when converting between lossless formats"
},
"trackConvertConverting": "Converting audio...",
"@trackConvertConverting": {
"description": "Snackbar while converting"
@@ -2820,6 +2889,90 @@
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
},
"cueSplitTitle": "Split CUE Sheet",
"@cueSplitTitle": {
"description": "Title for CUE split bottom sheet"
},
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
"@cueSplitSubtitle": {
"description": "Subtitle for CUE split menu item"
},
"cueSplitAlbum": "Album: {album}",
"@cueSplitAlbum": {
"description": "Album name in CUE split sheet",
"placeholders": {
"album": {
"type": "String"
}
}
},
"cueSplitArtist": "Artist: {artist}",
"@cueSplitArtist": {
"description": "Artist name in CUE split sheet",
"placeholders": {
"artist": {
"type": "String"
}
}
},
"cueSplitTrackCount": "{count} tracks",
"@cueSplitTrackCount": {
"description": "Number of tracks in CUE sheet",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitConfirmTitle": "Split CUE Album",
"@cueSplitConfirmTitle": {
"description": "CUE split confirmation dialog title"
},
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
"@cueSplitConfirmMessage": {
"description": "CUE split confirmation dialog message",
"placeholders": {
"album": {
"type": "String"
},
"count": {
"type": "int"
}
}
},
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
"@cueSplitSplitting": {
"description": "Snackbar while splitting CUE",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"cueSplitSuccess": "Split into {count} tracks successfully",
"@cueSplitSuccess": {
"description": "Snackbar after successful CUE split",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitFailed": "CUE split failed",
"@cueSplitFailed": {
"description": "Snackbar when CUE split fails"
},
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
"@cueSplitNoAudioFile": {
"description": "Error when CUE audio file is missing"
},
"cueSplitButton": "Split into Tracks",
"@cueSplitButton": {
"description": "Button text to start CUE splitting"
},
"actionCreate": "Create",
"@actionCreate": {
"description": "Generic action button - create"
@@ -3114,4 +3267,4 @@
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming"
}
}
}
+116 -24
View File
@@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。",
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -897,6 +897,18 @@
"@errorNoTracksFound": {
"description": "Error - search returned no results"
},
"errorUrlNotRecognized": "Link not recognized",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
},
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
"@errorUrlNotRecognizedMessage": {
"description": "Error message - URL not recognized explanation"
},
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
"@errorUrlFetchFailed": {
"description": "Error message - generic URL fetch failure"
},
"errorMissingExtensionSource": "{item} を読み込めません: 拡張ソースがありません",
"@errorMissingExtensionSource": {
"description": "Error - extension source not available",
@@ -991,7 +1003,7 @@
"@filenameFormat": {
"description": "Setting title - filename pattern"
},
"filenameShowAdvancedTags": "Show advanced tags",
"filenameShowAdvancedTags": "高度なタグを表示",
"@filenameShowAdvancedTags": {
"description": "Toggle label for showing advanced filename tags"
},
@@ -1003,6 +1015,14 @@
"@folderOrganizationNone": {
"description": "Folder option - flat structure"
},
"folderOrganizationByPlaylist": "By Playlist",
"@folderOrganizationByPlaylist": {
"description": "Folder option - playlist folders"
},
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
"@folderOrganizationByPlaylistSubtitle": {
"description": "Subtitle for playlist folder option"
},
"folderOrganizationByArtist": "アーティスト別",
"@folderOrganizationByArtist": {
"description": "Folder option - artist folders"
@@ -1097,7 +1117,7 @@
},
"providerBuiltIn": "内蔵",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "拡張",
"@providerExtension": {
@@ -1471,7 +1491,7 @@
"@trackLyricsEmbedded": {
"description": "Snackbar - lyrics saved to file"
},
"trackInstrumental": "Instrumental track",
"trackInstrumental": "インストゥルメンタルのトラック",
"@trackInstrumental": {
"description": "Message when track is instrumental (no lyrics)"
},
@@ -1753,18 +1773,6 @@
"@qualityNote": {
"description": "Note about quality availability"
},
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus のビットレート",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 のビットレート",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "ダウンロード前に確認する",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
@@ -2383,7 +2391,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},
@@ -2808,6 +2816,90 @@
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
},
"cueSplitTitle": "分割 CUE シート",
"@cueSplitTitle": {
"description": "Title for CUE split bottom sheet"
},
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
"@cueSplitSubtitle": {
"description": "Subtitle for CUE split menu item"
},
"cueSplitAlbum": "Album: {album}",
"@cueSplitAlbum": {
"description": "Album name in CUE split sheet",
"placeholders": {
"album": {
"type": "String"
}
}
},
"cueSplitArtist": "Artist: {artist}",
"@cueSplitArtist": {
"description": "Artist name in CUE split sheet",
"placeholders": {
"artist": {
"type": "String"
}
}
},
"cueSplitTrackCount": "{count} tracks",
"@cueSplitTrackCount": {
"description": "Number of tracks in CUE sheet",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitConfirmTitle": "Split CUE Album",
"@cueSplitConfirmTitle": {
"description": "CUE split confirmation dialog title"
},
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
"@cueSplitConfirmMessage": {
"description": "CUE split confirmation dialog message",
"placeholders": {
"album": {
"type": "String"
},
"count": {
"type": "int"
}
}
},
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
"@cueSplitSplitting": {
"description": "Snackbar while splitting CUE",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"cueSplitSuccess": "Split into {count} tracks successfully",
"@cueSplitSuccess": {
"description": "Snackbar after successful CUE split",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitFailed": "CUE split failed",
"@cueSplitFailed": {
"description": "Snackbar when CUE split fails"
},
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
"@cueSplitNoAudioFile": {
"description": "Error when CUE audio file is missing"
},
"cueSplitButton": "Split into Tracks",
"@cueSplitButton": {
"description": "Button text to start CUE splitting"
},
"actionCreate": "Create",
"@actionCreate": {
"description": "Generic action button - create"
@@ -2940,7 +3032,7 @@
"@collectionRemoveFromPlaylist": {
"description": "Tooltip for removing track from playlist"
},
"collectionRemoveFromFolder": "Remove from folder",
"collectionRemoveFromFolder": "フォルダから削除",
"@collectionRemoveFromFolder": {
"description": "Tooltip for removing track from wishlist/loved folder"
},
@@ -2997,23 +3089,23 @@
"@trackOptionRemoveFromLoved": {
"description": "Bottom sheet action label - remove track from loved folder"
},
"trackOptionAddToWishlist": "Add to Wishlist",
"trackOptionAddToWishlist": "ウィッシュリストに追加",
"@trackOptionAddToWishlist": {
"description": "Bottom sheet action label - add track to wishlist"
},
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
"trackOptionRemoveFromWishlist": "ウィッシュから削除",
"@trackOptionRemoveFromWishlist": {
"description": "Bottom sheet action label - remove track from wishlist"
},
"collectionPlaylistChangeCover": "Change cover image",
"collectionPlaylistChangeCover": "カバー画像を変更",
"@collectionPlaylistChangeCover": {
"description": "Bottom sheet action to pick a custom cover image for a playlist"
},
"collectionPlaylistRemoveCover": "Remove cover image",
"collectionPlaylistRemoveCover": "カバー画像を削除",
"@collectionPlaylistRemoveCover": {
"description": "Bottom sheet action to remove custom cover image from a playlist"
},
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
"selectionShareCount": "{count} {count, plural, =1{個のトラック} other{個のトラック}}を共有",
"@selectionShareCount": {
"description": "Share button text with count in selection mode",
"placeholders": {
@@ -3039,7 +3131,7 @@
"@selectionConvertNoConvertible": {
"description": "Snackbar when no selected tracks support conversion"
},
"selectionBatchConvertConfirmTitle": "Batch Convert",
"selectionBatchConvertConfirmTitle": "一括変換",
"@selectionBatchConvertConfirmTitle": {
"description": "Confirmation dialog title for batch conversion"
},
+107 -15
View File
@@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Tidal, Qobuz, Amazon Music에서 Spotify 트랙을 무손실 음질로 다운로드하세요.",
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -897,6 +897,18 @@
"@errorNoTracksFound": {
"description": "Error - search returned no results"
},
"errorUrlNotRecognized": "Link not recognized",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
},
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
"@errorUrlNotRecognizedMessage": {
"description": "Error message - URL not recognized explanation"
},
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
"@errorUrlFetchFailed": {
"description": "Error message - generic URL fetch failure"
},
"errorMissingExtensionSource": "확장 소스가 누락되어, {item}(을)를 로드할 수 없습니다",
"@errorMissingExtensionSource": {
"description": "Error - extension source not available",
@@ -1003,6 +1015,14 @@
"@folderOrganizationNone": {
"description": "Folder option - flat structure"
},
"folderOrganizationByPlaylist": "By Playlist",
"@folderOrganizationByPlaylist": {
"description": "Folder option - playlist folders"
},
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
"@folderOrganizationByPlaylistSubtitle": {
"description": "Subtitle for playlist folder option"
},
"folderOrganizationByArtist": "By Artist",
"@folderOrganizationByArtist": {
"description": "Folder option - artist folders"
@@ -1097,7 +1117,7 @@
},
"providerBuiltIn": "Built-in",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Extension",
"@providerExtension": {
@@ -1753,18 +1773,6 @@
"@qualityNote": {
"description": "Note about quality availability"
},
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
@@ -2383,7 +2391,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},
@@ -2808,6 +2816,90 @@
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
},
"cueSplitTitle": "Split CUE Sheet",
"@cueSplitTitle": {
"description": "Title for CUE split bottom sheet"
},
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
"@cueSplitSubtitle": {
"description": "Subtitle for CUE split menu item"
},
"cueSplitAlbum": "Album: {album}",
"@cueSplitAlbum": {
"description": "Album name in CUE split sheet",
"placeholders": {
"album": {
"type": "String"
}
}
},
"cueSplitArtist": "Artist: {artist}",
"@cueSplitArtist": {
"description": "Artist name in CUE split sheet",
"placeholders": {
"artist": {
"type": "String"
}
}
},
"cueSplitTrackCount": "{count} tracks",
"@cueSplitTrackCount": {
"description": "Number of tracks in CUE sheet",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitConfirmTitle": "Split CUE Album",
"@cueSplitConfirmTitle": {
"description": "CUE split confirmation dialog title"
},
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
"@cueSplitConfirmMessage": {
"description": "CUE split confirmation dialog message",
"placeholders": {
"album": {
"type": "String"
},
"count": {
"type": "int"
}
}
},
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
"@cueSplitSplitting": {
"description": "Snackbar while splitting CUE",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"cueSplitSuccess": "Split into {count} tracks successfully",
"@cueSplitSuccess": {
"description": "Snackbar after successful CUE split",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitFailed": "CUE split failed",
"@cueSplitFailed": {
"description": "Snackbar when CUE split fails"
},
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
"@cueSplitNoAudioFile": {
"description": "Error when CUE audio file is missing"
},
"cueSplitButton": "Split into Tracks",
"@cueSplitButton": {
"description": "Button text to start CUE splitting"
},
"actionCreate": "Create",
"@actionCreate": {
"description": "Generic action button - create"
+111 -19
View File
@@ -194,11 +194,11 @@
"@optionsConcurrentDownloads": {
"description": "Number of parallel downloads"
},
"optionsConcurrentSequential": "Sequential (1 at a time)",
"optionsConcurrentSequential": "Sequentiële (1 per keer)",
"@optionsConcurrentSequential": {
"description": "Download one at a time"
},
"optionsConcurrentParallel": "{count} parallel downloads",
"optionsConcurrentParallel": "",
"@optionsConcurrentParallel": {
"description": "Multiple parallel downloads",
"placeholders": {
@@ -207,7 +207,7 @@
}
}
},
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting",
"optionsConcurrentWarning": "Parallel downloaden kan leiden tot rate-limiting",
"@optionsConcurrentWarning": {
"description": "Warning about rate limits"
},
@@ -346,7 +346,7 @@
"@aboutContributors": {
"description": "Section for contributors"
},
"aboutMobileDeveloper": "Mobile version developer",
"aboutMobileDeveloper": "",
"@aboutMobileDeveloper": {
"description": "Role description for mobile dev"
},
@@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -897,6 +897,18 @@
"@errorNoTracksFound": {
"description": "Error - search returned no results"
},
"errorUrlNotRecognized": "Link not recognized",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
},
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
"@errorUrlNotRecognizedMessage": {
"description": "Error message - URL not recognized explanation"
},
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
"@errorUrlFetchFailed": {
"description": "Error message - generic URL fetch failure"
},
"errorMissingExtensionSource": "Cannot load {item}: missing extension source",
"@errorMissingExtensionSource": {
"description": "Error - extension source not available",
@@ -1003,6 +1015,14 @@
"@folderOrganizationNone": {
"description": "Folder option - flat structure"
},
"folderOrganizationByPlaylist": "By Playlist",
"@folderOrganizationByPlaylist": {
"description": "Folder option - playlist folders"
},
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
"@folderOrganizationByPlaylistSubtitle": {
"description": "Subtitle for playlist folder option"
},
"folderOrganizationByArtist": "By Artist",
"@folderOrganizationByArtist": {
"description": "Folder option - artist folders"
@@ -1097,7 +1117,7 @@
},
"providerBuiltIn": "Built-in",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Extension",
"@providerExtension": {
@@ -1753,18 +1773,6 @@
"@qualityNote": {
"description": "Note about quality availability"
},
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
@@ -2383,7 +2391,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},
@@ -2808,6 +2816,90 @@
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
},
"cueSplitTitle": "Split CUE Sheet",
"@cueSplitTitle": {
"description": "Title for CUE split bottom sheet"
},
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
"@cueSplitSubtitle": {
"description": "Subtitle for CUE split menu item"
},
"cueSplitAlbum": "Album: {album}",
"@cueSplitAlbum": {
"description": "Album name in CUE split sheet",
"placeholders": {
"album": {
"type": "String"
}
}
},
"cueSplitArtist": "Artist: {artist}",
"@cueSplitArtist": {
"description": "Artist name in CUE split sheet",
"placeholders": {
"artist": {
"type": "String"
}
}
},
"cueSplitTrackCount": "{count} tracks",
"@cueSplitTrackCount": {
"description": "Number of tracks in CUE sheet",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitConfirmTitle": "Split CUE Album",
"@cueSplitConfirmTitle": {
"description": "CUE split confirmation dialog title"
},
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
"@cueSplitConfirmMessage": {
"description": "CUE split confirmation dialog message",
"placeholders": {
"album": {
"type": "String"
},
"count": {
"type": "int"
}
}
},
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
"@cueSplitSplitting": {
"description": "Snackbar while splitting CUE",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"cueSplitSuccess": "Split into {count} tracks successfully",
"@cueSplitSuccess": {
"description": "Snackbar after successful CUE split",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitFailed": "CUE split failed",
"@cueSplitFailed": {
"description": "Snackbar when CUE split fails"
},
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
"@cueSplitNoAudioFile": {
"description": "Error when CUE audio file is missing"
},
"cueSplitButton": "Split into Tracks",
"@cueSplitButton": {
"description": "Button text to start CUE splitting"
},
"actionCreate": "Create",
"@actionCreate": {
"description": "Generic action button - create"
+401 -7
View File
@@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Baixe faixas do Spotify em qualidade sem perdas do Tidal e Qobuz.",
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -897,6 +897,18 @@
"@errorNoTracksFound": {
"description": "Error - search returned no results"
},
"errorUrlNotRecognized": "Link not recognized",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
},
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
"@errorUrlNotRecognizedMessage": {
"description": "Error message - URL not recognized explanation"
},
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
"@errorUrlFetchFailed": {
"description": "Error message - generic URL fetch failure"
},
"errorMissingExtensionSource": "Não é possível carregar {item}: faltando a fonte da extensão",
"@errorMissingExtensionSource": {
"description": "Error - extension source not available",
@@ -991,10 +1003,26 @@
"@filenameFormat": {
"description": "Setting title - filename pattern"
},
"filenameShowAdvancedTags": "Show advanced tags",
"@filenameShowAdvancedTags": {
"description": "Toggle label for showing advanced filename tags"
},
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
"@filenameShowAdvancedTagsDescription": {
"description": "Description for advanced filename tag toggle"
},
"folderOrganizationNone": "Nenhuma organização",
"@folderOrganizationNone": {
"description": "Folder option - flat structure"
},
"folderOrganizationByPlaylist": "By Playlist",
"@folderOrganizationByPlaylist": {
"description": "Folder option - playlist folders"
},
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
"@folderOrganizationByPlaylistSubtitle": {
"description": "Subtitle for playlist folder option"
},
"folderOrganizationByArtist": "Por Artista",
"@folderOrganizationByArtist": {
"description": "Folder option - artist folders"
@@ -1745,10 +1773,6 @@
"@qualityNote": {
"description": "Note about quality availability"
},
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"downloadAskBeforeDownload": "Perguntar qualidade antes de baixar",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
@@ -2198,6 +2222,15 @@
"@libraryAboutDescription": {
"description": "Description of local library feature"
},
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
"@libraryTracksUnit": {
"description": "Unit label for tracks count (without the number itself)",
"placeholders": {
"count": {
"type": "int"
}
}
},
"libraryLastScanned": "Last scanned: {time}",
"@libraryLastScanned": {
"description": "Last scan time display",
@@ -2358,7 +2391,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Obtenha áudio em qualidade FLAC do Tidal, Qobuz ou Deezer",
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},
@@ -2783,6 +2816,367 @@
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
},
"cueSplitTitle": "Split CUE Sheet",
"@cueSplitTitle": {
"description": "Title for CUE split bottom sheet"
},
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
"@cueSplitSubtitle": {
"description": "Subtitle for CUE split menu item"
},
"cueSplitAlbum": "Album: {album}",
"@cueSplitAlbum": {
"description": "Album name in CUE split sheet",
"placeholders": {
"album": {
"type": "String"
}
}
},
"cueSplitArtist": "Artist: {artist}",
"@cueSplitArtist": {
"description": "Artist name in CUE split sheet",
"placeholders": {
"artist": {
"type": "String"
}
}
},
"cueSplitTrackCount": "{count} tracks",
"@cueSplitTrackCount": {
"description": "Number of tracks in CUE sheet",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitConfirmTitle": "Split CUE Album",
"@cueSplitConfirmTitle": {
"description": "CUE split confirmation dialog title"
},
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
"@cueSplitConfirmMessage": {
"description": "CUE split confirmation dialog message",
"placeholders": {
"album": {
"type": "String"
},
"count": {
"type": "int"
}
}
},
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
"@cueSplitSplitting": {
"description": "Snackbar while splitting CUE",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"cueSplitSuccess": "Split into {count} tracks successfully",
"@cueSplitSuccess": {
"description": "Snackbar after successful CUE split",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitFailed": "CUE split failed",
"@cueSplitFailed": {
"description": "Snackbar when CUE split fails"
},
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
"@cueSplitNoAudioFile": {
"description": "Error when CUE audio file is missing"
},
"cueSplitButton": "Split into Tracks",
"@cueSplitButton": {
"description": "Button text to start CUE splitting"
},
"actionCreate": "Create",
"@actionCreate": {
"description": "Generic action button - create"
},
"collectionFoldersTitle": "My folders",
"@collectionFoldersTitle": {
"description": "Library section title for custom folders"
},
"collectionWishlist": "Wishlist",
"@collectionWishlist": {
"description": "Custom folder for saved tracks to download later"
},
"collectionLoved": "Loved",
"@collectionLoved": {
"description": "Custom folder for favorite tracks"
},
"collectionPlaylists": "Playlists",
"@collectionPlaylists": {
"description": "Custom user playlists folder"
},
"collectionPlaylist": "Playlist",
"@collectionPlaylist": {
"description": "Single playlist label"
},
"collectionAddToPlaylist": "Add to playlist",
"@collectionAddToPlaylist": {
"description": "Action to add a track to user playlist"
},
"collectionCreatePlaylist": "Create playlist",
"@collectionCreatePlaylist": {
"description": "Action to create a new playlist"
},
"collectionNoPlaylistsYet": "No playlists yet",
"@collectionNoPlaylistsYet": {
"description": "Empty state title when user has no playlists"
},
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
"@collectionNoPlaylistsSubtitle": {
"description": "Empty state subtitle when user has no playlists"
},
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
"@collectionPlaylistTracks": {
"description": "Track count label for custom playlists",
"placeholders": {
"count": {
"type": "int"
}
}
},
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
"@collectionAddedToPlaylist": {
"description": "Snackbar after adding track to playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
"@collectionAlreadyInPlaylist": {
"description": "Snackbar when track already exists in playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistCreated": "Playlist created",
"@collectionPlaylistCreated": {
"description": "Snackbar after creating playlist"
},
"collectionPlaylistNameHint": "Playlist name",
"@collectionPlaylistNameHint": {
"description": "Hint text for playlist name input"
},
"collectionPlaylistNameRequired": "Playlist name is required",
"@collectionPlaylistNameRequired": {
"description": "Validation error for empty playlist name"
},
"collectionRenamePlaylist": "Rename playlist",
"@collectionRenamePlaylist": {
"description": "Action to rename playlist"
},
"collectionDeletePlaylist": "Delete playlist",
"@collectionDeletePlaylist": {
"description": "Action to delete playlist"
},
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
"@collectionDeletePlaylistMessage": {
"description": "Confirmation message for deleting playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistDeleted": "Playlist deleted",
"@collectionPlaylistDeleted": {
"description": "Snackbar after deleting playlist"
},
"collectionPlaylistRenamed": "Playlist renamed",
"@collectionPlaylistRenamed": {
"description": "Snackbar after renaming playlist"
},
"collectionWishlistEmptyTitle": "Wishlist is empty",
"@collectionWishlistEmptyTitle": {
"description": "Wishlist empty state title"
},
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
"@collectionWishlistEmptySubtitle": {
"description": "Wishlist empty state subtitle"
},
"collectionLovedEmptyTitle": "Loved folder is empty",
"@collectionLovedEmptyTitle": {
"description": "Loved empty state title"
},
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
"@collectionLovedEmptySubtitle": {
"description": "Loved empty state subtitle"
},
"collectionPlaylistEmptyTitle": "Playlist is empty",
"@collectionPlaylistEmptyTitle": {
"description": "Playlist empty state title"
},
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
"@collectionPlaylistEmptySubtitle": {
"description": "Playlist empty state subtitle"
},
"collectionRemoveFromPlaylist": "Remove from playlist",
"@collectionRemoveFromPlaylist": {
"description": "Tooltip for removing track from playlist"
},
"collectionRemoveFromFolder": "Remove from folder",
"@collectionRemoveFromFolder": {
"description": "Tooltip for removing track from wishlist/loved folder"
},
"collectionRemoved": "\"{trackName}\" removed",
"@collectionRemoved": {
"description": "Snackbar after removing a track from a collection",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
"@collectionAddedToLoved": {
"description": "Snackbar after adding track to loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
"@collectionRemovedFromLoved": {
"description": "Snackbar after removing track from loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
"@collectionAddedToWishlist": {
"description": "Snackbar after adding track to wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
"@collectionRemovedFromWishlist": {
"description": "Snackbar after removing track from wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"trackOptionAddToLoved": "Add to Loved",
"@trackOptionAddToLoved": {
"description": "Bottom sheet action label - add track to loved folder"
},
"trackOptionRemoveFromLoved": "Remove from Loved",
"@trackOptionRemoveFromLoved": {
"description": "Bottom sheet action label - remove track from loved folder"
},
"trackOptionAddToWishlist": "Add to Wishlist",
"@trackOptionAddToWishlist": {
"description": "Bottom sheet action label - add track to wishlist"
},
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
"@trackOptionRemoveFromWishlist": {
"description": "Bottom sheet action label - remove track from wishlist"
},
"collectionPlaylistChangeCover": "Change cover image",
"@collectionPlaylistChangeCover": {
"description": "Bottom sheet action to pick a custom cover image for a playlist"
},
"collectionPlaylistRemoveCover": "Remove cover image",
"@collectionPlaylistRemoveCover": {
"description": "Bottom sheet action to remove custom cover image from a playlist"
},
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
"@selectionShareCount": {
"description": "Share button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionShareNoFiles": "No shareable files found",
"@selectionShareNoFiles": {
"description": "Snackbar when no selected files exist on disk"
},
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
"@selectionConvertCount": {
"description": "Convert button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionConvertNoConvertible": "No convertible tracks selected",
"@selectionConvertNoConvertible": {
"description": "Snackbar when no selected tracks support conversion"
},
"selectionBatchConvertConfirmTitle": "Batch Convert",
"@selectionBatchConvertConfirmTitle": {
"description": "Confirmation dialog title for batch conversion"
},
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
"@selectionBatchConvertConfirmMessage": {
"description": "Confirmation dialog message for batch conversion",
"placeholders": {
"count": {
"type": "int"
},
"format": {
"type": "String"
},
"bitrate": {
"type": "String"
}
}
},
"selectionBatchConvertProgress": "Converting {current} of {total}...",
"@selectionBatchConvertProgress": {
"description": "Snackbar during batch conversion progress",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
"@selectionBatchConvertSuccess": {
"description": "Snackbar after batch conversion completes",
"placeholders": {
"success": {
"type": "int"
},
"total": {
"type": "int"
},
"format": {
"type": "String"
}
}
},
"downloadedAlbumDownloadedCount": "{count} baixado(s)",
"@downloadedAlbumDownloadedCount": {
"description": "Downloaded tracks count badge",
@@ -2800,4 +3194,4 @@
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming"
}
}
}
+114 -22
View File
@@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.",
"aboutAppDescription": "Скачивайте треки Spotify в lossless качестве с Tidal и Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -897,6 +897,18 @@
"@errorNoTracksFound": {
"description": "Error - search returned no results"
},
"errorUrlNotRecognized": "Ссылка не распознана",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
},
"errorUrlNotRecognizedMessage": "Эта ссылка не поддерживается. Убедитесь, что URL-адрес указан правильно и установлено совместимое расширение.",
"@errorUrlNotRecognizedMessage": {
"description": "Error message - URL not recognized explanation"
},
"errorUrlFetchFailed": "Не удалось загрузить контент по этой ссылке. Пожалуйста, попробуйте еще раз.",
"@errorUrlFetchFailed": {
"description": "Error message - generic URL fetch failure"
},
"errorMissingExtensionSource": "Невозможно загрузить {item}: отсутствует источник расширения",
"@errorMissingExtensionSource": {
"description": "Error - extension source not available",
@@ -1003,6 +1015,14 @@
"@folderOrganizationNone": {
"description": "Folder option - flat structure"
},
"folderOrganizationByPlaylist": "По плейлисту",
"@folderOrganizationByPlaylist": {
"description": "Folder option - playlist folders"
},
"folderOrganizationByPlaylistSubtitle": "Отдельная папка для каждого плейлиста",
"@folderOrganizationByPlaylistSubtitle": {
"description": "Subtitle for playlist folder option"
},
"folderOrganizationByArtist": "По исполнителю",
"@folderOrganizationByArtist": {
"description": "Folder option - artist folders"
@@ -1097,7 +1117,7 @@
},
"providerBuiltIn": "Встроенные",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Расширение",
"@providerExtension": {
@@ -1753,18 +1773,6 @@
"@qualityNote": {
"description": "Note about quality availability"
},
"youtubeQualityNote": "YouTube обеспечивает только звук с потерями(Lossy).",
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "Битрейт YouTube Opus",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "Битрейт YouTube MP3",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Спрашивать перед скачиванием",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
@@ -1789,7 +1797,7 @@
"@downloadUsePrimaryArtistOnly": {
"description": "Setting - strip featured artists from folder name"
},
"downloadUsePrimaryArtistOnlyEnabled": "Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)",
"downloadUsePrimaryArtistOnlyEnabled": "Список исполнителей, чьи работы были удалены из названия папки (например, Джастин Бибер, Quavo → Джастин Бибер)",
"@downloadUsePrimaryArtistOnlyEnabled": {
"description": "Subtitle when primary artist only is enabled"
},
@@ -2383,7 +2391,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Скачайте FLAC с Tidal, Qobuz или Amazon Music",
"tutorialWelcomeTip2": "Получите аудио в качестве FLAC от Tidal, Qobuz или Deezer",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},
@@ -2487,7 +2495,7 @@
"@cleanupOrphanedDownloadsSubtitle": {
"description": "Subtitle for orphaned cleanup button"
},
"cleanupOrphanedDownloadsResult": "Removed {count} orphaned entries from history",
"cleanupOrphanedDownloadsResult": "Удалено {count} утерянных записей из истории",
"@cleanupOrphanedDownloadsResult": {
"description": "Snackbar after orphan cleanup",
"placeholders": {
@@ -2525,7 +2533,7 @@
"@cacheSectionStorage": {
"description": "Section header for cache entries"
},
"cacheSectionMaintenance": "Maintenance",
"cacheSectionMaintenance": "Обслуживание",
"@cacheSectionMaintenance": {
"description": "Section header for cleanup actions"
},
@@ -2577,7 +2585,7 @@
"@cacheTrackLookupDesc": {
"description": "Description of what track lookup cache contains"
},
"cacheCleanupUnusedDesc": "Remove orphaned download history and library entries for missing files.",
"cacheCleanupUnusedDesc": "Удалить записи из истории загрузок и библиотеки, которые остались без файлов.",
"@cacheCleanupUnusedDesc": {
"description": "Description of what cleanup unused data does"
},
@@ -2653,7 +2661,7 @@
"@cacheCleanupUnused": {
"description": "Action title for cleaning unused entries"
},
"cacheCleanupUnusedSubtitle": "Remove orphaned download history and missing library entries",
"cacheCleanupUnusedSubtitle": "Удалить историю загрузок, оставшихся без просмотра, и отсутствующие записи в библиотеке",
"@cacheCleanupUnusedSubtitle": {
"description": "Subtitle for cleanup unused data action"
},
@@ -2808,6 +2816,90 @@
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
},
"cueSplitTitle": "Разделить CUE Sheet",
"@cueSplitTitle": {
"description": "Title for CUE split bottom sheet"
},
"cueSplitSubtitle": "Разделить файл CUE+FLAC на отдельные треки",
"@cueSplitSubtitle": {
"description": "Subtitle for CUE split menu item"
},
"cueSplitAlbum": "Альбом: {album}",
"@cueSplitAlbum": {
"description": "Album name in CUE split sheet",
"placeholders": {
"album": {
"type": "String"
}
}
},
"cueSplitArtist": "Артист: {artist}",
"@cueSplitArtist": {
"description": "Artist name in CUE split sheet",
"placeholders": {
"artist": {
"type": "String"
}
}
},
"cueSplitTrackCount": "{count} треков",
"@cueSplitTrackCount": {
"description": "Number of tracks in CUE sheet",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitConfirmTitle": "Разделенный CUE-альбом",
"@cueSplitConfirmTitle": {
"description": "CUE split confirmation dialog title"
},
"cueSplitConfirmMessage": "Разбить \"{album}\" на {count} отдельных FLAC-файлов?",
"@cueSplitConfirmMessage": {
"description": "CUE split confirmation dialog message",
"placeholders": {
"album": {
"type": "String"
},
"count": {
"type": "int"
}
}
},
"cueSplitSplitting": "Разделение CUE sheet... ({current}/{total})",
"@cueSplitSplitting": {
"description": "Snackbar while splitting CUE",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"cueSplitSuccess": "Успешно разделено на {count} треков",
"@cueSplitSuccess": {
"description": "Snackbar after successful CUE split",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitFailed": "Разделение CUE не удалось",
"@cueSplitFailed": {
"description": "Snackbar when CUE split fails"
},
"cueSplitNoAudioFile": "Аудиофайл для этого CUE sheet не найден",
"@cueSplitNoAudioFile": {
"description": "Error when CUE audio file is missing"
},
"cueSplitButton": "Разделить на Треки",
"@cueSplitButton": {
"description": "Button text to start CUE splitting"
},
"actionCreate": "Создать",
"@actionCreate": {
"description": "Generic action button - create"
@@ -3022,7 +3114,7 @@
}
}
},
"selectionShareNoFiles": "No shareable files found",
"selectionShareNoFiles": "Файлы, доступные для совместного доступа, не найдены",
"@selectionShareNoFiles": {
"description": "Snackbar when no selected files exist on disk"
},
@@ -3043,7 +3135,7 @@
"@selectionBatchConvertConfirmTitle": {
"description": "Confirmation dialog title for batch conversion"
},
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
"selectionBatchConvertConfirmMessage": "Преобразовать {count} {count, plural, =1{track} other{tracks}} в {format} с {bitrate}?",
"@selectionBatchConvertConfirmMessage": {
"description": "Confirmation dialog message for batch conversion",
"placeholders": {
+536 -142
View File
File diff suppressed because it is too large Load Diff
+203 -111
View File
@@ -5,143 +5,143 @@
"@appName": {
"description": "App name - DO NOT TRANSLATE"
},
"navHome": "Home",
"navHome": "主页",
"@navHome": {
"description": "Bottom navigation - Home tab"
},
"navLibrary": "Library",
"navLibrary": "乐库",
"@navLibrary": {
"description": "Bottom navigation - Library tab"
},
"navSettings": "Settings",
"navSettings": "设置",
"@navSettings": {
"description": "Bottom navigation - Settings tab"
},
"navStore": "Store",
"navStore": "商店",
"@navStore": {
"description": "Bottom navigation - Extension store tab"
},
"homeTitle": "Home",
"homeTitle": "主页",
"@homeTitle": {
"description": "Home screen title"
},
"homeSubtitle": "Paste a Spotify link or search by name",
"homeSubtitle": "粘贴 Spotify 链接或按名称搜索",
"@homeSubtitle": {
"description": "Subtitle shown below search box"
},
"homeSupports": "Supports: Track, Album, Playlist, Artist URLs",
"homeSupports": "支持:歌曲、专辑、播放列表、艺人网址",
"@homeSupports": {
"description": "Info text about supported URL types"
},
"homeRecent": "Recent",
"homeRecent": "最近",
"@homeRecent": {
"description": "Section header for recent searches"
},
"historyFilterAll": "All",
"historyFilterAll": "全部",
"@historyFilterAll": {
"description": "Filter chip - show all items"
},
"historyFilterAlbums": "Albums",
"historyFilterAlbums": "专辑",
"@historyFilterAlbums": {
"description": "Filter chip - show albums only"
},
"historyFilterSingles": "Singles",
"historyFilterSingles": "单曲",
"@historyFilterSingles": {
"description": "Filter chip - show singles only"
},
"historySearchHint": "Search history...",
"historySearchHint": "搜索历史……",
"@historySearchHint": {
"description": "Search bar placeholder in history"
},
"settingsTitle": "Settings",
"settingsTitle": "设置",
"@settingsTitle": {
"description": "Settings screen title"
},
"settingsDownload": "Download",
"settingsDownload": "下载",
"@settingsDownload": {
"description": "Settings section - download options"
},
"settingsAppearance": "Appearance",
"settingsAppearance": "外观",
"@settingsAppearance": {
"description": "Settings section - visual customization"
},
"settingsOptions": "Options",
"settingsOptions": "选项",
"@settingsOptions": {
"description": "Settings section - app options"
},
"settingsExtensions": "Extensions",
"settingsExtensions": "扩展",
"@settingsExtensions": {
"description": "Settings section - extension management"
},
"settingsAbout": "About",
"settingsAbout": "关于",
"@settingsAbout": {
"description": "Settings section - app info"
},
"downloadTitle": "Download",
"downloadTitle": "下载",
"@downloadTitle": {
"description": "Download settings page title"
},
"downloadAskQualitySubtitle": "Show quality picker for each download",
"downloadAskQualitySubtitle": "为每次下载显示质量选择器",
"@downloadAskQualitySubtitle": {
"description": "Subtitle for ask quality toggle"
},
"downloadFilenameFormat": "Filename Format",
"downloadFilenameFormat": "文件名格式",
"@downloadFilenameFormat": {
"description": "Setting for output filename pattern"
},
"downloadFolderOrganization": "Folder Organization",
"downloadFolderOrganization": "文件夹结构",
"@downloadFolderOrganization": {
"description": "Setting for folder structure"
},
"appearanceTitle": "Appearance",
"appearanceTitle": "外观",
"@appearanceTitle": {
"description": "Appearance settings page title"
},
"appearanceThemeSystem": "System",
"appearanceThemeSystem": "系统",
"@appearanceThemeSystem": {
"description": "Follow system theme"
},
"appearanceThemeLight": "Light",
"appearanceThemeLight": "浅色",
"@appearanceThemeLight": {
"description": "Light theme"
},
"appearanceThemeDark": "Dark",
"appearanceThemeDark": "深色",
"@appearanceThemeDark": {
"description": "Dark theme"
},
"appearanceDynamicColor": "Dynamic Color",
"appearanceDynamicColor": "动态色彩",
"@appearanceDynamicColor": {
"description": "Material You dynamic colors"
},
"appearanceDynamicColorSubtitle": "Use colors from your wallpaper",
"appearanceDynamicColorSubtitle": "使用壁纸的颜色",
"@appearanceDynamicColorSubtitle": {
"description": "Subtitle for dynamic color"
},
"appearanceHistoryView": "History View",
"appearanceHistoryView": "历史记录",
"@appearanceHistoryView": {
"description": "Layout style for history"
},
"appearanceHistoryViewList": "List",
"appearanceHistoryViewList": "列表",
"@appearanceHistoryViewList": {
"description": "List layout option"
},
"appearanceHistoryViewGrid": "Grid",
"appearanceHistoryViewGrid": "网格",
"@appearanceHistoryViewGrid": {
"description": "Grid layout option"
},
"optionsTitle": "Options",
"optionsTitle": "选项",
"@optionsTitle": {
"description": "Options settings page title"
},
"optionsPrimaryProvider": "Primary Provider",
"optionsPrimaryProvider": "主要提供者",
"@optionsPrimaryProvider": {
"description": "Main search provider setting"
},
"optionsPrimaryProviderSubtitle": "Service used when searching by track name.",
"optionsPrimaryProviderSubtitle": "按歌曲名称搜索时使用的服务。",
"@optionsPrimaryProviderSubtitle": {
"description": "Subtitle for primary provider"
},
"optionsUsingExtension": "Using extension: {extensionName}",
"optionsUsingExtension": "使用扩展:{extensionName}",
"@optionsUsingExtension": {
"description": "Shows active extension name",
"placeholders": {
@@ -150,55 +150,55 @@
}
}
},
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
"optionsSwitchBack": "点击 Deezer Spotify 即可从扩展程序切换回来",
"@optionsSwitchBack": {
"description": "Hint to switch back to built-in providers"
},
"optionsAutoFallback": "Auto Fallback",
"optionsAutoFallback": "自动回退",
"@optionsAutoFallback": {
"description": "Auto-retry with other services"
},
"optionsAutoFallbackSubtitle": "Try other services if download fails",
"optionsAutoFallbackSubtitle": "如果下载失败,请尝试其他服务",
"@optionsAutoFallbackSubtitle": {
"description": "Subtitle for auto fallback"
},
"optionsUseExtensionProviders": "Use Extension Providers",
"optionsUseExtensionProviders": "使用扩展提供商",
"@optionsUseExtensionProviders": {
"description": "Enable extension download providers"
},
"optionsUseExtensionProvidersOn": "Extensions will be tried first",
"optionsUseExtensionProvidersOn": "扩展会被最先尝试",
"@optionsUseExtensionProvidersOn": {
"description": "Status when extension providers enabled"
},
"optionsUseExtensionProvidersOff": "Using built-in providers only",
"optionsUseExtensionProvidersOff": "仅使用内置提供商",
"@optionsUseExtensionProvidersOff": {
"description": "Status when extension providers disabled"
},
"optionsEmbedLyrics": "Embed Lyrics",
"optionsEmbedLyrics": "内嵌歌词",
"@optionsEmbedLyrics": {
"description": "Embed lyrics in audio files"
},
"optionsEmbedLyricsSubtitle": "Embed synced lyrics into FLAC files",
"optionsEmbedLyricsSubtitle": "嵌入已同步歌词到 FLAC 文件",
"@optionsEmbedLyricsSubtitle": {
"description": "Subtitle for embed lyrics"
},
"optionsMaxQualityCover": "Max Quality Cover",
"optionsMaxQualityCover": "最高质量封面",
"@optionsMaxQualityCover": {
"description": "Download highest quality album art"
},
"optionsMaxQualityCoverSubtitle": "Download highest resolution cover art",
"optionsMaxQualityCoverSubtitle": "下载最高分辨率封面",
"@optionsMaxQualityCoverSubtitle": {
"description": "Subtitle for max quality cover"
},
"optionsConcurrentDownloads": "Concurrent Downloads",
"optionsConcurrentDownloads": "并行下载数",
"@optionsConcurrentDownloads": {
"description": "Number of parallel downloads"
},
"optionsConcurrentSequential": "Sequential (1 at a time)",
"optionsConcurrentSequential": "按顺序下载(一次一首)",
"@optionsConcurrentSequential": {
"description": "Download one at a time"
},
"optionsConcurrentParallel": "{count} parallel downloads",
"optionsConcurrentParallel": "同时下载 {count} ",
"@optionsConcurrentParallel": {
"description": "Multiple parallel downloads",
"placeholders": {
@@ -207,67 +207,67 @@
}
}
},
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting",
"optionsConcurrentWarning": "并行下载可能会触发速率限制",
"@optionsConcurrentWarning": {
"description": "Warning about rate limits"
},
"optionsExtensionStore": "Extension Store",
"optionsExtensionStore": "扩展商店",
"@optionsExtensionStore": {
"description": "Show/hide store tab"
},
"optionsExtensionStoreSubtitle": "Show Store tab in navigation",
"optionsExtensionStoreSubtitle": "在导航中显示商店标签",
"@optionsExtensionStoreSubtitle": {
"description": "Subtitle for extension store toggle"
},
"optionsCheckUpdates": "Check for Updates",
"optionsCheckUpdates": "检查更新",
"@optionsCheckUpdates": {
"description": "Auto update check toggle"
},
"optionsCheckUpdatesSubtitle": "Notify when new version is available",
"optionsCheckUpdatesSubtitle": "当有新版本可用时通知",
"@optionsCheckUpdatesSubtitle": {
"description": "Subtitle for update check"
},
"optionsUpdateChannel": "Update Channel",
"optionsUpdateChannel": "更新频道",
"@optionsUpdateChannel": {
"description": "Stable vs preview releases"
},
"optionsUpdateChannelStable": "Stable releases only",
"optionsUpdateChannelStable": "仅稳定版本",
"@optionsUpdateChannelStable": {
"description": "Only stable updates"
},
"optionsUpdateChannelPreview": "Get preview releases",
"optionsUpdateChannelPreview": "获取预览版本",
"@optionsUpdateChannelPreview": {
"description": "Include beta/preview updates"
},
"optionsUpdateChannelWarning": "Preview may contain bugs or incomplete features",
"optionsUpdateChannelWarning": "预览版本可能包含错误或者尚未完善的功能",
"@optionsUpdateChannelWarning": {
"description": "Warning about preview channel"
},
"optionsClearHistory": "Clear Download History",
"optionsClearHistory": "清除下载历史记录",
"@optionsClearHistory": {
"description": "Delete all download history"
},
"optionsClearHistorySubtitle": "Remove all downloaded tracks from history",
"optionsClearHistorySubtitle": "从历史记录中清除所有已下载的曲目",
"@optionsClearHistorySubtitle": {
"description": "Subtitle for clear history"
},
"optionsDetailedLogging": "Detailed Logging",
"optionsDetailedLogging": "详细日志",
"@optionsDetailedLogging": {
"description": "Enable verbose logs for debugging"
},
"optionsDetailedLoggingOn": "Detailed logs are being recorded",
"optionsDetailedLoggingOn": "正在记录详细日志",
"@optionsDetailedLoggingOn": {
"description": "Status when logging enabled"
},
"optionsDetailedLoggingOff": "Enable for bug reports",
"optionsDetailedLoggingOff": "为错误报告启用",
"@optionsDetailedLoggingOff": {
"description": "Status when logging disabled"
},
"optionsSpotifyCredentials": "Spotify Credentials",
"optionsSpotifyCredentials": "Spotify 凭据",
"@optionsSpotifyCredentials": {
"description": "Spotify API credentials setting"
},
"optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...",
"optionsSpotifyCredentialsConfigured": "客户端 ID{clientId}……",
"@optionsSpotifyCredentialsConfigured": {
"description": "Shows configured client ID preview",
"placeholders": {
@@ -276,27 +276,27 @@
}
}
},
"optionsSpotifyCredentialsRequired": "Required - tap to configure",
"optionsSpotifyCredentialsRequired": "必填 - 点击配置",
"@optionsSpotifyCredentialsRequired": {
"description": "Prompt to set up credentials"
},
"optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com",
"optionsSpotifyWarning": "Spotify 需要您自己的 API 凭据。在 developer.spotify.com 免费获取",
"@optionsSpotifyWarning": {
"description": "Info about Spotify API requirement"
},
"optionsSpotifyDeprecationWarning": "Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.",
"optionsSpotifyDeprecationWarning": "Spotify 搜索将在 2026 年 3 月 3 日因 Spotify API 更改而被废弃。请切换到 Deezer",
"@optionsSpotifyDeprecationWarning": {
"description": "Warning about Spotify API deprecation"
},
"extensionsTitle": "Extensions",
"extensionsTitle": "扩展",
"@extensionsTitle": {
"description": "Extensions page title"
},
"extensionsDisabled": "Disabled",
"extensionsDisabled": "禁用",
"@extensionsDisabled": {
"description": "Extension status - inactive"
},
"extensionsVersion": "Version {version}",
"extensionsVersion": "版本 {version}",
"@extensionsVersion": {
"description": "Extension version display",
"placeholders": {
@@ -305,7 +305,7 @@
}
}
},
"extensionsAuthor": "by {author}",
"extensionsAuthor": "来自 {author}",
"@extensionsAuthor": {
"description": "Extension author credit",
"placeholders": {
@@ -314,75 +314,75 @@
}
}
},
"extensionsUninstall": "Uninstall",
"extensionsUninstall": "卸载",
"@extensionsUninstall": {
"description": "Uninstall extension button"
},
"storeTitle": "Extension Store",
"storeTitle": "扩展商店",
"@storeTitle": {
"description": "Store screen title"
},
"storeSearch": "Search extensions...",
"storeSearch": "搜索扩展……",
"@storeSearch": {
"description": "Store search placeholder"
},
"storeInstall": "Install",
"storeInstall": "安装",
"@storeInstall": {
"description": "Install extension button"
},
"storeInstalled": "Installed",
"storeInstalled": "已安装",
"@storeInstalled": {
"description": "Already installed badge"
},
"storeUpdate": "Update",
"storeUpdate": "更新",
"@storeUpdate": {
"description": "Update available button"
},
"aboutTitle": "About",
"aboutTitle": "关于",
"@aboutTitle": {
"description": "About page title"
},
"aboutContributors": "Contributors",
"aboutContributors": "贡献者",
"@aboutContributors": {
"description": "Section for contributors"
},
"aboutMobileDeveloper": "Mobile version developer",
"aboutMobileDeveloper": "移动版本开发者",
"@aboutMobileDeveloper": {
"description": "Role description for mobile dev"
},
"aboutOriginalCreator": "Creator of the original SpotiFLAC",
"aboutOriginalCreator": "原 SpotiLDAC 创建者",
"@aboutOriginalCreator": {
"description": "Role description for original creator"
},
"aboutLogoArtist": "The talented artist who created our beautiful app logo!",
"aboutLogoArtist": "有才华的艺术家创建了我们美丽的应用图标!",
"@aboutLogoArtist": {
"description": "Role description for logo artist"
},
"aboutTranslators": "Translators",
"aboutTranslators": "译者",
"@aboutTranslators": {
"description": "Section for translators"
},
"aboutSpecialThanks": "Special Thanks",
"aboutSpecialThanks": "特别鸣谢",
"@aboutSpecialThanks": {
"description": "Section for special thanks"
},
"aboutLinks": "Links",
"aboutLinks": "相关链接",
"@aboutLinks": {
"description": "Section for external links"
},
"aboutMobileSource": "Mobile source code",
"aboutMobileSource": "移动版本源代码",
"@aboutMobileSource": {
"description": "Link to mobile GitHub repo"
},
"aboutPCSource": "PC source code",
"aboutPCSource": "桌面版本源代码",
"@aboutPCSource": {
"description": "Link to PC GitHub repo"
},
"aboutReportIssue": "Report an issue",
"aboutReportIssue": "报告一个问题",
"@aboutReportIssue": {
"description": "Link to report bugs"
},
"aboutReportIssueSubtitle": "Report any problems you encounter",
"aboutReportIssueSubtitle": "报告您遇到的任何问题",
"@aboutReportIssueSubtitle": {
"description": "Subtitle for report issue"
},
@@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -603,23 +603,23 @@
"@setupNotificationGranted": {
"description": "Success message for notification permission"
},
"setupNotificationEnable": "Enable Notifications",
"setupNotificationEnable": "启用通知",
"@setupNotificationEnable": {
"description": "Button to enable notifications"
},
"setupFolderChoose": "Choose Download Folder",
"setupFolderChoose": "选择下载文件夹",
"@setupFolderChoose": {
"description": "Button to choose folder"
},
"setupFolderDescription": "Select a folder where your downloaded music will be saved.",
"setupFolderDescription": "选择保存您下载的音乐的文件夹。",
"@setupFolderDescription": {
"description": "Explanation for folder selection"
},
"setupSelectFolder": "Select Folder",
"setupSelectFolder": "选择文件夹",
"@setupSelectFolder": {
"description": "Button to select folder"
},
"setupEnableNotifications": "Enable Notifications",
"setupEnableNotifications": "启用通知",
"@setupEnableNotifications": {
"description": "Button to enable notifications"
},
@@ -889,14 +889,26 @@
"@errorRateLimited": {
"description": "Error title - too many requests"
},
"errorRateLimitedMessage": "Too many requests. Please wait a moment before searching again.",
"errorRateLimitedMessage": "请求过多。请等一会再搜索。",
"@errorRateLimitedMessage": {
"description": "Error message - rate limit explanation"
},
"errorNoTracksFound": "No tracks found",
"errorNoTracksFound": "未找到曲目",
"@errorNoTracksFound": {
"description": "Error - search returned no results"
},
"errorUrlNotRecognized": "Link not recognized",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
},
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
"@errorUrlNotRecognizedMessage": {
"description": "Error message - URL not recognized explanation"
},
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
"@errorUrlFetchFailed": {
"description": "Error message - generic URL fetch failure"
},
"errorMissingExtensionSource": "Cannot load {item}: missing extension source",
"@errorMissingExtensionSource": {
"description": "Error - extension source not available",
@@ -1003,6 +1015,14 @@
"@folderOrganizationNone": {
"description": "Folder option - flat structure"
},
"folderOrganizationByPlaylist": "By Playlist",
"@folderOrganizationByPlaylist": {
"description": "Folder option - playlist folders"
},
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
"@folderOrganizationByPlaylistSubtitle": {
"description": "Subtitle for playlist folder option"
},
"folderOrganizationByArtist": "By Artist",
"@folderOrganizationByArtist": {
"description": "Folder option - artist folders"
@@ -1097,7 +1117,7 @@
},
"providerBuiltIn": "Built-in",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Extension",
"@providerExtension": {
@@ -1753,18 +1773,6 @@
"@qualityNote": {
"description": "Note about quality availability"
},
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
@@ -2383,7 +2391,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},
@@ -2808,6 +2816,90 @@
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
},
"cueSplitTitle": "Split CUE Sheet",
"@cueSplitTitle": {
"description": "Title for CUE split bottom sheet"
},
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
"@cueSplitSubtitle": {
"description": "Subtitle for CUE split menu item"
},
"cueSplitAlbum": "Album: {album}",
"@cueSplitAlbum": {
"description": "Album name in CUE split sheet",
"placeholders": {
"album": {
"type": "String"
}
}
},
"cueSplitArtist": "Artist: {artist}",
"@cueSplitArtist": {
"description": "Artist name in CUE split sheet",
"placeholders": {
"artist": {
"type": "String"
}
}
},
"cueSplitTrackCount": "{count} tracks",
"@cueSplitTrackCount": {
"description": "Number of tracks in CUE sheet",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitConfirmTitle": "Split CUE Album",
"@cueSplitConfirmTitle": {
"description": "CUE split confirmation dialog title"
},
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
"@cueSplitConfirmMessage": {
"description": "CUE split confirmation dialog message",
"placeholders": {
"album": {
"type": "String"
},
"count": {
"type": "int"
}
}
},
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
"@cueSplitSplitting": {
"description": "Snackbar while splitting CUE",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"cueSplitSuccess": "Split into {count} tracks successfully",
"@cueSplitSuccess": {
"description": "Snackbar after successful CUE split",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitFailed": "CUE split failed",
"@cueSplitFailed": {
"description": "Snackbar when CUE split fails"
},
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
"@cueSplitNoAudioFile": {
"description": "Error when CUE audio file is missing"
},
"cueSplitButton": "Split into Tracks",
"@cueSplitButton": {
"description": "Button text to start CUE splitting"
},
"actionCreate": "Create",
"@actionCreate": {
"description": "Generic action button - create"
+107 -15
View File
@@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": {
"description": "App description in header card"
},
@@ -897,6 +897,18 @@
"@errorNoTracksFound": {
"description": "Error - search returned no results"
},
"errorUrlNotRecognized": "Link not recognized",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
},
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
"@errorUrlNotRecognizedMessage": {
"description": "Error message - URL not recognized explanation"
},
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
"@errorUrlFetchFailed": {
"description": "Error message - generic URL fetch failure"
},
"errorMissingExtensionSource": "Cannot load {item}: missing extension source",
"@errorMissingExtensionSource": {
"description": "Error - extension source not available",
@@ -1003,6 +1015,14 @@
"@folderOrganizationNone": {
"description": "Folder option - flat structure"
},
"folderOrganizationByPlaylist": "By Playlist",
"@folderOrganizationByPlaylist": {
"description": "Folder option - playlist folders"
},
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
"@folderOrganizationByPlaylistSubtitle": {
"description": "Subtitle for playlist folder option"
},
"folderOrganizationByArtist": "By Artist",
"@folderOrganizationByArtist": {
"description": "Folder option - artist folders"
@@ -1097,7 +1117,7 @@
},
"providerBuiltIn": "Built-in",
"@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
"description": "Label for built-in providers (Tidal/Qobuz)"
},
"providerExtension": "Extension",
"@providerExtension": {
@@ -1753,18 +1773,6 @@
"@qualityNote": {
"description": "Note about quality availability"
},
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
@@ -2383,7 +2391,7 @@
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},
@@ -2808,6 +2816,90 @@
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
},
"cueSplitTitle": "Split CUE Sheet",
"@cueSplitTitle": {
"description": "Title for CUE split bottom sheet"
},
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
"@cueSplitSubtitle": {
"description": "Subtitle for CUE split menu item"
},
"cueSplitAlbum": "Album: {album}",
"@cueSplitAlbum": {
"description": "Album name in CUE split sheet",
"placeholders": {
"album": {
"type": "String"
}
}
},
"cueSplitArtist": "Artist: {artist}",
"@cueSplitArtist": {
"description": "Artist name in CUE split sheet",
"placeholders": {
"artist": {
"type": "String"
}
}
},
"cueSplitTrackCount": "{count} tracks",
"@cueSplitTrackCount": {
"description": "Number of tracks in CUE sheet",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitConfirmTitle": "Split CUE Album",
"@cueSplitConfirmTitle": {
"description": "CUE split confirmation dialog title"
},
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
"@cueSplitConfirmMessage": {
"description": "CUE split confirmation dialog message",
"placeholders": {
"album": {
"type": "String"
},
"count": {
"type": "int"
}
}
},
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
"@cueSplitSplitting": {
"description": "Snackbar while splitting CUE",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"cueSplitSuccess": "Split into {count} tracks successfully",
"@cueSplitSuccess": {
"description": "Snackbar after successful CUE split",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitFailed": "CUE split failed",
"@cueSplitFailed": {
"description": "Snackbar when CUE split fails"
},
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
"@cueSplitNoAudioFile": {
"description": "Error when CUE audio file is missing"
},
"cueSplitButton": "Split into Tracks",
"@cueSplitButton": {
"description": "Button text to start CUE splitting"
},
"actionCreate": "Create",
"@actionCreate": {
"description": "Generic action button - create"
+70 -2
View File
@@ -4,6 +4,7 @@ import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/app.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
@@ -13,6 +14,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
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';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -90,16 +92,19 @@ class _EagerInitialization extends ConsumerStatefulWidget {
_EagerInitializationState();
}
class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
class _EagerInitializationState extends ConsumerState<_EagerInitialization>
with WidgetsBindingObserver {
ProviderSubscription<bool>? _localLibraryEnabledSub;
Timer? _downloadHistoryWarmupTimer;
Timer? _libraryCollectionsWarmupTimer;
Timer? _localLibraryWarmupTimer;
bool _localLibraryWarmupScheduled = false;
bool _autoScanTriggeredOnLaunch = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_initializeAppServices();
@@ -110,6 +115,7 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_localLibraryEnabledSub?.close();
_downloadHistoryWarmupTimer?.cancel();
_libraryCollectionsWarmupTimer?.cancel();
@@ -117,6 +123,13 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_maybeAutoScanLocalLibrary();
}
}
void _initializeDeferredProviders() {
_downloadHistoryWarmupTimer = _scheduleProviderWarmup(
const Duration(milliseconds: 400),
@@ -155,10 +168,65 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
_localLibraryWarmupScheduled = true;
_localLibraryWarmupTimer = _scheduleProviderWarmup(
const Duration(milliseconds: 1600),
() => ref.read(localLibraryProvider),
() {
ref.read(localLibraryProvider);
// Trigger auto-scan after initial warmup on first app launch.
if (!_autoScanTriggeredOnLaunch) {
_autoScanTriggeredOnLaunch = true;
// Give the provider a moment to load existing data before scanning.
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) _maybeAutoScanLocalLibrary();
});
}
},
);
}
/// Checks whether an automatic incremental scan should be triggered based on
/// the user's auto-scan preference and the time since the last scan.
Future<void> _maybeAutoScanLocalLibrary() async {
if (!mounted) return;
final settings = ref.read(settingsProvider);
if (!settings.localLibraryEnabled) return;
if (settings.localLibraryPath.isEmpty) return;
if (settings.localLibraryAutoScan == 'off') return;
final libraryState = ref.read(localLibraryProvider);
if (libraryState.isScanning) return;
final now = DateTime.now();
final prefs = await SharedPreferences.getInstance();
final lastScanned = readLocalLibraryLastScannedAt(prefs);
if (lastScanned != null) {
final elapsed = now.difference(lastScanned);
switch (settings.localLibraryAutoScan) {
case 'on_open':
// Cooldown of 10 minutes to prevent rapid re-scans.
if (elapsed.inMinutes < 10) return;
break;
case 'daily':
if (elapsed.inHours < 24) return;
break;
case 'weekly':
if (elapsed.inDays < 7) return;
break;
default:
return;
}
}
final iosBookmark = settings.localLibraryBookmark;
ref
.read(localLibraryProvider.notifier)
.startScan(
settings.localLibraryPath,
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
);
}
Future<void> _initializeAppServices() async {
try {
await CoverCacheManager.initialize();
+16 -10
View File
@@ -20,6 +20,7 @@ class AppSettings {
final String updateChannel;
final bool hasSearchedBefore;
final String folderOrganization;
final bool createPlaylistFolder;
final bool useAlbumArtistForFolders;
final bool usePrimaryArtistOnly; // Strip featured artists from folder name
final bool filterContributingArtistsInAlbumArtist;
@@ -33,6 +34,7 @@ class AppSettings {
final bool enableLogging;
final bool useExtensionProviders;
final String? searchProvider;
final String? homeFeedProvider;
final bool separateSingles;
final String albumFolderStructure;
final bool showExtensionStore;
@@ -40,10 +42,6 @@ class AppSettings {
final String lyricsMode;
final String
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
final int
youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256 kbps)
final int
youtubeMp3Bitrate; // YouTube MP3 bitrate (supported: 128/256/320 kbps)
final bool
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
final bool
@@ -61,6 +59,8 @@ class AppSettings {
localLibraryBookmark; // Base64-encoded iOS security-scoped bookmark
final bool
localLibraryShowDuplicates; // Show indicator when searching for existing tracks
final String
localLibraryAutoScan; // Auto-scan mode: 'off', 'on_open', 'daily', 'weekly'
final bool
hasCompletedTutorial; // Track if user has completed the app tutorial
@@ -96,6 +96,7 @@ class AppSettings {
this.updateChannel = 'stable',
this.hasSearchedBefore = false,
this.folderOrganization = 'none',
this.createPlaylistFolder = false,
this.useAlbumArtistForFolders = true,
this.usePrimaryArtistOnly = false,
this.filterContributingArtistsInAlbumArtist = false,
@@ -109,14 +110,13 @@ class AppSettings {
this.enableLogging = false,
this.useExtensionProviders = true,
this.searchProvider,
this.homeFeedProvider,
this.separateSingles = false,
this.albumFolderStructure = 'artist_album',
this.showExtensionStore = true,
this.locale = 'system',
this.lyricsMode = 'embed',
this.tidalHighFormat = 'mp3_320',
this.youtubeOpusBitrate = 256,
this.youtubeMp3Bitrate = 320,
this.useAllFilesAccess = false,
this.autoExportFailedDownloads = false,
this.downloadNetworkMode = 'any',
@@ -126,6 +126,7 @@ class AppSettings {
this.localLibraryPath = '',
this.localLibraryBookmark = '',
this.localLibraryShowDuplicates = true,
this.localLibraryAutoScan = 'off',
this.hasCompletedTutorial = false,
this.lyricsProviders = const [
'lrclib',
@@ -159,6 +160,7 @@ class AppSettings {
String? updateChannel,
bool? hasSearchedBefore,
String? folderOrganization,
bool? createPlaylistFolder,
bool? useAlbumArtistForFolders,
bool? usePrimaryArtistOnly,
bool? filterContributingArtistsInAlbumArtist,
@@ -173,14 +175,14 @@ class AppSettings {
bool? useExtensionProviders,
String? searchProvider,
bool clearSearchProvider = false,
String? homeFeedProvider,
bool clearHomeFeedProvider = false,
bool? separateSingles,
String? albumFolderStructure,
bool? showExtensionStore,
String? locale,
String? lyricsMode,
String? tidalHighFormat,
int? youtubeOpusBitrate,
int? youtubeMp3Bitrate,
bool? useAllFilesAccess,
bool? autoExportFailedDownloads,
String? downloadNetworkMode,
@@ -190,6 +192,7 @@ class AppSettings {
String? localLibraryPath,
String? localLibraryBookmark,
bool? localLibraryShowDuplicates,
String? localLibraryAutoScan,
bool? hasCompletedTutorial,
List<String>? lyricsProviders,
bool? lyricsIncludeTranslationNetease,
@@ -215,6 +218,7 @@ class AppSettings {
updateChannel: updateChannel ?? this.updateChannel,
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
folderOrganization: folderOrganization ?? this.folderOrganization,
createPlaylistFolder: createPlaylistFolder ?? this.createPlaylistFolder,
useAlbumArtistForFolders:
useAlbumArtistForFolders ?? this.useAlbumArtistForFolders,
usePrimaryArtistOnly: usePrimaryArtistOnly ?? this.usePrimaryArtistOnly,
@@ -236,14 +240,15 @@ class AppSettings {
searchProvider: clearSearchProvider
? null
: (searchProvider ?? this.searchProvider),
homeFeedProvider: clearHomeFeedProvider
? null
: (homeFeedProvider ?? this.homeFeedProvider),
separateSingles: separateSingles ?? this.separateSingles,
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
locale: locale ?? this.locale,
lyricsMode: lyricsMode ?? this.lyricsMode,
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
youtubeOpusBitrate: youtubeOpusBitrate ?? this.youtubeOpusBitrate,
youtubeMp3Bitrate: youtubeMp3Bitrate ?? this.youtubeMp3Bitrate,
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
autoExportFailedDownloads:
autoExportFailedDownloads ?? this.autoExportFailedDownloads,
@@ -256,6 +261,7 @@ class AppSettings {
localLibraryBookmark: localLibraryBookmark ?? this.localLibraryBookmark,
localLibraryShowDuplicates:
localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
localLibraryAutoScan: localLibraryAutoScan ?? this.localLibraryAutoScan,
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
lyricsProviders: lyricsProviders ?? this.lyricsProviders,
lyricsIncludeTranslationNetease:
+6 -4
View File
@@ -23,6 +23,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
updateChannel: json['updateChannel'] as String? ?? 'stable',
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
folderOrganization: json['folderOrganization'] as String? ?? 'none',
createPlaylistFolder: json['createPlaylistFolder'] as bool? ?? false,
useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true,
usePrimaryArtistOnly: json['usePrimaryArtistOnly'] as bool? ?? false,
filterContributingArtistsInAlbumArtist:
@@ -38,6 +39,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
enableLogging: json['enableLogging'] as bool? ?? false,
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
searchProvider: json['searchProvider'] as String?,
homeFeedProvider: json['homeFeedProvider'] as String?,
separateSingles: json['separateSingles'] as bool? ?? false,
albumFolderStructure:
json['albumFolderStructure'] as String? ?? 'artist_album',
@@ -45,8 +47,6 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
locale: json['locale'] as String? ?? 'system',
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
youtubeOpusBitrate: (json['youtubeOpusBitrate'] as num?)?.toInt() ?? 256,
youtubeMp3Bitrate: (json['youtubeMp3Bitrate'] as num?)?.toInt() ?? 320,
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
autoExportFailedDownloads:
json['autoExportFailedDownloads'] as bool? ?? false,
@@ -58,6 +58,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
localLibraryBookmark: json['localLibraryBookmark'] as String? ?? '',
localLibraryShowDuplicates:
json['localLibraryShowDuplicates'] as bool? ?? true,
localLibraryAutoScan: json['localLibraryAutoScan'] as String? ?? 'off',
hasCompletedTutorial: json['hasCompletedTutorial'] as bool? ?? false,
lyricsProviders:
(json['lyricsProviders'] as List<dynamic>?)
@@ -100,6 +101,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'updateChannel': instance.updateChannel,
'hasSearchedBefore': instance.hasSearchedBefore,
'folderOrganization': instance.folderOrganization,
'createPlaylistFolder': instance.createPlaylistFolder,
'useAlbumArtistForFolders': instance.useAlbumArtistForFolders,
'usePrimaryArtistOnly': instance.usePrimaryArtistOnly,
'filterContributingArtistsInAlbumArtist':
@@ -114,14 +116,13 @@ Map<String, dynamic> _$AppSettingsToJson(
'enableLogging': instance.enableLogging,
'useExtensionProviders': instance.useExtensionProviders,
'searchProvider': instance.searchProvider,
'homeFeedProvider': instance.homeFeedProvider,
'separateSingles': instance.separateSingles,
'albumFolderStructure': instance.albumFolderStructure,
'showExtensionStore': instance.showExtensionStore,
'locale': instance.locale,
'lyricsMode': instance.lyricsMode,
'tidalHighFormat': instance.tidalHighFormat,
'youtubeOpusBitrate': instance.youtubeOpusBitrate,
'youtubeMp3Bitrate': instance.youtubeMp3Bitrate,
'useAllFilesAccess': instance.useAllFilesAccess,
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
'downloadNetworkMode': instance.downloadNetworkMode,
@@ -131,6 +132,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'localLibraryPath': instance.localLibraryPath,
'localLibraryBookmark': instance.localLibraryBookmark,
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
'localLibraryAutoScan': instance.localLibraryAutoScan,
'hasCompletedTutorial': instance.hasCompletedTutorial,
'lyricsProviders': instance.lyricsProviders,
'lyricsIncludeTranslationNetease': instance.lyricsIncludeTranslationNetease,
File diff suppressed because it is too large Load Diff
+28 -30
View File
@@ -4,13 +4,14 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
final _log = AppLogger('ExploreProvider');
class ExploreItem {
final String id;
final String uri;
final String type; // track, album, playlist, artist, station
final String type;
final String name;
final String artists;
final String? description;
@@ -167,7 +168,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
return const ExploreState();
}
/// Restore cached home feed from SharedPreferences immediately on startup
Future<void> _restoreFromCache() async {
try {
final prefs = await SharedPreferences.getInstance();
@@ -198,13 +198,10 @@ class ExploreNotifier extends Notifier<ExploreState> {
}
}
/// Save home feed to SharedPreferences for instant restore on next launch
Future<void> _saveToCache(List<ExploreSection> sections) async {
try {
final prefs = await SharedPreferences.getInstance();
final data = {
'sections': sections.map((s) => s.toJson()).toList(),
};
final data = {'sections': sections.map((s) => s.toJson()).toList()};
await prefs.setString(_cacheKey, jsonEncode(data));
await prefs.setInt(_cacheTsKey, DateTime.now().millisecondsSinceEpoch);
_log.d('Saved ${sections.length} explore sections to cache');
@@ -213,45 +210,52 @@ class ExploreNotifier extends Notifier<ExploreState> {
}
}
/// Fetch home feed from spotify-web extension
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
// If we have cached content and it's fresh enough, skip network fetch
if (!forceRefresh &&
state.hasContent &&
if (!forceRefresh &&
state.hasContent &&
state.lastFetched != null &&
DateTime.now().difference(state.lastFetched!).inMinutes < 5) {
_log.d('Using cached home feed (fresh enough)');
return;
}
if (state.isLoading) {
_log.d('Home feed fetch already in progress');
return;
}
// Only show loading spinner if we have no cached content to display
final showLoading = !state.hasContent;
state = state.copyWith(isLoading: showLoading, error: null);
try {
final extState = ref.read(extensionProvider);
_log.d('Extensions count: ${extState.extensions.length}');
final settings = ref.read(settingsProvider);
final preferredId = settings.homeFeedProvider;
_log.d(
'Extensions count: ${extState.extensions.length}, preferred home feed: $preferredId',
);
Extension? targetExt;
for (final extension in extState.extensions) {
if (!extension.enabled || !extension.hasHomeFeed) {
continue;
}
if (preferredId != null &&
preferredId.isNotEmpty &&
extension.id == preferredId) {
targetExt = extension;
break;
}
if (targetExt == null || extension.id == 'spotify-web') {
targetExt = extension;
if (extension.id == 'spotify-web') {
if (preferredId == null && extension.id == 'spotify-web') {
break;
}
}
}
if (targetExt == null) {
_log.w('No extension with homeFeed capability found');
state = state.copyWith(
@@ -260,7 +264,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
);
return;
}
_log.i('Fetching home feed from ${targetExt.id}...');
final result = await PlatformBridge.getExtensionHomeFeed(targetExt.id);
@@ -276,10 +280,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
_log.d('getExtensionHomeFeed success=$success');
if (!success) {
final error = result['error'] as String? ?? 'Unknown error';
state = state.copyWith(
isLoading: false,
error: error,
);
state = state.copyWith(isLoading: false, error: error);
return;
}
@@ -291,10 +292,12 @@ class ExploreNotifier extends Notifier<ExploreState> {
.toList();
_log.i('Fetched ${sections.length} sections');
if (sections.isNotEmpty && sections.first.items.isNotEmpty) {
final firstItem = sections.first.items.first;
_log.d('First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}');
_log.d(
'First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}',
);
}
final localGreeting = _getLocalGreeting();
@@ -307,14 +310,10 @@ class ExploreNotifier extends Notifier<ExploreState> {
lastFetched: DateTime.now(),
);
// Save to disk cache for instant restore on next app launch
_saveToCache(sections);
} catch (e, stack) {
_log.e('Error fetching home feed: $e', e, stack);
state = state.copyWith(
isLoading: false,
error: e.toString(),
);
state = state.copyWith(isLoading: false, error: e.toString());
}
}
@@ -325,7 +324,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
Future<void> refresh() => fetchHomeFeed(forceRefresh: true);
}
final exploreProvider = NotifierProvider<ExploreNotifier, ExploreState>(() {
return ExploreNotifier();
});
+23 -13
View File
@@ -32,14 +32,12 @@ class Extension {
final bool hasMetadataProvider;
final bool hasDownloadProvider;
final bool hasLyricsProvider;
final bool
skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
final bool skipMetadataEnrichment;
final SearchBehavior? searchBehavior;
final URLHandler? urlHandler;
final TrackMatching? trackMatching;
final PostProcessing? postProcessing;
final Map<String, dynamic>
capabilities; // Extension capabilities (homeFeed, browseCategories, etc.)
final Map<String, dynamic> capabilities;
const Extension({
required this.id,
@@ -198,12 +196,10 @@ class SearchBehavior {
final String? placeholder;
final bool primary;
final String? icon;
final String?
thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3)
final String? thumbnailRatio;
final int? thumbnailWidth;
final int? thumbnailHeight;
final List<SearchFilter>
filters; // Available search filters (e.g., track, album, artist, playlist)
final List<SearchFilter> filters;
const SearchBehavior({
required this.enabled,
@@ -239,11 +235,11 @@ class SearchBehavior {
}
switch (thumbnailRatio) {
case 'wide': // 16:9 - YouTube style
case 'wide':
return (defaultSize * 16 / 9, defaultSize);
case 'portrait': // 2:3 - Poster style
case 'portrait':
return (defaultSize * 2 / 3, defaultSize);
case 'square': // 1:1 - Album art style
case 'square':
default:
return (defaultSize, defaultSize);
}
@@ -290,7 +286,6 @@ class PostProcessing {
}
}
/// URL handler configuration for custom URL patterns
class URLHandler {
final bool enabled;
final List<String> patterns;
@@ -304,7 +299,6 @@ class URLHandler {
);
}
/// Check if a URL matches any of the patterns
bool matchesURL(String url) {
if (!enabled || patterns.isEmpty) return false;
final lowerUrl = url.toLowerCase();
@@ -504,6 +498,11 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
Future<void> _cleanupExtensions({required String reason}) async {
if (!PlatformBridge.supportsExtensionSystem) {
_cleanupInFlight = false;
return;
}
try {
await PlatformBridge.cleanupExtensions();
_log.d('Extensions cleaned up ($reason)');
@@ -519,6 +518,17 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
state = state.copyWith(isLoading: true, error: null);
if (!PlatformBridge.supportsExtensionSystem) {
state = state.copyWith(
isInitialized: true,
isLoading: false,
extensions: const [],
error: null,
);
_log.i('Extension system disabled on this platform');
return;
}
try {
await PlatformBridge.initExtensionSystem(extensionsDir, dataDir);
await loadExtensions(extensionsDir);
@@ -666,7 +666,6 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
final destPath = p.join(coversDir.path, '$playlistId$ext');
if (playlist.coverImagePath == destPath) return;
// Copy image to persistent location
await File(sourceFilePath).copy(destPath);
final now = DateTime.now();
@@ -686,7 +685,6 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
final playlist = state.playlistById(playlistId);
if (playlist == null || playlist.coverImagePath == null) return;
// Delete the file if it exists
final path = playlist.coverImagePath;
if (path != null) {
final file = File(path);
+13 -26
View File
@@ -9,11 +9,11 @@ import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/local_library_scan_prefs.dart';
import 'package:spotiflac_android/utils/path_match_keys.dart';
final _log = AppLogger('LocalLibrary');
const _lastScannedAtKey = 'local_library_last_scanned_at';
const _excludedDownloadedCountKey = 'local_library_excluded_downloaded_count';
final _prefs = SharedPreferences.getInstance();
@@ -165,10 +165,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
var excludedDownloadedCount = 0;
try {
final prefs = await prefsFuture;
final lastScannedAtStr = prefs.getString(_lastScannedAtKey);
if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) {
lastScannedAt = DateTime.tryParse(lastScannedAtStr);
}
lastScannedAt = readLocalLibraryLastScannedAt(prefs);
excludedDownloadedCount =
prefs.getInt(_excludedDownloadedCountKey) ?? 0;
} catch (e) {
@@ -255,8 +252,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_startProgressPolling();
// On iOS, start accessing the security-scoped bookmark so the Go backend
// can read files outside the app sandbox.
String? resolvedPath;
bool didStartSecurityAccess = false;
if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) {
@@ -278,9 +273,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try {
final isSaf = effectiveFolderPath.startsWith('content://');
// Get all file paths from download history to exclude them.
// Merge DB + in-memory state to avoid race when a fresh download has not
// been flushed to SQLite yet.
final downloadedPaths = await _historyDb.getAllFilePaths();
final inMemoryHistoryPaths = ref
.read(downloadHistoryProvider)
@@ -301,7 +293,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
);
if (forceFullScan) {
// Full scan path - ignores existing data
final results = isSaf
? await PlatformBridge.scanSafTree(effectiveFolderPath)
: await PlatformBridge.scanLibraryFolder(effectiveFolderPath);
@@ -327,16 +318,13 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_log.i('Skipped $skippedDownloads files already in download history');
}
// Full scan should replace library index entirely.
await _db.clearAll();
if (items.isNotEmpty) {
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
}
await _db.replaceAll(items.map((e) => e.toJson()).toList());
final persistedItems = [...items]..sort(_compareLibraryItems);
final now = DateTime.now();
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
await writeLocalLibraryLastScannedAt(prefs, now);
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
_log.d('Saved lastScannedAt: $now');
} catch (e) {
@@ -344,7 +332,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
}
state = state.copyWith(
items: items,
items: persistedItems,
isScanning: false,
scanProgress: 100,
lastScannedAt: now,
@@ -353,16 +341,15 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
);
_log.i(
'Full scan complete: ${items.length} tracks found, '
'Full scan complete: ${persistedItems.length} tracks found, '
'$skippedDownloads already in downloads',
);
await _showScanCompleteNotification(
totalTracks: items.length,
totalTracks: persistedItems.length,
excludedDownloadedCount: skippedDownloads,
errorCount: state.scanErrorCount,
);
} else {
// Incremental scan path - only scans new/modified files
final existingFiles = await _db.getFileModTimes();
_log.i(
'Incremental scan: ${existingFiles.length} existing files in database',
@@ -421,7 +408,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
return;
}
// SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths'
final scannedList =
(result['files'] as List<dynamic>?) ??
(result['scanned'] as List<dynamic>?) ??
@@ -442,8 +428,10 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total',
);
final existingJson = await _db.getAll();
final currentByPath = <String, LocalLibraryItem>{
for (final item in state.items) item.filePath: item,
for (final item in existingJson.map(LocalLibraryItem.fromJson))
item.filePath: item,
};
final existingDownloadedPaths = <String>[];
currentByPath.removeWhere((path, _) {
@@ -460,7 +448,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
);
}
// Upsert new/modified items (excluding downloaded files)
final updatedItems = <LocalLibraryItem>[];
int skippedDownloads = existingDownloadedPaths.length;
if (scannedList.isNotEmpty) {
@@ -500,7 +487,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
final now = DateTime.now();
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
await writeLocalLibraryLastScannedAt(prefs, now);
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
_log.d('Saved lastScannedAt: $now');
} catch (e) {
@@ -818,7 +805,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_lastScannedAtKey);
await clearLocalLibraryLastScannedAt(prefs);
await prefs.remove(_excludedDownloadedCountKey);
} catch (e) {
_log.w('Failed to clear lastScannedAt: $e');
+2 -16
View File
@@ -5,18 +5,16 @@ import 'package:spotiflac_android/services/app_state_database.dart';
const _maxRecentItems = 20;
/// Types of items that can be accessed
enum RecentAccessType { artist, album, track, playlist }
/// Represents a recently accessed item
class RecentAccessItem {
final String id;
final String name;
final String? subtitle; // Artist name for tracks/albums, null for artists
final String? subtitle;
final String? imageUrl;
final RecentAccessType type;
final DateTime accessedAt;
final String? providerId; // Extension ID or 'deezer' for built-in
final String? providerId;
const RecentAccessItem({
required this.id,
@@ -53,7 +51,6 @@ class RecentAccessItem {
);
}
/// Create a unique key for deduplication
String get uniqueKey => '${type.name}:${providerId ?? 'default'}:$id';
@override
@@ -67,7 +64,6 @@ class RecentAccessItem {
int get hashCode => uniqueKey.hashCode;
}
/// State for recent access history
class RecentAccessState {
final List<RecentAccessItem> items;
final Set<String> hiddenDownloadIds;
@@ -92,7 +88,6 @@ class RecentAccessState {
}
}
/// Provider for managing recent access history
class RecentAccessNotifier extends Notifier<RecentAccessState> {
final AppStateDatabase _appStateDb = AppStateDatabase.instance;
@@ -135,7 +130,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
}
}
/// Record an access to an artist
void recordArtistAccess({
required String id,
required String name,
@@ -154,7 +148,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
);
}
/// Record an access to an album
void recordAlbumAccess({
required String id,
required String name,
@@ -175,7 +168,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
);
}
/// Record an access to a track
void recordTrackAccess({
required String id,
required String name,
@@ -196,7 +188,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
);
}
/// Record an access to a playlist
void recordPlaylistAccess({
required String id,
required String name,
@@ -242,7 +233,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
}
}
/// Remove a specific item from history
void removeItem(RecentAccessItem item) {
final updatedItems = state.items
.where((e) => e.uniqueKey != item.uniqueKey)
@@ -251,25 +241,21 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
unawaited(_appStateDb.deleteRecentAccessRow(item.uniqueKey));
}
/// Hide a download item from recents (without deleting the actual download)
void hideDownloadFromRecents(String downloadId) {
final updatedHidden = {...state.hiddenDownloadIds, downloadId};
state = state.copyWith(hiddenDownloadIds: updatedHidden);
unawaited(_appStateDb.addHiddenRecentDownloadId(downloadId));
}
/// Check if a download is hidden from recents
bool isDownloadHidden(String downloadId) {
return state.hiddenDownloadIds.contains(downloadId);
}
/// Clear all history
void clearHistory() {
state = state.copyWith(items: []);
unawaited(_appStateDb.clearRecentAccessRows());
}
/// Clear hidden downloads (show all again)
void clearHiddenDownloads() {
state = state.copyWith(hiddenDownloadIds: {});
unawaited(_appStateDb.clearHiddenRecentDownloadIds());
+40 -55
View File
@@ -1,21 +1,21 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/logger.dart';
const _settingsKey = 'app_settings';
const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 5;
const _currentMigrationVersion = 7;
const _spotifyClientSecretKey = 'spotify_client_secret';
final _log = AppLogger('SettingsProvider');
class SettingsNotifier extends Notifier<AppSettings> {
static const List<int> _youtubeOpusSupportedBitrates = [128, 256];
static const List<int> _youtubeMp3SupportedBitrates = [128, 256, 320];
static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$');
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
@@ -37,7 +37,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = AppSettings.fromJson(jsonDecode(json));
await _runMigrations(prefs);
await _normalizeYouTubeBitratesIfNeeded();
await _normalizeIosDownloadDirectoryIfNeeded();
await _normalizeSongLinkRegionIfNeeded();
}
@@ -50,6 +50,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
void _syncLyricsSettingsToBackend() {
if (!PlatformBridge.supportsCoreBackend) return;
PlatformBridge.setLyricsProviders(state.lyricsProviders).catchError((e) {
_log.w('Failed to sync lyrics providers to backend: $e');
});
@@ -65,6 +67,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
void _syncNetworkCompatibilitySettingsToBackend() {
if (!PlatformBridge.supportsCoreBackend) return;
final compatibilityMode = state.networkCompatibilityMode;
PlatformBridge.setNetworkCompatibilityOptions(
allowHttp: compatibilityMode,
@@ -115,6 +119,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
);
}
state = state.copyWith(lastSeenVersion: AppInfo.version);
// Migration 7: YouTube is no longer a built-in service reset to Tidal
if (state.defaultService == 'youtube') {
state = state.copyWith(defaultService: 'tidal');
}
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
await _saveSettings();
}
@@ -146,46 +154,17 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
}
int _nearestSupportedBitrate(int value, List<int> supported) {
var nearest = supported.first;
var nearestDistance = (value - nearest).abs();
Future<void> _normalizeIosDownloadDirectoryIfNeeded() async {
if (!Platform.isIOS) return;
for (final option in supported.skip(1)) {
final distance = (value - option).abs();
// On tie, prefer higher quality bitrate.
if (distance < nearestDistance ||
(distance == nearestDistance && option > nearest)) {
nearest = option;
nearestDistance = distance;
}
}
final currentDir = state.downloadDirectory.trim();
if (currentDir.isEmpty) return;
return nearest;
}
final normalizedDir = await validateOrFixIosPath(currentDir);
if (normalizedDir == currentDir) return;
int _normalizeYouTubeOpusBitrate(int bitrate) {
return _nearestSupportedBitrate(bitrate, _youtubeOpusSupportedBitrates);
}
int _normalizeYouTubeMp3Bitrate(int bitrate) {
return _nearestSupportedBitrate(bitrate, _youtubeMp3SupportedBitrates);
}
Future<void> _normalizeYouTubeBitratesIfNeeded() async {
final normalizedOpus = _normalizeYouTubeOpusBitrate(
state.youtubeOpusBitrate,
);
final normalizedMp3 = _normalizeYouTubeMp3Bitrate(state.youtubeMp3Bitrate);
if (normalizedOpus == state.youtubeOpusBitrate &&
normalizedMp3 == state.youtubeMp3Bitrate) {
return;
}
state = state.copyWith(
youtubeOpusBitrate: normalizedOpus,
youtubeMp3Bitrate: normalizedMp3,
);
_log.i('Normalized iOS download directory: $currentDir -> $normalizedDir');
state = state.copyWith(downloadDirectory: normalizedDir);
await _saveSettings();
}
@@ -354,6 +333,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setCreatePlaylistFolder(bool enabled) {
state = state.copyWith(createPlaylistFolder: enabled);
_saveSettings();
}
void setUseAlbumArtistForFolders(bool enabled) {
state = state.copyWith(useAlbumArtistForFolders: enabled);
_saveSettings();
@@ -385,8 +369,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
void setMetadataSource(String source) {
final normalized = source == 'deezer' ? 'deezer' : 'deezer';
state = state.copyWith(metadataSource: normalized);
state = state.copyWith(metadataSource: source);
_saveSettings();
}
@@ -399,6 +382,15 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setHomeFeedProvider(String? provider) {
if (provider == null || provider.isEmpty) {
state = state.copyWith(clearHomeFeedProvider: true);
} else {
state = state.copyWith(homeFeedProvider: provider);
}
_saveSettings();
}
void setEnableLogging(bool enabled) {
state = state.copyWith(enableLogging: enabled);
_saveSettings();
@@ -435,18 +427,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setYoutubeOpusBitrate(int bitrate) {
final normalized = _normalizeYouTubeOpusBitrate(bitrate);
state = state.copyWith(youtubeOpusBitrate: normalized);
_saveSettings();
}
void setYoutubeMp3Bitrate(int bitrate) {
final normalized = _normalizeYouTubeMp3Bitrate(bitrate);
state = state.copyWith(youtubeMp3Bitrate: normalized);
_saveSettings();
}
void setUseAllFilesAccess(bool enabled) {
state = state.copyWith(useAllFilesAccess: enabled);
_saveSettings();
@@ -502,6 +482,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setLocalLibraryAutoScan(String mode) {
state = state.copyWith(localLibraryAutoScan: mode);
_saveSettings();
}
void setTutorialComplete() {
state = state.copyWith(hasCompletedTutorial: true);
_saveSettings();
+77 -34
View File
@@ -12,13 +12,13 @@ const _registryUrlPrefKey = 'store_registry_url';
int compareVersions(String v1, String v2) {
final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.');
final parts2 = v2.replaceAll(_leadingVersionPrefix, '').split('.');
final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length;
for (var i = 0; i < maxLen; i++) {
final n1 = i < parts1.length ? (int.tryParse(parts1[i]) ?? 0) : 0;
final n2 = i < parts2.length ? (int.tryParse(parts2[i]) ?? 0) : 0;
if (n1 < n2) return -1;
if (n1 > n2) return 1;
}
@@ -26,14 +26,19 @@ int compareVersions(String v1, String v2) {
}
class StoreCategory {
static const String metadata = 'metadata';
static const String download = 'download';
static const String utility = 'utility';
static const String lyrics = 'lyrics';
static const String integration = 'integration';
static const List<String> all = [metadata, download, utility, lyrics, integration];
static const List<String> all = [
metadata,
download,
utility,
lyrics,
integration,
];
static String getDisplayName(String category) {
switch (category) {
@@ -94,7 +99,8 @@ class StoreExtension {
return StoreExtension(
id: json['id'] as String? ?? '',
name: json['name'] as String? ?? '',
displayName: json['display_name'] as String? ?? json['name'] as String? ?? '',
displayName:
json['display_name'] as String? ?? json['name'] as String? ?? '',
version: json['version'] as String? ?? '0.0.0',
author: json['author'] as String? ?? 'Unknown',
description: json['description'] as String? ?? '',
@@ -117,7 +123,6 @@ class StoreExtension {
}
}
class StoreState {
final List<StoreExtension> extensions;
final String? selectedCategory;
@@ -160,11 +165,15 @@ class StoreState {
}) {
return StoreState(
extensions: extensions ?? this.extensions,
selectedCategory: clearCategory ? null : (selectedCategory ?? this.selectedCategory),
selectedCategory: clearCategory
? null
: (selectedCategory ?? this.selectedCategory),
searchQuery: searchQuery ?? this.searchQuery,
isLoading: isLoading ?? this.isLoading,
isDownloading: isDownloading ?? this.isDownloading,
downloadingId: clearDownloadingId ? null : (downloadingId ?? this.downloadingId),
downloadingId: clearDownloadingId
? null
: (downloadingId ?? this.downloadingId),
error: clearError ? null : (error ?? this.error),
isInitialized: isInitialized ?? this.isInitialized,
registryUrl: registryUrl ?? this.registryUrl,
@@ -180,13 +189,16 @@ class StoreState {
if (searchQuery.isNotEmpty) {
final query = searchQuery.toLowerCase();
result = result.where((e) =>
e.name.toLowerCase().contains(query) ||
e.displayName.toLowerCase().contains(query) ||
e.description.toLowerCase().contains(query) ||
e.author.toLowerCase().contains(query) ||
e.tags.any((t) => t.toLowerCase().contains(query))
).toList();
result = result
.where(
(e) =>
e.name.toLowerCase().contains(query) ||
e.displayName.toLowerCase().contains(query) ||
e.description.toLowerCase().contains(query) ||
e.author.toLowerCase().contains(query) ||
e.tags.any((t) => t.toLowerCase().contains(query)),
)
.toList();
}
return result;
@@ -206,23 +218,28 @@ class StoreNotifier extends Notifier<StoreState> {
Future<void> initialize(String cacheDir) async {
if (state.isInitialized) return;
state = state.copyWith(isLoading: true, clearError: true);
// Load saved registry URL early to avoid UI flash (empty setup screen)
final prefs = await SharedPreferences.getInstance();
final savedUrl = prefs.getString(_registryUrlPrefKey) ?? '';
state = state.copyWith(
isLoading: true,
clearError: true,
registryUrl: savedUrl,
);
try {
await PlatformBridge.initExtensionStore(cacheDir);
// Load saved registry URL from SharedPreferences
final prefs = await SharedPreferences.getInstance();
final savedUrl = prefs.getString(_registryUrlPrefKey) ?? '';
if (savedUrl.isNotEmpty) {
await PlatformBridge.setStoreRegistryUrl(savedUrl);
state = state.copyWith(registryUrl: savedUrl);
await refresh();
}
state = state.copyWith(isInitialized: true, isLoading: false);
_log.i('Extension store initialized (registryUrl: ${savedUrl.isEmpty ? "not set" : savedUrl})');
_log.i(
'Extension store initialized (registryUrl: ${savedUrl.isEmpty ? "not set" : savedUrl})',
);
} catch (e) {
_log.e('Failed to initialize store: $e');
state = state.copyWith(isLoading: false, error: e.toString());
@@ -247,13 +264,12 @@ class StoreNotifier extends Notifier<StoreState> {
// Read back the resolved URL (may differ from input after normalisation).
final resolvedUrl = await PlatformBridge.getStoreRegistryUrl();
// Persist to SharedPreferences
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_registryUrlPrefKey, resolvedUrl);
state = state.copyWith(
registryUrl: resolvedUrl,
extensions: const [], // Clear old extensions
extensions: const [],
);
_log.i('Registry URL set to: $resolvedUrl');
@@ -292,7 +308,9 @@ class StoreNotifier extends Notifier<StoreState> {
state = state.copyWith(isLoading: true, clearError: true);
try {
final extensions = await PlatformBridge.getStoreExtensions(forceRefresh: forceRefresh);
final extensions = await PlatformBridge.getStoreExtensions(
forceRefresh: forceRefresh,
);
state = state.copyWith(
extensions: extensions.map((e) => StoreExtension.fromJson(e)).toList(),
isLoading: false,
@@ -320,12 +338,23 @@ class StoreNotifier extends Notifier<StoreState> {
state = state.copyWith(searchQuery: '', clearCategory: true);
}
Future<bool> installExtension(String extensionId, String tempDir, String extensionsDir) async {
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
Future<bool> installExtension(
String extensionId,
String tempDir,
String extensionsDir,
) async {
state = state.copyWith(
isDownloading: true,
downloadingId: extensionId,
clearError: true,
);
try {
_log.i('Downloading extension: $extensionId');
final downloadPath = await PlatformBridge.downloadStoreExtension(extensionId, tempDir);
final downloadPath = await PlatformBridge.downloadStoreExtension(
extensionId,
tempDir,
);
_log.i('Installing extension from: $downloadPath');
final extNotifier = ref.read(extensionProvider.notifier);
@@ -340,18 +369,28 @@ class StoreNotifier extends Notifier<StoreState> {
return success;
} catch (e) {
_log.e('Failed to install extension: $e');
state = state.copyWith(isDownloading: false, clearDownloadingId: true, error: e.toString());
state = state.copyWith(
isDownloading: false,
clearDownloadingId: true,
error: e.toString(),
);
return false;
}
}
Future<bool> updateExtension(String extensionId, String tempDir) async {
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
state = state.copyWith(
isDownloading: true,
downloadingId: extensionId,
clearError: true,
);
try {
_log.i('Downloading update for: $extensionId');
final downloadPath = await PlatformBridge.downloadStoreExtension(extensionId, tempDir);
final downloadPath = await PlatformBridge.downloadStoreExtension(
extensionId,
tempDir,
);
_log.i('Upgrading extension from: $downloadPath');
final extNotifier = ref.read(extensionProvider.notifier);
@@ -366,7 +405,11 @@ class StoreNotifier extends Notifier<StoreState> {
return success;
} catch (e) {
_log.e('Failed to update extension: $e');
state = state.copyWith(isDownloading: false, clearDownloadingId: true, error: e.toString());
state = state.copyWith(
isDownloading: false,
clearDownloadingId: true,
error: e.toString(),
);
return false;
}
}
-2
View File
@@ -57,7 +57,6 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
await _saveToStorage();
}
/// Set custom seed color (used when dynamic color is disabled)
Future<void> setSeedColor(Color color) async {
state = state.copyWith(seedColorValue: color.toARGB32());
await _saveToStorage();
@@ -81,4 +80,3 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
);
}
}
+129 -84
View File
@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
@@ -17,19 +18,18 @@ class TrackState {
final String? artistId;
final String? artistName;
final String? coverUrl;
final String? headerImageUrl; // Artist header image for background
final String? headerImageUrl;
final int? monthlyListeners;
final List<ArtistAlbum>? artistAlbums; // For artist page
final List<Track>? artistTopTracks; // Artist's popular tracks
final List<SearchArtist>? searchArtists; // For search results
final List<SearchAlbum>? searchAlbums; // For search results (albums)
final List<SearchPlaylist>? searchPlaylists; // For search results (playlists)
final bool hasSearchText; // For back button handling
final bool isShowingRecentAccess; // For recent access mode
final String?
searchExtensionId; // Extension ID used for current search results
final String?
selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
final List<ArtistAlbum>? artistAlbums;
final List<Track>? artistTopTracks;
final List<SearchArtist>? searchArtists;
final List<SearchAlbum>? searchAlbums;
final List<SearchPlaylist>? searchPlaylists;
final bool hasSearchText;
final bool isShowingRecentAccess;
final String? searchExtensionId;
final String? selectedSearchFilter;
final String? searchSource;
const TrackState({
this.tracks = const [],
@@ -52,6 +52,7 @@ class TrackState {
this.isShowingRecentAccess = false,
this.searchExtensionId,
this.selectedSearchFilter,
this.searchSource,
});
bool get hasContent =>
@@ -83,6 +84,8 @@ class TrackState {
String? searchExtensionId,
String? selectedSearchFilter,
bool clearSelectedSearchFilter = false,
String? searchSource,
bool clearSearchSource = false,
}) {
return TrackState(
tracks: tracks ?? this.tracks,
@@ -108,6 +111,9 @@ class TrackState {
selectedSearchFilter: clearSelectedSearchFilter
? null
: (selectedSearchFilter ?? this.selectedSearchFilter),
searchSource: clearSearchSource
? null
: (searchSource ?? this.searchSource),
);
}
}
@@ -118,9 +124,9 @@ class ArtistAlbum {
final String releaseDate;
final int totalTracks;
final String? coverUrl;
final String albumType; // album, single, compilation
final String albumType;
final String artists;
final String? providerId; // Extension ID if from extension
final String? providerId;
const ArtistAlbum({
required this.id,
@@ -195,7 +201,6 @@ class TrackNotifier extends Notifier<TrackState> {
return const TrackState();
}
/// Check if request is still valid (not cancelled by newer request)
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
@@ -208,7 +213,6 @@ class TrackNotifier extends Notifier<TrackState> {
if (extensionHandler != null) {
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
// Retry logic for extension URL handlers (up to 3 attempts)
Map<String, dynamic>? result;
for (int attempt = 1; attempt <= 3; attempt++) {
result = await PlatformBridge.handleURLWithExtension(url);
@@ -278,7 +282,9 @@ class TrackNotifier extends Notifier<TrackState> {
playlistName: type == 'playlist'
? result['name'] as String?
: null,
coverUrl: result['cover_url'] as String?,
coverUrl: normalizeCoverReference(
result['cover_url']?.toString(),
),
searchExtensionId: extensionId,
);
return;
@@ -305,10 +311,12 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
artistId: artistData['id'] as String?,
artistName: artistData['name'] as String?,
coverUrl:
artistData['image_url'] as String? ??
artistData['images'] as String?,
headerImageUrl: artistData['header_image'] as String?,
coverUrl: normalizeRemoteHttpUrl(
(artistData['image_url'] ?? artistData['images'])?.toString(),
),
headerImageUrl: normalizeRemoteHttpUrl(
artistData['header_image']?.toString(),
),
monthlyListeners: artistData['listeners'] as int?,
artistAlbums: albums,
artistTopTracks: topTracks.isNotEmpty ? topTracks : null,
@@ -349,7 +357,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
albumId: id,
albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?,
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
);
_preWarmCacheForTracks(tracks);
} else if (type == 'playlist') {
@@ -363,7 +371,9 @@ class TrackNotifier extends Notifier<TrackState> {
tracks: tracks,
isLoading: false,
playlistName: playlistInfo['name'] as String?,
coverUrl: playlistInfo['images'] as String?,
coverUrl: normalizeRemoteHttpUrl(
playlistInfo['images']?.toString(),
),
);
_preWarmCacheForTracks(tracks);
} else if (type == 'artist') {
@@ -377,7 +387,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?,
coverUrl: artistInfo['images'] as String?,
coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
artistAlbums: albums,
);
}
@@ -414,7 +424,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
albumId: 'qobuz:$id',
albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?,
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
);
_preWarmCacheForTracks(tracks);
} else if (type == 'playlist') {
@@ -427,8 +437,9 @@ class TrackNotifier extends Notifier<TrackState> {
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
final playlistName =
(playlistInfo['name'] ?? owner?['name']) as String?;
final coverUrl =
(playlistInfo['images'] ?? owner?['images']) as String?;
final coverUrl = normalizeRemoteHttpUrl(
(playlistInfo['images'] ?? owner?['images'])?.toString(),
);
state = TrackState(
tracks: tracks,
isLoading: false,
@@ -447,7 +458,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?,
coverUrl: artistInfo['images'] as String?,
coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
artistAlbums: albums,
);
}
@@ -484,7 +495,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
albumId: 'tidal:$id',
albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?,
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
);
_preWarmCacheForTracks(tracks);
} else if (type == 'playlist') {
@@ -497,8 +508,9 @@ class TrackNotifier extends Notifier<TrackState> {
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
final playlistName =
(playlistInfo['name'] ?? owner?['name']) as String?;
final coverUrl =
(playlistInfo['images'] ?? owner?['images']) as String?;
final coverUrl = normalizeRemoteHttpUrl(
(playlistInfo['images'] ?? owner?['images'])?.toString(),
);
state = TrackState(
tracks: tracks,
isLoading: false,
@@ -517,14 +529,13 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?,
coverUrl: artistInfo['images'] as String?,
coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
artistAlbums: albums,
);
}
return;
}
// If URL doesn't match any known service, it's unrecognized
final isSpotifyUrl =
url.contains('open.spotify.com') ||
url.contains('spotify.link') ||
@@ -572,7 +583,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
albumId: parsed['id'] as String?,
albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?,
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
);
_preWarmCacheForTracks(tracks);
} else if (type == 'playlist') {
@@ -584,8 +595,9 @@ class TrackNotifier extends Notifier<TrackState> {
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
final playlistName =
(playlistInfo['name'] ?? owner?['name']) as String?;
final coverUrl =
(playlistInfo['images'] ?? owner?['images']) as String?;
final coverUrl = normalizeRemoteHttpUrl(
(playlistInfo['images'] ?? owner?['images'])?.toString(),
);
state = TrackState(
tracks: tracks,
isLoading: false,
@@ -604,7 +616,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?,
coverUrl: artistInfo['images'] as String?,
coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
artistAlbums: albums,
);
}
@@ -618,10 +630,13 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
Future<void> search(String query, {String? filterOverride}) async {
Future<void> search(
String query, {
String? filterOverride,
String? builtInSearchProvider,
}) async {
final requestId = ++_currentRequestId;
// Preserve selected filter during loading
final currentFilter = filterOverride ?? state.selectedSearchFilter;
state = TrackState(
@@ -640,39 +655,65 @@ class TrackNotifier extends Notifier<TrackState> {
final includeExtensions =
settings.useExtensionProviders && hasActiveMetadataExtensions;
final effectiveProvider = builtInSearchProvider ?? 'deezer';
_log.i(
'Search started: metadataProviders, query="$query", includeExtensions=$includeExtensions, filter=$currentFilter',
'Search started: provider=$effectiveProvider, query="$query", includeExtensions=$includeExtensions, filter=$currentFilter',
);
Map<String, dynamic> results;
List<Map<String, dynamic>> metadataTrackResults = [];
try {
_log.d('Calling metadata provider search API...');
metadataTrackResults =
await PlatformBridge.searchTracksWithMetadataProviders(
query,
limit: 20,
includeExtensions: includeExtensions,
);
_log.i(
'Metadata providers returned ${metadataTrackResults.length} tracks',
);
} catch (e) {
_log.w(
'Metadata provider search failed, falling back to Deezer tracks: $e',
);
if (effectiveProvider == 'deezer') {
try {
_log.d('Calling metadata provider search API...');
metadataTrackResults =
await PlatformBridge.searchTracksWithMetadataProviders(
query,
limit: 20,
includeExtensions: includeExtensions,
);
_log.i(
'Metadata providers returned ${metadataTrackResults.length} tracks',
);
} catch (e) {
_log.w(
'Metadata provider search failed, falling back to Deezer tracks: $e',
);
}
}
_log.d('Calling Deezer search API...');
results = await PlatformBridge.searchDeezerAll(
query,
trackLimit: 20,
artistLimit: 2,
filter: currentFilter,
);
switch (effectiveProvider) {
case 'tidal':
_log.d('Calling Tidal search API...');
results = await PlatformBridge.searchTidalAll(
query,
trackLimit: 20,
artistLimit: 2,
filter: currentFilter,
);
break;
case 'qobuz':
_log.d('Calling Qobuz search API...');
results = await PlatformBridge.searchQobuzAll(
query,
trackLimit: 20,
artistLimit: 2,
filter: currentFilter,
);
break;
default:
_log.d('Calling Deezer search API...');
results = await PlatformBridge.searchDeezerAll(
query,
trackLimit: 20,
artistLimit: 2,
filter: currentFilter,
);
break;
}
_log.i(
'Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums',
'$effectiveProvider returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums',
);
if (!_isRequestValid(requestId)) {
@@ -757,7 +798,8 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess,
selectedSearchFilter: currentFilter, // Preserve filter in results
selectedSearchFilter: currentFilter,
searchSource: effectiveProvider,
);
} catch (e, stackTrace) {
if (!_isRequestValid(requestId)) return;
@@ -784,7 +826,7 @@ class TrackNotifier extends Notifier<TrackState> {
hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess,
selectedSearchFilter:
state.selectedSearchFilter, // Preserve filter during loading
state.selectedSearchFilter,
);
try {
@@ -823,9 +865,8 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess,
searchExtensionId: extensionId, // Store which extension was used
selectedSearchFilter:
state.selectedSearchFilter, // Preserve selected filter
searchExtensionId: extensionId,
selectedSearchFilter: state.selectedSearchFilter,
);
} catch (e, stackTrace) {
if (!_isRequestValid(requestId)) return;
@@ -881,7 +922,6 @@ class TrackNotifier extends Notifier<TrackState> {
tracks[index] = updatedTrack;
state = state.copyWith(tracks: tracks);
} catch (_) {
// Silently ignore update failures - track may have been removed
}
}
@@ -889,7 +929,6 @@ class TrackNotifier extends Notifier<TrackState> {
state = const TrackState();
}
/// Set selected search filter for extension search
void setSearchFilter(String? filter) {
if (state.selectedSearchFilter == filter) return;
state = state.copyWith(
@@ -898,7 +937,6 @@ class TrackNotifier extends Notifier<TrackState> {
);
}
/// Set search text state for back button handling
void setSearchText(bool hasText) {
if (state.hasSearchText == hasText) {
return;
@@ -913,7 +951,6 @@ class TrackNotifier extends Notifier<TrackState> {
state = state.copyWith(isShowingRecentAccess: showing);
}
/// Set tracks from a collection (album/playlist) opened from search results
void setTracksFromCollection({
required List<Track> tracks,
String? albumName,
@@ -943,7 +980,7 @@ class TrackNotifier extends Notifier<TrackState> {
albumArtist: data['album_artist'] as String?,
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
albumId: data['album_id']?.toString(),
coverUrl: data['images'] as String?,
coverUrl: normalizeCoverReference(data['images']?.toString()),
isrc: data['isrc'] as String?,
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
@@ -958,26 +995,32 @@ class TrackNotifier extends Notifier<TrackState> {
final durationMs = _extractDurationMs(data);
final itemType = data['item_type']?.toString();
final effectiveSource =
source ?? data['source']?.toString() ?? data['provider_id']?.toString();
final spotifyId = (data['spotify_id'] ?? '').toString();
final nativeId = (data['id'] ?? '').toString();
final preferredId = effectiveSource != null && effectiveSource.isNotEmpty
? (nativeId.isNotEmpty ? nativeId : spotifyId)
: (spotifyId.isNotEmpty ? spotifyId : nativeId);
return Track(
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
id: preferredId,
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
albumArtist: data['album_artist']?.toString(),
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
albumId: data['album_id']?.toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
coverUrl: normalizeCoverReference(
(data['cover_url'] ?? data['images'])?.toString(),
),
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
releaseDate: data['release_date']?.toString(),
totalTracks: data['total_tracks'] as int?,
source:
source ??
data['source']?.toString() ??
data['provider_id']?.toString(),
source: effectiveSource,
albumType: data['album_type']?.toString(),
itemType: itemType,
);
@@ -1015,7 +1058,9 @@ class TrackNotifier extends Notifier<TrackState> {
name: data['name'] as String? ?? '',
releaseDate: data['release_date'] as String? ?? '',
totalTracks: data['total_tracks'] as int? ?? 0,
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
coverUrl: normalizeCoverReference(
(data['cover_url'] ?? data['images'])?.toString(),
),
albumType: data['album_type'] as String? ?? 'album',
artists: data['artists'] as String? ?? '',
providerId: data['provider_id']?.toString(),
@@ -1026,7 +1071,7 @@ class TrackNotifier extends Notifier<TrackState> {
return SearchArtist(
id: data['id'] as String? ?? '',
name: data['name'] as String? ?? '',
imageUrl: data['images'] as String?,
imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()),
followers: data['followers'] as int? ?? 0,
popularity: data['popularity'] as int? ?? 0,
);
@@ -1037,7 +1082,7 @@ class TrackNotifier extends Notifier<TrackState> {
id: data['id'] as String? ?? '',
name: data['name'] as String? ?? '',
artists: data['artists'] as String? ?? '',
imageUrl: data['images'] as String?,
imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()),
releaseDate: data['release_date'] as String?,
totalTracks: data['total_tracks'] as int? ?? 0,
albumType: data['album_type'] as String? ?? 'album',
@@ -1049,7 +1094,7 @@ class TrackNotifier extends Notifier<TrackState> {
id: data['id'] as String? ?? '',
name: data['name'] as String? ?? '',
owner: data['owner'] as String? ?? '',
imageUrl: data['images'] as String?,
imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()),
totalTracks: data['total_tracks'] as int? ?? 0,
);
}
@@ -1066,7 +1111,7 @@ class TrackNotifier extends Notifier<TrackState> {
'isrc': isrc,
'track_name': track.name,
'artist_name': track.artistName,
'spotify_id': track.id, // Include Spotify ID for Amazon lookup
'spotify_id': track.id,
'service': 'tidal',
});
if (cacheRequests.length >= _maxPreWarmTracksPerRequest) {
+92 -28
View File
@@ -11,8 +11,10 @@ import 'package:spotiflac_android/providers/local_library_provider.dart';
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/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
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';
@@ -81,7 +83,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
_scrollController.addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) {
// Use extensionId if available, otherwise detect from albumId prefix
final providerId =
widget.extensionId ??
(() {
@@ -95,7 +96,10 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
.recordAlbumAccess(
id: widget.albumId,
name: widget.albumName,
artistName: widget.tracks?.firstOrNull?.artistName,
artistName:
widget.artistName ??
widget.tracks?.firstOrNull?.albumArtist ??
widget.tracks?.firstOrNull?.artistName,
imageUrl: widget.coverUrl,
providerId: providerId,
);
@@ -134,9 +138,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
}
/// Upgrade cover URL to a reasonable resolution for full-screen display.
/// Spotify CDN only has 300, 640, ~2000 we stay at 640 (no intermediate).
/// Deezer CDN: upgrade to 1000x1000 (available: 56, 250, 500, 1000, 1400, 1800).
/// Upgrade cover URL to a higher resolution for full-screen display.
String? _highResCoverUrl(String? url) {
if (url == null) return null;
// Spotify CDN: upgrade 300 640 only (no intermediate between 640 and 2000)
@@ -229,7 +231,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
artistId:
(data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId,
albumId: data['album_id']?.toString() ?? widget.albumId,
coverUrl: data['images'] as String?,
coverUrl: normalizeCoverReference(data['images']?.toString()),
isrc: data['isrc'] as String?,
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
trackNumber: data['track_number'] as int?,
@@ -240,6 +242,16 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
);
}
String? _recommendedDownloadService() {
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
return widget.extensionId;
}
if (widget.albumId.startsWith('tidal:')) return 'tidal';
if (widget.albumId.startsWith('qobuz:')) return 'qobuz';
if (widget.albumId.startsWith('deezer:')) return 'deezer';
return null;
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
@@ -256,8 +268,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
if (_isLoading)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
padding: EdgeInsets.all(16),
child: AlbumTrackListSkeleton(itemCount: 10),
),
),
if (_error != null)
@@ -283,7 +295,11 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
) {
final expandedHeight = _calculateExpandedHeight(context);
final tracks = _tracks ?? [];
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
final artistName =
widget.artistName ??
(tracks.isNotEmpty
? (tracks.first.albumArtist ?? tracks.first.artistName)
: null);
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
return SliverAppBar(
@@ -516,7 +532,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
// Info is now displayed in the full-screen cover overlay
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
@@ -530,9 +545,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
final track = tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _AlbumTrackItem(
track: track,
onDownload: () => _downloadTrack(context, track),
child: StaggeredListItem(
index: index,
child: _AlbumTrackItem(
track: track,
onDownload: () => _downloadTrack(context, track),
),
),
);
}, childCount: tracks.length),
@@ -547,6 +565,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
trackName: track.name,
artistName: track.artistName,
coverUrl: track.coverUrl,
recommendedService: _recommendedDownloadService(),
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
@@ -571,37 +590,82 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
void _downloadAll(BuildContext context) {
final tracks = _tracks;
if (tracks == null || tracks.isEmpty) return;
final historyState = ref.read(downloadHistoryProvider);
final settings = ref.read(settingsProvider);
final localLibState =
(settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
? ref.read(localLibraryProvider)
: null;
final tracksToQueue = <Track>[];
int skippedCount = 0;
for (final track in tracks) {
final isInHistory =
historyState.isDownloaded(track.id) ||
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null) ||
historyState.findByTrackAndArtist(track.name, track.artistName) !=
null;
final isInLocal =
localLibState?.existsInLibrary(
isrc: track.isrc,
trackName: track.name,
artistName: track.artistName,
) ??
false;
if (isInHistory || isInLocal) {
skippedCount++;
} else {
tracksToQueue.add(track);
}
}
if (tracksToQueue.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.discographySkippedDownloaded(0, skippedCount),
),
),
);
return;
}
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
trackName: '${tracks.length} tracks',
trackName: '${tracksToQueue.length} tracks',
artistName: widget.albumName,
recommendedService: _recommendedDownloadService(),
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAddedTracksToQueue(tracks.length),
),
),
);
.addMultipleToQueue(
tracksToQueue,
service,
qualityOverride: quality,
);
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
},
);
} else {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
),
);
.addMultipleToQueue(tracksToQueue, settings.defaultService);
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
}
}
void _showQueuedSnackbar(BuildContext context, int added, int skipped) {
final message = skipped > 0
? context.l10n.discographySkippedDownloaded(added, skipped)
: context.l10n.snackbarAddedTracksToQueue(added);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
Widget _buildLoveAllButton() {
final collectionsState = ref.watch(libraryCollectionsProvider);
final tracks = _tracks;
+43 -50
View File
@@ -14,11 +14,13 @@ import 'package:spotiflac_android/providers/local_library_provider.dart';
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/screens/album_screen.dart';
import 'package:spotiflac_android/screens/home_tab.dart'
show ExtensionAlbumScreen;
import 'package:spotiflac_android/widgets/download_service_picker.dart';
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';
class _ArtistCache {
@@ -151,6 +153,16 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
return tileSize + 64 + ((textScale - 1) * 14);
}
String? _recommendedDownloadService() {
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
return widget.extensionId;
}
if (widget.artistId.startsWith('tidal:')) return 'tidal';
if (widget.artistId.startsWith('qobuz:')) return 'qobuz';
if (widget.artistId.startsWith('deezer:')) return 'deezer';
return null;
}
@override
void initState() {
super.initState();
@@ -297,8 +309,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
.toList();
}
final topTracksList =
artistData['top_tracks'] as List<dynamic>? ?? [];
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
if (topTracksList.isNotEmpty) {
topTracks = topTracksList
.map((t) => _parseTrack(t as Map<String, dynamic>))
@@ -399,8 +410,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
(data['artist_id'] ?? data['artistId'])?.toString() ??
widget.artistId,
albumId: data['album_id']?.toString() ?? album?.id,
coverUrl: (data['cover_url'] ?? data['images'] ?? album?.coverUrl)
?.toString(),
coverUrl: normalizeCoverReference(
(data['cover_url'] ?? data['images'] ?? album?.coverUrl)?.toString(),
),
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
@@ -414,18 +426,18 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
final totalTracksValue = data['total_tracks'];
final totalTracks =
totalTracksValue is int
? totalTracksValue
: int.tryParse(totalTracksValue?.toString() ?? '') ?? 0;
final totalTracks = totalTracksValue is int
? totalTracksValue
: int.tryParse(totalTracksValue?.toString() ?? '') ?? 0;
return ArtistAlbum(
id: data['id'] as String? ?? '',
name: (data['name'] ?? data['title'] ?? '').toString(),
releaseDate: (data['release_date'] ?? '').toString(),
totalTracks: totalTracks,
coverUrl: (data['cover_url'] ?? data['images'] ?? data['cover_art'])
?.toString(),
coverUrl: normalizeCoverReference(
(data['cover_url'] ?? data['images'] ?? data['cover_art'])?.toString(),
),
albumType: (data['album_type'] ?? data['type'] ?? 'album').toString(),
artists: (data['artists'] ?? data['artist'] ?? widget.artistName)
.toString(),
@@ -480,12 +492,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
hasDiscography: hasDiscography,
),
if (_isLoadingDiscography)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
),
),
const SliverToBoxAdapter(child: ArtistScreenSkeleton()),
if (_error != null)
SliverToBoxAdapter(
child: Padding(
@@ -888,6 +895,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
recommendedService: _recommendedDownloadService(),
onSelect: (quality, service) {
_fetchAndQueueAlbums(albums, service, quality);
},
@@ -947,7 +955,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
fetchedCount++;
// Update progress dialog
if (mounted) {
_FetchingProgressDialog.updateProgress(
context,
@@ -978,7 +985,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
return;
}
// Check which tracks are already downloaded
final historyState = ref.read(downloadHistoryProvider);
final tracksToQueue = <Track>[];
int skippedCount = 0;
@@ -1029,10 +1035,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
content: Text(message),
action: SnackBarAction(
label: context.l10n.snackbarViewQueue,
onPressed: () {
// Navigate to queue tab (index 1)
// This will be handled by the navigation system
},
onPressed: () {},
),
),
);
@@ -1153,6 +1156,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
imageUrl.isNotEmpty &&
Uri.tryParse(imageUrl)?.hasAuthority == true;
final isDark = Theme.of(context).brightness == Brightness.dark;
String? listenersText;
final listeners = _monthlyListeners ?? widget.monthlyListeners;
if (listeners != null && listeners > 0) {
@@ -1223,7 +1228,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
Colors.transparent,
Colors.black.withValues(alpha: 0.3),
Colors.black.withValues(alpha: 0.7),
colorScheme.surface,
isDark
? colorScheme.surface
: Colors.black.withValues(alpha: 0.85),
],
stops: const [0.0, 0.5, 0.75, 1.0],
),
@@ -1264,7 +1271,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
listenersText,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: Colors.white.withValues(alpha: 0.8),
color: Colors.white,
shadows: [
Shadow(
offset: const Offset(0, 1),
@@ -1359,8 +1366,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
},
itemBuilder: (context, pageIndex) {
final startIndex = pageIndex * tracksPerPage;
final endIndex =
(startIndex + tracksPerPage).clamp(0, tracks.length);
final endIndex = (startIndex + tracksPerPage).clamp(
0,
tracks.length,
);
final pageTracks = tracks.sublist(startIndex, endIndex);
return Column(
@@ -1686,6 +1695,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
recommendedService: _recommendedDownloadService(),
onSelect: (quality, service) {
if (!mounted) return;
enqueue(service, quality: quality);
@@ -1836,29 +1846,14 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
Positioned(
top: 8,
right: 8,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 28,
height: 28,
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary
: colorScheme.surface.withValues(alpha: 0.9),
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
),
child: AnimatedSelectionCheckbox(
visible: true,
selected: isSelected,
colorScheme: colorScheme,
size: 28,
unselectedColor: colorScheme.surface.withValues(
alpha: 0.9,
),
child: isSelected
? Icon(
Icons.check,
color: colorScheme.onPrimary,
size: 18,
)
: null,
),
),
if (showTypeBadge)
@@ -2067,7 +2062,6 @@ class _FetchingProgressDialog extends StatefulWidget {
required this.onCancel,
});
// Static method to update progress from outside
static void updateProgress(BuildContext context, int current, int total) {
final state = context
.findAncestorStateOfType<_FetchingProgressDialogState>();
@@ -2140,7 +2134,6 @@ class _FetchingProgressDialogState extends State<_FetchingProgressDialog> {
),
),
const SizedBox(height: 8),
// Progress bar
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
+154 -90
View File
@@ -17,6 +17,7 @@ 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';
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
class DownloadedAlbumScreen extends ConsumerStatefulWidget {
final String albumName;
@@ -120,7 +121,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final tracks =
allItems.where((item) {
// Use albumArtist if available and not empty, otherwise artistName
final itemArtist =
(item.albumArtist != null && item.albumArtist!.isNotEmpty)
? item.albumArtist!
@@ -129,7 +129,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
'${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
return itemKey == _albumLookupKey;
}).toList()..sort((a, b) {
// Sort by disc number first, then by track number
final aDisc = a.discNumber ?? 1;
final bDisc = b.discNumber ?? 1;
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
@@ -310,14 +309,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
if (!mounted) return;
final result = await navigator.push(
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) =>
TrackMetadataScreen(item: item),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
),
slidePageRoute(page: TrackMetadataScreen(item: item)),
);
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
item.filePath,
@@ -693,7 +685,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final track = tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _buildTrackItem(context, colorScheme, track),
child: StaggeredListItem(
index: index,
child: _buildTrackItem(context, colorScheme, track),
),
);
}, childCount: tracks.length),
);
@@ -701,6 +696,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final discNumbers = _getSortedDiscNumbers(tracks);
final List<Widget> children = [];
var revealIndex = 0;
for (final discNumber in discNumbers) {
final discTracks = discMap[discNumber];
@@ -712,7 +708,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
children.add(
KeyedSubtree(
key: ValueKey(track.id),
child: _buildTrackItem(context, colorScheme, track),
child: StaggeredListItem(
index: revealIndex++,
child: _buildTrackItem(context, colorScheme, track),
),
),
);
}
@@ -796,28 +795,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
mainAxisSize: MainAxisSize.min,
children: [
if (_isSelectionMode) ...[
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary
: Colors.transparent,
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
),
),
child: isSelected
? Icon(
Icons.check,
color: colorScheme.onPrimary,
size: 16,
)
: null,
AnimatedSelectionCheckbox(
visible: true,
selected: isSelected,
colorScheme: colorScheme,
size: 24,
),
const SizedBox(width: 12),
],
@@ -910,8 +892,45 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
BuildContext context,
List<DownloadHistoryItem> allTracks,
) {
String selectedFormat = 'MP3';
String selectedBitrate = '320k';
final tracksById = {for (final t in allTracks) t.id: t};
final sourceFormats = <String>{};
for (final id in _selectedIds) {
final item = tracksById[id];
if (item == null) continue;
final nameToCheck =
(item.safFileName != null && item.safFileName!.isNotEmpty)
? item.safFileName!.toLowerCase()
: item.filePath.toLowerCase();
final ext = nameToCheck.endsWith('.flac')
? 'FLAC'
: nameToCheck.endsWith('.m4a')
? 'M4A'
: nameToCheck.endsWith('.mp3')
? 'MP3'
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
? 'Opus'
: null;
if (ext != null) sourceFormats.add(ext);
}
final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) {
return sourceFormats.any((src) {
if (src == target) return false;
final isLosslessTarget = target == 'ALAC' || target == 'FLAC';
final isLosslessSource = src == 'FLAC' || src == 'M4A';
if (isLosslessTarget && !isLosslessSource) return false;
return true;
});
}).toList();
if (formats.isEmpty) return;
String selectedFormat = formats.first;
bool isLosslessTarget =
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
String selectedBitrate = isLosslessTarget
? '320k'
: (selectedFormat == 'Opus' ? '128k' : '320k');
showModalBottomSheet(
context: context,
@@ -923,7 +942,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
return StatefulBuilder(
builder: (context, setSheetState) {
final colorScheme = Theme.of(context).colorScheme;
final formats = ['MP3', 'Opus'];
final bitrates = ['128k', '192k', '256k', '320k'];
return SafeArea(
@@ -960,51 +978,73 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
),
),
const SizedBox(height: 8),
Row(
children: formats.map((format) {
final isSelected = format == selectedFormat;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(format),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() {
selectedFormat = format;
selectedBitrate = format == 'Opus'
? '128k'
: '320k';
});
}
},
),
);
}).toList(),
),
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;
children: formats.map((format) {
final isSelected = format == selectedFormat;
return ChoiceChip(
label: Text(br),
label: Text(format),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() => selectedBitrate = br);
setSheetState(() {
selectedFormat = format;
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
if (!isLosslessTarget) {
selectedBitrate = format == 'Opus'
? '128k'
: '320k';
}
});
}
},
);
}).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,
@@ -1057,12 +1097,18 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
: item.filePath.toLowerCase();
final ext = nameToCheck.endsWith('.flac')
? 'FLAC'
: nameToCheck.endsWith('.m4a')
? 'M4A'
: nameToCheck.endsWith('.mp3')
? 'MP3'
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
? 'Opus'
: null;
if (ext != null && ext != targetFormat) selected.add(item);
if (ext == null || ext == targetFormat) continue;
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final isLosslessSource = ext == 'FLAC' || ext == 'M4A';
if (isLosslessTarget && !isLosslessSource) continue;
selected.add(item);
}
if (selected.isEmpty) {
@@ -1074,16 +1120,22 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
return;
}
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
content: Text(
context.l10n.selectionBatchConvertConfirmMessage(
selected.length,
targetFormat,
bitrate,
),
isLossless
? context.l10n.selectionBatchConvertConfirmMessageLossless(
selected.length,
targetFormat,
)
: context.l10n.selectionBatchConvertConfirmMessage(
selected.length,
targetFormat,
bitrate,
),
),
actions: [
TextButton(
@@ -1104,7 +1156,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final total = selected.length;
final historyDb = HistoryDatabase.instance;
final newQuality =
'${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
(targetFormat.toUpperCase() == 'ALAC' ||
targetFormat.toUpperCase() == 'FLAC')
? '${targetFormat.toUpperCase()} Lossless'
: '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
final settings = ref.read(settingsProvider);
final shouldEmbedLyrics =
settings.embedLyrics && settings.lyricsMode != 'external';
@@ -1132,12 +1187,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
try {
final result = await PlatformBridge.readFileMetadata(item.filePath);
if (result['error'] == null) {
result.forEach((key, value) {
if (key == 'error' || value == null) return;
final v = value.toString().trim();
if (v.isEmpty) return;
metadata[key.toUpperCase()] = v;
});
mergePlatformMetadataForTagEmbed(target: metadata, source: result);
}
} catch (_) {}
await ensureLyricsMetadataForConversion(
@@ -1207,13 +1257,27 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final baseName = dotIdx > 0
? oldFileName.substring(0, dotIdx)
: oldFileName;
final newExt = targetFormat.toLowerCase() == 'opus'
? '.opus'
: '.mp3';
String newExt;
String mimeType;
switch (targetFormat.toLowerCase()) {
case 'opus':
newExt = '.opus';
mimeType = 'audio/opus';
break;
case 'alac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
case 'flac':
newExt = '.flac';
mimeType = 'audio/flac';
break;
default:
newExt = '.mp3';
mimeType = 'audio/mpeg';
break;
}
final newFileName = '$baseName$newExt';
final mimeType = targetFormat.toLowerCase() == 'opus'
? 'audio/opus'
: 'audio/mpeg';
final safUri = await PlatformBridge.createSafFileFromPath(
treeUri: treeUri,
+471 -109
View File
@@ -23,10 +23,12 @@ import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.da
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/screens/playlist_screen.dart';
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
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';
class HomeTab extends ConsumerStatefulWidget {
@@ -82,6 +84,18 @@ class _SearchResultBuckets {
});
}
enum _SearchSortOption {
defaultOrder,
titleAsc,
titleDesc,
artistAsc,
artistDesc,
durationAsc,
durationDesc,
dateAsc,
dateDesc,
}
const _homeHistoryPreviewLimit = 48;
class _HomeHistoryPreview {
@@ -243,6 +257,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
Map<String, (double, double)>? _thumbnailSizesCache;
List<Track>? _searchBucketsSourceTracks;
_SearchResultBuckets? _searchBucketsCache;
_SearchSortOption _searchSortOption = _SearchSortOption.defaultOrder;
double _responsiveScale({
required BuildContext context,
@@ -279,13 +294,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
double _exploreCardSize(BuildContext context) {
final scale = _responsiveScale(context: context, min: 0.82, max: 1.08);
final textScale = _effectiveTextScale(context);
return 120 * scale * (1 + (textScale - 1) * 0.12);
return 145 * scale * (1 + (textScale - 1) * 0.12);
}
double _exploreSectionHeight(BuildContext context) {
final cardSize = _exploreCardSize(context);
final textScale = _effectiveTextScale(context);
return cardSize + 55 + ((textScale - 1) * 12);
return cardSize + 58 + ((textScale - 1) * 12);
}
@override
@@ -489,6 +504,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (searchProvider == null || searchProvider.isEmpty) return false;
// Built-in providers (tidal, qobuz) also support live search
if (_builtInSearchProviders.contains(searchProvider)) return true;
final extension = extState.extensions
.where((e) => e.id == searchProvider && e.enabled)
.firstOrNull;
@@ -546,6 +564,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
}
/// Built-in search providers that are not extensions
static const _builtInSearchProviders = {'tidal', 'qobuz'};
Future<void> _performSearch(String query, {String? filterOverride}) async {
final settings = ref.read(settingsProvider);
final extState = ref.read(extensionProvider);
@@ -557,10 +578,16 @@ class _HomeTabState extends ConsumerState<HomeTab>
'${searchProvider ?? 'default'}:$query:${selectedFilter ?? 'all'}';
if (_lastSearchQuery == searchKey) return;
_lastSearchQuery = searchKey;
_searchSortOption = _SearchSortOption.defaultOrder;
final isBuiltInProvider =
searchProvider != null &&
_builtInSearchProviders.contains(searchProvider);
final isExtensionEnabled =
searchProvider != null &&
searchProvider.isNotEmpty &&
!isBuiltInProvider &&
extState.extensions.any((e) => e.id == searchProvider && e.enabled);
if (isExtensionEnabled) {
@@ -571,10 +598,20 @@ class _HomeTabState extends ConsumerState<HomeTab>
await ref
.read(trackProvider.notifier)
.customSearch(searchProvider, query, options: options);
} else if (isBuiltInProvider) {
// Use built-in Tidal or Qobuz search
await ref
.read(trackProvider.notifier)
.search(
query,
filterOverride: selectedFilter,
builtInSearchProvider: searchProvider,
);
} else {
if (searchProvider != null &&
searchProvider.isNotEmpty &&
!isExtensionEnabled) {
!isExtensionEnabled &&
!isBuiltInProvider) {
ref.read(settingsProvider.notifier).setSearchProvider(null);
}
await ref
@@ -676,6 +713,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
playlistName: trackState.playlistName!,
coverUrl: trackState.coverUrl,
tracks: trackState.tracks,
recommendedService:
trackState.searchExtensionId ?? trackState.searchSource,
),
),
);
@@ -718,6 +757,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
trackName: track.name,
artistName: track.artistName,
coverUrl: track.coverUrl,
recommendedService: trackState.searchSource,
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
@@ -1258,8 +1298,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
exploreLoading)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
padding: EdgeInsets.all(16),
child: TrackListSkeleton(itemCount: 5),
),
),
@@ -1462,7 +1502,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
delegate: SliverChildBuilderDelegate((context, index) {
if (hasGreeting && index == 0) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
greeting,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
@@ -1477,7 +1517,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
return _buildExploreSection(sections[sectionIndex], colorScheme);
}
return const SizedBox(height: 16);
return const SizedBox(height: 24);
}, childCount: totalCount),
),
];
@@ -1493,7 +1533,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
padding: const EdgeInsets.fromLTRB(16, 20, 16, 12),
child: Text(
section.title,
style: Theme.of(
@@ -1509,7 +1549,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
itemCount: section.items.length,
itemBuilder: (context, index) {
final item = section.items[index];
return _buildExploreItem(item, colorScheme);
return StaggeredListItem(
index: index,
staggerDelay: const Duration(milliseconds: 50),
child: _buildExploreItem(item, colorScheme),
);
},
),
),
@@ -1556,7 +1600,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
children: [
ClipRRect(
borderRadius: BorderRadius.circular(
isArtist ? cardSize / 2 : 8,
isArtist ? cardSize / 2 : 10,
),
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
? CachedNetworkImage(
@@ -1595,8 +1639,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: isArtist ? TextAlign.center : TextAlign.start,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
@@ -1609,7 +1653,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
fontSize: 12,
),
),
],
@@ -2099,7 +2143,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (item.providerId != null &&
item.providerId!.isNotEmpty &&
item.providerId != 'deezer' &&
item.providerId != 'spotify') {
item.providerId != 'spotify' &&
item.providerId != 'tidal' &&
item.providerId != 'qobuz') {
Navigator.push(
context,
MaterialPageRoute(
@@ -2139,7 +2185,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
} else if (item.providerId != null &&
item.providerId!.isNotEmpty &&
item.providerId != 'deezer' &&
item.providerId != 'spotify') {
item.providerId != 'spotify' &&
item.providerId != 'tidal' &&
item.providerId != 'qobuz') {
Navigator.push(
context,
MaterialPageRoute(
@@ -2187,7 +2235,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (item.providerId != null &&
item.providerId!.isNotEmpty &&
item.providerId != 'deezer' &&
item.providerId != 'spotify') {
item.providerId != 'spotify' &&
item.providerId != 'tidal' &&
item.providerId != 'qobuz') {
Navigator.push(
context,
MaterialPageRoute(
@@ -2225,14 +2275,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
if (!mounted) return;
final result = await navigator.push(
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) =>
TrackMetadataScreen(item: item),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
),
slidePageRoute(page: TrackMetadataScreen(item: item)),
);
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
item.filePath,
@@ -2370,6 +2413,168 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
}
// Search result sorting
String _sortOptionLabel(_SearchSortOption option) {
switch (option) {
case _SearchSortOption.defaultOrder:
return context.l10n.searchSortDefault;
case _SearchSortOption.titleAsc:
return context.l10n.searchSortTitleAZ;
case _SearchSortOption.titleDesc:
return context.l10n.searchSortTitleZA;
case _SearchSortOption.artistAsc:
return context.l10n.searchSortArtistAZ;
case _SearchSortOption.artistDesc:
return context.l10n.searchSortArtistZA;
case _SearchSortOption.durationAsc:
return context.l10n.searchSortDurationShort;
case _SearchSortOption.durationDesc:
return context.l10n.searchSortDurationLong;
case _SearchSortOption.dateAsc:
return context.l10n.searchSortDateOldest;
case _SearchSortOption.dateDesc:
return context.l10n.searchSortDateNewest;
}
}
void _showSortOptions(ColorScheme colorScheme) {
var tempSort = _searchSortOption;
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: colorScheme.surfaceContainerLow,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (ctx) => StatefulBuilder(
builder: (ctx, setSheetState) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 32,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(2),
),
),
),
Row(
children: [
Text(
context.l10n.searchSortTitle,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
TextButton(
onPressed: () => setSheetState(
() => tempSort = _SearchSortOption.defaultOrder,
),
child: Text(context.l10n.libraryFilterReset),
),
],
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: _SearchSortOption.values.map((option) {
return FilterChip(
label: Text(_sortOptionLabel(option)),
selected: tempSort == option,
showCheckmark: false,
onSelected: (_) =>
setSheetState(() => tempSort = option),
);
}).toList(),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () {
Navigator.pop(ctx);
if (_searchSortOption != tempSort) {
setState(() {
_searchSortOption = tempSort;
});
}
},
child: Text(context.l10n.libraryFilterApply),
),
),
],
),
),
);
},
),
);
}
List<T> _applySortToList<T>(
List<T> items,
String Function(T) getName,
String Function(T) getArtist,
int Function(T) getDuration,
String? Function(T) getDate,
) {
if (_searchSortOption == _SearchSortOption.defaultOrder) return items;
final sorted = List<T>.of(items);
switch (_searchSortOption) {
case _SearchSortOption.defaultOrder:
break;
case _SearchSortOption.titleAsc:
sorted.sort(
(a, b) =>
getName(a).toLowerCase().compareTo(getName(b).toLowerCase()),
);
case _SearchSortOption.titleDesc:
sorted.sort(
(a, b) =>
getName(b).toLowerCase().compareTo(getName(a).toLowerCase()),
);
case _SearchSortOption.artistAsc:
sorted.sort(
(a, b) =>
getArtist(a).toLowerCase().compareTo(getArtist(b).toLowerCase()),
);
case _SearchSortOption.artistDesc:
sorted.sort(
(a, b) =>
getArtist(b).toLowerCase().compareTo(getArtist(a).toLowerCase()),
);
case _SearchSortOption.durationAsc:
sorted.sort((a, b) => getDuration(a).compareTo(getDuration(b)));
case _SearchSortOption.durationDesc:
sorted.sort((a, b) => getDuration(b).compareTo(getDuration(a)));
case _SearchSortOption.dateAsc:
sorted.sort((a, b) {
final da = getDate(a) ?? '';
final db = getDate(b) ?? '';
return da.compareTo(db);
});
case _SearchSortOption.dateDesc:
sorted.sort((a, b) {
final da = getDate(a) ?? '';
final db = getDate(b) ?? '';
return db.compareTo(da);
});
}
return sorted;
}
List<Widget> _buildSearchResults({
required List<Track> tracks,
required List<SearchArtist>? searchArtists,
@@ -2383,6 +2588,15 @@ class _HomeTabState extends ConsumerState<HomeTab>
required bool showLocalLibraryIndicator,
required Map<String, (double, double)> thumbnailSizesByExtensionId,
}) {
final hasActualData =
tracks.isNotEmpty ||
(searchArtists != null && searchArtists.isNotEmpty) ||
(searchAlbums != null && searchAlbums.isNotEmpty) ||
(searchPlaylists != null && searchPlaylists.isNotEmpty);
if (!hasActualData && isLoading) {
return [const SliverToBoxAdapter(child: HomeSearchSkeleton())];
}
if (!hasResults) {
return [const SliverToBoxAdapter(child: SizedBox.shrink())];
}
@@ -2394,6 +2608,59 @@ class _HomeTabState extends ConsumerState<HomeTab>
final playlistItems = buckets.playlistItems;
final artistItems = buckets.artistItems;
final sortedArtists = searchArtists != null && searchArtists.isNotEmpty
? _applySortToList<SearchArtist>(
searchArtists,
(a) => a.name,
(a) => a.name,
(a) => 0,
(a) => null,
)
: searchArtists;
final sortedAlbums = searchAlbums != null && searchAlbums.isNotEmpty
? _applySortToList<SearchAlbum>(
searchAlbums,
(a) => a.name,
(a) => a.artists,
(a) => 0,
(a) => a.releaseDate,
)
: searchAlbums;
final sortedPlaylists =
searchPlaylists != null && searchPlaylists.isNotEmpty
? _applySortToList<SearchPlaylist>(
searchPlaylists,
(p) => p.name,
(p) => p.owner,
(p) => 0,
(p) => null,
)
: searchPlaylists;
List<Track> sortedTracks;
List<int> sortedTrackIndexes;
if (realTracks.isNotEmpty &&
_searchSortOption != _SearchSortOption.defaultOrder) {
final paired = List.generate(
realTracks.length,
(i) => (realTracks[i], realTrackIndexes[i]),
);
final sortedPairs = _applySortToList<(Track, int)>(
paired,
(p) => p.$1.name,
(p) => p.$1.artistName,
(p) => p.$1.duration,
(p) => p.$1.releaseDate,
);
sortedTracks = sortedPairs.map((p) => p.$1).toList();
sortedTrackIndexes = sortedPairs.map((p) => p.$2).toList();
} else {
sortedTracks = realTracks;
sortedTrackIndexes = realTrackIndexes;
}
final slivers = <Widget>[
if (error != null)
SliverToBoxAdapter(
@@ -2411,24 +2678,28 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
];
if (searchArtists != null && searchArtists.isNotEmpty) {
bool sortButtonShown = false;
if (sortedArtists != null && sortedArtists.isNotEmpty) {
slivers.addAll(
_buildVirtualizedResultSection(
title: context.l10n.searchArtists,
itemCount: searchArtists.length,
itemCount: sortedArtists.length,
colorScheme: colorScheme,
showSortButton: !sortButtonShown,
itemBuilder: (index, showDivider) => _SearchArtistItemWidget(
key: ValueKey('search-artist-${searchArtists[index].id}'),
artist: searchArtists[index],
key: ValueKey('search-artist-${sortedArtists[index].id}'),
artist: sortedArtists[index],
showDivider: showDivider,
onTap: () => _navigateToArtist(
searchArtists[index].id,
searchArtists[index].name,
searchArtists[index].imageUrl,
sortedArtists[index].id,
sortedArtists[index].name,
sortedArtists[index].imageUrl,
),
),
),
);
sortButtonShown = true;
}
if (artistItems.isNotEmpty) {
@@ -2437,6 +2708,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
title: context.l10n.searchArtists,
itemCount: artistItems.length,
colorScheme: colorScheme,
showSortButton: !sortButtonShown,
itemBuilder: (index, showDivider) => _CollectionItemWidget(
key: ValueKey('artist-${artistItems[index].id}'),
item: artistItems[index],
@@ -2445,22 +2717,25 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
);
sortButtonShown = true;
}
if (searchAlbums != null && searchAlbums.isNotEmpty) {
if (sortedAlbums != null && sortedAlbums.isNotEmpty) {
slivers.addAll(
_buildVirtualizedResultSection(
title: context.l10n.searchAlbums,
itemCount: searchAlbums.length,
itemCount: sortedAlbums.length,
colorScheme: colorScheme,
showSortButton: !sortButtonShown,
itemBuilder: (index, showDivider) => _SearchAlbumItemWidget(
key: ValueKey('search-album-${searchAlbums[index].id}'),
album: searchAlbums[index],
key: ValueKey('search-album-${sortedAlbums[index].id}'),
album: sortedAlbums[index],
showDivider: showDivider,
onTap: () => _navigateToSearchAlbum(searchAlbums[index]),
onTap: () => _navigateToSearchAlbum(sortedAlbums[index]),
),
),
);
sortButtonShown = true;
}
if (albumItems.isNotEmpty) {
@@ -2469,6 +2744,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
title: context.l10n.searchAlbums,
itemCount: albumItems.length,
colorScheme: colorScheme,
showSortButton: !sortButtonShown,
itemBuilder: (index, showDivider) => _CollectionItemWidget(
key: ValueKey('album-${albumItems[index].id}'),
item: albumItems[index],
@@ -2477,22 +2753,25 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
);
sortButtonShown = true;
}
if (searchPlaylists != null && searchPlaylists.isNotEmpty) {
if (sortedPlaylists != null && sortedPlaylists.isNotEmpty) {
slivers.addAll(
_buildVirtualizedResultSection(
title: context.l10n.searchPlaylists,
itemCount: searchPlaylists.length,
itemCount: sortedPlaylists.length,
colorScheme: colorScheme,
showSortButton: !sortButtonShown,
itemBuilder: (index, showDivider) => _SearchPlaylistItemWidget(
key: ValueKey('search-playlist-${searchPlaylists[index].id}'),
playlist: searchPlaylists[index],
key: ValueKey('search-playlist-${sortedPlaylists[index].id}'),
playlist: sortedPlaylists[index],
showDivider: showDivider,
onTap: () => _navigateToSearchPlaylist(searchPlaylists[index]),
onTap: () => _navigateToSearchPlaylist(sortedPlaylists[index]),
),
),
);
sortButtonShown = true;
}
if (playlistItems.isNotEmpty) {
@@ -2501,6 +2780,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
title: context.l10n.searchPlaylists,
itemCount: playlistItems.length,
colorScheme: colorScheme,
showSortButton: !sortButtonShown,
itemBuilder: (index, showDivider) => _CollectionItemWidget(
key: ValueKey('playlist-${playlistItems[index].id}'),
item: playlistItems[index],
@@ -2509,20 +2789,22 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
);
sortButtonShown = true;
}
if (realTracks.isNotEmpty) {
if (sortedTracks.isNotEmpty) {
slivers.addAll(
_buildVirtualizedResultSection(
title: context.l10n.searchSongs,
itemCount: realTracks.length,
itemCount: sortedTracks.length,
colorScheme: colorScheme,
showSortButton: !sortButtonShown,
itemBuilder: (index, showDivider) => _TrackItemWithStatus(
key: ValueKey(realTracks[index].id),
track: realTracks[index],
index: realTrackIndexes[index],
key: ValueKey(sortedTracks[index].id),
track: sortedTracks[index],
index: sortedTrackIndexes[index],
showDivider: showDivider,
onDownload: () => _downloadTrack(realTrackIndexes[index]),
onDownload: () => _downloadTrack(sortedTrackIndexes[index]),
searchExtensionId: searchExtensionId,
showLocalLibraryIndicator: showLocalLibraryIndicator,
thumbnailSizesByExtensionId: thumbnailSizesByExtensionId,
@@ -2540,6 +2822,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
required int itemCount,
required ColorScheme colorScheme,
required Widget Function(int index, bool showDivider) itemBuilder,
bool showSortButton = false,
}) {
final sectionColor = Theme.of(context).brightness == Brightness.dark
? Color.alphaBlend(
@@ -2551,12 +2834,47 @@ class _HomeTabState extends ConsumerState<HomeTab>
return [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
child: Row(
children: [
Expanded(
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
if (showSortButton)
SizedBox(
height: 32,
child: TextButton.icon(
onPressed: () => _showSortOptions(colorScheme),
icon: Icon(
Icons.swap_vert,
size: 18,
color: _searchSortOption != _SearchSortOption.defaultOrder
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
label: Text(
_searchSortOption != _SearchSortOption.defaultOrder
? _sortOptionLabel(_searchSortOption)
: context.l10n.libraryFilterSort,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color:
_searchSortOption != _SearchSortOption.defaultOrder
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
visualDensity: VisualDensity.compact,
),
),
),
],
),
),
),
@@ -2564,19 +2882,22 @@ class _HomeTabState extends ConsumerState<HomeTab>
delegate: SliverChildBuilderDelegate((context, index) {
final isFirst = index == 0;
final isLast = index == itemCount - 1;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: sectionColor,
borderRadius: BorderRadius.vertical(
top: isFirst ? const Radius.circular(20) : Radius.zero,
bottom: isLast ? const Radius.circular(20) : Radius.zero,
return StaggeredListItem(
index: index,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: sectionColor,
borderRadius: BorderRadius.vertical(
top: isFirst ? const Radius.circular(20) : Radius.zero,
bottom: isLast ? const Radius.circular(20) : Radius.zero,
),
),
clipBehavior: Clip.antiAlias,
child: Material(
color: Colors.transparent,
child: itemBuilder(index, !isLast),
),
),
clipBehavior: Clip.antiAlias,
child: Material(
color: Colors.transparent,
child: itemBuilder(index, !isLast),
),
);
}, childCount: itemCount),
@@ -2770,6 +3091,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
if (searchProvider != null && searchProvider.isNotEmpty) {
if (searchProvider == 'tidal') {
return 'Search with Tidal...';
}
if (searchProvider == 'qobuz') {
return 'Search with Qobuz...';
}
final ext = extState.extensions
.where((e) => e.id == searchProvider)
.firstOrNull;
@@ -2804,16 +3132,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
_triggerSearchWithFilter(null);
},
showCheckmark: false,
selectedColor: colorScheme.primaryContainer,
backgroundColor: colorScheme.surfaceContainerHighest,
labelStyle: TextStyle(
color: selectedFilter == null
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
fontWeight: selectedFilter == null
? FontWeight.w600
: FontWeight.normal,
),
),
),
...filters.map((filter) {
@@ -2828,24 +3146,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
_triggerSearchWithFilter(filter.id);
},
showCheckmark: false,
selectedColor: colorScheme.primaryContainer,
backgroundColor: colorScheme.surfaceContainerHighest,
labelStyle: TextStyle(
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
),
avatar: filter.icon != null
? Icon(
_getFilterIcon(filter.icon!),
size: 18,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
)
? Icon(_getFilterIcon(filter.icon!), size: 18)
: null,
),
);
@@ -2882,7 +3184,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (text.isEmpty || text.length < _minLiveSearchChars) return;
if (text.startsWith('http') || text.startsWith('spotify:')) return;
// Reset last search query to force new search
_lastSearchQuery = null;
_performSearch(text, filterOverride: filter);
}
@@ -2900,15 +3201,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
fillColor: colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(
color: colorScheme.outline.withValues(alpha: 0.5),
),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(
color: colorScheme.outline.withValues(alpha: 0.5),
),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
@@ -2956,6 +3253,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
onSubmitted: (_) => _onSearchSubmitted(),
onTapOutside: (_) {
FocusScope.of(context).unfocus();
},
);
}
@@ -3004,6 +3304,10 @@ class _SearchProviderDropdown extends ConsumerWidget {
.firstOrNull;
}
const builtInProviders = {'tidal', 'qobuz'};
final isBuiltInProvider =
currentProvider != null && builtInProviders.contains(currentProvider);
IconData displayIcon = Icons.search;
String? iconPath;
if (currentExt != null) {
@@ -3011,10 +3315,8 @@ class _SearchProviderDropdown extends ConsumerWidget {
if (currentExt.searchBehavior?.icon != null) {
displayIcon = _getIconFromName(currentExt.searchBehavior!.icon!);
}
}
if (searchProviders.isEmpty) {
return const Icon(Icons.search);
} else if (isBuiltInProvider) {
displayIcon = Icons.music_note;
}
return Padding(
@@ -3081,6 +3383,60 @@ class _SearchProviderDropdown extends ConsumerWidget {
],
),
),
PopupMenuItem<String>(
value: 'tidal',
child: Row(
children: [
Icon(
Icons.music_note,
size: 20,
color: currentProvider == 'tidal'
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Tidal',
style: TextStyle(
fontWeight: currentProvider == 'tidal'
? FontWeight.w600
: FontWeight.normal,
),
),
),
if (currentProvider == 'tidal')
Icon(Icons.check, size: 18, color: colorScheme.primary),
],
),
),
PopupMenuItem<String>(
value: 'qobuz',
child: Row(
children: [
Icon(
Icons.music_note,
size: 20,
color: currentProvider == 'qobuz'
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Qobuz',
style: TextStyle(
fontWeight: currentProvider == 'qobuz'
? FontWeight.w600
: FontWeight.normal,
),
),
),
if (currentProvider == 'qobuz')
Icon(Icons.check, size: 18, color: colorScheme.primary),
],
),
),
if (searchProviders.isNotEmpty) const PopupMenuDivider(),
...searchProviders.map(
(ext) => PopupMenuItem<String>(
@@ -3876,7 +4232,6 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
// Extract artist info from album response
final artistId = (result['artist_id'] ?? result['artistId'])?.toString();
final artistName = result['artists'] as String?;
@@ -3909,6 +4264,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? widget.albumName).toString(),
albumArtist: (data['album_artist'] ?? _artistName)?.toString(),
artistId:
(data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId,
albumId: data['album_id']?.toString() ?? widget.albumId,
@@ -3933,7 +4289,10 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
if (_isLoading) {
return Scaffold(
appBar: AppBar(title: Text(widget.albumName)),
body: const Center(child: CircularProgressIndicator()),
body: const AlbumTrackListSkeleton(
itemCount: 10,
showCoverHeader: true,
),
);
}
@@ -4087,7 +4446,7 @@ class _ExtensionPlaylistScreenState
if (_isLoading) {
return Scaffold(
appBar: AppBar(title: Text(widget.playlistName)),
body: const Center(child: CircularProgressIndicator()),
body: const TrackListSkeleton(itemCount: 8, showCoverHeader: true),
);
}
@@ -4117,6 +4476,7 @@ class _ExtensionPlaylistScreenState
playlistName: widget.playlistName,
coverUrl: widget.coverUrl,
tracks: _tracks!,
recommendedService: widget.extensionId,
);
}
}
@@ -4216,7 +4576,7 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
artists: (data['artists'] ?? '').toString(),
releaseDate: (data['release_date'] ?? '').toString(),
totalTracks: data['total_tracks'] as int? ?? 0,
coverUrl: data['cover_url']?.toString(),
coverUrl: normalizeCoverReference(data['cover_url']?.toString()),
albumType: (data['album_type'] ?? 'album').toString(),
providerId: (data['provider_id'] ?? widget.extensionId).toString(),
);
@@ -4241,7 +4601,9 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
(data['artist_id'] ?? data['artistId'])?.toString() ??
widget.artistId,
albumId: data['album_id']?.toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
coverUrl: normalizeCoverReference(
(data['cover_url'] ?? data['images'])?.toString(),
),
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
@@ -4256,7 +4618,7 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
if (_isLoading) {
return Scaffold(
appBar: AppBar(title: Text(widget.artistName)),
body: const Center(child: CircularProgressIndicator()),
body: const ArtistScreenSkeleton(),
);
}
+4 -40
View File
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
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';
class LibraryPlaylistsScreen extends ConsumerWidget {
@@ -210,7 +211,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
_PlaylistOptionTile(
BottomSheetOptionTile(
icon: Icons.edit_outlined,
title: context.l10n.collectionRenamePlaylist,
onTap: () {
@@ -224,7 +225,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
},
),
_PlaylistOptionTile(
BottomSheetOptionTile(
icon: Icons.image_outlined,
title: context.l10n.collectionPlaylistChangeCover,
onTap: () {
@@ -233,7 +234,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
},
),
_PlaylistOptionTile(
BottomSheetOptionTile(
icon: Icons.delete_outline,
iconColor: colorScheme.error,
title: context.l10n.collectionDeletePlaylist,
@@ -543,40 +544,3 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
);
}
}
/// Styled like _OptionTile in track_collection_quick_actions.dart
class _PlaylistOptionTile extends StatelessWidget {
final IconData icon;
final Color? iconColor;
final String title;
final VoidCallback onTap;
const _PlaylistOptionTile({
required this.icon,
this.iconColor,
required this.title,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
icon,
color: iconColor ?? colorScheme.onPrimaryContainer,
size: 20,
),
),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
onTap: onTap,
);
}
}
+168 -130
View File
@@ -15,7 +15,9 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/bottom_sheet_option_tile.dart';
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
class LibraryTracksFolderScreen extends ConsumerStatefulWidget {
final LibraryTracksFolderMode mode;
@@ -39,6 +41,7 @@ class _LibraryTracksFolderScreenState
bool _isSelectionMode = false;
final Set<String> _selectedKeys = {};
UserPlaylistCollection? playlist;
@override
void initState() {
@@ -243,7 +246,6 @@ class _LibraryTracksFolderScreenState
final colorScheme = Theme.of(context).colorScheme;
ref.watch(localLibraryProvider.select((s) => s.items));
final localState = ref.read(localLibraryProvider);
final UserPlaylistCollection? playlist;
final List<CollectionTrackEntry> entries;
switch (widget.mode) {
@@ -272,7 +274,6 @@ class _LibraryTracksFolderScreenState
break;
}
// Stale selection cleanup
if (_isSelectionMode) {
final validKeys = entries.map((e) => e.key).toSet();
_selectedKeys.removeWhere((key) => !validKeys.contains(key));
@@ -348,20 +349,23 @@ class _LibraryTracksFolderScreenState
final isSelected = _selectedKeys.contains(entry.key);
return KeyedSubtree(
key: ValueKey(entry.key),
child: _CollectionTrackTile(
entry: entry,
mode: widget.mode,
playlistId: widget.playlistId,
localLibraryState: localState,
folderTracks: folderTracks,
isSelectionMode: _isSelectionMode,
isSelected: isSelected,
onTap: _isSelectionMode
? () => _toggleSelection(entry.key)
: null,
onLongPress: _isSelectionMode
? null
: () => _enterSelectionMode(entry.key),
child: StaggeredListItem(
index: index,
child: _CollectionTrackTile(
entry: entry,
mode: widget.mode,
playlistId: widget.playlistId,
localLibraryState: localState,
folderTracks: folderTracks,
isSelectionMode: _isSelectionMode,
isSelected: isSelected,
onTap: _isSelectionMode
? () => _toggleSelection(entry.key)
: null,
onLongPress: _isSelectionMode
? null
: () => _enterSelectionMode(entry.key),
),
),
);
}, childCount: entries.length),
@@ -372,7 +376,6 @@ class _LibraryTracksFolderScreenState
],
),
// Selection bottom bar
AnimatedPositioned(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
@@ -872,11 +875,54 @@ class _LibraryTracksFolderScreenState
void _downloadAll(List<Track> tracks) {
if (tracks.isEmpty) return;
final historyState = ref.read(downloadHistoryProvider);
final settings = ref.read(settingsProvider);
final localLibState =
(settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
? ref.read(localLibraryProvider)
: null;
final playlistName = widget.mode == LibraryTracksFolderMode.playlist
? playlist?.name ?? context.l10n.collectionPlaylist
: null;
final tracksToQueue = <Track>[];
var skippedCount = 0;
for (final track in tracks) {
final isInHistory =
historyState.isDownloaded(track.id) ||
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null) ||
historyState.findByTrackAndArtist(track.name, track.artistName) !=
null;
final isInLocal =
localLibState?.existsInLibrary(
isrc: track.isrc,
trackName: track.name,
artistName: track.artistName,
) ??
false;
if (isInHistory || isInLocal) {
skippedCount++;
} else {
tracksToQueue.add(track);
}
}
if (tracksToQueue.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.discographySkippedDownloaded(0, skippedCount),
),
),
);
return;
}
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
trackName: '${tracks.length} tracks',
trackName: '${tracksToQueue.length} tracks',
artistName: switch (widget.mode) {
LibraryTracksFolderMode.wishlist => context.l10n.collectionWishlist,
LibraryTracksFolderMode.loved => context.l10n.collectionLoved,
@@ -885,12 +931,24 @@ class _LibraryTracksFolderScreenState
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracks, service, qualityOverride: quality);
.addMultipleToQueue(
tracksToQueue,
service,
qualityOverride: quality,
playlistName: playlistName,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAddedTracksToQueue(tracks.length),
skippedCount > 0
? context.l10n.discographySkippedDownloaded(
tracksToQueue.length,
skippedCount,
)
: context.l10n.snackbarAddedTracksToQueue(
tracksToQueue.length,
),
),
),
);
@@ -899,10 +957,21 @@ class _LibraryTracksFolderScreenState
} else {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracks, settings.defaultService);
.addMultipleToQueue(
tracksToQueue,
settings.defaultService,
playlistName: playlistName,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
content: Text(
skippedCount > 0
? context.l10n.discographySkippedDownloaded(
tracksToQueue.length,
skippedCount,
)
: context.l10n.snackbarAddedTracksToQueue(tracksToQueue.length),
),
),
);
}
@@ -1015,14 +1084,19 @@ class _CollectionTrackTile extends ConsumerWidget {
final track = entry.track;
final colorScheme = Theme.of(context).colorScheme;
final effectiveCoverUrl = _resolveCoverUrl(track);
final isInHistory = ref.watch(
// Fine-grained provider watches only this tile rebuilds when its own
// history / local-library entry changes.
final historyItem = ref.watch(
downloadHistoryProvider.select((state) {
if (state.isDownloaded(track.id)) return true;
final byId = state.getBySpotifyId(track.id);
if (byId != null) return byId;
final isrc = track.isrc?.trim();
if (isrc != null && isrc.isNotEmpty && state.getByIsrc(isrc) != null) {
return true;
if (isrc != null && isrc.isNotEmpty) {
final byIsrc = state.getByIsrc(isrc);
if (byIsrc != null) return byIsrc;
}
return state.findByTrackAndArtist(track.name, track.artistName) != null;
return state.findByTrackAndArtist(track.name, track.artistName);
}),
);
final showLocalLibraryIndicator = ref.watch(
@@ -1030,17 +1104,26 @@ class _CollectionTrackTile extends ConsumerWidget {
(s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
),
);
final isInLocalLibrary = showLocalLibraryIndicator
final localItem = showLocalLibraryIndicator
? ref.watch(
localLibraryProvider.select(
(state) => state.existsInLibrary(
isrc: track.isrc,
trackName: track.name,
artistName: track.artistName,
),
),
localLibraryProvider.select((state) {
final isrc = track.isrc?.trim();
if (isrc != null && isrc.isNotEmpty) {
final byIsrc = state.getByIsrc(isrc);
if (byIsrc != null) return byIsrc;
}
return state.findByTrackAndArtist(track.name, track.artistName);
}),
)
: false;
: null;
final isInHistory = historyItem != null;
final isInLocalLibrary = localItem != null;
final heroTag = historyItem != null
? 'cover_${historyItem.id}'
: localItem != null
? 'cover_lib_${localItem.id}'
: null;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
@@ -1058,43 +1141,51 @@ class _CollectionTrackTile extends ConsumerWidget {
mainAxisSize: MainAxisSize.min,
children: [
if (isSelectionMode) ...[
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary
: Colors.transparent,
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
),
),
child: isSelected
? Icon(
Icons.check,
color: colorScheme.onPrimary,
size: 16,
)
: null,
AnimatedSelectionCheckbox(
visible: true,
selected: isSelected,
colorScheme: colorScheme,
size: 24,
),
const SizedBox(width: 12),
],
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: effectiveCoverUrl != null && effectiveCoverUrl.isNotEmpty
? _buildTrackCover(context, effectiveCoverUrl, 52)
: Container(
width: 52,
height: 52,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
HeroMode(
enabled: heroTag != null,
child: heroTag != null
? Hero(
tag: heroTag,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child:
effectiveCoverUrl != null &&
effectiveCoverUrl.isNotEmpty
? _buildTrackCover(context, effectiveCoverUrl, 52)
: Container(
width: 52,
height: 52,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
),
),
),
)
: ClipRRect(
borderRadius: BorderRadius.circular(8),
child:
effectiveCoverUrl != null &&
effectiveCoverUrl.isNotEmpty
? _buildTrackCover(context, effectiveCoverUrl, 52)
: Container(
width: 52,
height: 52,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
),
),
),
),
],
@@ -1324,9 +1415,8 @@ class _CollectionTrackTile extends ConsumerWidget {
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
// Add to playlist (hidden in wishlist unless already downloaded)
if (showAddToPlaylist)
_CollectionOptionTile(
BottomSheetOptionTile(
icon: Icons.playlist_add,
title: context.l10n.collectionAddToPlaylist,
onTap: () {
@@ -1335,8 +1425,7 @@ class _CollectionTrackTile extends ConsumerWidget {
},
),
// Remove from folder / playlist
_CollectionOptionTile(
BottomSheetOptionTile(
icon: Icons.remove_circle_outline,
iconColor: colorScheme.error,
title: mode == LibraryTracksFolderMode.playlist
@@ -1434,16 +1523,9 @@ class _CollectionTrackTile extends ConsumerWidget {
);
if (historyItem != null) {
await Navigator.of(context).push(
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) =>
TrackMetadataScreen(item: historyItem),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
),
);
await Navigator.of(
context,
).push(slidePageRoute(page: TrackMetadataScreen(item: historyItem)));
return;
}
@@ -1458,16 +1540,9 @@ class _CollectionTrackTile extends ConsumerWidget {
localItem ??= localState.findByTrackAndArtist(track.name, track.artistName);
if (localItem != null) {
await Navigator.of(context).push(
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) =>
TrackMetadataScreen(localItem: localItem),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
),
);
await Navigator.of(
context,
).push(slidePageRoute(page: TrackMetadataScreen(localItem: localItem)));
return;
}
@@ -1476,43 +1551,6 @@ class _CollectionTrackTile extends ConsumerWidget {
}
}
/// Styled like _OptionTile in track_collection_quick_actions.dart
class _CollectionOptionTile extends StatelessWidget {
final IconData icon;
final Color? iconColor;
final String title;
final VoidCallback onTap;
const _CollectionOptionTile({
required this.icon,
this.iconColor,
required this.title,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
icon,
color: iconColor ?? colorScheme.onPrimaryContainer,
size: 20,
),
),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
onTap: onTap,
);
}
}
class _SelectionActionButton extends StatelessWidget {
final IconData icon;
final String label;
+318 -96
View File
@@ -4,14 +4,19 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/services/local_track_redownload_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
class LocalAlbumScreen extends ConsumerStatefulWidget {
final String albumName;
@@ -41,11 +46,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
void _showCueVirtualTrackSnackBar() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(cueVirtualTrackRequiresSplitMessage),
),
const SnackBar(content: Text(cueVirtualTrackRequiresSplitMessage)),
);
}
late List<int> _sortedDiscNumbersCache;
late bool _hasMultipleDiscsCache;
String? _commonQualityCache;
@@ -528,7 +532,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
if (tracks.isEmpty) return null;
final first = tracks.first;
// For lossy formats, use bitrate
if (first.bitrate != null && first.bitrate! > 0) {
final fmt = first.format?.toUpperCase() ?? '';
final firstBitrate = first.bitrate;
@@ -540,7 +543,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return '$fmt ${firstBitrate}kbps'.trim();
}
// For lossless formats, use bit depth / sample rate
if (first.bitDepth == null ||
first.bitDepth == 0 ||
first.sampleRate == null) {
@@ -623,11 +625,16 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
slivers.add(
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) =>
_buildTrackItem(context, colorScheme, discTracks[index]),
childCount: discTracks.length,
),
delegate: SliverChildBuilderDelegate((context, index) {
final track = discTracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: StaggeredListItem(
index: index,
child: _buildTrackItem(context, colorScheme, track),
),
);
}, childCount: discTracks.length),
),
);
}
@@ -664,28 +671,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
mainAxisSize: MainAxisSize.min,
children: [
if (_isSelectionMode) ...[
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary
: Colors.transparent,
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
),
),
child: isSelected
? Icon(
Icons.check,
color: colorScheme.onPrimary,
size: 16,
)
: null,
AnimatedSelectionCheckbox(
visible: true,
selected: isSelected,
colorScheme: colorScheme,
size: 24,
),
const SizedBox(width: 12),
],
@@ -815,6 +805,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final format = item.format?.toLowerCase();
final lowerPath = item.filePath.toLowerCase();
final isMp3 = format == 'mp3' || lowerPath.endsWith('.mp3');
final isM4A =
format == 'm4a' ||
format == 'aac' ||
lowerPath.endsWith('.m4a') ||
lowerPath.endsWith('.aac');
final isOpus =
format == 'opus' ||
format == 'ogg' ||
@@ -828,6 +823,12 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
coverPath: effectiveCoverPath,
metadata: metadata,
);
} else if (isM4A) {
ffmpegResult = await FFmpegService.embedMetadataToM4a(
m4aPath: ffmpegTarget,
coverPath: effectiveCoverPath,
metadata: metadata,
);
} else if (isOpus) {
ffmpegResult = await FFmpegService.embedMetadataToOpus(
opusPath: ffmpegTarget,
@@ -897,6 +898,128 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return false;
}
List<LocalLibraryItem> _selectedFlacEligibleItems(
List<LocalLibraryItem> allTracks,
) {
final tracksById = {for (final t in allTracks) t.id: t};
return _selectedIds
.map((id) => tracksById[id])
.whereType<LocalLibraryItem>()
.where(LocalTrackRedownloadService.isFlacUpgradeEligible)
.toList(growable: false);
}
Future<void> _queueSelectedAsFlac(List<LocalLibraryItem> allTracks) async {
final selected = _selectedFlacEligibleItems(allTracks);
if (selected.isEmpty) {
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.queueFlacAction),
content: Text(context.l10n.queueFlacConfirmMessage(selected.length)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(context.l10n.queueFlacAction),
),
],
),
);
if (confirmed != true || !mounted) {
return;
}
final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider);
final includeExtensions =
settings.useExtensionProviders &&
extensionState.extensions.any(
(ext) => ext.enabled && ext.hasMetadataProvider,
);
final targetService = LocalTrackRedownloadService.preferredFlacService(
settings,
);
final targetQuality =
LocalTrackRedownloadService.preferredFlacQualityForService(
targetService,
);
final matchedTracks = <Track>[];
var skippedCount = 0;
final total = selected.length;
for (var i = 0; i < total; i++) {
if (!mounted) break;
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.queueFlacFindingProgress(i + 1, total)),
duration: const Duration(seconds: 30),
),
);
try {
final resolution = await LocalTrackRedownloadService.resolveBestMatch(
selected[i],
includeExtensions: includeExtensions,
);
if (resolution.canQueue && resolution.match != null) {
matchedTracks.add(resolution.match!);
} else {
skippedCount++;
}
} catch (_) {
skippedCount++;
}
}
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).clearSnackBars();
if (matchedTracks.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.queueFlacNoReliableMatches)),
);
return;
}
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(
matchedTracks,
targetService,
qualityOverride: targetQuality,
);
final summary = skippedCount == 0
? context.l10n.snackbarAddedTracksToQueue(matchedTracks.length)
: context.l10n.queueFlacQueuedWithSkipped(
matchedTracks.length,
skippedCount,
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(summary)));
setState(() {
_selectedIds.clear();
_isSelectionMode = false;
});
}
Future<void> _reEnrichSelected(List<LocalLibraryItem> allTracks) async {
final tracksById = {for (final t in allTracks) t.id: t};
final selected = <LocalLibraryItem>[];
@@ -1005,8 +1128,57 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
BuildContext context,
List<LocalLibraryItem> allTracks,
) {
String selectedFormat = 'MP3';
String selectedBitrate = '320k';
final tracksById = {for (final t in allTracks) t.id: t};
final sourceFormats = <String>{};
for (final id in _selectedIds) {
final item = tracksById[id];
if (item == null) continue;
String? ext;
if (item.format != null && item.format!.isNotEmpty) {
final fmt = item.format!.toLowerCase();
if (fmt == 'flac') {
ext = 'FLAC';
} else if (fmt == 'm4a') {
ext = 'M4A';
} else if (fmt == 'mp3') {
ext = 'MP3';
} else if (fmt == 'opus' || fmt == 'ogg') {
ext = 'Opus';
}
}
if (ext == null) {
final lower = item.filePath.toLowerCase();
if (lower.endsWith('.flac')) {
ext = 'FLAC';
} else if (lower.endsWith('.m4a')) {
ext = 'M4A';
} else if (lower.endsWith('.mp3')) {
ext = 'MP3';
} else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
ext = 'Opus';
}
}
if (ext != null) sourceFormats.add(ext);
}
final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) {
return sourceFormats.any((src) {
if (src == target) return false;
final isLosslessTarget = target == 'ALAC' || target == 'FLAC';
final isLosslessSource = src == 'FLAC' || src == 'M4A';
if (isLosslessTarget && !isLosslessSource) return false;
return true;
});
}).toList();
if (formats.isEmpty) return;
String selectedFormat = formats.first;
bool isLosslessTarget =
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
String selectedBitrate = isLosslessTarget
? '320k'
: (selectedFormat == 'Opus' ? '128k' : '320k');
showModalBottomSheet(
context: context,
@@ -1018,7 +1190,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return StatefulBuilder(
builder: (context, setSheetState) {
final colorScheme = Theme.of(context).colorScheme;
final formats = ['MP3', 'Opus'];
final bitrates = ['128k', '192k', '256k', '320k'];
return SafeArea(
@@ -1055,51 +1226,73 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
),
),
const SizedBox(height: 8),
Row(
children: formats.map((format) {
final isSelected = format == selectedFormat;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(format),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() {
selectedFormat = format;
selectedBitrate = format == 'Opus'
? '128k'
: '320k';
});
}
},
),
);
}).toList(),
),
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;
children: formats.map((format) {
final isSelected = format == selectedFormat;
return ChoiceChip(
label: Text(br),
label: Text(format),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() => selectedBitrate = br);
setSheetState(() {
selectedFormat = format;
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
if (!isLosslessTarget) {
selectedBitrate = format == 'Opus'
? '128k'
: '320k';
}
});
}
},
);
}).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,
@@ -1152,6 +1345,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final fmt = item.format!.toLowerCase();
if (fmt == 'flac') {
currentFormat = 'FLAC';
} else if (fmt == 'm4a') {
currentFormat = 'M4A';
} else if (fmt == 'mp3') {
currentFormat = 'MP3';
} else if (fmt == 'opus' || fmt == 'ogg') {
@@ -1163,15 +1358,20 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final lower = item.filePath.toLowerCase();
if (lower.endsWith('.flac')) {
currentFormat = 'FLAC';
} else if (lower.endsWith('.m4a')) {
currentFormat = 'M4A';
} else if (lower.endsWith('.mp3')) {
currentFormat = 'MP3';
} else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
currentFormat = 'Opus';
}
}
if (currentFormat != null && currentFormat != targetFormat) {
selected.add(item);
}
if (currentFormat == null || currentFormat == targetFormat) continue;
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final isLosslessSource =
currentFormat == 'FLAC' || currentFormat == 'M4A';
if (isLosslessTarget && !isLosslessSource) continue;
selected.add(item);
}
if (selected.isEmpty) {
@@ -1183,16 +1383,22 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return;
}
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
content: Text(
context.l10n.selectionBatchConvertConfirmMessage(
selected.length,
targetFormat,
bitrate,
),
isLossless
? context.l10n.selectionBatchConvertConfirmMessageLossless(
selected.length,
targetFormat,
)
: context.l10n.selectionBatchConvertConfirmMessage(
selected.length,
targetFormat,
bitrate,
),
),
actions: [
TextButton(
@@ -1239,12 +1445,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
try {
final result = await PlatformBridge.readFileMetadata(item.filePath);
if (result['error'] == null) {
result.forEach((key, value) {
if (key == 'error' || value == null) return;
final v = value.toString().trim();
if (v.isEmpty) return;
metadata[key.toUpperCase()] = v;
});
mergePlatformMetadataForTagEmbed(target: metadata, source: result);
}
} catch (_) {}
await ensureLyricsMetadataForConversion(
@@ -1286,7 +1487,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
bitrate: bitrate,
metadata: metadata,
coverPath: coverPath,
deleteOriginal: !isSaf, // Only delete original for regular files
deleteOriginal: !isSaf,
);
if (coverPath != null) {
@@ -1305,15 +1506,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
}
if (isSaf) {
// For SAF: derive the parent tree URI and relative dir from the content URI,
// then create new SAF file and delete old one
// Parse the SAF URI to get the tree document path:
// content://...tree/...document/.../oldName.flac
// We need tree URI and relative dir to create the new file
final uri = Uri.parse(item.filePath);
final pathSegments = uri.pathSegments;
// Try to find 'tree' and 'document' segments
String? treeUri;
String relativeDir = '';
String oldFileName = '';
@@ -1357,13 +1552,27 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final baseName = dotIdx > 0
? oldFileName.substring(0, dotIdx)
: oldFileName;
final newExt = targetFormat.toLowerCase() == 'opus'
? '.opus'
: '.mp3';
String newExt;
String mimeType;
switch (targetFormat.toLowerCase()) {
case 'opus':
newExt = '.opus';
mimeType = 'audio/opus';
break;
case 'alac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
case 'flac':
newExt = '.flac';
mimeType = 'audio/flac';
break;
default:
newExt = '.mp3';
mimeType = 'audio/mpeg';
break;
}
final newFileName = '$baseName$newExt';
final mimeType = targetFormat.toLowerCase() == 'opus'
? 'audio/opus'
: 'audio/mpeg';
final safUri = await PlatformBridge.createSafFileFromPath(
treeUri: treeUri,
@@ -1434,6 +1643,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
double bottomPadding,
) {
final selectedCount = _selectedIds.length;
final flacEligibleCount = _selectedFlacEligibleItems(tracks).length;
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
return Container(
@@ -1525,6 +1735,18 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
Row(
children: [
if (flacEligibleCount > 0) ...[
Expanded(
child: _LocalAlbumSelectionActionButton(
icon: Icons.download_for_offline_outlined,
label:
'${context.l10n.queueFlacAction} ($flacEligibleCount)',
onPressed: () => _queueSelectedAsFlac(tracks),
colorScheme: colorScheme,
),
),
const SizedBox(width: 8),
],
Expanded(
child: _LocalAlbumSelectionActionButton(
icon: Icons.auto_fix_high_outlined,
+71 -38
View File
@@ -20,6 +20,7 @@ import 'package:spotiflac_android/services/shell_navigation_service.dart';
import 'package:spotiflac_android/services/share_intent_service.dart';
import 'package:spotiflac_android/services/update_checker.dart';
import 'package:spotiflac_android/widgets/update_dialog.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('MainShell');
@@ -31,9 +32,11 @@ class MainShell extends ConsumerStatefulWidget {
ConsumerState<MainShell> createState() => _MainShellState();
}
class _MainShellState extends ConsumerState<MainShell> {
class _MainShellState extends ConsumerState<MainShell>
with SingleTickerProviderStateMixin {
int _currentIndex = 0;
late final PageController _pageController;
late final AnimationController _tabJumpTransitionController;
bool _hasCheckedUpdate = false;
StreamSubscription<String>? _shareSubscription;
DateTime? _lastBackPress;
@@ -48,6 +51,11 @@ class _MainShellState extends ConsumerState<MainShell> {
void initState() {
super.initState();
_pageController = PageController(initialPage: _currentIndex);
_tabJumpTransitionController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 180),
value: 1,
);
ShellNavigationService.syncState(
currentTabIndex: _currentIndex,
showStoreTab: false,
@@ -83,7 +91,6 @@ class _MainShellState extends ConsumerState<MainShell> {
final extState = ref.read(extensionProvider);
if (!extState.isInitialized) {
_log.d('Waiting for extensions to initialize before handling URL...');
// Wait up to 5 seconds for extensions to initialize
for (int i = 0; i < 50; i++) {
await Future.delayed(const Duration(milliseconds: 100));
if (!mounted) return;
@@ -155,7 +162,6 @@ class _MainShellState extends ConsumerState<MainShell> {
if (!Platform.isAndroid) return;
final settings = ref.read(settingsProvider);
// Only show if user is still on legacy storage mode with a download dir set
if (settings.storageMode == 'saf') return;
if (settings.downloadDirectory.isEmpty) return;
@@ -230,6 +236,7 @@ class _MainShellState extends ConsumerState<MainShell> {
void dispose() {
_shareSubscription?.cancel();
_pageController.dispose();
_tabJumpTransitionController.dispose();
super.dispose();
}
@@ -252,7 +259,8 @@ class _MainShellState extends ConsumerState<MainShell> {
}
if (_currentIndex != index) {
final shouldResetHome = index == 0;
final previousIndex = _currentIndex;
final isNonAdjacentJump = (previousIndex - index).abs() > 1;
HapticFeedback.selectionClick();
setState(() => _currentIndex = index);
final showStore = ref.read(
@@ -263,19 +271,23 @@ class _MainShellState extends ConsumerState<MainShell> {
showStoreTab: showStore,
);
FocusManager.instance.primaryFocus?.unfocus();
if (shouldResetHome) {
_resetHomeToMain();
// Jump directly when skipping intermediate tabs to avoid
// sliding through them. For those jumps, keep a short fade-in
// so the transition still feels intentional.
if (isNonAdjacentJump) {
_pageController.jumpToPage(index);
_tabJumpTransitionController.forward(from: 0);
} else {
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
);
}
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
);
}
}
void _onPageChanged(int index) {
final previousIndex = _currentIndex;
if (_currentIndex != index) {
setState(() => _currentIndex = index);
final showStore = ref.read(
@@ -286,9 +298,6 @@ class _MainShellState extends ConsumerState<MainShell> {
showStoreTab: showStore,
);
FocusManager.instance.primaryFocus?.unfocus();
if (index == 0 && previousIndex != 0) {
_resetHomeToMain();
}
}
}
@@ -452,32 +461,44 @@ class _MainShellState extends ConsumerState<MainShell> {
label: l10n.navHome,
),
NavigationDestination(
icon: Badge(
isLabelVisible: queueState > 0,
label: Text('$queueState'),
child: const Icon(Icons.library_music_outlined),
),
selectedIcon: SlidingIcon(
icon: AnimatedBadge(
count: queueState,
child: Badge(
isLabelVisible: queueState > 0,
label: Text('$queueState'),
child: const Icon(Icons.library_music),
child: const Icon(Icons.library_music_outlined),
),
),
selectedIcon: SlidingIcon(
child: AnimatedBadge(
count: queueState,
child: Badge(
isLabelVisible: queueState > 0,
label: Text('$queueState'),
child: const Icon(Icons.library_music),
),
),
),
label: l10n.navLibrary,
),
if (showStore)
NavigationDestination(
icon: Badge(
isLabelVisible: storeUpdatesCount > 0,
label: Text('$storeUpdatesCount'),
child: const Icon(Icons.store_outlined),
),
selectedIcon: SwingIcon(
icon: AnimatedBadge(
count: storeUpdatesCount,
child: Badge(
isLabelVisible: storeUpdatesCount > 0,
label: Text('$storeUpdatesCount'),
child: const Icon(Icons.store),
child: const Icon(Icons.store_outlined),
),
),
selectedIcon: SwingIcon(
child: AnimatedBadge(
count: storeUpdatesCount,
child: Badge(
isLabelVisible: storeUpdatesCount > 0,
label: Text('$storeUpdatesCount'),
child: const Icon(Icons.store),
),
),
),
label: l10n.navStore,
@@ -505,15 +526,27 @@ class _MainShellState extends ConsumerState<MainShell> {
return true;
},
child: Scaffold(
body: PageView.builder(
controller: _pageController,
itemCount: tabs.length,
onPageChanged: _onPageChanged,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) => _KeepAliveTabPage(
key: ValueKey('page-$index'),
child: tabs[index],
body: AnimatedBuilder(
animation: _tabJumpTransitionController,
child: PageView.builder(
controller: _pageController,
itemCount: tabs.length,
onPageChanged: _onPageChanged,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) => _KeepAliveTabPage(
key: ValueKey('page-$index'),
child: tabs[index],
),
),
builder: (context, child) {
final t = Curves.easeOutCubic.transform(
_tabJumpTransitionController.value,
);
return Opacity(
opacity: t,
child: Transform.scale(scale: 0.985 + (0.015 * t), child: child),
);
},
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex.clamp(0, maxIndex),
@@ -708,7 +741,7 @@ class _SwingIconState extends State<SwingIcon>
TweenSequenceItem(tween: Tween(begin: 0.15, end: -0.1), weight: 20),
TweenSequenceItem(tween: Tween(begin: -0.1, end: 0.05), weight: 20),
TweenSequenceItem(tween: Tween(begin: 0.05, end: 0.0), weight: 20),
]).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
]).animate(_controller);
_controller.forward();
}
+103 -24
View File
@@ -8,18 +8,21 @@ import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/string_utils.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';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
class PlaylistScreen extends ConsumerStatefulWidget {
final String playlistName;
final String? coverUrl;
final List<Track> tracks;
final String? playlistId;
final String? recommendedService;
const PlaylistScreen({
super.key,
@@ -27,6 +30,7 @@ class PlaylistScreen extends ConsumerStatefulWidget {
this.coverUrl,
required this.tracks,
this.playlistId,
this.recommendedService,
});
@override
@@ -46,6 +50,31 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
String get _playlistName => _resolvedPlaylistName ?? widget.playlistName;
String? get _coverUrl => _resolvedCoverUrl ?? widget.coverUrl;
String? _recommendedDownloadService() {
final explicit = widget.recommendedService;
if (explicit != null && explicit.isNotEmpty) {
return explicit;
}
final playlistId = widget.playlistId;
if (playlistId != null) {
if (playlistId.startsWith('tidal:')) return 'tidal';
if (playlistId.startsWith('qobuz:')) return 'qobuz';
if (playlistId.startsWith('deezer:')) return 'deezer';
}
final source = _tracks.firstOrNull?.source;
if (source != null && source.isNotEmpty) {
return source;
}
final trackId = _tracks.firstOrNull?.id ?? '';
if (trackId.startsWith('tidal:')) return 'tidal';
if (trackId.startsWith('qobuz:')) return 'qobuz';
if (trackId.startsWith('deezer:')) return 'deezer';
return null;
}
@override
void initState() {
super.initState();
@@ -128,7 +157,9 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
albumArtist: data['album_artist']?.toString(),
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
albumId: data['album_id']?.toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
coverUrl: normalizeCoverReference(
(data['cover_url'] ?? data['images'])?.toString(),
),
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
@@ -350,7 +381,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
// Info is now displayed in the full-screen cover overlay
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
@@ -358,8 +388,8 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
if (_isLoading) {
return const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
padding: EdgeInsets.all(16),
child: TrackListSkeleton(itemCount: 8),
),
);
}
@@ -409,9 +439,12 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
final track = _tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _PlaylistTrackItem(
track: track,
onDownload: () => _downloadTrack(context, track),
child: StaggeredListItem(
index: index,
child: _PlaylistTrackItem(
track: track,
onDownload: () => _downloadTrack(context, track),
),
),
);
}, childCount: _tracks.length),
@@ -427,6 +460,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
trackName: track.name,
artistName: track.artistName,
coverUrl: track.coverUrl,
recommendedService: _recommendedDownloadService(),
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
@@ -533,7 +567,12 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
tooltip: context.l10n.tooltipAddToPlaylist,
onPressed: _tracks.isEmpty
? null
: () => showAddTracksToPlaylistSheet(context, ref, _tracks),
: () => showAddTracksToPlaylistSheet(
context,
ref,
_tracks,
playlistNamePrefill: widget.playlistName,
),
);
}
@@ -608,45 +647,86 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
void _downloadTracks(BuildContext context, List<Track> tracks) {
if (tracks.isEmpty) return;
final historyState = ref.read(downloadHistoryProvider);
final settings = ref.read(settingsProvider);
final localLibState =
(settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
? ref.read(localLibraryProvider)
: null;
final tracksToQueue = <Track>[];
int skippedCount = 0;
for (final track in tracks) {
final isInHistory =
historyState.isDownloaded(track.id) ||
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null) ||
historyState.findByTrackAndArtist(track.name, track.artistName) !=
null;
final isInLocal =
localLibState?.existsInLibrary(
isrc: track.isrc,
trackName: track.name,
artistName: track.artistName,
) ??
false;
if (isInHistory || isInLocal) {
skippedCount++;
} else {
tracksToQueue.add(track);
}
}
if (tracksToQueue.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.discographySkippedDownloaded(0, skippedCount),
),
),
);
return;
}
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
trackName: '${tracks.length} tracks',
trackName: '${tracksToQueue.length} tracks',
artistName: _playlistName,
recommendedService: _recommendedDownloadService(),
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(
tracks,
tracksToQueue,
service,
qualityOverride: quality,
playlistName: _playlistName,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAddedTracksToQueue(tracks.length),
),
),
);
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
},
);
} else {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(
tracks,
tracksToQueue,
settings.defaultService,
playlistName: _playlistName,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
),
);
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
}
}
void _showQueuedSnackbar(BuildContext context, int added, int skipped) {
final message = skipped > 0
? context.l10n.discographySkippedDownloaded(added, skipped)
: context.l10n.snackbarAddedTracksToQueue(added);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
}
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
@@ -677,7 +757,6 @@ class _PlaylistTrackItem extends ConsumerWidget {
}),
);
// Check local library for duplicate detection
final showLocalLibraryIndicator = ref.watch(
settingsProvider.select(
(s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
+1343 -1138
View File
File diff suppressed because it is too large Load Diff
+39 -33
View File
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
class SearchScreen extends ConsumerStatefulWidget {
@@ -51,9 +52,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
ref
.read(downloadQueueProvider.notifier)
.addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
);
}
@override
@@ -95,13 +96,20 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
child: Text(error, style: TextStyle(color: colorScheme.error)),
),
Expanded(
child: tracks.isEmpty
? _buildEmptyState(colorScheme)
: ListView.builder(
itemCount: tracks.length,
itemBuilder: (context, index) =>
_buildTrackTile(tracks[index], colorScheme),
),
child: AnimatedStateSwitcher(
child: isLoading && tracks.isEmpty
? const TrackListSkeleton(key: ValueKey('loading'))
: tracks.isEmpty
? _buildEmptyState(colorScheme)
: ListView.builder(
key: const ValueKey('results'),
itemCount: tracks.length,
itemBuilder: (context, index) => StaggeredListItem(
index: index,
child: _buildTrackTile(tracks[index], colorScheme),
),
),
),
),
],
),
@@ -127,32 +135,30 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
}
Widget _buildTrackTile(Track track, ColorScheme colorScheme) {
return ListTile(
leading: track.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: track.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
memCacheWidth: 144,
memCacheHeight: 144,
cacheManager: CoverCacheManager.instance,
),
)
: Container(
final coverWidget = track.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: track.coverUrl!,
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
),
fit: BoxFit.cover,
memCacheWidth: 144,
memCacheHeight: 144,
cacheManager: CoverCacheManager.instance,
),
)
: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
);
return ListTile(
leading: coverWidget,
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
+24
View File
@@ -511,6 +511,30 @@ class _TranslatorsSection extends StatelessWidget {
language: 'Japanese',
flag: '🇯🇵',
),
_Translator(
name: 'unkn0wn',
crowdinUsername: 'rdclvi',
language: 'Indonesian',
flag: '🇮🇩',
),
_Translator(
name: 'lunching1272',
crowdinUsername: 'lunching1272',
language: 'Chinese Simplified',
flag: '🇨🇳',
),
_Translator(
name: 'Сергей Ильченко',
crowdinUsername: 'Sega_Mostky',
language: 'Russian',
flag: '🇷🇺',
),
_Translator(
name: 'Girl-lass',
crowdinUsername: 'Girl-lass',
language: 'Chinese Simplified',
flag: '🇨🇳',
),
_Translator(
name: 'Kaan',
crowdinUsername: 'glai',
+20 -46
View File
@@ -164,7 +164,13 @@ class _RecentDonorsCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
const donorNames = <String>['a fan'];
const donorNames = <String>[
'McNuggets Jimmy',
'zcc09',
'micahRichie',
'a fan',
'CJBGR',
];
// Match SettingsGroup color logic
final cardColor = isDark
@@ -471,17 +477,6 @@ class _CryptoWalletItem extends StatelessWidget {
}
}
int _cr(String v) {
int r = 0x1F;
for (final c in v.codeUnits) {
r = (r * 31 + c) & 0x7FFFFFFF;
}
return r;
}
// Highlighted supporters (hashes of names): none for now.
const _cv = <int>{};
class _SupporterChip extends StatelessWidget {
final String name;
final ColorScheme colorScheme;
@@ -490,53 +485,32 @@ class _SupporterChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
final e = _cv.contains(_cr(name));
const goldChipColor = Color(0xFFFFF8DC);
const goldAccentColor = Color(0xFFB8860B);
const goldDarkChipColor = Color(0xFF3A3000);
final chipColor = e ? goldChipColor : colorScheme.secondaryContainer;
final accentColor = e ? goldAccentColor : colorScheme.primary;
final isDark = Theme.of(context).brightness == Brightness.dark;
final effectiveChipColor = e && isDark ? goldDarkChipColor : chipColor;
return Material(
color: effectiveChipColor,
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(20),
child: Container(
decoration: e
? BoxDecoration(
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: accentColor.withValues(alpha: 0.4),
width: 1,
),
)
: null,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
radius: 10,
backgroundColor: accentColor.withValues(alpha: 0.2),
child: e
? Icon(Icons.star_rounded, size: 12, color: accentColor)
: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: accentColor,
),
),
backgroundColor: colorScheme.primary.withValues(alpha: 0.2),
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
),
),
const SizedBox(width: 8),
Text(
name,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: e ? accentColor : colorScheme.onSecondaryContainer,
fontWeight: e ? FontWeight.w600 : FontWeight.w500,
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w500,
),
),
],

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