Compare commits

...

75 Commits

Author SHA1 Message Date
github-actions[bot] fe72286273 chore: update AltStore source to v4.7.1 2026-07-01 18:42:31 +00:00
zarzet 2b8ec744dd feat(extensions): embed full metadata and cover after extension downloads
Replace duplicate genre/label-only embedding with a shared post-download
step that writes complete FLAC tags and optional cover art when the output
file is available locally.
2026-07-02 01:24:22 +07:00
zarzet 5f11f5b114 chore(release): bump version to 4.7.1+137 2026-07-02 01:24:22 +07:00
zarzet 61f62363b3 fix(convert): make convert bottom sheets draggable and scroll-controlled
Use DraggableScrollableSheet for single and batch convert flows and open
convert sheets with isScrollControlled so long option lists remain usable
on smaller screens.
2026-07-02 01:24:21 +07:00
zarzet 3278e32711 fix(extensions): default verification browser to in-app first
Prefer the in-app browser for signed-session verification challenges,
normalize invalid saved modes to the new default, and keep the help
dialog modal until the user explicitly dismisses it.
2026-07-02 01:24:21 +07:00
zarzet 0be6455d46 fix(download): hand off native worker verification to interactive queue
Detect verification-required failures from the Android native worker,
cancel the worker, and route the item back through the interactive
verification flow with the correct service identifier.
2026-07-02 01:24:21 +07:00
zarzet 0bf5a39a92 fix(ac4): reject truncated AC-4 sample entries safely
Validate audio sample entry header bounds before QuickTime v1
normalization and dac4 injection so malformed MP4 trees are left
unchanged or rejected instead of panicking on truncated boxes.
2026-07-02 01:24:21 +07:00
zarzet 5424648158 feat(audio): add dither and resampler options for lossless conversion
Let users choose FFmpeg dithering when reducing bit depth and SoXr or
SWR resampling when changing sample rate across single-track and batch
lossless conversion flows.
2026-07-02 01:24:20 +07:00
zarzet dcfd95f276 feat(extensions): manual verification help when browser launch fails
Expose a root navigator for global dialogs, show a fallback help sheet
with copy and reopen actions when verification URLs cannot launch, and
schedule the same prompt after a timeout during pending grants.
2026-07-02 01:24:20 +07:00
zarzet 4d6f7d8b08 l10n: add extension verification and lossless processing strings
Add localization keys for verification browser settings, manual
verification help dialog actions, and lossless conversion dithering and
resampler option labels.
2026-07-02 01:24:20 +07:00
zarzet 2c2cf8cdf8 fix(extensions): bootstrap and clear pending signed-session auth
Ensure pending auth requests are created when verification is needed but
missing, and clear them after signed-session grant completion or clear
so stale challenges do not block later verification flows.
2026-07-02 01:24:19 +07:00
zarzet 08c738dc69 feat(settings): add extension verification browser mode preference
Let users choose whether signed-session verification opens in the
external browser or in-app browser first, with automatic fallback to
the other mode when launch fails.
2026-07-02 01:24:19 +07:00
github-actions[bot] eb36b0bb7b chore: update AltStore source to v4.7.0 2026-06-30 20:59:56 +00:00
zarzet 3fd14e21eb feat(extension-repo): preserve package suffix from download URL
Detect .sflx and .spotiflac-ext from the registry download URL when saving
repo downloads, and extract findExtension so the destination path can use
the correct package extension.
2026-07-01 03:19:02 +07:00
zarzet 408895b607 fix(download): track verification retries per service
Key verification retry state by item and service so a completed challenge
for one provider does not block retries when another service also requires
verification for the same download.
2026-07-01 02:03:19 +07:00
zarzet 1a01147a95 fix(extensions): scope signed session files by endpoint and app context
Hash session cache paths from namespace, base URL, app version, and
platform so credentials do not leak across environments, and invalidate
stored sessions when that scope changes.
2026-07-01 02:03:14 +07:00
zarzet 8950907428 feat(extensions): route Deezer metadata through enabled extension
Prefer the enabled Deezer metadata extension over the built-in provider
when resolving album, track, and playlist metadata requests.
2026-07-01 02:03:10 +07:00
zarzet eb40a88437 fix(lyrics): sync provider priority to backend on save
Await backend lyrics provider sync when saving the priority page so
fetch order changes take effect immediately instead of only updating
local settings.
2026-06-30 11:13:23 +07:00
zarzet 7f82049beb fix(lyrics): prefer workers.dev LyricsPlus mirror first
Trim the LyricsPlus server list to the workers.dev and binimum mirrors
and update tests to match the new primary endpoint order.
2026-06-30 09:13:55 +07:00
zarzet c0c1d745f3 refactor: hoist lossless labels before async convert paths
Capture lossless conversion labels before await boundaries in queue,
downloaded album, and track metadata flows to avoid BuildContext usage
across async gaps.
2026-06-30 06:20:57 +07:00
zarzet c2b38a7c5a fix(banner): allow motion artwork video to mix with other audio
Enable mixWithOthers on HLS motion header playback so preview audio and
other apps are less likely to be ducked or interrupted.
2026-06-30 06:20:55 +07:00
zarzet ae8638a4b2 chore(player): log internal pause and audio interruption events
Add debug/info logging around audio focus interruptions, becoming-noisy
events, and user pause requests to make preview playback issues easier
to diagnose.
2026-06-30 06:20:51 +07:00
zarzet b864fafa82 feat(ui): stabilize album and playlist header layouts
Keep header metadata and action buttons visible while track lists are
still loading, disable download-all when empty, and show consistent
placeholder artwork when cover art is missing.
2026-06-30 06:20:47 +07:00
zarzet ee5ab1a751 fix(queue): resolve local album tracks by album_key
Load queue local album tracks from album_key when available and fall back
to album/artist matching with empty album_artist handling. Thread album_key
through grouped local album rows for more reliable navigation.
2026-06-30 06:20:37 +07:00
zarzet 64b884e27a fix(lyrics): sync provider settings to backend before fetch
Expose an awaitable lyrics settings sync on SettingsNotifier and call it
before metadata re-enrich, lyrics autofill, and queue/local embed flows so
the backend uses the latest provider order and fetch options.
2026-06-30 06:20:25 +07:00
zarzet dc8bb2cbc2 feat(extension-health): lengthen cache TTL and honor per-check minimum
Raise default extension health cache to 10 minutes with a 1-minute floor
and a shorter TTL for unknown status. Mirror the TTL rules in the Go
backend and stop force-refreshing health checks from the download
service picker on every open.
2026-06-30 06:20:10 +07:00
zarzet d882fc292c l10n: localize settings, store, and metadata UI strings
Wire remaining hardcoded strings in about, download region picker, file
organization settings, extension repo snackbars, release type badges,
metadata queue actions, online cover labels, and update changelog fallback.
2026-06-30 05:10:37 +07:00
zarzet 5dc0980ced l10n: localize audio conversion labels and confirmations
Pass localized lossless conversion labels through shared helpers and
replace hardcoded capped-lossless confirmation text in single-track and
batch convert flows across metadata, queue, and album screens.
2026-06-30 05:10:36 +07:00
zarzet 1cd668c869 l10n: localize library settings and announcements
Move hardcoded strings in library settings, announcement link errors,
and unknown track title/artist fallbacks to AppLocalizations. Sync
locale-dependent fallback strings from MainShell.
2026-06-30 05:10:34 +07:00
zarzet a827ebf6f4 l10n: add localization keys for hardcoded UI strings
Add English template strings and Indonesian translations for playback,
audio conversion, library settings, metadata actions, store snackbars,
SongLink region names, and other previously hardcoded user-facing text.
Regenerate AppLocalizations from the updated ARB files.
2026-06-30 03:40:13 +07:00
zarzet 3917ae02e2 feat: apply lossless conversion quality cap to album screen batch convert
Wire bit depth/sample rate caps and real converted-quality persistence
into the downloaded/local album batch conversion paths, matching the
queue tab behavior.
2026-06-29 06:47:36 +07:00
zarzet bd14c7dc63 fix(go): widen lyrics priority grace to 5s so priority provider wins
Apple Music (priority) was losing to LRCLIB when slightly slower than the
1.2s grace window. Widen to 5s so a slower priority provider still wins,
while still bounding the wait if it hangs/fails.
2026-06-29 06:47:04 +07:00
zarzet e0e28aee38 fix: preview player lifecycle and minor safety cleanups
- search_screen caches PreviewPlayerController in initState to avoid
  ref access during dispose
- extension_provider: convert enabledExtensions/searchProviders getters
  to methods
- artist_screen: drop redundant null assertions on motion banner inputs
2026-06-29 06:46:47 +07:00
zarzet 1550eedc12 feat: reorderable up-next queue + playback stability
- Up Next sheet supports drag-to-reorder via ReorderableListView with
  drag handles; MusicPlayerHandler.moveQueueItem keeps current track active
- Active lyric line centers higher in viewport (0.35) so it sits visually
  centered instead of leaning low
- Audio focus rework: explicit music AudioContext, ignore duck
  interruptions, drop the fragile ignore-complete timer, recover playback
  on transient stop/complete during track switches
2026-06-29 06:46:31 +07:00
zarzet b2074dfd02 feat: cap bit depth/sample rate on lossless conversion + WAV/AIFF
- LosslessConversionQuality model with bit depth/sample rate caps,
  applied only when they reduce source quality
- FFmpegService probes sample rate and appends codec-specific args
  (-ar, -sample_fmt, -bits_per_raw_sample) for FLAC/ALAC/WAV/AIFF
- Batch + single-track convert sheets expose quality cap options
- Persist real converted bit depth/sample rate to history/library DB
- track_metadata: recognize and convert to WAV/AIFF targets
- convertedAudioQualityLabel reflects actual output quality
2026-06-29 06:46:19 +07:00
zarzet e9171d6f21 feat(ui): hide audio quality badge on downloading queue items 2026-06-29 06:45:54 +07:00
zarzet ef60bba2e1 feat(ui): redesign local/downloaded album and folder screen headers
- Consistent header design across all local screens: blurred cover,
  black overlay, bottom gradient, centered square cover, adaptive title,
  inline meta line, Play + Shuffle buttons
- Height normalized to 0.6x (clamp 400-580) everywhere
2026-06-28 22:23:08 +07:00
zarzet 12fb942f16 feat: playback queue, preview exclusivity, player bug fixes
- PlaybackController with queue methods for albums/library/playlists
- Library tab play builds merged queue (downloaded + local together)
- Preview vs main player exclusivity
- Preview stops on bottom-nav tab switch
- Duration 0:00 fix, deleted track cleanup, Up Next sheet
- Animation utilities improvements
2026-06-28 22:22:51 +07:00
zarzet 3a2481e8b2 feat: add new playback experience and media integration 2026-06-28 22:22:33 +07:00
zarzet bede5ae8d7 feat(go): verification early-abort in fallback + album metadata from tracks
- DownloadWithExtensionFallback now immediately surfaces verification_required
  when any provider needs verification (availability + download stages),
  instead of letting later providers mask it
- classifyDownloadErrorType treats 'session is not authenticated' as
  verification_required (Go + Dart side)
- parseExtensionAlbumValue.withTrackFallbacks() derives album artist,
  release date, and audio traits from tracks when album-level missing
- albumAudioTraitsFromTracks detects dolby_atmos/hi_res_lossless/lossless
  from per-track audio_quality/audio_modes fields
- parseBitDepthSampleRate parses '24bit/96kHz' style quality labels
2026-06-28 22:16:36 +07:00
zarzet 445b186e3b fix(go): classify transient timeout/5xx in extension health as unknown (grey) 2026-06-28 22:15:55 +07:00
zarzet 354fe61b85 refactor(track-provider): rework verification retry, parse preview/header video
Replace the pending-verification-search bookkeeping with an inline open-and-wait flow that retries the custom search once the session grant arrives (with timeout). Parse preview_url into Track, and carry headerVideoUrl through TrackState for URL-resolved tracks/albums/artists.
2026-06-28 06:07:15 +07:00
zarzet 95f5ae610e feat(banner): HLS motion-artwork header banners and audio-quality badges
Add a MotionHeaderBanner (video_player) that plays a looping muted HLS header video with a static image fallback for artist, album, and playlist screens. The Go backend now exposes header_video, header_image, and audio_traits from extensions. Album/playlist headers show the release year and Dolby Atmos / Lossless badges inline, full date and song count in a footer, a centered square cover when no video is present, and a full-bleed video when one is.
2026-06-28 06:06:54 +07:00
zarzet 2e806a28b9 fix(ui): prevent home search skeleton row from overflowing on narrow screens 2026-06-28 06:06:32 +07:00
zarzet 2ab0350733 feat(preview): play short track preview snippets in lists
Add a preview player provider and PreviewButton, a previewUrl field on Track (parsed from search and local redownload), preview buttons in search results, and stop the preview on navigation/tab change via per-tab navigator observers. Includes preview play/stop/unavailable localization strings.
2026-06-28 06:06:12 +07:00
zarzet ce813bc216 feat(extension-health): cache results, force refresh, treat lookup errors as transient
Cache health results after each check, add a force flag to bypass the cache when the download picker opens or a service is selected, guard stale concurrent results via a per-extension request serial, and treat DNS lookup failures as indeterminate/transient.
2026-06-28 06:05:48 +07:00
zarzet 21fe047e00 feat(ui): polish album folder structure picker bottom sheet
Add a localized description header and Material 3 styling (rounded
corners, scrollable layout, surface color) to the album folder structure
picker in Files settings.
2026-06-27 10:46:04 +07:00
zarzet 8558450378 fix(lyrics): improve provider fallback and health handling 2026-06-27 06:35:32 +07:00
zarzet f9e68b628d chore: bump version to 4.7.0+136 2026-06-27 02:21:44 +07:00
zarzet 50509d0a16 chore: clean up redundant comments across Go backend and Flutter sources 2026-06-26 22:57:03 +07:00
zarzet c1c0494912 perf: optimize queue/library pagination, counts, scan, and extension runtime
- Queue/Library: switch from growing-limit refetch to offset-based append pagination per filter/search/sort, with page cache reassembly and protected-page eviction to avoid top-of-list gaps

- Queue/Library: watch only the active filter page instead of all/singles/albums simultaneously; filter mode follows PageView swipe and tab tap

- library_database: combine three union-based count queries into a single round-trip

- library_scan: parallel scan of non-CUE audio files with a bounded 2-4 worker pool, preserving order, progress, and cancellation; CUE handling stays sequential

- extension_manager: cache compiled goja.Program per extension and RunProgram per isolated download instead of re-reading and re-parsing index.js
2026-06-26 22:39:22 +07:00
zarzet 58e615462c feat(ui): add Beta badge to Backup & Restore settings entry
Extract the BETA pill into a reusable BetaBadge widget in settings_group.dart and add titleTrailing support to SettingsItem. Show the badge on the Backup & Restore entry in the settings list, and reuse the shared widget in the download settings page (removing the duplicated private badge).
2026-06-26 22:16:21 +07:00
zarzet f0bf769f0d refactor(ui): make backup/restore page consistent with other settings
Replace the custom _ActionCard/SwitchListTile layout with the shared SettingsSectionHeader + SettingsGroup + SettingsItem/SettingsSwitchItem components used across the other settings pages, so spacing, grouping and switch styling match. Busy state shows an inline progress indicator on the action row.
2026-06-26 22:12:07 +07:00
zarzet 423d50cfb5 build(android): bump Java to 25 and targetSdk to 37
Raise sourceCompatibility/targetCompatibility to Java 25 and Kotlin jvmTarget to JVM_25 across app and subprojects, and CI java-version to 25 (Kotlin 2.3.21 supports JVM_25). Bump targetSdk to 37 to match compileSdk. Building locally requires Flutter to use JDK 25 (flutter config --jdk-dir).
2026-06-26 22:08:20 +07:00
zarzet 2f4a62e03c build(android): bump compileSdk to 37 and pin file_picker to beta.5
Pin file_picker to exact 12.0.0-beta.5 since beta.7's published artifact fails to compile on Android. Set compileSdk = 37 so receive_sharing_intent 1.9.0 (compiles against SDK 37) builds against a stable platform.
2026-06-26 21:40:04 +07:00
zarzet e64bea41e6 fix(deps): keep file_picker on 12.x beta to stay win32 6 compatible
file_picker has no stable release compatible with win32 ^6.x (required by device_info_plus/share_plus/connectivity_plus). Its Windows FFI code is compiled even for Android builds, so a win32 override breaks compilation. Revert file_picker to 12.0.0-beta and restore the original pickFile usage. Other Flutter/Go dependency updates remain on latest stable.
2026-06-26 21:33:13 +07:00
zarzet f0acda0f01 feat(network): add opt-in allow local/private network setting
Add a setting that relaxes the SSRF guard so extensions and built-in network code can reach private/local/loopback targets, for users routing traffic through a local proxy or custom DNS. Disabled by default. Wired end-to-end: Go backend (SetAllowPrivateNetwork toggles isPrivateIP guard), Android/iOS platform bridge, Dart settings model/provider, and a toggle in Download settings.
2026-06-26 20:29:15 +07:00
zarzet af4e4561ec chore(deps): update Flutter and Go dependencies to latest stable
Flutter: bump connectivity_plus, device_info_plus, share_plus, receive_sharing_intent, path_provider, flutter_local_notifications to latest stable. Revert file_picker from 12.0.0-beta to 11.0.2 stable and migrate pickFile (singular) to pickFiles. Add win32 ^6.0.1 dependency_overrides to resolve the win32 5 vs 6 conflict between file_picker stable and device_info_plus (Windows-desktop-only transitive dep, not used on mobile targets). Go: update goja, golang.org/x/mobile, golang.org/x/tools, regexp2/v2 to latest.
2026-06-26 20:26:43 +07:00
zarzet 1787059f42 feat(backup): include installed extensions and settings in backup/restore
Back up the store registry URL plus each installed extension (id, version, enabled flag and settings) and restore them on a new device by reinstalling from the store and re-applying settings. Secret-flagged settings (tokens/API keys) are excluded by default behind an opt-in 'Include extension credentials' toggle. Device-bound signed sessions are never backed up. Settings are merged on restore so omitted secrets are not wiped; failed reinstalls are reported.
2026-06-26 20:11:51 +07:00
zarzet b2705cb2ae feat(settings): add backup and restore for settings, history and library
Add a Backup & Restore page that exports app settings, download history, liked tracks, wishlist, playlists (with cover images) and favorite artists into a single JSON file, and restores them on another device. Settings restore preserves device-specific storage location (SAF tree URI, download dir). Includes EN strings and ID translations.
2026-06-26 19:54:20 +07:00
zarzet f236d72a19 fix(download): detect actual output format from audio codec on fallback
When a fallback provider returns a non-FLAC container (e.g. YouTube Opus) and neither an explicit extension field nor a recognizable path suffix is available (such as SAF content URIs), infer the output extension from the backend-probed audio codec. Prevents Opus/MP3/AAC downloads from being mislabeled and embedded as FLAC, which left metadata empty.
2026-06-26 19:06:04 +07:00
zarzet cf270a36ff feat(library): add rename action to playlist folder screen
Add an edit button in the playlist folder app bar that opens a rename dialog with validation, calling renamePlaylist on the collections provider.
2026-06-26 18:35:41 +07:00
zarzet 6d932386b0 feat(download): add {playlist_position} filename placeholder
Add a playlist position token usable in filename templates, plumbed from the playlist screen through the download queue to the Go backend. Auto-prefix playlist downloads with the position when the template lacks the token. Includes Go filename test.
2026-06-26 18:35:32 +07:00
zarzet 9c054b9e3a feat: handle extension verification and retry-after on download
Classify verification-required and rate-limit errors from extensions, propagate Retry-After seconds through the download fallback, report session-grant success/failure, and add a completeGrant action fallback in the runtime.
2026-06-26 07:12:12 +07:00
zarzet d9f0007a2d fix: navigate to correct artist when tapping artist name
Resolve artist taps by id when available and fall back to name-similarity matching instead of blindly picking the first search result. Pass provider id at more call sites and map multiple artist ids positionally to their names.
2026-06-26 07:11:54 +07:00
zarzet ee35f52baf feat: extension runtime and provider improvements 2026-06-26 04:14:51 +07:00
zarzet 21347420f3 feat(download): AC-4 passthrough support
Decrypt AC-4 via the FFmpeg mov muxer with a -f mov fallback, then repair the output to a standards-compliant ISO MP4: inject the dac4 config box from the encrypted source, normalize the QuickTime container/sample entry, and write iTunes metadata (incl. cover and lyrics) natively. Codec-keyed and generic, so it applies to any extension that returns AC-4 streams. Wired through PlatformBridge/MainActivity for both SAF and local decrypt paths.
2026-06-23 02:44:08 +07:00
zarzet 26987459f3 fix(metadata): cap oversized cover art and support QuickTime/MP4 tags
Re-encode/downscale cover art that exceeds the FLAC 24-bit picture block limit (go-flac silently truncated it into a corrupt file). Locate ilst in both ISO and QuickTime-style meta atoms, and skip freeform tags gracefully when no iTunes container exists.
2026-06-23 02:41:42 +07:00
zarzet 897388853b chore: move website to spotiflacapp/Website repo 2026-06-22 17:25:31 +07:00
zarzet ef52332b8b feat(replaygain): add Opus R128 gain tags
Write R128_TRACK_GAIN/R128_ALBUM_GAIN (Q7.8, ref -23 LUFS) alongside the existing REPLAYGAIN_* tags for .opus files so spec-compliant Opus players (RFC 7845) apply gain. Covers per-track and album embed during download (incl. SAF) and standalone re-scan.
2026-06-20 02:24:50 +07:00
zarzet 1489378ffd fix(android): disable Impeller on Sony audio players and Vivante GPUs
The GL renderer string is empty when Flutter shell args are built (no GL context yet), so the GPU-pattern check never matched Sony Walkman devices and Impeller crashed in the Vivante driver's glLinkProgram. Match MANUFACTURER 'SonyAudio' (distinct from Xperia 'Sony') and add Vivante GC GPU patterns.
2026-06-19 00:50:52 +07:00
zarzet ccc93f881a fix(metadata): write M4A ISRC/label natively and read all edited fields from file
FFmpeg's MP4 muxer drops ISRC and label, so write them as iTunes freeform atoms natively after every M4A embed pass. The metadata screen now reads all edited fields from the file on load (file is the source of truth), fixing edited values reverting to stale cached values after reopening.
2026-06-14 15:00:34 +07:00
zarzet ded8b68098 fix(download): honor requested quality when fallback provider recognizes it 2026-06-14 14:12:37 +07:00
zarzet 983be8b37a feat(queue): tap a failed download to view its error details
Tapping a failed download tile (grid or list) now opens a dialog showing the full error message with retry and remove actions.
2026-06-14 05:26:30 +07:00
github-actions[bot] 7b22bbf25f chore: update AltStore source to v4.6.0 2026-06-13 19:43:33 +00:00
159 changed files with 22853 additions and 10022 deletions
+5 -7
View File
@@ -66,7 +66,7 @@ jobs:
uses: actions/setup-java@v5
with:
distribution: "temurin"
java-version: "17"
java-version: "25"
- name: Setup Go
uses: actions/setup-go@v6
@@ -388,8 +388,6 @@ jobs:
### Installation
**Android**: Enable "Install from unknown sources" and install the APK
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
![arm64](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-arm64.apk?style=flat-square&logo=android&label=arm64&color=3DDC84) ![arm32](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-arm32.apk?style=flat-square&logo=android&label=arm32&color=3DDC84) ![iOS](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa?style=flat-square&logo=apple&label=iOS&color=0078D6)
FOOTER
echo "Release body:"
@@ -399,7 +397,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.get-version.outputs.version }}
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
name: SpotiFLAC-Mobile ${{ needs.get-version.outputs.version }}
body_path: /tmp/release_body.txt
files: ./release/*
draft: false
@@ -565,7 +563,7 @@ jobs:
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
-F document=@"${ARM64_APK}" \
-F caption="SpotiFLAC ${VERSION} - arm64 (recommended)"
-F caption="SpotiFLAC Mobile ${VERSION} - arm64 (recommended)"
fi
# Upload arm32 APK to channel
@@ -574,7 +572,7 @@ jobs:
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
-F document=@"${ARM32_APK}" \
-F caption="SpotiFLAC ${VERSION} - arm32"
-F caption="SpotiFLAC Mobile ${VERSION} - arm32"
fi
# Upload iOS IPA to channel
@@ -584,7 +582,7 @@ jobs:
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
-F document=@"${IOS_IPA}" \
-F caption="SpotiFLAC ${VERSION} - iOS (unsigned, sideload required)"
-F caption="SpotiFLAC Mobile ${VERSION} - iOS (unsigned, sideload required)"
fi
echo "Telegram notification sent!"
+3 -1
View File
@@ -60,7 +60,9 @@ ios/Flutter/Flutter.framework/
ios/Flutter/Flutter.podspec
# Extension folder
extension/
extension/*
extension/v2/
extension/v2/**
# Agent instructions
AGENTS.md
+7 -5
View File
@@ -17,7 +17,7 @@ if (keystorePropertiesFile.exists()) {
android {
namespace = "com.zarz.spotiflac"
compileSdk = flutter.compileSdkVersion
compileSdk = 37
ndkVersion = flutter.ndkVersion
buildFeatures {
@@ -26,13 +26,13 @@ android {
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
sourceCompatibility = JavaVersion.VERSION_25
targetCompatibility = JavaVersion.VERSION_25
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
}
}
@@ -50,7 +50,7 @@ android {
defaultConfig {
applicationId = "com.zarz.spotiflac"
minSdk = flutter.minSdkVersion
targetSdk = 36
targetSdk = 37
versionCode = flutter.versionCode
versionName = flutter.versionName
multiDexEnabled = true
@@ -62,6 +62,8 @@ android {
buildTypes {
getByName("debug") {
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
ndk {
debugSymbolLevel = "FULL"
}
+27
View File
@@ -100,6 +100,12 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="spotiflac" android:host="spotify-callback" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="spotiflac" android:host="session-grant" />
</intent-filter>
</activity>
<!-- Download Service -->
@@ -108,6 +114,23 @@
android:exported="false"
android:foregroundServiceType="dataSync" />
<service
android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback"
android:exported="true"
android:enabled="true">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<!-- flutter_local_notifications receivers -->
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
@@ -124,6 +147,10 @@
android:name="flutterEmbedding"
android:value="2" />
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<!-- FileProvider for APK installation -->
<provider
android:name="androidx.core.content.FileProvider"
@@ -1,6 +1,7 @@
package com.zarz.spotiflac
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
@@ -17,6 +18,7 @@ import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterShellArgs
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
import com.ryanheise.audioservice.AudioServicePlugin
import gobackend.Gobackend
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -36,6 +38,10 @@ import java.security.MessageDigest
import java.util.Locale
class MainActivity: FlutterFragmentActivity() {
override fun provideFlutterEngine(context: Context): FlutterEngine {
return AudioServicePlugin.getFlutterEngine(context)
}
private val CHANNEL = "com.zarz.spotiflac/backend"
private val DOWNLOAD_PROGRESS_STREAM_CHANNEL =
"com.zarz.spotiflac/download_progress_stream"
@@ -47,6 +53,8 @@ class MainActivity: FlutterFragmentActivity() {
private val LARGE_JSON_RESULT_FILE_KEY = "__json_file"
private val LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES = 256 * 1024
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var backendChannel: MethodChannel? = null
private val pendingSessionGrantEvents = mutableListOf<Map<String, Any>>()
private var pendingSafTreeResult: MethodChannel.Result? = null
private val safScanLock = Any()
private val safDirLock = Any()
@@ -148,8 +156,15 @@ class MainActivity: FlutterFragmentActivity() {
"mali-t7",
"powervr sgx",
"powervr ge8320",
"vivante",
"gc1000",
"gc2000",
"gc4000",
"gc5000",
"gc7000",
"gc8000",
"gc820",
"gc880",
)
private val PROBLEMATIC_CHIPSETS = listOf(
@@ -163,6 +178,15 @@ class MainActivity: FlutterFragmentActivity() {
"apq8084",
)
// Sony Walkman / audio players report MANUFACTURER "SonyAudio" (distinct
// from Xperia phones, which use "Sony"). They ship legacy Vivante GPUs
// whose drivers crash in glLinkProgram with Impeller shaders, and the GL
// renderer string is unavailable when shell args are built, so match on
// the manufacturer instead.
private val PROBLEMATIC_MANUFACTURERS = listOf(
"sonyaudio",
)
private val PROBLEMATIC_MODELS = listOf(
"sm-t220",
"sm-t225",
@@ -173,6 +197,14 @@ class MainActivity: FlutterFragmentActivity() {
val board = Build.BOARD.lowercase(Locale.ROOT)
val model = Build.MODEL.lowercase(Locale.ROOT)
val device = Build.DEVICE.lowercase(Locale.ROOT)
val manufacturer = Build.MANUFACTURER.lowercase(Locale.ROOT)
for (problematicManufacturer in PROBLEMATIC_MANUFACTURERS) {
if (manufacturer.contains(problematicManufacturer)) {
android.util.Log.i("SpotiFLAC", "Matched problematic manufacturer: $problematicManufacturer")
return true
}
}
for (problematicModel in PROBLEMATIC_MODELS) {
if (model.contains(problematicModel) || device.contains(problematicModel)) {
@@ -2049,14 +2081,22 @@ class MainActivity: FlutterFragmentActivity() {
}
val host = (uri.host ?: "").lowercase(Locale.US)
val path = (uri.path ?: "").lowercase(Locale.US)
val isSessionGrant = host == "session-grant"
val isCallback =
host == "callback" ||
isSessionGrant ||
host == "callback" ||
host == "spotify-callback" ||
path.contains("callback")
if (!isCallback) {
return
}
val code = uri.getQueryParameter("code")?.trim().orEmpty()
val code = (
if (isSessionGrant) {
uri.getQueryParameter("grant") ?: uri.getQueryParameter("code")
} else {
uri.getQueryParameter("code")
}
)?.trim().orEmpty()
if (code.isEmpty()) {
return
}
@@ -2068,15 +2108,43 @@ class MainActivity: FlutterFragmentActivity() {
intent.data = null
scope.launch(Dispatchers.IO) {
try {
Gobackend.setExtensionAuthCodeByID(extId, code)
val json = Gobackend.invokeExtensionActionJSON(extId, "completeSpotifyLogin")
android.util.Log.i("SpotiFLAC", "Extension OAuth complete for $extId: $json")
val json = if (isSessionGrant) {
Gobackend.setExtensionSessionGrantByID(extId, code)
Gobackend.invokeExtensionActionJSON(extId, "completeGrant")
} else {
Gobackend.setExtensionAuthCodeByID(extId, code)
Gobackend.invokeExtensionActionJSON(extId, "completeSpotifyLogin")
}
android.util.Log.i("SpotiFLAC", "Extension callback complete for $extId: $json")
if (isSessionGrant) {
withContext(Dispatchers.Main) {
notifySessionGrantCompleted(extId, true)
}
}
} catch (e: Exception) {
android.util.Log.w("SpotiFLAC", "Extension OAuth failed: ${e.message}")
android.util.Log.w("SpotiFLAC", "Extension callback failed: ${e.message}")
if (isSessionGrant) {
withContext(Dispatchers.Main) {
notifySessionGrantCompleted(extId, false)
}
}
}
}
}
private fun notifySessionGrantCompleted(extensionId: String, success: Boolean) {
val payload = mapOf(
"extension_id" to extensionId,
"success" to success,
)
val channel = backendChannel
if (channel == null) {
pendingSessionGrantEvents.add(payload)
return
}
channel.invokeMethod("extensionSessionGrantCompleted", payload)
}
override fun onDestroy() {
try {
Gobackend.cleanupExtensions()
@@ -2140,7 +2208,17 @@ class MainActivity: FlutterFragmentActivity() {
},
)
MethodChannel(messenger, CHANNEL).setMethodCallHandler { call, result ->
val channel = MethodChannel(messenger, CHANNEL)
backendChannel = channel
if (pendingSessionGrantEvents.isNotEmpty()) {
val events = pendingSessionGrantEvents.toList()
pendingSessionGrantEvents.clear()
for (event in events) {
channel.invokeMethod("extensionSessionGrantCompleted", event)
}
}
channel.setMethodCallHandler { call, result ->
scope.launch {
try {
when (call.method) {
@@ -2225,6 +2303,13 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
"setAllowPrivateNetwork" -> {
val allowed = call.argument<Boolean>("allowed") ?: false
withContext(Dispatchers.IO) {
Gobackend.setAllowPrivateNetwork(allowed)
}
result.success(null)
}
"checkDuplicate" -> {
val outputDir = call.argument<String>("output_dir") ?: ""
val isrc = call.argument<String>("isrc") ?: ""
@@ -2643,6 +2728,46 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"writeM4AFreeformTags" -> {
val filePath = call.argument<String>("file_path") ?: ""
val metadataJson = call.argument<String>("metadata_json") ?: "{}"
val response = withContext(Dispatchers.IO) {
try {
Gobackend.writeM4AFreeformTags(filePath, metadataJson)
} catch (e: Exception) {
android.util.Log.e("SpotiFLAC", "writeM4AFreeformTags failed: ${e.message}", e)
"""{"error":"${e.message?.replace("\"", "'")}"}"""
}
}
result.success(response)
}
"ensureAC4Config" -> {
val filePath = call.argument<String>("file_path") ?: ""
val sourcePath = call.argument<String>("source_path") ?: ""
val response = withContext(Dispatchers.IO) {
try {
Gobackend.ensureAC4Config(filePath, sourcePath)
} catch (e: Exception) {
android.util.Log.e("SpotiFLAC", "ensureAC4Config failed: ${e.message}", e)
"""{"error":"${e.message?.replace("\"", "'")}"}"""
}
}
result.success(response)
}
"writeAC4Metadata" -> {
val filePath = call.argument<String>("file_path") ?: ""
val metadataJson = call.argument<String>("metadata_json") ?: "{}"
val coverPath = call.argument<String>("cover_path") ?: ""
val response = withContext(Dispatchers.IO) {
try {
Gobackend.writeAC4Metadata(filePath, metadataJson, coverPath)
} catch (e: Exception) {
android.util.Log.e("SpotiFLAC", "writeAC4Metadata failed: ${e.message}", e)
"""{"error":"${e.message?.replace("\"", "'")}"}"""
}
}
result.success(response)
}
"writeTempToSaf" -> {
val tempPath = call.argument<String>("temp_path") ?: ""
val safUri = call.argument<String>("saf_uri") ?: ""
@@ -334,7 +334,6 @@ object NativeDownloadFinalizer {
}
private fun currentStatus(@Suppress("UNUSED_PARAMETER") status: String) {
// Kept as a narrow hook for future richer progress snapshots.
}
private fun cleanupFailedFinalizationOutput(
@@ -422,16 +421,19 @@ object NativeDownloadFinalizer {
try {
for (candidate in decryptionKeyCandidates(key)) {
checkCancelled(shouldCancel)
val attempts = mutableListOf<Pair<String, Boolean>>()
attempts.add(outputPath to (preferredExt == ".flac"))
val attempts = mutableListOf<Triple<String, Boolean, Boolean>>()
attempts.add(Triple(outputPath, preferredExt == ".flac", false))
if (preferredExt == ".flac") {
attempts.add(buildOutputPath(localInput, ".m4a") to false)
attempts.add(Triple(buildOutputPath(localInput, ".m4a"), false, false))
}
if (preferredExt == ".flac" || preferredExt == ".m4a") {
attempts.add(buildOutputPath(localInput, ".mp4") to false)
attempts.add(Triple(buildOutputPath(localInput, ".mp4"), false, false))
}
// MOV muxer fallback for codecs the MP4 muxer rejects (e.g. AC-4):
// keeps the .mp4 filename but stores the codec params.
attempts.add(Triple(buildOutputPath(localInput, ".mp4"), false, true))
for ((candidateOutput, mapAudioOnly) in attempts) {
for ((candidateOutput, mapAudioOnly, forceMov) in attempts) {
try {
val audioMap = if (mapAudioOnly) "-map 0:a " else ""
// Force the flac muxer when the target extension is
@@ -439,7 +441,11 @@ object NativeDownloadFinalizer {
// stream layout, producing FLAC-in-MP4 under a .flac
// filename which downstream native FLAC tag writers
// cannot read.
val muxerOverride = if (candidateOutput.lowercase(Locale.ROOT).endsWith(".flac")) "-f flac " else ""
val muxerOverride = when {
forceMov -> "-f mov "
candidateOutput.lowercase(Locale.ROOT).endsWith(".flac") -> "-f flac "
else -> ""
}
val command = "-v error -decryption_key ${q(candidate)} -f $inputFormat -i ${q(localInput)} ${audioMap}-c copy ${muxerOverride}${q(candidateOutput)} -y"
val result = runFFmpeg(command, shouldCancel)
lastOutput = result.second
@@ -1159,18 +1165,28 @@ object NativeDownloadFinalizer {
val mp3Flags = if (format == "mp3") "-id3v2_version 3 " else ""
var adoptedTemp = false
var originalDeleted = false
try {
val command = if (isM4a && coverFile != null) {
fun buildEmbedCommand(forceMov: Boolean): String {
return if (isM4a && coverFile != null) {
"-v error -hide_banner -i ${q(path)} -i ${q(coverFile.absolutePath)} " +
"-map 0:a -c:a copy -map_metadata 0 -map 1:v -c:v copy " +
"-disposition:v:0 attached_pic " +
"-metadata:s:v ${q("title=Album cover")} " +
"-metadata:s:v ${q("comment=Cover (front)")} " +
"$metadataArgs -f mp4 ${q(temp.absolutePath)} -y"
"$metadataArgs -f ${if (forceMov) "mov" else "mp4"} ${q(temp.absolutePath)} -y"
} else {
"-v error -hide_banner -i ${q(path)} -map 0 -c copy -map_metadata 0 $metadataArgs $mp3Flags${q(temp.absolutePath)} -y"
val movFlag = if (forceMov) "-f mov " else ""
"-v error -hide_banner -i ${q(path)} -map 0 -c copy -map_metadata 0 $metadataArgs $mp3Flags$movFlag${q(temp.absolutePath)} -y"
}
}
try {
var result = runFFmpeg(buildEmbedCommand(false))
// MOV muxer fallback for codecs the MP4 muxer rejects (e.g. AC-4).
if (!result.first && (isM4a || ext.equals(".mp4", ignoreCase = true))) {
temp.delete()
result = runFFmpeg(buildEmbedCommand(true))
}
val result = runFFmpeg(command)
if (result.first && temp.exists()) {
if (inputFile.delete()) {
originalDeleted = true
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<uses name="media" />
</automotiveApp>
+3 -3
View File
@@ -11,8 +11,8 @@ subprojects {
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
sourceCompatibility = JavaVersion.VERSION_25
targetCompatibility = JavaVersion.VERSION_25
}
// Enable multidex for all subprojects
@@ -27,7 +27,7 @@ subprojects {
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
}
}
}
+4 -4
View File
@@ -7,12 +7,12 @@
"name": "SpotiFLAC Mobile",
"bundleIdentifier": "com.zarzet.spotiflac",
"developerName": "zarzet",
"version": "4.5.6",
"versionDate": "2026-06-01",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.5.6/SpotiFLAC-v4.5.6-ios-unsigned.ipa",
"version": "4.7.1",
"versionDate": "2026-07-01",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.7.1/SpotiFLAC-v4.7.1-ios-unsigned.ipa",
"localizedDescription": "SpotiFLAC Mobile is written in Flutter. Download tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
"size": 34059797
"size": 37455821
}
]
}
+322
View File
@@ -0,0 +1,322 @@
package gobackend
import (
"encoding/binary"
"fmt"
"os"
)
// mp4Box is a minimal ISO-BMFF / QuickTime box view over an in-memory buffer.
type mp4Box struct {
offset int64
size int64
hdr int64
typ string
}
func (b mp4Box) body() int64 { return b.offset + b.hdr }
func (b mp4Box) end() int64 { return b.offset + b.size }
func readMP4Box(data []byte, pos int64) (mp4Box, bool) {
n := int64(len(data))
if pos < 0 || pos+8 > n {
return mp4Box{}, false
}
size := int64(binary.BigEndian.Uint32(data[pos : pos+4]))
typ := string(data[pos+4 : pos+8])
hdr := int64(8)
if size == 1 {
if pos+16 > n {
return mp4Box{}, false
}
size = int64(binary.BigEndian.Uint64(data[pos+8 : pos+16]))
hdr = 16
} else if size == 0 {
size = n - pos
}
if size < hdr || pos+size > n {
return mp4Box{}, false
}
return mp4Box{offset: pos, size: size, hdr: hdr, typ: typ}, true
}
func findChildMP4(data []byte, start, end int64, typ string) (mp4Box, bool) {
pos := start
for pos+8 <= end {
b, ok := readMP4Box(data, pos)
if !ok {
return mp4Box{}, false
}
if b.typ == typ {
return b, true
}
pos = b.end()
}
return mp4Box{}, false
}
func eachChildMP4(data []byte, start, end int64, typ string, fn func(mp4Box) bool) {
pos := start
for pos+8 <= end {
b, ok := readMP4Box(data, pos)
if !ok {
return
}
if b.typ == typ && !fn(b) {
return
}
pos = b.end()
}
}
// findBoxBySignature scans [start,end) for a box of the given type, matching the
// 4-byte type tag and validating the preceding size field. Used to locate dac4
// which may be nested inside an encrypted (enca) sample entry.
func findBoxBySignature(data []byte, start, end int64, typ string) (mp4Box, bool) {
if len(typ) != 4 {
return mp4Box{}, false
}
for i := start; i+8 <= end; i++ {
if data[i+4] == typ[0] && data[i+5] == typ[1] && data[i+6] == typ[2] && data[i+7] == typ[3] {
if b, ok := readMP4Box(data, i); ok && b.typ == typ {
return b, true
}
}
}
return mp4Box{}, false
}
// audioSampleEntryHeaderLen returns the byte length of the fixed audio sample
// entry header (from the box body start) before child boxes begin. ok is false
// for malformed/truncated entries whose declared header is not fully present.
func audioSampleEntryHeaderLen(data []byte, entry mp4Box) (hdrLen int64, ok bool) {
// 6 bytes reserved + 2 bytes data_reference_index, then the audio fields.
base := entry.body()
if base+10 > entry.end() {
return 0, false
}
version := binary.BigEndian.Uint16(data[base+8 : base+10])
hdrLen = 8 + 20
switch version {
case 1:
hdrLen += 16
case 2:
hdrLen += 36
}
if base+hdrLen > entry.end() {
return 0, false
}
return hdrLen, true
}
type ac4Location struct {
chain []mp4Box // moov, trak, mdia, minf, stbl, stsd (ancestors to grow)
entry mp4Box // the ac-4 sample entry
}
func locateAC4Entry(data []byte) (ac4Location, bool) {
moov, ok := findChildMP4(data, 0, int64(len(data)), "moov")
if !ok {
return ac4Location{}, false
}
var found ac4Location
var ok2 bool
eachChildMP4(data, moov.body(), moov.end(), "trak", func(trak mp4Box) bool {
mdia, ok := findChildMP4(data, trak.body(), trak.end(), "mdia")
if !ok {
return true
}
minf, ok := findChildMP4(data, mdia.body(), mdia.end(), "minf")
if !ok {
return true
}
stbl, ok := findChildMP4(data, minf.body(), minf.end(), "stbl")
if !ok {
return true
}
stsd, ok := findChildMP4(data, stbl.body(), stbl.end(), "stsd")
if !ok {
return true
}
entry, ok := findChildMP4(data, stsd.body()+8, stsd.end(), "ac-4")
if !ok {
return true
}
found = ac4Location{chain: []mp4Box{moov, trak, mdia, minf, stbl, stsd}, entry: entry}
ok2 = true
return false
})
return found, ok2
}
func growBoxSize(data []byte, b mp4Box, delta int64) {
if b.hdr == 16 {
binary.BigEndian.PutUint64(data[b.offset+8:b.offset+16], uint64(b.size+delta))
} else {
binary.BigEndian.PutUint32(data[b.offset:b.offset+4], uint32(b.size+delta))
}
}
// shiftChunkOffsets adds delta to every stco/co64 entry that references a file
// offset at or beyond insertPos, keeping sample pointers valid after bytes are
// inserted into moov.
func shiftChunkOffsets(data []byte, moov mp4Box, insertPos, delta int64) {
eachChildMP4(data, moov.body(), moov.end(), "trak", func(trak mp4Box) bool {
mdia, ok := findChildMP4(data, trak.body(), trak.end(), "mdia")
if !ok {
return true
}
minf, ok := findChildMP4(data, mdia.body(), mdia.end(), "minf")
if !ok {
return true
}
stbl, ok := findChildMP4(data, minf.body(), minf.end(), "stbl")
if !ok {
return true
}
if stco, ok := findChildMP4(data, stbl.body(), stbl.end(), "stco"); ok {
base := stco.body() + 4
if base+4 <= stco.end() {
count := int64(binary.BigEndian.Uint32(data[base : base+4]))
p := base + 4
for i := int64(0); i < count && p+4 <= stco.end(); i++ {
v := int64(binary.BigEndian.Uint32(data[p : p+4]))
if v >= insertPos {
binary.BigEndian.PutUint32(data[p:p+4], uint32(v+delta))
}
p += 4
}
}
}
if co64, ok := findChildMP4(data, stbl.body(), stbl.end(), "co64"); ok {
base := co64.body() + 4
if base+4 <= co64.end() {
count := int64(binary.BigEndian.Uint32(data[base : base+4]))
p := base + 4
for i := int64(0); i < count && p+8 <= co64.end(); i++ {
v := int64(binary.BigEndian.Uint64(data[p : p+8]))
if v >= insertPos {
binary.BigEndian.PutUint64(data[p:p+8], uint64(v+delta))
}
p += 8
}
}
}
return true
})
}
// normalizeQuickTimeAudioToMP4 rewrites a QuickTime-flavored file (FFmpeg mov
// muxer output: ftyp brand "qt " and a version-1 sound sample entry) into a
// standard ISO MP4: an isom/mp42 brand and a plain version-0 AudioSampleEntry.
// Windows Media Foundation (and other strict parsers) reject the QuickTime
// flavor for AC-4 even when dac4 is present.
func normalizeQuickTimeAudioToMP4(data []byte) []byte {
if ftyp, ok := findChildMP4(data, 0, int64(len(data)), "ftyp"); ok {
if ftyp.body()+4 <= int64(len(data)) {
copy(data[ftyp.body():ftyp.body()+4], []byte("mp42"))
}
for p := ftyp.body() + 8; p+4 <= ftyp.end(); p += 4 {
if string(data[p:p+4]) == "qt " {
copy(data[p:p+4], []byte("isom"))
}
}
}
loc, ok := locateAC4Entry(data)
if !ok {
return data
}
entry := loc.entry
verPos := entry.body() + 8
if verPos+2 > entry.end() {
return data
}
if binary.BigEndian.Uint16(data[verPos:verPos+2]) != 1 {
return data // already v0 (or v2, left untouched)
}
// The v1 QuickTime sound extension is the 16 bytes following the 20-byte v0
// audio fields (samplesPerPacket, bytesPerPacket, bytesPerFrame, bytesPerSample).
extStart := entry.body() + 8 + 20
extEnd := extStart + 16
if extEnd > entry.end() {
return data
}
delta := int64(-16)
binary.BigEndian.PutUint16(data[verPos:verPos+2], 0)
shiftChunkOffsets(data, loc.chain[0], extStart, delta)
for _, b := range loc.chain {
growBoxSize(data, b, delta)
}
growBoxSize(data, entry, delta)
out := make([]byte, 0, len(data)-16)
out = append(out, data[:extStart]...)
out = append(out, data[extEnd:]...)
return out
}
// EnsureAC4ConfigBox makes a decrypted AC-4 MP4 standards-compliant and
// playable: it normalizes FFmpeg's QuickTime-flavored mov output to an ISO MP4
// and injects the AC-4 configuration box (dac4) into the ac-4 sample entry. The
// dac4 box is copied verbatim from sourcePath (the original MP4, whose plaintext
// moov still carries it). No-op when the file has no AC-4 track.
func EnsureAC4ConfigBox(decryptedPath, sourcePath string) error {
dst, err := os.ReadFile(decryptedPath)
if err != nil {
return err
}
if _, ok := locateAC4Entry(dst); !ok {
return nil // not an AC-4 file; nothing to do
}
dst = normalizeQuickTimeAudioToMP4(dst)
loc, ok := locateAC4Entry(dst)
if !ok {
return nil
}
hdrLen, ok := audioSampleEntryHeaderLen(dst, loc.entry)
if !ok {
return fmt.Errorf("malformed ac-4 sample entry")
}
childStart := loc.entry.body() + hdrLen
if _, has := findChildMP4(dst, childStart, loc.entry.end(), "dac4"); has {
// Already has dac4; still persist any normalization changes.
return os.WriteFile(decryptedPath, dst, 0o644)
}
src, err := os.ReadFile(sourcePath)
if err != nil {
return err
}
srcMoov, ok := findChildMP4(src, 0, int64(len(src)), "moov")
if !ok {
return fmt.Errorf("source has no moov")
}
dac4Box, ok := findBoxBySignature(src, srcMoov.body(), srcMoov.end(), "dac4")
if !ok {
return fmt.Errorf("dac4 not found in source")
}
dac4 := append([]byte{}, src[dac4Box.offset:dac4Box.end()]...)
insertPos := childStart
delta := int64(len(dac4))
shiftChunkOffsets(dst, loc.chain[0], insertPos, delta)
for _, b := range loc.chain {
growBoxSize(dst, b, delta)
}
growBoxSize(dst, loc.entry, delta)
out := make([]byte, 0, len(dst)+len(dac4))
out = append(out, dst[:insertPos]...)
out = append(out, dac4...)
out = append(out, dst[insertPos:]...)
return os.WriteFile(decryptedPath, out, 0o644)
}
+76
View File
@@ -0,0 +1,76 @@
package gobackend
import (
"bytes"
"encoding/binary"
"os"
"path/filepath"
"testing"
)
func mp4TestBox(typ string, body []byte) []byte {
out := make([]byte, 8+len(body))
binary.BigEndian.PutUint32(out[:4], uint32(len(out)))
copy(out[4:8], typ)
copy(out[8:], body)
return out
}
func mp4TestAC4Tree(entryBody []byte) []byte {
entry := mp4TestBox("ac-4", entryBody)
stsdBody := append([]byte{
0, 0, 0, 0, // version/flags
0, 0, 0, 1, // entry_count
}, entry...)
stsd := mp4TestBox("stsd", stsdBody)
stbl := mp4TestBox("stbl", stsd)
minf := mp4TestBox("minf", stbl)
mdia := mp4TestBox("mdia", minf)
trak := mp4TestBox("trak", mdia)
moov := mp4TestBox("moov", trak)
return moov
}
func shortAC4SampleEntryBody(version uint16) []byte {
body := make([]byte, 10)
binary.BigEndian.PutUint16(body[8:10], version)
return body
}
func TestNormalizeQuickTimeAudioToMP4IgnoresTruncatedAC4Entry(t *testing.T) {
input := mp4TestAC4Tree(shortAC4SampleEntryBody(1))
defer func() {
if r := recover(); r != nil {
t.Fatalf("normalizeQuickTimeAudioToMP4 panicked: %v", r)
}
}()
got := normalizeQuickTimeAudioToMP4(append([]byte{}, input...))
if !bytes.Equal(got, input) {
t.Fatal("truncated QuickTime AC-4 entry should be left unchanged")
}
}
func TestEnsureAC4ConfigBoxRejectsTruncatedAC4Entry(t *testing.T) {
dir := t.TempDir()
decryptedPath := filepath.Join(dir, "decrypted.mp4")
sourcePath := filepath.Join(dir, "source.mp4")
if err := os.WriteFile(decryptedPath, mp4TestAC4Tree(shortAC4SampleEntryBody(2)), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(sourcePath, mp4TestBox("moov", mp4TestBox("dac4", []byte{1, 2, 3, 4})), 0o644); err != nil {
t.Fatal(err)
}
defer func() {
if r := recover(); r != nil {
t.Fatalf("EnsureAC4ConfigBox panicked: %v", r)
}
}()
if err := EnsureAC4ConfigBox(decryptedPath, sourcePath); err == nil {
t.Fatal("expected malformed AC-4 sample entry error")
}
}
+182
View File
@@ -0,0 +1,182 @@
package gobackend
import (
"encoding/binary"
"encoding/json"
"os"
"strconv"
"strings"
)
// ac4Metadata mirrors the tag fields the app embeds for other formats. Numeric
// fields are strings because they arrive as a JSON-encoded map of strings.
type ac4Metadata struct {
Title string `json:"title"`
Artist string `json:"artist"`
Album string `json:"album"`
AlbumArtist string `json:"albumArtist"`
Date string `json:"date"`
Genre string `json:"genre"`
Composer string `json:"composer"`
TrackNumber string `json:"trackNumber"`
TotalTracks string `json:"totalTracks"`
DiscNumber string `json:"discNumber"`
TotalDiscs string `json:"totalDiscs"`
ISRC string `json:"isrc"`
Label string `json:"label"`
Copyright string `json:"copyright"`
Lyrics string `json:"lyrics"`
}
func atoiSafe(s string) int {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil {
return 0
}
return n
}
func itunesTextTag(atomType, value string) []byte {
data := make([]byte, 8+len(value))
binary.BigEndian.PutUint32(data[0:4], 1) // well-known type 1 = UTF-8
copy(data[8:], []byte(value))
return buildM4AAtom(atomType, buildM4AAtom("data", data))
}
func itunesNumberPairTag(atomType string, number, total int) []byte {
payload := make([]byte, 8)
binary.BigEndian.PutUint16(payload[2:4], uint16(number))
binary.BigEndian.PutUint16(payload[4:6], uint16(total))
data := make([]byte, 8+len(payload))
binary.BigEndian.PutUint32(data[0:4], 0) // type 0 = implicit/binary
copy(data[8:], payload)
return buildM4AAtom(atomType, buildM4AAtom("data", data))
}
func itunesCoverTag(image []byte) []byte {
typeCode := uint32(13) // JPEG
if len(image) >= 8 &&
image[0] == 0x89 && image[1] == 0x50 && image[2] == 0x4E && image[3] == 0x47 {
typeCode = 14 // PNG
}
data := make([]byte, 8+len(image))
binary.BigEndian.PutUint32(data[0:4], typeCode)
copy(data[8:], image)
return buildM4AAtom("covr", buildM4AAtom("data", data))
}
func itunesMetadataHandler() []byte {
payload := make([]byte, 0, 25)
payload = append(payload, 0, 0, 0, 0) // version + flags
payload = append(payload, 0, 0, 0, 0) // pre_defined
payload = append(payload, []byte("mdir")...) // handler type
payload = append(payload, []byte("appl")...) // reserved[0]
payload = append(payload, 0, 0, 0, 0, 0, 0, 0, 0) // reserved[1..2]
payload = append(payload, 0) // empty name
return buildM4AAtom("hdlr", payload)
}
// buildITunesUdta assembles a fresh udta>meta>(hdlr+ilst) box from metadata.
func buildITunesUdta(md ac4Metadata, cover []byte) []byte {
ilst := make([]byte, 0, 256)
add := func(atomType, value string) {
if strings.TrimSpace(value) != "" {
ilst = append(ilst, itunesTextTag(atomType, value)...)
}
}
add("\xa9nam", md.Title)
add("\xa9ART", md.Artist)
add("\xa9alb", md.Album)
add("aART", md.AlbumArtist)
add("\xa9day", md.Date)
add("\xa9gen", md.Genre)
add("\xa9wrt", md.Composer)
if tn := atoiSafe(md.TrackNumber); tn > 0 {
ilst = append(ilst, itunesNumberPairTag("trkn", tn, atoiSafe(md.TotalTracks))...)
}
if dn := atoiSafe(md.DiscNumber); dn > 0 {
ilst = append(ilst, itunesNumberPairTag("disk", dn, atoiSafe(md.TotalDiscs))...)
}
if strings.TrimSpace(md.ISRC) != "" {
ilst = append(ilst, buildM4AFreeformAtom("ISRC", strings.TrimSpace(md.ISRC))...)
}
if strings.TrimSpace(md.Label) != "" {
ilst = append(ilst, buildM4AFreeformAtom("LABEL", strings.TrimSpace(md.Label))...)
}
if strings.TrimSpace(md.Copyright) != "" {
add("cprt", md.Copyright)
}
if strings.TrimSpace(md.Lyrics) != "" {
add("\xa9lyr", md.Lyrics)
}
if len(cover) > 0 {
ilst = append(ilst, itunesCoverTag(cover)...)
}
ilstBox := buildM4AAtom("ilst", ilst)
metaPayload := append([]byte{0, 0, 0, 0}, itunesMetadataHandler()...)
metaPayload = append(metaPayload, ilstBox...)
meta := buildM4AAtom("meta", metaPayload)
return buildM4AAtom("udta", meta)
}
// writeMP4iTunesMetadata replaces (or inserts) a udta>meta>ilst metadata box in
// the moov of an MP4 buffer and returns the rewritten bytes.
func writeMP4iTunesMetadata(data []byte, md ac4Metadata, cover []byte) []byte {
moov, ok := findChildMP4(data, 0, int64(len(data)), "moov")
if !ok {
return data
}
newUdta := buildITunesUdta(md, cover)
if udta, ok := findChildMP4(data, moov.body(), moov.end(), "udta"); ok {
delta := int64(len(newUdta)) - udta.size
shiftChunkOffsets(data, moov, udta.offset, delta)
growBoxSize(data, moov, delta)
out := make([]byte, 0, len(data)+len(newUdta))
out = append(out, data[:udta.offset]...)
out = append(out, newUdta...)
out = append(out, data[udta.end():]...)
return out
}
delta := int64(len(newUdta))
insertPos := moov.end()
shiftChunkOffsets(data, moov, insertPos, delta)
growBoxSize(data, moov, delta)
out := make([]byte, 0, len(data)+len(newUdta))
out = append(out, data[:insertPos]...)
out = append(out, newUdta...)
out = append(out, data[insertPos:]...)
return out
}
// WriteAC4MetadataIfApplicable writes iTunes metadata into an AC-4 MP4. Returns
// true when the file was an AC-4 track and metadata was written; false when the
// file is not AC-4 (the caller should fall back to its normal metadata path).
func WriteAC4MetadataIfApplicable(decryptedPath, metadataJSON, coverPath string) (bool, error) {
data, err := os.ReadFile(decryptedPath)
if err != nil {
return false, err
}
if _, ok := locateAC4Entry(data); !ok {
return false, nil
}
var md ac4Metadata
if strings.TrimSpace(metadataJSON) != "" {
_ = json.Unmarshal([]byte(metadataJSON), &md)
}
var cover []byte
if strings.TrimSpace(coverPath) != "" {
if b, err := os.ReadFile(coverPath); err == nil {
cover = b
}
}
out := writeMP4iTunesMetadata(data, md, cover)
if err := os.WriteFile(decryptedPath, out, 0o644); err != nil {
return false, err
}
return true, nil
}
-1
View File
@@ -314,7 +314,6 @@ func marshalAPETag(tag *APETag) ([]byte, error) {
footerFlags := uint32(1 << 31)
footer := buildAPEHeaderFooter(version, tagSize, itemCount, footerFlags)
// Final layout: header + items + footer
result := make([]byte, 0, len(header)+len(itemsData)+len(footer))
result = append(result, header...)
result = append(result, itemsData...)
+23 -12
View File
@@ -3,6 +3,7 @@ package gobackend
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -783,7 +784,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
// not include this field. Albums whose track count is already known (non-zero)
// are skipped.
func (c *DeezerClient) fetchAlbumTrackCounts(ctx context.Context, albums []ArtistAlbumMetadata) {
// Find albums that need track counts
type indexedID struct {
idx int
albumID string
@@ -1267,16 +1267,7 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
}
lastErr = err
errStr := err.Error()
isRetryable := strings.Contains(errStr, "timeout") ||
strings.Contains(errStr, "connection reset") ||
strings.Contains(errStr, "connection refused") ||
strings.Contains(errStr, "EOF") ||
strings.Contains(errStr, "status 5") ||
strings.Contains(errStr, "status 429")
if !isRetryable {
if !isDeezerRetryableError(err) {
return err
}
@@ -1286,6 +1277,26 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
return fmt.Errorf("all %d attempts failed: %w", deezerMaxRetries+1, lastErr)
}
type deezerAPIError struct {
StatusCode int
Body string
}
func (e *deezerAPIError) Error() string {
return fmt.Sprintf("deezer API returned status %d: %s", e.StatusCode, e.Body)
}
func isDeezerRetryableError(err error) bool {
if isConnectivityFailure(err) || errors.Is(err, io.ErrUnexpectedEOF) {
return true
}
var apiErr *deezerAPIError
if errors.As(err, &apiErr) {
return apiErr.StatusCode == http.StatusTooManyRequests || apiErr.StatusCode >= http.StatusInternalServerError
}
return false
}
func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
@@ -1306,7 +1317,7 @@ func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst inter
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("deezer API returned status %d: %s", resp.StatusCode, string(body))
return &deezerAPIError{StatusCode: resp.StatusCode, Body: string(body)}
}
return json.Unmarshal(body, dst)
+177 -16
View File
@@ -283,6 +283,7 @@ type DownloadRequest struct {
PostProcessingEnabled bool `json:"post_processing_enabled,omitempty"`
TidalHighFormat string `json:"tidal_high_format,omitempty"`
TrackNumber int `json:"track_number"`
PlaylistPosition int `json:"playlist_position,omitempty"`
DiscNumber int `json:"disc_number"`
TotalTracks int `json:"total_tracks"`
TotalDiscs int `json:"total_discs,omitempty"`
@@ -310,6 +311,7 @@ type DownloadResponse struct {
FilePath string `json:"file_path,omitempty"`
Error string `json:"error,omitempty"`
ErrorType string `json:"error_type,omitempty"`
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
@@ -1378,7 +1380,6 @@ func ReadFileMetadata(filePath string) (string, error) {
} else if isApe || isWv || isMpc {
result["format"] = strings.TrimPrefix(filepath.Ext(filePath), ".")
result["audio_codec"] = result["format"]
// APE, WavPack, Musepack: read APEv2 tags
apeTag, apeErr := ReadAPETags(filePath)
if apeErr == nil && apeTag != nil {
meta := APETagToAudioMetadata(apeTag)
@@ -1510,6 +1511,48 @@ func ScanCueSheetForLibraryWithCoverCacheKey(cuePath, audioDir, virtualPathPrefi
return string(jsonBytes), nil
}
// WriteM4AFreeformTags writes ISRC and label into an M4A/MP4 file as iTunes
// freeform atoms. FFmpeg's MP4 muxer ignores these keys, so they must be
// written natively after the FFmpeg metadata pass for the values to persist.
// Only keys present in the JSON are touched; an empty value clears the tag.
func WriteM4AFreeformTags(filePath, metadataJSON string) (string, error) {
var fields map[string]string
if err := json.Unmarshal([]byte(metadataJSON), &fields); err != nil {
return "", fmt.Errorf("invalid metadata JSON: %w", err)
}
if err := EditM4AFreeformText(filePath, fields); err != nil {
return "", fmt.Errorf("failed to write M4A freeform tags: %w", err)
}
resp := map[string]any{"success": true, "method": "native_m4a_freeform"}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
// EnsureAC4Config normalizes a decrypted AC-4 file to a standards-compliant ISO
// MP4 and injects the dac4 configuration box copied from sourcePath. No-op when
// the file is not AC-4.
func EnsureAC4Config(filePath, sourcePath string) (string, error) {
if err := EnsureAC4ConfigBox(filePath, sourcePath); err != nil {
return "", fmt.Errorf("failed to finalize AC-4 container: %w", err)
}
return `{"success":true}`, nil
}
// WriteAC4Metadata writes iTunes-style metadata into an AC-4 MP4. The JSON
// "handled" field reports whether the file was AC-4 (true) so the caller can
// skip the FFmpeg metadata pass that would re-wrap it as QuickTime.
func WriteAC4Metadata(filePath, metadataJSON, coverPath string) (string, error) {
handled, err := WriteAC4MetadataIfApplicable(filePath, metadataJSON, coverPath)
if err != nil {
return "", fmt.Errorf("failed to write AC-4 metadata: %w", err)
}
resp := map[string]any{"success": true, "handled": handled}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
// EditFileMetadata writes audio file tags: FLAC via native Go library, MP3/Opus returns map for Dart/FFmpeg.
func EditFileMetadata(filePath, metadataJSON string) (string, error) {
var fields map[string]string
@@ -1569,7 +1612,6 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
return string(jsonBytes), nil
}
// APE/WV/MPC: write APEv2 tags natively
if isApeFile {
trackNum := 0
totalTracks := 0
@@ -2035,6 +2077,7 @@ func normalizeExtensionTrackMetadataMap(
"duration_ms": track.DurationMS,
"images": coverURL,
"cover_url": coverURL,
"preview_url": track.PreviewURL,
"release_date": track.ReleaseDate,
"track_number": trackNum,
"total_tracks": track.TotalTracks,
@@ -2063,9 +2106,12 @@ func normalizeExtensionAlbumInfoMap(album *ExtAlbumMetadata) map[string]interfac
"artist_id": album.ArtistID,
"images": album.CoverURL,
"cover_url": album.CoverURL,
"header_image": album.HeaderImage,
"header_video": album.HeaderVideo,
"release_date": album.ReleaseDate,
"total_tracks": album.TotalTracks,
"album_type": album.AlbumType,
"audio_traits": album.AudioTraits,
"provider_id": album.ProviderID,
}
}
@@ -2150,11 +2196,13 @@ func getExtensionProviderMetadataResponse(
return map[string]interface{}{
"playlist_info": map[string]interface{}{
"id": playlist.ID,
"name": playlist.Name,
"images": playlist.CoverURL,
"cover_url": playlist.CoverURL,
"provider_id": playlist.ProviderID,
"id": playlist.ID,
"name": playlist.Name,
"images": playlist.CoverURL,
"cover_url": playlist.CoverURL,
"header_image": playlist.HeaderImage,
"header_video": playlist.HeaderVideo,
"provider_id": playlist.ProviderID,
"owner": map[string]interface{}{
"name": playlist.Artists,
"images": playlist.CoverURL,
@@ -2183,6 +2231,7 @@ func getExtensionProviderMetadataResponse(
"images": firstNonEmptyTrimmed(artist.HeaderImage, artist.ImageURL),
"cover_url": artist.ImageURL,
"header_image": artist.HeaderImage,
"header_video": artist.HeaderVideo,
"provider_id": artist.ProviderID,
},
"albums": albums,
@@ -2232,6 +2281,16 @@ func GetProviderMetadataJSON(providerID, resourceType, resourceID string) (strin
switch strings.ToLower(trimmedProviderID) {
case "deezer":
if response, ok, err := getEnabledExtensionProviderMetadataResponse(trimmedProviderID, resourceType, resourceID); ok || err != nil {
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(response)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
return GetDeezerMetadata(resourceType, resourceID)
default:
response, err := getExtensionProviderMetadataResponse(trimmedProviderID, resourceType, resourceID)
@@ -2247,6 +2306,19 @@ func GetProviderMetadataJSON(providerID, resourceType, resourceID string) (strin
}
}
func getEnabledExtensionProviderMetadataResponse(providerID, resourceType, resourceID string) (map[string]interface{}, bool, error) {
manager := getExtensionManager()
ext, err := manager.GetExtension(providerID)
if err != nil || ext == nil || !ext.Enabled || !ext.Manifest.IsMetadataProvider() {
return nil, false, nil
}
response, err := getExtensionProviderMetadataResponse(providerID, resourceType, resourceID)
if err != nil {
return nil, true, err
}
return response, true, nil
}
func GetDeezerExtendedMetadata(trackID string) (string, error) {
if trackID == "" {
return "", fmt.Errorf("empty track ID")
@@ -2470,8 +2542,19 @@ func classifyDownloadErrorType(msg string) string {
return "isp_blocked"
} else if strings.Contains(lowerMsg, "cancel") {
return "cancelled"
} else if strings.Contains(lowerMsg, "verify_required") ||
strings.Contains(lowerMsg, "verification_required") ||
strings.Contains(lowerMsg, "verification required") ||
strings.Contains(lowerMsg, "needs verification") ||
strings.Contains(lowerMsg, "session is not authenticated") ||
strings.Contains(lowerMsg, "signed session is not authenticated") ||
strings.Contains(lowerMsg, "unauthorized") ||
strings.Contains(lowerMsg, "precondition required") ||
messageHasHTTPStatusCode(lowerMsg, "401") ||
messageHasHTTPStatusCode(lowerMsg, "428") {
return "verification_required"
} else if strings.Contains(lowerMsg, "rate limit") ||
strings.Contains(lowerMsg, "429") ||
messageHasHTTPStatusCode(lowerMsg, "429") ||
strings.Contains(lowerMsg, "too many requests") {
return "rate_limit"
} else if strings.Contains(lowerMsg, "permission") ||
@@ -2496,6 +2579,15 @@ func classifyDownloadErrorType(msg string) string {
return "unknown"
}
func messageHasHTTPStatusCode(lowerMsg, code string) bool {
return strings.Contains(lowerMsg, "http "+code) ||
strings.Contains(lowerMsg, "http status "+code) ||
strings.Contains(lowerMsg, "status "+code) ||
strings.Contains(lowerMsg, code+" for ") ||
strings.Contains(lowerMsg, code+":") ||
strings.Contains(lowerMsg, code+";")
}
func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error {
if coverURL == "" {
return fmt.Errorf("no cover URL provided")
@@ -2648,8 +2740,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
GoLog("[ReEnrich] Starting re-enrichment for: %s\n", req.FilePath)
// When search_online is true, search for metadata from internet using the
// configured metadata-provider priority.
if req.SearchOnline {
found := false
@@ -2818,7 +2908,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
}
if isFlac {
// Native Go FLAC metadata embedding.
// Only populate Metadata fields for selected update groups; empty/zero
// values cause EmbedMetadata's setComment() to skip those tags,
// preserving whatever is already in the file.
@@ -3198,7 +3287,7 @@ func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
}
func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
req := GetPendingAuthRequest(extensionID)
req := ensureExtensionPendingAuthRequest(extensionID)
if req == nil {
return "", nil
}
@@ -3217,10 +3306,48 @@ func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
return string(jsonBytes), nil
}
func ensureExtensionPendingAuthRequest(extensionID string) *PendingAuthRequest {
extensionID = strings.TrimSpace(extensionID)
if extensionID == "" {
return nil
}
if req := GetPendingAuthRequest(extensionID); req != nil {
return req
}
manager := getExtensionManager()
ext, err := manager.GetExtension(extensionID)
if err != nil || ext == nil || !ext.Enabled || ext.Manifest == nil || ext.Manifest.SignedSession == nil {
return nil
}
if err := ext.ensureRuntimeReady(); err != nil || ext.runtime == nil {
return nil
}
config := signedSessionConfigWithDefaults(ext.Manifest.SignedSession)
if config.Namespace == "" || config.BaseURL == "" {
return nil
}
if record, err := ext.runtime.loadSignedSession(config); err == nil {
record.SessionID = ""
record.SessionSecret = ""
record.ExpiresAt = ""
_ = ext.runtime.saveSignedSession(config, record)
}
ext.runtime.startSignedSessionVerification(config, "pending-auth-request")
return GetPendingAuthRequest(extensionID)
}
func SetExtensionAuthCodeByID(extensionID, authCode string) {
SetExtensionAuthCode(extensionID, authCode)
}
func SetExtensionSessionGrantByID(extensionID, grant string) {
setPendingSignedSessionGrant(extensionID, grant)
}
func SetExtensionTokensByID(extensionID, accessToken, refreshToken string, expiresIn int) {
var expiresAt time.Time
if expiresIn > 0 {
@@ -3387,6 +3514,7 @@ func CustomSearchWithExtensionJSONWithRequestID(extensionID, query string, optio
"album_artist": track.AlbumArtist,
"duration_ms": track.DurationMS,
"images": track.ResolvedCoverURL(),
"preview_url": track.PreviewURL,
"release_date": track.ReleaseDate,
"track_number": track.TrackNumber,
"total_tracks": track.TotalTracks,
@@ -3452,6 +3580,8 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
"extension_id": extensionID,
"name": result.Name,
"cover_url": result.CoverURL,
"header_image": result.HeaderImage,
"header_video": result.HeaderVideo,
}
if result.Track != nil {
@@ -3463,6 +3593,7 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
"album_artist": result.Track.AlbumArtist,
"duration_ms": result.Track.DurationMS,
"images": result.Track.ResolvedCoverURL(),
"preview_url": result.Track.PreviewURL,
"release_date": result.Track.ReleaseDate,
"track_number": result.Track.TrackNumber,
"total_tracks": result.Track.TotalTracks,
@@ -3485,6 +3616,7 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
"album_artist": track.AlbumArtist,
"duration_ms": track.DurationMS,
"images": track.ResolvedCoverURL(),
"preview_url": track.PreviewURL,
"release_date": track.ReleaseDate,
"track_number": track.TrackNumber,
"total_tracks": track.TotalTracks,
@@ -3506,6 +3638,9 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
"name": result.Album.Name,
"artists": result.Album.Artists,
"cover_url": result.Album.CoverURL,
"header_image": result.Album.HeaderImage,
"header_video": result.Album.HeaderVideo,
"audio_traits": result.Album.AudioTraits,
"release_date": result.Album.ReleaseDate,
"total_tracks": result.Album.TotalTracks,
"album_type": result.Album.AlbumType,
@@ -3519,6 +3654,7 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
"name": result.Artist.Name,
"image_url": result.Artist.ImageURL,
"header_image": result.Artist.HeaderImage,
"header_video": result.Artist.HeaderVideo,
"listeners": result.Artist.Listeners,
"provider_id": result.Artist.ProviderID,
}
@@ -3578,6 +3714,7 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
"album_artist": track.AlbumArtist,
"duration_ms": track.DurationMS,
"images": track.ResolvedCoverURL(),
"preview_url": track.PreviewURL,
"release_date": track.ReleaseDate,
"track_number": track.TrackNumber,
"total_tracks": track.TotalTracks,
@@ -3813,13 +3950,29 @@ func GetStoreCategoriesJSON() (string, error) {
return string(jsonBytes), nil
}
func buildStoreExtensionDestPath(destDir, extensionID string) (string, error) {
func storeExtensionPackageSuffix(downloadURL string) string {
rawPath := downloadURL
if parsed, err := url.Parse(downloadURL); err == nil {
rawPath = parsed.Path
}
lowerPath := strings.ToLower(rawPath)
if strings.HasSuffix(lowerPath, ".sflx") {
return ".sflx"
}
if strings.HasSuffix(lowerPath, ".spotiflac-ext") {
return ".spotiflac-ext"
}
return ".spotiflac-ext"
}
func buildStoreExtensionDestPath(destDir, extensionID, downloadURL string) (string, error) {
if strings.TrimSpace(extensionID) == "" {
return "", fmt.Errorf("invalid extension id")
}
safeExtensionID := sanitizeFilename(extensionID)
return filepath.Join(destDir, safeExtensionID+".spotiflac-ext"), nil
return filepath.Join(destDir, safeExtensionID+storeExtensionPackageSuffix(downloadURL)), nil
}
func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
@@ -3828,7 +3981,12 @@ func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
return "", fmt.Errorf("extension store not initialized")
}
destPath, err := buildStoreExtensionDestPath(destDir, extensionID)
ext, err := store.findExtension(extensionID)
if err != nil {
return "", err
}
destPath, err := buildStoreExtensionDestPath(destDir, extensionID, ext.getDownloadURL())
if err != nil {
return "", err
}
@@ -3893,9 +4051,12 @@ func callExtensionFunctionJSONWithRequestID(extensionID, functionName string, ti
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
return extension.%s();
}
if (typeof %s === 'function') {
return %s();
}
return null;
})()
`, functionName, functionName)
`, functionName, functionName, functionName, functionName)
jsStartedAt := time.Now()
result, err := RunWithTimeoutContextAndRecover(requestCtx, vm, script, timeout)
+55 -2
View File
@@ -31,6 +31,44 @@ func TestDownloadErrorClassificationPrioritizesRateLimit(t *testing.T) {
}
}
func TestDownloadErrorClassificationDetectsVerificationRequired(t *testing.T) {
cases := []string{
"HTTP 401 for /tickets",
"HTTP status 428: precondition required",
"Verification required",
}
for _, tc := range cases {
if got := classifyDownloadErrorType(tc); got != "verification_required" {
t.Fatalf("classifyDownloadErrorType(%q) = %q, want verification_required", tc, got)
}
}
}
func TestGetProviderMetadataPrefersEnabledDeezerExtension(t *testing.T) {
dir := t.TempDir()
if err := InitExtensionSystem(filepath.Join(dir, "extensions"), filepath.Join(dir, "data")); err != nil {
t.Fatalf("InitExtensionSystem: %v", err)
}
CleanupExtensions()
defer CleanupExtensions()
ext := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
ext.ID = "deezer"
ext.Manifest.Name = "deezer"
manager := getExtensionManager()
manager.mu.Lock()
manager.extensions = map[string]*loadedExtension{ext.ID: ext}
manager.mu.Unlock()
jsonText, err := GetProviderMetadataJSON("deezer", "album", "201")
if err != nil {
t.Fatalf("GetProviderMetadataJSON deezer album: %v", err)
}
if !strings.Contains(jsonText, "album-track") {
t.Fatalf("expected enabled deezer extension metadata, got %s", jsonText)
}
}
func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
dir := t.TempDir()
dataDir := filepath.Join(dir, "data")
@@ -390,10 +428,25 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
if catsJSON, err := GetStoreCategoriesJSON(); err != nil || !strings.Contains(catsJSON, "metadata") {
t.Fatalf("GetStoreCategoriesJSON = %q/%v", catsJSON, err)
}
if dest, err := buildStoreExtensionDestPath(dir, "coverage/ext"); err != nil || !strings.HasSuffix(dest, ".spotiflac-ext") {
if dest, err := buildStoreExtensionDestPath(
dir,
"coverage/ext",
"https://registry.example.com/coverage.spotiflac-ext",
); err != nil || !strings.HasSuffix(dest, ".spotiflac-ext") {
t.Fatalf("buildStoreExtensionDestPath = %q/%v", dest, err)
}
if _, err := buildStoreExtensionDestPath(dir, " "); err == nil {
if dest, err := buildStoreExtensionDestPath(
dir,
"coverage/ext",
"https://registry.example.com/coverage.sflx",
); err != nil || !strings.HasSuffix(dest, ".sflx") {
t.Fatalf("buildStoreExtensionDestPath sflx = %q/%v", dest, err)
}
if _, err := buildStoreExtensionDestPath(
dir,
" ",
"https://registry.example.com/coverage.sflx",
); err == nil {
t.Fatal("expected invalid extension id")
}
if err := ClearStoreCacheJSON(); err != nil {
+86 -17
View File
@@ -15,7 +15,9 @@ import (
const (
extensionHealthDefaultTimeout = 4 * time.Second
extensionHealthMaxBodyBytes = 64 * 1024
extensionHealthDefaultCache = 60 * time.Second
extensionHealthDefaultCache = 10 * time.Minute
extensionHealthMinCache = 60 * time.Second
extensionHealthUnknownCache = 2 * time.Minute
)
type ExtensionHealthResult struct {
@@ -58,6 +60,7 @@ func CheckExtensionHealthJSON(extensionID string) (string, error) {
}
result := CheckExtensionHealth(ext)
cacheExtensionHealthResult(ext, result)
bytes, err := json.Marshal(result)
if err != nil {
return "", err
@@ -85,16 +88,31 @@ func CheckExtensionHealthCached(ext *loadedExtension) ExtensionHealthResult {
extensionHealthCacheMu.Unlock()
result := CheckExtensionHealth(ext)
cacheExtensionHealthResult(ext, result)
return result
}
func cacheExtensionHealthResult(ext *loadedExtension, result ExtensionHealthResult) {
if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 {
return
}
cacheKey := strings.TrimSpace(ext.ID)
if cacheKey == "" {
return
}
ttl := extensionHealthCacheTTL(ext.Manifest.ServiceHealth)
if result.Status == "unknown" && ttl > extensionHealthUnknownCache {
ttl = extensionHealthUnknownCache
}
extensionHealthCacheMu.Lock()
extensionHealthCache[cacheKey] = cachedExtensionHealthResult{
result: result,
expiresAt: now.Add(ttl),
expiresAt: time.Now().Add(ttl),
}
extensionHealthCacheMu.Unlock()
return result
}
func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult {
@@ -149,6 +167,9 @@ func extensionHealthCacheTTL(checks []ExtensionHealthCheck) time.Duration {
continue
}
checkTTL := time.Duration(check.CacheTTLSeconds) * time.Second
if checkTTL < extensionHealthMinCache {
checkTTL = extensionHealthMinCache
}
if checkTTL < ttl {
ttl = checkTTL
}
@@ -226,7 +247,11 @@ func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthC
resp, err := NewMetadataHTTPClient(timeout).Do(req)
result.LatencyMs = time.Since(start).Milliseconds()
if err != nil {
result.Status = "offline"
if isTransientExtensionHealthError(err) {
result.Status = "unknown"
} else {
result.Status = "offline"
}
result.Error = err.Error()
return result
}
@@ -262,6 +287,10 @@ func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthC
return result
}
func isTransientExtensionHealthError(err error) bool {
return isTransientNetworkError(err) || isConnectivityFailure(err)
}
func classifyExtensionHealthBody(body []byte, serviceKey string) (string, string) {
if len(strings.TrimSpace(string(body))) == 0 {
return "online", ""
@@ -287,6 +316,9 @@ func classifyExtensionHealthBody(body []byte, serviceKey string) (string, string
case "degraded", "partial", "warning", "warn":
return "degraded", rawStatus
case "down", "offline", "error", "failed", "fail", "unhealthy":
if isTransientHealthStatusMessage(string(body)) {
return "unknown", rawStatus
}
return "offline", rawStatus
default:
return "online", rawStatus
@@ -327,42 +359,53 @@ func classifyExtensionHealthService(payload map[string]interface{}, serviceKey s
rawStatus, hasStatus := service["status"]
okValue, hasOK := service["ok"].(bool)
joinedMessage := strings.Join(messageParts, ": ")
transient := isTransientHealthStatusMessage(detail) ||
isTransientHealthStatusMessage(errText) ||
isTransientHealthStatusMessage(label)
if statusCode, ok := healthNumber(rawStatus); ok {
if statusCode >= 200 && statusCode < 300 {
return "online", strings.Join(messageParts, ": "), true
return "online", joinedMessage, true
}
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
return "degraded", strings.Join(messageParts, ": "), true
return "degraded", joinedMessage, true
}
if statusCode == http.StatusInternalServerError && hasOK && okValue {
return "online", strings.Join(messageParts, ": "), true
return "online", joinedMessage, true
}
return "offline", strings.Join(messageParts, ": "), true
if transient || isTransientHealthStatusCode(statusCode) {
return "unknown", joinedMessage, true
}
return "offline", joinedMessage, true
}
if isExtensionHealthAuthRequired(detail) {
return "degraded", strings.Join(messageParts, ": "), true
return "degraded", joinedMessage, true
}
if transient {
return "unknown", joinedMessage, true
}
if hasOK {
if okValue {
return "online", strings.Join(messageParts, ": "), true
return "online", joinedMessage, true
}
return "offline", strings.Join(messageParts, ": "), true
return "offline", joinedMessage, true
}
if !hasStatus {
return "unknown", strings.Join(messageParts, ": "), true
return "unknown", joinedMessage, true
}
statusString := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", rawStatus)))
switch statusString {
case "ok", "up", "online", "healthy", "operational":
return "online", strings.Join(messageParts, ": "), true
return "online", joinedMessage, true
case "degraded", "partial", "warning", "warn":
return "degraded", strings.Join(messageParts, ": "), true
return "degraded", joinedMessage, true
case "down", "offline", "error", "failed", "fail", "unhealthy":
return "offline", strings.Join(messageParts, ": "), true
return "offline", joinedMessage, true
default:
return "unknown", strings.Join(messageParts, ": "), true
return "unknown", joinedMessage, true
}
}
@@ -375,6 +418,32 @@ func isExtensionHealthAuthRequired(detail string) bool {
}
}
func isTransientHealthStatusMessage(text string) bool {
t := strings.ToLower(strings.TrimSpace(text))
if t == "" {
return false
}
return strings.Contains(t, "context deadline exceeded") ||
strings.Contains(t, "deadline exceeded") ||
strings.Contains(t, "timeout") ||
strings.Contains(t, "timed out") ||
strings.Contains(t, "temporarily unavailable") ||
strings.Contains(t, "try again")
}
func isTransientHealthStatusCode(code int) bool {
switch code {
case http.StatusRequestTimeout,
http.StatusTooManyRequests,
http.StatusBadGateway,
http.StatusServiceUnavailable,
http.StatusGatewayTimeout:
return true
default:
return false
}
}
func healthNumber(value interface{}) (int, bool) {
switch v := value.(type) {
case float64:
@@ -1,8 +1,10 @@
package gobackend
import (
"context"
"encoding/json"
"io"
"net"
"net/http"
"strings"
"testing"
@@ -27,6 +29,12 @@ func TestExtensionHealthClassificationAndValidation(t *testing.T) {
if !isExtensionHealthAuthRequired(" unauthorized ") {
t.Fatal("expected auth required")
}
if !isTransientExtensionHealthError(context.DeadlineExceeded) || !isTransientExtensionHealthError(&net.DNSError{IsTimeout: true}) {
t.Fatal("expected timeout health errors to be transient")
}
if !isTransientExtensionHealthError(&net.DNSError{IsNotFound: true}) {
t.Fatal("expected health transport lookup errors to be indeterminate")
}
if result := CheckExtensionHealth(nil); result.Status != "offline" {
t.Fatalf("nil health = %#v", result)
+64 -37
View File
@@ -44,18 +44,24 @@ func compareVersions(v1, v2 string) int {
return 0
}
func isExtensionPackagePath(filePath string) bool {
lowerPath := strings.ToLower(filePath)
return strings.HasSuffix(lowerPath, ".spotiflac-ext") || strings.HasSuffix(lowerPath, ".sflx")
}
type loadedExtension struct {
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"`
ID string `json:"id"`
Manifest *ExtensionManifest `json:"manifest"`
VM *goja.Runtime `json:"-"`
VMMu sync.Mutex `json:"-"`
runtime *extensionRuntime
indexProgram *goja.Program
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{} {
@@ -166,8 +172,8 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens
}
func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loadedExtension, error) {
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
if !isExtensionPackagePath(filePath) {
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
}
zipReader, err := zip.OpenReader(filePath)
@@ -306,6 +312,7 @@ func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loaded
func initializeVMLocked(ext *loadedExtension) error {
ext.VM = nil
ext.runtime = nil
ext.indexProgram = nil
ext.initialized = false
vm := goja.New()
ext.VM = vm
@@ -315,6 +322,11 @@ func initializeVMLocked(ext *loadedExtension) error {
if err != nil {
return fmt.Errorf("failed to read index.js: %w", err)
}
indexProgram, err := goja.Compile(indexPath, string(jsCode), false)
if err != nil {
return fmt.Errorf("failed to compile extension code: %w", err)
}
ext.indexProgram = indexProgram
runtime := newExtensionRuntime(ext)
ext.runtime = runtime
@@ -341,7 +353,7 @@ func initializeVMLocked(ext *loadedExtension) error {
return goja.Undefined()
})
_, err = vm.RunString(string(jsCode))
_, err = vm.RunProgram(indexProgram)
if err != nil {
return fmt.Errorf("failed to execute extension code: %w", err)
}
@@ -356,10 +368,17 @@ func initializeVMLocked(ext *loadedExtension) error {
func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) {
vm := goja.New()
indexPath := filepath.Join(ext.SourceDir, "index.js")
jsCode, err := os.ReadFile(indexPath)
if err != nil {
return nil, nil, fmt.Errorf("failed to read index.js: %w", err)
indexProgram := ext.indexProgram
if indexProgram == nil {
indexPath := filepath.Join(ext.SourceDir, "index.js")
jsCode, err := os.ReadFile(indexPath)
if err != nil {
return nil, nil, fmt.Errorf("failed to read index.js: %w", err)
}
indexProgram, err = goja.Compile(indexPath, string(jsCode), false)
if err != nil {
return nil, nil, fmt.Errorf("failed to compile extension code: %w", err)
}
}
runtime := &extensionRuntime{
@@ -402,7 +421,7 @@ func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensio
return goja.Undefined()
})
if _, err := vm.RunString(string(jsCode)); err != nil {
if _, err := vm.RunProgram(indexProgram); err != nil {
runtime.closeStorageFlusher()
return nil, nil, fmt.Errorf("failed to execute extension code: %w", err)
}
@@ -673,7 +692,7 @@ func (m *extensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
loaded = append(loaded, ext.ID)
}
}
} else if strings.HasSuffix(strings.ToLower(entry.Name()), ".spotiflac-ext") {
} else if isExtensionPackagePath(entry.Name()) {
ext, err := m.LoadExtensionFromFile(filepath.Join(dirPath, entry.Name()))
if err != nil {
GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err)
@@ -775,8 +794,8 @@ func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension,
}
func (m *extensionManager) upgradeExtensionLocked(filePath string) (*loadedExtension, error) {
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
if !isExtensionPackagePath(filePath) {
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
}
zipReader, err := zip.OpenReader(filePath)
@@ -924,8 +943,8 @@ type ExtensionUpgradeInfo struct {
}
func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
if !isExtensionPackagePath(filePath) {
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
}
zipReader, err := zip.OpenReader(filePath)
@@ -1170,14 +1189,16 @@ func (m *extensionManager) InvokeAction(extensionID string, actionName string) (
// Merge extension return values onto the top-level JSON object so Flutter can read
// message, open_auth_url, setting_updates without unwrapping a nested "result" key.
actionNameLiteral := strconv.Quote(actionName)
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
try {
var result = extension.%s();
if (result && typeof result.then === 'function') {
return { success: true, pending: true, message: 'Action started' };
}
(function() {
var actionName = %s;
function runAction(fn) {
try {
var result = fn();
if (result && typeof result.then === 'function') {
return { success: true, pending: true, message: 'Action started' };
}
if (result !== null && result !== undefined && typeof result === 'object') {
var isArr = false;
if (typeof Array !== 'undefined' && Array.isArray) {
@@ -1192,13 +1213,19 @@ func (m *extensionManager) InvokeAction(extensionID string, actionName string) (
}
}
return { success: true, result: result };
} catch (e) {
return { success: false, error: e.toString() };
} catch (e) {
return { success: false, error: e.toString() };
}
}
}
return { success: false, error: 'Action function not found: %s' };
})()
`, actionName, actionName, actionName)
if (typeof extension !== 'undefined' && extension && typeof extension[actionName] === 'function') {
return runAction(function() { return extension[actionName](); });
}
if (actionName === 'completeGrant' && typeof session !== 'undefined' && session && typeof session.completeGrant === 'function') {
return runAction(function() { return session.completeGrant(); });
}
return { success: false, error: 'Action function not found: ' + actionName };
})()
`, actionNameLiteral)
result, err := RunWithTimeoutAndRecover(vm, script, DefaultJSTimeout)
if err != nil {
+63 -22
View File
@@ -3,6 +3,7 @@ package gobackend
import (
"encoding/json"
"fmt"
"net/url"
"strings"
)
@@ -113,28 +114,49 @@ type ExtensionHealthCheck struct {
Required bool `json:"required,omitempty"`
}
type SignedSessionEndpoints struct {
Bootstrap string `json:"bootstrap,omitempty"`
Challenge string `json:"challenge,omitempty"`
Exchange string `json:"exchange,omitempty"`
Refresh string `json:"refresh,omitempty"`
}
type SignedSessionConfig struct {
Namespace string `json:"namespace"`
BaseURL string `json:"baseUrl"`
AppVersion string `json:"appVersion,omitempty"`
Platform string `json:"platform,omitempty"`
CallbackURL string `json:"callbackUrl,omitempty"`
SchemeLabel string `json:"schemeLabel,omitempty"`
HeaderPrefix string `json:"headerPrefix,omitempty"`
TimeWindowSeconds int `json:"timeWindowSeconds,omitempty"`
Endpoints SignedSessionEndpoints `json:"endpoints,omitempty"`
}
type ExtensionManifest struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
Version string `json:"version"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
Icon string `json:"icon,omitempty"`
Types []ExtensionType `json:"type"`
Permissions ExtensionPermissions `json:"permissions"`
Settings []ExtensionSetting `json:"settings,omitempty"`
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
MinAppVersion string `json:"minAppVersion,omitempty"`
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
SkipLyrics bool `json:"skipLyrics,omitempty"`
StopProviderFallback bool `json:"stopProviderFallback,omitempty"`
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
ServiceHealth []ExtensionHealthCheck `json:"serviceHealth,omitempty"`
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
Version string `json:"version"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
Icon string `json:"icon,omitempty"`
Types []ExtensionType `json:"type"`
Permissions ExtensionPermissions `json:"permissions"`
Settings []ExtensionSetting `json:"settings,omitempty"`
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
MinAppVersion string `json:"minAppVersion,omitempty"`
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
SkipLyrics bool `json:"skipLyrics,omitempty"`
StopProviderFallback bool `json:"stopProviderFallback,omitempty"`
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
ServiceHealth []ExtensionHealthCheck `json:"serviceHealth,omitempty"`
SignedSession *SignedSessionConfig `json:"signedSession,omitempty"`
RequiredRuntimeFeatures []string `json:"requiredRuntimeFeatures,omitempty"`
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
}
type ManifestValidationError struct {
@@ -200,7 +222,6 @@ func (m *ExtensionManifest) Validate() error {
}
}
// Select type requires options
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
return &ManifestValidationError{
Field: fmt.Sprintf("settings[%d].options", i),
@@ -238,6 +259,26 @@ func (m *ExtensionManifest) Validate() error {
}
}
if m.SignedSession != nil {
if strings.TrimSpace(m.SignedSession.Namespace) == "" {
return &ManifestValidationError{Field: "signedSession.namespace", Message: "namespace is required"}
}
baseURL := strings.TrimSpace(m.SignedSession.BaseURL)
if baseURL == "" {
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl is required"}
}
if !strings.HasPrefix(strings.ToLower(baseURL), "https://") {
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl must use https"}
}
parsed, err := url.Parse(baseURL)
if err != nil || parsed.Hostname() == "" {
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl is invalid"}
}
if !m.IsDomainAllowed(parsed.Hostname()) {
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl host must be listed in permissions.network"}
}
}
return nil
}
+413 -108
View File
@@ -29,6 +29,7 @@ type ExtTrackMetadata struct {
ExternalURL string `json:"external_urls,omitempty"`
DurationMS int `json:"duration_ms"`
CoverURL string `json:"cover_url,omitempty"`
PreviewURL string `json:"preview_url,omitempty"`
Images string `json:"images,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
TrackNumber int `json:"track_number,omitempty"`
@@ -68,9 +69,12 @@ type ExtAlbumMetadata struct {
Artists string `json:"artists"`
ArtistID string `json:"artist_id,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
HeaderImage string `json:"header_image,omitempty"`
HeaderVideo string `json:"header_video,omitempty"`
ReleaseDate string `json:"release_date,omitempty"`
TotalTracks int `json:"total_tracks"`
AlbumType string `json:"album_type,omitempty"`
AudioTraits []string `json:"audio_traits,omitempty"`
Tracks []ExtTrackMetadata `json:"tracks"`
ProviderID string `json:"provider_id"`
}
@@ -80,6 +84,7 @@ type ExtArtistMetadata struct {
Name string `json:"name"`
ImageURL string `json:"image_url,omitempty"`
HeaderImage string `json:"header_image,omitempty"`
HeaderVideo string `json:"header_video,omitempty"`
Listeners int `json:"listeners,omitempty"`
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
Releases []ExtAlbumMetadata `json:"releases,omitempty"`
@@ -473,6 +478,18 @@ func shouldAbortCancelledFallback(itemID string, err error) bool {
return itemID != "" && isDownloadCancelled(itemID)
}
func normalizeExtensionDownloadErrorType(errorType, message string) string {
normalized := strings.TrimSpace(errorType)
classified := classifyDownloadErrorType(message)
if classified != "" && classified != "unknown" {
switch strings.ToLower(normalized) {
case "", "unknown", "runtime_error", "api_error", "download_error", "extension_error":
return classified
}
}
return normalized
}
type DownloadDecryptionInfo struct {
Strategy string `json:"strategy,omitempty"`
Key string `json:"key,omitempty"`
@@ -483,14 +500,15 @@ type DownloadDecryptionInfo struct {
}
type ExtDownloadResult struct {
Success bool `json:"success"`
FilePath string `json:"file_path,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
BitDepth int `json:"bit_depth,omitempty"`
SampleRate int `json:"sample_rate,omitempty"`
AudioCodec string `json:"audio_codec,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
ErrorType string `json:"error_type,omitempty"`
Success bool `json:"success"`
FilePath string `json:"file_path,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
BitDepth int `json:"bit_depth,omitempty"`
SampleRate int `json:"sample_rate,omitempty"`
AudioCodec string `json:"audio_codec,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
ErrorType string `json:"error_type,omitempty"`
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
Title string `json:"title,omitempty"`
Artist string `json:"artist,omitempty"`
@@ -724,6 +742,32 @@ func gojaObjectStringMap(vm *goja.Runtime, obj *goja.Object, keys ...string) map
return result
}
func gojaObjectStringSlice(obj *goja.Object, keys ...string) []string {
value := gojaObjectValue(obj, keys...)
if gojaValueIsEmpty(value) {
return nil
}
exported, ok := value.Export().([]interface{})
if !ok || len(exported) == 0 {
return nil
}
result := make([]string, 0, len(exported))
for _, item := range exported {
str, ok := item.(string)
if !ok {
continue
}
str = strings.TrimSpace(str)
if str != "" {
result = append(result, str)
}
}
if len(result) == 0 {
return nil
}
return result
}
func gojaArrayLength(value goja.Value, vm *goja.Runtime) (int, error) {
if gojaValueIsEmpty(value) {
return 0, nil
@@ -754,6 +798,7 @@ func parseExtensionTrackValue(vm *goja.Runtime, value goja.Value) ExtTrackMetada
ExternalURL: gojaObjectString(obj, "external_urls", "externalUrls", "external_url", "externalUrl", "url"),
DurationMS: gojaObjectInt(obj, "duration_ms", "durationMs"),
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
PreviewURL: gojaObjectString(obj, "preview_url", "previewUrl"),
Images: gojaObjectString(obj, "images"),
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"),
@@ -820,12 +865,147 @@ func parseExtensionAlbumValue(vm *goja.Runtime, value goja.Value) (ExtAlbumMetad
Artists: gojaObjectString(obj, "artists"),
ArtistID: gojaObjectString(obj, "artist_id", "artistId"),
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl", "images"),
HeaderImage: gojaObjectString(obj, "header_image", "headerImage"),
HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"),
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"),
AlbumType: gojaObjectString(obj, "album_type", "albumType"),
AudioTraits: gojaObjectStringSlice(obj, "audio_traits", "audioTraits"),
Tracks: tracks,
ProviderID: gojaObjectString(obj, "provider_id", "providerId"),
}, nil
}.withTrackFallbacks(), nil
}
// withTrackFallbacks fills the album-level artist and release date from the
// album's tracks when the extension did not provide them at the album level.
// This is a generic mechanism so any extension benefits, without per-extension
// special-casing in the app.
func (a ExtAlbumMetadata) withTrackFallbacks() ExtAlbumMetadata {
if strings.TrimSpace(a.Artists) == "" {
a.Artists = albumArtistFromTracks(a.Tracks)
}
if strings.TrimSpace(a.ReleaseDate) == "" {
a.ReleaseDate = albumReleaseDateFromTracks(a.Tracks)
}
if len(a.AudioTraits) == 0 {
a.AudioTraits = albumAudioTraitsFromTracks(a.Tracks)
}
return a
}
// albumArtistFromTracks prefers an explicit per-track album artist, then falls
// back to the most common track artist across the album.
func albumArtistFromTracks(tracks []ExtTrackMetadata) string {
for _, t := range tracks {
if s := strings.TrimSpace(t.AlbumArtist); s != "" {
return s
}
}
counts := map[string]int{}
order := []string{}
for _, t := range tracks {
artist := strings.TrimSpace(t.Artists)
if artist == "" {
continue
}
if _, ok := counts[artist]; !ok {
order = append(order, artist)
}
counts[artist]++
}
best := ""
bestCount := 0
for _, artist := range order {
if counts[artist] > bestCount {
best = artist
bestCount = counts[artist]
}
}
return best
}
// albumReleaseDateFromTracks returns the first non-empty track release date.
func albumReleaseDateFromTracks(tracks []ExtTrackMetadata) string {
for _, t := range tracks {
if s := strings.TrimSpace(t.ReleaseDate); s != "" {
return s
}
}
return ""
}
// albumAudioTraitsFromTracks derives album-level audio badges (Dolby Atmos,
// Hi-Res Lossless, Lossless) from the per-track audio quality/mode fields that
// extensions like Tidal and Qobuz already provide. Tokens match what the album
// header understands ("dolby_atmos", "hi_res_lossless", "lossless").
func albumAudioTraitsFromTracks(tracks []ExtTrackMetadata) []string {
atmos := false
hiRes := false
lossless := false
for _, t := range tracks {
modes := strings.ToUpper(t.AudioModes)
quality := strings.ToUpper(t.AudioQuality)
if strings.Contains(modes, "ATMOS") || strings.Contains(quality, "ATMOS") {
atmos = true
}
if strings.Contains(quality, "HI_RES") ||
strings.Contains(quality, "HIRES") ||
strings.Contains(quality, "MASTER") ||
strings.Contains(quality, "MQA") {
hiRes = true
}
if strings.Contains(quality, "LOSSLESS") ||
strings.Contains(quality, "FLAC") {
lossless = true
}
if bd, sr := parseBitDepthSampleRate(quality); bd > 0 {
if bd > 16 || sr > 48 {
hiRes = true
} else {
lossless = true
}
}
}
traits := []string{}
if atmos {
traits = append(traits, "dolby_atmos")
}
if hiRes {
traits = append(traits, "hi_res_lossless")
} else if lossless {
traits = append(traits, "lossless")
}
return traits
}
// parseBitDepthSampleRate extracts a bit depth and sample rate (in kHz) from
// labels such as "24bit/96kHz", "16bit/44.1kHz" or "24bit".
func parseBitDepthSampleRate(quality string) (int, float64) {
lower := strings.ToLower(quality)
bitDepth := 0
sampleRate := 0.0
if idx := strings.Index(lower, "bit"); idx > 0 {
j := idx
for j > 0 && lower[j-1] >= '0' && lower[j-1] <= '9' {
j--
}
if n, err := strconv.Atoi(lower[j:idx]); err == nil {
bitDepth = n
}
}
if idx := strings.Index(lower, "khz"); idx > 0 {
j := idx
for j > 0 && ((lower[j-1] >= '0' && lower[j-1] <= '9') || lower[j-1] == '.') {
j--
}
if f, err := strconv.ParseFloat(lower[j:idx], 64); err == nil {
sampleRate = f
}
}
return bitDepth, sampleRate
}
func parseExtensionAlbumArray(vm *goja.Runtime, value goja.Value) ([]ExtAlbumMetadata, error) {
@@ -891,6 +1071,7 @@ func parseExtensionArtistValue(vm *goja.Runtime, value goja.Value) (ExtArtistMet
Name: gojaObjectString(obj, "name"),
ImageURL: gojaObjectString(obj, "image_url", "imageUrl"),
HeaderImage: gojaObjectString(obj, "header_image", "headerImage"),
HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"),
Listeners: gojaObjectInt(obj, "listeners"),
Albums: albums,
Releases: releases,
@@ -942,35 +1123,36 @@ func parseExtensionDownloadDecryptionValue(vm *goja.Runtime, value goja.Value) *
func parseExtensionDownloadResultValue(vm *goja.Runtime, value goja.Value) ExtDownloadResult {
obj := value.ToObject(vm)
return ExtDownloadResult{
Success: gojaObjectBool(obj, "success"),
FilePath: gojaObjectString(obj, "file_path", "filePath", "path"),
AlreadyExists: gojaObjectBool(obj, "already_exists", "alreadyExists"),
BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"),
SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"),
AudioCodec: gojaObjectString(obj, "audio_codec", "audioCodec", "codec"),
ErrorMessage: gojaObjectString(obj, "error_message", "errorMessage", "error"),
ErrorType: gojaObjectString(obj, "error_type", "errorType"),
Title: gojaObjectString(obj, "title"),
Artist: gojaObjectString(obj, "artist"),
Album: gojaObjectString(obj, "album"),
AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"),
TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"),
DiscNumber: gojaObjectInt(obj, "disc_number", "discNumber"),
TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"),
TotalDiscs: gojaObjectInt(obj, "total_discs", "totalDiscs"),
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
ISRC: gojaObjectString(obj, "isrc"),
Genre: gojaObjectString(obj, "genre"),
Label: gojaObjectString(obj, "label"),
Copyright: gojaObjectString(obj, "copyright"),
Composer: gojaObjectString(obj, "composer"),
LyricsLRC: gojaObjectString(obj, "lyrics_lrc", "lyricsLrc"),
DecryptionKey: gojaObjectString(obj, "decryption_key", "decryptionKey"),
Decryption: parseExtensionDownloadDecryptionValue(vm, gojaObjectValue(obj, "decryption")),
ActualExtension: gojaObjectString(obj, "actual_extension", "actualExtension"),
OutputExtension: gojaObjectString(obj, "output_extension", "outputExtension"),
ActualContainer: gojaObjectString(obj, "actual_container", "actualContainer", "container"),
Success: gojaObjectBool(obj, "success"),
FilePath: gojaObjectString(obj, "file_path", "filePath", "path"),
AlreadyExists: gojaObjectBool(obj, "already_exists", "alreadyExists"),
BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"),
SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"),
AudioCodec: gojaObjectString(obj, "audio_codec", "audioCodec", "codec"),
ErrorMessage: gojaObjectString(obj, "error_message", "errorMessage", "error"),
ErrorType: gojaObjectString(obj, "error_type", "errorType"),
RetryAfterSeconds: gojaObjectInt(obj, "retry_after_seconds", "retryAfterSeconds"),
Title: gojaObjectString(obj, "title"),
Artist: gojaObjectString(obj, "artist"),
Album: gojaObjectString(obj, "album"),
AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"),
TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"),
DiscNumber: gojaObjectInt(obj, "disc_number", "discNumber"),
TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"),
TotalDiscs: gojaObjectInt(obj, "total_discs", "totalDiscs"),
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
ISRC: gojaObjectString(obj, "isrc"),
Genre: gojaObjectString(obj, "genre"),
Label: gojaObjectString(obj, "label"),
Copyright: gojaObjectString(obj, "copyright"),
Composer: gojaObjectString(obj, "composer"),
LyricsLRC: gojaObjectString(obj, "lyrics_lrc", "lyricsLrc"),
DecryptionKey: gojaObjectString(obj, "decryption_key", "decryptionKey"),
Decryption: parseExtensionDownloadDecryptionValue(vm, gojaObjectValue(obj, "decryption")),
ActualExtension: gojaObjectString(obj, "actual_extension", "actualExtension"),
OutputExtension: gojaObjectString(obj, "output_extension", "outputExtension"),
ActualContainer: gojaObjectString(obj, "actual_container", "actualContainer", "container"),
RequiresContainerConversion: gojaObjectBool(
obj,
"requires_container_conversion",
@@ -982,9 +1164,11 @@ func parseExtensionDownloadResultValue(vm *goja.Runtime, value goja.Value) ExtDo
func parseExtensionURLHandleValue(vm *goja.Runtime, value goja.Value) (ExtURLHandleResult, error) {
obj := value.ToObject(vm)
handleResult := ExtURLHandleResult{
Type: gojaObjectString(obj, "type"),
Name: gojaObjectString(obj, "name"),
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
Type: gojaObjectString(obj, "type"),
Name: gojaObjectString(obj, "name"),
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
HeaderImage: gojaObjectString(obj, "header_image", "headerImage"),
HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"),
}
if trackValue := gojaObjectValue(obj, "track"); !gojaValueIsEmpty(trackValue) {
@@ -2135,6 +2319,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
var lastErr error
var lastErrType string
var lastRetryAfterSeconds int
var stopProviderFallback bool
var sourceExtensionLocked bool
var sourceExtensionAvailability *ExtAvailabilityResult
@@ -2416,15 +2602,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
resp.Composer = req.Composer
}
if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(normalizedResult.FilePath) {
if err := EmbedGenreLabel(normalizedResult.FilePath, req.Genre, req.Label); err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
} else {
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
}
} else if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
GoLog("[DownloadWithExtensionFallback] Skipping genre/label embed for non-local output path: %q\n", normalizedResult.FilePath)
}
embedExtensionDownloadMetadata(resp, req, alreadyExists)
if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" {
indexISRC := strings.TrimSpace(resp.ISRC)
@@ -2449,11 +2627,24 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}, nil
}
lastErr = err
lastErrType = ""
} else if result.ErrorMessage != "" {
lastErr = fmt.Errorf("%s", result.ErrorMessage)
lastErrType = normalizeExtensionDownloadErrorType(result.ErrorType, result.ErrorMessage)
lastRetryAfterSeconds = result.RetryAfterSeconds
}
GoLog("[DownloadWithExtensionFallback] Source extension %s failed: %v\n", req.Source, lastErr)
if strings.EqualFold(lastErrType, "verification_required") {
GoLog("[DownloadWithExtensionFallback] Source extension %s requires verification, not trying other providers\n", req.Source)
return &DownloadResponse{
Success: false,
Error: "Download failed: " + lastErr.Error(),
ErrorType: "verification_required",
Service: req.Source,
}, nil
}
if stopProviderFallback || sourceExtensionLocked {
if sourceExtensionLocked {
GoLog("[DownloadWithExtensionFallback] Source extension %s requested skip_fallback, not trying other providers\n", req.Source)
@@ -2461,10 +2652,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
GoLog("[DownloadWithExtensionFallback] stopProviderFallback is true, not trying other providers\n")
return &DownloadResponse{
Success: false,
Error: "Download failed: " + lastErr.Error(),
ErrorType: "extension_error",
Service: req.Source,
Success: false,
Error: "Download failed: " + lastErr.Error(),
ErrorType: firstNonEmptyString(lastErrType, "extension_error"),
RetryAfterSeconds: lastRetryAfterSeconds,
Service: req.Source,
}, nil
}
} else {
@@ -2518,6 +2710,15 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID)
if err != nil {
lastErr = err
if strings.EqualFold(classifyDownloadErrorType(err.Error()), "verification_required") {
GoLog("[DownloadWithExtensionFallback] %s requires verification (availability); pausing fallback to open the challenge\n", providerID)
return &DownloadResponse{
Success: false,
Error: "Download failed: " + err.Error(),
ErrorType: "verification_required",
Service: providerID,
}, nil
}
}
if terminalAvailability {
GoLog("[DownloadWithExtensionFallback] %s requested skip_fallback after availability check\n", providerID)
@@ -2532,12 +2733,26 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
StartItemProgress(req.ItemID)
}
// Fallback provider: request its own highest quality, not the
// source provider's quality token.
// Honor the requested quality when this provider recognizes it
// (e.g. an explicit user selection). Only when the token is not
// one of this provider's own options do we fall back to its
// highest quality, since a source provider's token may not map.
fallbackQuality := req.Quality
if len(ext.Manifest.QualityOptions) > 0 {
if best := strings.TrimSpace(ext.Manifest.QualityOptions[0].ID); best != "" {
fallbackQuality = best
requested := strings.TrimSpace(req.Quality)
recognized := false
if requested != "" {
for _, opt := range ext.Manifest.QualityOptions {
if strings.EqualFold(strings.TrimSpace(opt.ID), requested) {
recognized = true
break
}
}
}
if !recognized {
if best := strings.TrimSpace(ext.Manifest.QualityOptions[0].ID); best != "" {
fallbackQuality = best
}
}
}
@@ -2585,15 +2800,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
applyExtensionRequestFallbacks(&resp, req)
if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(normalizedResult.FilePath) {
if err := EmbedGenreLabel(normalizedResult.FilePath, req.Genre, req.Label); err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
} else {
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
}
} else if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
GoLog("[DownloadWithExtensionFallback] Skipping genre/label embed for non-local output path: %q\n", normalizedResult.FilePath)
}
embedExtensionDownloadMetadata(resp, req, alreadyExists)
if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" {
indexISRC := strings.TrimSpace(resp.ISRC)
@@ -2618,10 +2825,31 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}, nil
}
lastErr = err
lastErrType = ""
} else if result.ErrorMessage != "" {
lastErr = fmt.Errorf("%s", result.ErrorMessage)
lastErrType = normalizeExtensionDownloadErrorType(result.ErrorType, result.ErrorMessage)
lastRetryAfterSeconds = result.RetryAfterSeconds
}
GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, lastErr)
if lastErr != nil {
effType := lastErrType
if effType == "" {
effType = classifyDownloadErrorType(lastErr.Error())
}
if strings.EqualFold(effType, "verification_required") {
GoLog("[DownloadWithExtensionFallback] %s requires verification; pausing fallback to open the challenge\n", providerID)
return &DownloadResponse{
Success: false,
Error: "Download failed: " + lastErr.Error(),
ErrorType: "verification_required",
RetryAfterSeconds: lastRetryAfterSeconds,
Service: providerID,
}, nil
}
}
if terminalAvailability {
GoLog("[DownloadWithExtensionFallback] %s requested skip_fallback after download failure\n", providerID)
return buildExtensionFallbackStoppedResponse(providerID, availability, lastErr), nil
@@ -2630,14 +2858,15 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
if lastErr != nil {
errorType := classifyDownloadErrorType(lastErr.Error())
errorType := firstNonEmptyString(lastErrType, classifyDownloadErrorType(lastErr.Error()))
if errorType == "unknown" {
errorType = "not_found"
}
return &DownloadResponse{
Success: false,
Error: "All providers failed. Last error: " + lastErr.Error(),
ErrorType: errorType,
Success: false,
Error: "All providers failed. Last error: " + lastErr.Error(),
ErrorType: errorType,
RetryAfterSeconds: lastRetryAfterSeconds,
}, nil
}
@@ -2654,21 +2883,22 @@ func buildOutputPath(req DownloadRequest) string {
}
metadata := map[string]interface{}{
"title": req.TrackName,
"artist": req.ArtistName,
"album": req.AlbumName,
"album_artist": req.AlbumArtist,
"track": req.TrackNumber,
"track_number": req.TrackNumber,
"total_tracks": req.TotalTracks,
"disc": req.DiscNumber,
"disc_number": req.DiscNumber,
"total_discs": req.TotalDiscs,
"year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate,
"release_date": req.ReleaseDate,
"isrc": req.ISRC,
"composer": req.Composer,
"title": req.TrackName,
"artist": req.ArtistName,
"album": req.AlbumName,
"album_artist": req.AlbumArtist,
"track": req.TrackNumber,
"track_number": req.TrackNumber,
"total_tracks": req.TotalTracks,
"playlist_position": req.PlaylistPosition,
"disc": req.DiscNumber,
"disc_number": req.DiscNumber,
"total_discs": req.TotalDiscs,
"year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate,
"release_date": req.ReleaseDate,
"isrc": req.ISRC,
"composer": req.Composer,
}
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
@@ -2713,21 +2943,22 @@ func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) stri
AddAllowedDownloadDir(tempDir)
metadata := map[string]interface{}{
"title": req.TrackName,
"artist": req.ArtistName,
"album": req.AlbumName,
"album_artist": req.AlbumArtist,
"track": req.TrackNumber,
"track_number": req.TrackNumber,
"total_tracks": req.TotalTracks,
"disc": req.DiscNumber,
"disc_number": req.DiscNumber,
"total_discs": req.TotalDiscs,
"year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate,
"release_date": req.ReleaseDate,
"isrc": req.ISRC,
"composer": req.Composer,
"title": req.TrackName,
"artist": req.ArtistName,
"album": req.AlbumName,
"album_artist": req.AlbumArtist,
"track": req.TrackNumber,
"track_number": req.TrackNumber,
"total_tracks": req.TotalTracks,
"playlist_position": req.PlaylistPosition,
"disc": req.DiscNumber,
"disc_number": req.DiscNumber,
"total_discs": req.TotalDiscs,
"year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate,
"release_date": req.ReleaseDate,
"isrc": req.ISRC,
"composer": req.Composer,
}
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
@@ -2761,6 +2992,78 @@ func canEmbedGenreLabel(filePath string) bool {
return err == nil && !info.IsDir() && info.Size() > 0
}
func embedExtensionDownloadMetadata(resp DownloadResponse, req DownloadRequest, alreadyExists bool) {
if alreadyExists || !req.EmbedMetadata {
return
}
filePath := strings.TrimSpace(resp.FilePath)
if !canEmbedGenreLabel(filePath) {
if req.Genre != "" || req.Label != "" || resp.CoverURL != "" || req.CoverURL != "" {
GoLog("[DownloadWithExtensionFallback] Skipping metadata/cover embed for non-local FLAC output path: %q\n", filePath)
}
return
}
coverURL := firstNonEmptyTrimmed(resp.CoverURL, req.CoverURL)
var coverData []byte
if coverURL != "" {
data, err := downloadCoverToMemory(coverURL, req.EmbedMaxQualityCover)
if err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to download cover for metadata embed: %v\n", err)
} else if len(data) > 0 {
coverData = data
}
}
metadata := Metadata{
Title: firstNonEmptyTrimmed(resp.Title, req.TrackName),
Artist: firstNonEmptyTrimmed(resp.Artist, req.ArtistName),
Album: firstNonEmptyTrimmed(resp.Album, req.AlbumName),
AlbumArtist: firstNonEmptyTrimmed(resp.AlbumArtist, req.AlbumArtist),
ArtistTagMode: req.ArtistTagMode,
Date: firstNonEmptyTrimmed(resp.ReleaseDate, req.ReleaseDate),
TrackNumber: firstPositiveInt(resp.TrackNumber, req.TrackNumber),
TotalTracks: firstPositiveInt(resp.TotalTracks, req.TotalTracks),
DiscNumber: firstPositiveInt(resp.DiscNumber, req.DiscNumber),
TotalDiscs: firstPositiveInt(resp.TotalDiscs, req.TotalDiscs),
ISRC: firstNonEmptyTrimmed(resp.ISRC, req.ISRC),
Genre: firstNonEmptyTrimmed(resp.Genre, req.Genre),
Label: firstNonEmptyTrimmed(resp.Label, req.Label),
Copyright: firstNonEmptyTrimmed(resp.Copyright, req.Copyright),
Composer: firstNonEmptyTrimmed(resp.Composer, req.Composer),
}
if req.EmbedLyrics {
metadata.Lyrics = resp.LyricsLRC
}
var err error
if len(coverData) > 0 {
err = EmbedMetadataWithCoverData(filePath, metadata, coverData)
} else {
err = EmbedMetadata(filePath, metadata, "")
}
if err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed metadata/cover: %v\n", err)
return
}
if len(coverData) > 0 {
GoLog("[DownloadWithExtensionFallback] Embedded metadata and cover from %q\n", coverURL)
} else {
GoLog("[DownloadWithExtensionFallback] Embedded metadata without cover\n")
}
}
func firstPositiveInt(values ...int) int {
for _, value := range values {
if value > 0 {
return value
}
}
return 0
}
func (p *extensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) {
return p.customSearch(query, options, "", "")
}
@@ -2884,13 +3187,15 @@ func (p *extensionProviderWrapper) customSearch(query string, options map[string
}
type ExtURLHandleResult struct {
Type string `json:"type"`
Track *ExtTrackMetadata `json:"track,omitempty"`
Tracks []ExtTrackMetadata `json:"tracks,omitempty"`
Album *ExtAlbumMetadata `json:"album,omitempty"`
Artist *ExtArtistMetadata `json:"artist,omitempty"`
Name string `json:"name,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
Type string `json:"type"`
Track *ExtTrackMetadata `json:"track,omitempty"`
Tracks []ExtTrackMetadata `json:"tracks,omitempty"`
Album *ExtAlbumMetadata `json:"album,omitempty"`
Artist *ExtArtistMetadata `json:"artist,omitempty"`
Name string `json:"name,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
HeaderImage string `json:"header_image,omitempty"`
HeaderVideo string `json:"header_video,omitempty"`
}
func (p *extensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) {
+39
View File
@@ -8,11 +8,35 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/dop251/goja"
)
// allowPrivateNetworkAccess, when enabled, disables the SSRF guard that blocks
// requests resolving to private/local/loopback addresses. This is opt-in and
// intended for users who route the app's traffic through a local proxy or
// custom DNS (e.g. a local mirror of api.zarz.moe). Disabled by default.
var allowPrivateNetworkAccess atomic.Bool
// SetAllowPrivateNetwork toggles whether extensions and built-in network code
// are permitted to reach private/local network targets. Exposed to the Flutter
// layer via the platform bridge.
func SetAllowPrivateNetwork(allowed bool) {
allowPrivateNetworkAccess.Store(allowed)
if allowed {
GoLog("[HTTP] Private/local network access ENABLED (SSRF guard relaxed)\n")
} else {
GoLog("[HTTP] Private/local network access disabled (default)\n")
}
}
// IsPrivateNetworkAllowed reports the current state of the private-network guard.
func IsPrivateNetworkAllowed() bool {
return allowPrivateNetworkAccess.Load()
}
const DefaultJSTimeout = 30 * time.Second
var (
@@ -303,6 +327,12 @@ func (e *RedirectBlockedError) Error() string {
}
func isPrivateIP(host string) bool {
// Opt-in escape hatch: when the user has enabled private/local network
// access, treat every host as public so local proxies / custom DNS work.
if allowPrivateNetworkAccess.Load() {
return false
}
hostLower := strings.ToLower(strings.TrimSpace(host))
if hostLower == "" {
return false
@@ -465,6 +495,15 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
vm.Set("auth", authObj)
if r.manifest != nil && r.manifest.SignedSession != nil {
sessionObj := vm.NewObject()
sessionObj.Set("signedFetch", r.signedSessionFetch)
sessionObj.Set("completeGrant", r.signedSessionCompleteGrant)
sessionObj.Set("status", r.signedSessionStatus)
sessionObj.Set("clear", r.signedSessionClear)
vm.Set("session", sessionObj)
}
fileObj := vm.NewObject()
fileObj.Set("download", r.fileDownload)
fileObj.Set("exists", r.fileExists)
-1
View File
@@ -286,7 +286,6 @@ func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt
}
switch parsedOptions.Mode {
case "cbc", "ctr":
// supported
default:
return r.vm.ToValue(map[string]interface{}{
"success": false,
-3
View File
@@ -370,7 +370,6 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
var totalSize int64
contentRange := probeResp.Header.Get("Content-Range")
if contentRange != "" {
// Format: "bytes 0-1/12345"
if idx := strings.LastIndex(contentRange, "/"); idx >= 0 {
sizeStr := contentRange[idx+1:]
if sizeStr != "*" {
@@ -457,7 +456,6 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
break // Success
}
// Non-success status
io.Copy(io.Discard, chunkResp.Body)
chunkResp.Body.Close()
@@ -474,7 +472,6 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
})
}
// Read chunk body and write to file
chunkWritten := int64(0)
for {
nr, er := chunkResp.Body.Read(buf)
+664
View File
@@ -0,0 +1,664 @@
package gobackend
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/dop251/goja"
)
const signedSessionRefreshSkew = time.Hour
var (
pendingSignedSessionGrants = make(map[string]string)
pendingSignedSessionGrantsMu sync.Mutex
)
type signedSessionRecord struct {
InstallID string `json:"install_id"`
SessionID string `json:"session_id,omitempty"`
SessionSecret string `json:"session_secret,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
Namespace string `json:"namespace,omitempty"`
BaseURL string `json:"base_url,omitempty"`
AppVersion string `json:"app_version,omitempty"`
Platform string `json:"platform,omitempty"`
}
type signedSessionExchangeResponse struct {
SessionID string `json:"session_id,omitempty"`
SessionSecret string `json:"session_secret,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
ChallengeID string `json:"challenge_id,omitempty"`
ChallengeURL string `json:"challenge_url,omitempty"`
AuthURL string `json:"auth_url,omitempty"`
}
func signedSessionConfigWithDefaults(config *SignedSessionConfig) SignedSessionConfig {
if config == nil {
return SignedSessionConfig{}
}
resolved := *config
if resolved.AppVersion == "" {
resolved.AppVersion = "ext-1.0"
}
if resolved.Platform == "" {
resolved.Platform = "extension"
}
if resolved.CallbackURL == "" {
resolved.CallbackURL = "spotiflac://session-grant"
}
if resolved.SchemeLabel == "" {
resolved.SchemeLabel = "SPOTIFLAC-HMAC-V1"
}
if resolved.HeaderPrefix == "" {
resolved.HeaderPrefix = "X-Sig-"
}
if resolved.TimeWindowSeconds <= 0 {
resolved.TimeWindowSeconds = 300
}
if resolved.Endpoints.Bootstrap == "" {
resolved.Endpoints.Bootstrap = "/bootstrap"
}
if resolved.Endpoints.Challenge == "" {
resolved.Endpoints.Challenge = "/challenge"
}
if resolved.Endpoints.Exchange == "" {
resolved.Endpoints.Exchange = "/session/exchange"
}
return resolved
}
func (r *extensionRuntime) signedSessionFilePath(config SignedSessionConfig) (string, error) {
namespace := sanitizeSignedSessionNamespace(config.Namespace)
if namespace == "" {
return "", fmt.Errorf("signed session namespace is empty")
}
baseDir := filepath.Dir(r.dataDir)
if baseDir == "." || baseDir == "" {
baseDir = r.dataDir
}
dir := filepath.Join(baseDir, "signed_sessions")
if err := os.MkdirAll(dir, 0700); err != nil {
return "", err
}
scope := strings.Join([]string{
namespace,
strings.TrimSpace(strings.ToLower(config.BaseURL)),
strings.TrimSpace(strings.ToLower(config.AppVersion)),
strings.TrimSpace(strings.ToLower(config.Platform)),
}, "\n")
sum := sha256.Sum256([]byte(scope))
return filepath.Join(dir, namespace+"-"+hex.EncodeToString(sum[:])[:16]+".json"), nil
}
func sanitizeSignedSessionNamespace(namespace string) string {
namespace = strings.TrimSpace(strings.ToLower(namespace))
var b strings.Builder
for _, ch := range namespace {
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' || ch == '.' {
b.WriteRune(ch)
}
}
return strings.Trim(b.String(), ".-_")
}
func (r *extensionRuntime) loadSignedSession(config SignedSessionConfig) (*signedSessionRecord, error) {
path, err := r.signedSessionFilePath(config)
if err != nil {
return nil, err
}
record := &signedSessionRecord{}
if data, err := os.ReadFile(path); err == nil {
_ = json.Unmarshal(data, record)
}
if strings.TrimSpace(record.InstallID) == "" {
record.InstallID = randomHex(16)
}
normalizeSignedSessionRecordScope(config, record)
if err := r.saveSignedSession(config, record); err != nil {
return nil, err
}
return record, nil
}
func normalizeSignedSessionRecordScope(config SignedSessionConfig, record *signedSessionRecord) {
namespace := sanitizeSignedSessionNamespace(config.Namespace)
baseURL := strings.TrimSpace(config.BaseURL)
appVersion := strings.TrimSpace(config.AppVersion)
platform := strings.TrimSpace(config.Platform)
if record.Namespace == "" && record.BaseURL == "" && record.AppVersion == "" && record.Platform == "" {
record.Namespace = namespace
record.BaseURL = baseURL
record.AppVersion = appVersion
record.Platform = platform
return
}
if record.Namespace != namespace ||
record.BaseURL != baseURL ||
record.AppVersion != appVersion ||
record.Platform != platform {
record.SessionID = ""
record.SessionSecret = ""
record.ExpiresAt = ""
}
record.Namespace = namespace
record.BaseURL = baseURL
record.AppVersion = appVersion
record.Platform = platform
}
func (r *extensionRuntime) saveSignedSession(config SignedSessionConfig, record *signedSessionRecord) error {
path, err := r.signedSessionFilePath(config)
if err != nil {
return err
}
data, err := json.MarshalIndent(record, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
func randomHex(bytesLen int) string {
buf := make([]byte, bytesLen)
if _, err := rand.Read(buf); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(buf)
}
func parseSignedSessionTime(value string) (time.Time, bool) {
value = strings.TrimSpace(value)
if value == "" {
return time.Time{}, false
}
layouts := []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02T15:04:05.000Z",
}
for _, layout := range layouts {
if parsed, err := time.Parse(layout, value); err == nil {
return parsed, true
}
}
return time.Time{}, false
}
func (r *extensionRuntime) signedSessionStatus(call goja.FunctionCall) goja.Value {
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
if config.Namespace == "" || config.BaseURL == "" {
return r.vm.ToValue(map[string]interface{}{"authenticated": false, "error": "signedSession is not configured"})
}
record, err := r.loadSignedSession(config)
if err != nil {
return r.vm.ToValue(map[string]interface{}{"authenticated": false, "error": err.Error()})
}
authenticated := record.SessionID != "" && record.SessionSecret != ""
if expiresAt, ok := parseSignedSessionTime(record.ExpiresAt); ok && time.Now().After(expiresAt) {
authenticated = false
}
return r.vm.ToValue(map[string]interface{}{
"authenticated": authenticated,
"expires_at": record.ExpiresAt,
"install_id": record.InstallID,
"session_id": record.SessionID,
"app_version": config.AppVersion,
"platform": config.Platform,
})
}
func (r *extensionRuntime) signedSessionClear(call goja.FunctionCall) goja.Value {
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
record, err := r.loadSignedSession(config)
if err != nil {
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
}
record.SessionID = ""
record.SessionSecret = ""
record.ExpiresAt = ""
if err := r.saveSignedSession(config, record); err != nil {
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
}
ClearPendingAuthRequest(r.extensionID)
return r.vm.ToValue(map[string]interface{}{"success": true})
}
func (r *extensionRuntime) signedSessionCompleteGrant(call goja.FunctionCall) goja.Value {
grant := ""
if len(call.Arguments) > 0 {
grant = strings.TrimSpace(call.Arguments[0].String())
}
if grant == "" {
pendingSignedSessionGrantsMu.Lock()
grant = pendingSignedSessionGrants[r.extensionID]
delete(pendingSignedSessionGrants, r.extensionID)
pendingSignedSessionGrantsMu.Unlock()
}
if grant == "" {
return r.vm.ToValue(map[string]interface{}{"success": false, "error": "no pending grant"})
}
if err := r.exchangeSignedSessionGrant(grant); err != nil {
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
}
ClearPendingAuthRequest(r.extensionID)
return r.vm.ToValue(map[string]interface{}{"success": true})
}
func (r *extensionRuntime) exchangeSignedSessionGrant(grant string) error {
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
record, err := r.loadSignedSession(config)
if err != nil {
return err
}
endpoint, err := signedSessionURL(config, config.Endpoints.Exchange)
if err != nil {
return err
}
payload := map[string]interface{}{
"grant": grant,
"install_id": record.InstallID,
"app_version": config.AppVersion,
"platform": config.Platform,
}
body, _ := json.Marshal(payload)
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
resp, err := r.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, err := readExtensionHTTPResponseBody(resp)
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("session exchange failed: HTTP %d", resp.StatusCode)
}
var exchanged signedSessionExchangeResponse
if err := json.Unmarshal(respBody, &exchanged); err != nil {
return fmt.Errorf("invalid session exchange response: %w", err)
}
if exchanged.SessionID == "" || exchanged.SessionSecret == "" || exchanged.ExpiresAt == "" {
return fmt.Errorf("session exchange response missing session fields")
}
record.SessionID = exchanged.SessionID
record.SessionSecret = exchanged.SessionSecret
record.ExpiresAt = exchanged.ExpiresAt
return r.saveSignedSession(config, record)
}
func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": "method and path are required"})
}
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
if config.Namespace == "" || config.BaseURL == "" {
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": "signedSession is not configured"})
}
method := strings.ToUpper(strings.TrimSpace(call.Arguments[0].String()))
requestPath := call.Arguments[1].String()
body := []byte{}
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
switch v := call.Arguments[2].Export().(type) {
case string:
body = []byte(v)
case map[string]interface{}, []interface{}:
encoded, err := json.Marshal(v)
if err != nil {
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": err.Error()})
}
body = encoded
default:
body = []byte(call.Arguments[2].String())
}
}
extraHeaders := map[string]string{}
if len(call.Arguments) > 3 && !goja.IsUndefined(call.Arguments[3]) && !goja.IsNull(call.Arguments[3]) {
if h, ok := call.Arguments[3].Export().(map[string]interface{}); ok {
for k, v := range h {
extraHeaders[k] = fmt.Sprintf("%v", v)
}
}
}
record, err := r.ensureSignedSession(config)
if err != nil {
if authURL := r.startSignedSessionVerification(config, ""); authURL != "" {
return r.signedSessionVerificationRequiredValue(authURL)
}
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": err.Error()})
}
resp, respBody, respHeaders, err := r.doSignedSessionRequest(config, record, method, requestPath, body, extraHeaders)
if err != nil {
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": err.Error()})
}
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusPreconditionRequired {
record.SessionID = ""
record.SessionSecret = ""
record.ExpiresAt = ""
_ = r.saveSignedSession(config, record)
if authURL := r.startSignedSessionVerification(config, ""); authURL != "" {
return r.signedSessionVerificationRequiredValue(authURL)
}
}
return r.vm.ToValue(map[string]interface{}{
"statusCode": resp.StatusCode,
"status": resp.StatusCode,
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"url": resp.Request.URL.String(),
"body": string(respBody),
"headers": respHeaders,
"retryAfterSeconds": signedSessionRetryAfterSeconds(resp),
})
}
func (r *extensionRuntime) signedSessionVerificationRequiredValue(authURL string) goja.Value {
return r.vm.ToValue(map[string]interface{}{
"ok": false,
"needsVerification": true,
"error": "VERIFY_REQUIRED",
"open_auth_url": authURL,
"auth_url": authURL,
})
}
func (r *extensionRuntime) ensureSignedSession(config SignedSessionConfig) (*signedSessionRecord, error) {
record, err := r.loadSignedSession(config)
if err != nil {
return nil, err
}
if record.SessionID == "" || record.SessionSecret == "" {
return nil, fmt.Errorf("signed session is not authenticated")
}
if expiresAt, ok := parseSignedSessionTime(record.ExpiresAt); ok {
if time.Now().After(expiresAt) {
record.SessionID = ""
record.SessionSecret = ""
record.ExpiresAt = ""
_ = r.saveSignedSession(config, record)
return nil, fmt.Errorf("signed session expired")
}
if config.Endpoints.Refresh != "" && time.Until(expiresAt) <= signedSessionRefreshSkew {
_ = r.refreshSignedSession(config, record)
}
}
return record, nil
}
func (r *extensionRuntime) refreshSignedSession(config SignedSessionConfig, record *signedSessionRecord) error {
body, _ := json.Marshal(map[string]string{"install_id": record.InstallID})
resp, respBody, _, err := r.doSignedSessionRequest(config, record, http.MethodPost, config.Endpoints.Refresh, body, nil)
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("session refresh failed: HTTP %d", resp.StatusCode)
}
var refreshed signedSessionExchangeResponse
if err := json.Unmarshal(respBody, &refreshed); err != nil {
return err
}
changed := false
if refreshed.SessionID != "" {
record.SessionID = refreshed.SessionID
changed = true
}
if refreshed.SessionSecret != "" {
record.SessionSecret = refreshed.SessionSecret
changed = true
}
if refreshed.ExpiresAt != "" && refreshed.ExpiresAt != record.ExpiresAt {
record.ExpiresAt = refreshed.ExpiresAt
changed = true
}
if changed {
return r.saveSignedSession(config, record)
}
return nil
}
func (r *extensionRuntime) startSignedSessionVerification(config SignedSessionConfig, reason string) string {
record, err := r.loadSignedSession(config)
if err != nil {
return ""
}
bootstrapURL, err := signedSessionURL(config, config.Endpoints.Bootstrap)
if err != nil {
return ""
}
parsed, _ := url.Parse(bootstrapURL)
query := parsed.Query()
query.Set("app_version", config.AppVersion)
query.Set("install_id", record.InstallID)
parsed.RawQuery = query.Encode()
req, err := http.NewRequest(http.MethodGet, parsed.String(), nil)
if err != nil {
return ""
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
resp, err := r.httpClient.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxExtensionHTTPResponseBytes))
if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
return ""
}
var boot signedSessionExchangeResponse
if err := json.Unmarshal(body, &boot); err != nil {
return ""
}
if boot.SessionID != "" && boot.SessionSecret != "" && boot.ExpiresAt != "" {
record.SessionID = boot.SessionID
record.SessionSecret = boot.SessionSecret
record.ExpiresAt = boot.ExpiresAt
_ = r.saveSignedSession(config, record)
return ""
}
authURL := boot.AuthURL
if authURL == "" && boot.ChallengeURL != "" {
authURL = boot.ChallengeURL
}
if authURL == "" && boot.ChallengeID != "" {
authURL = r.buildSignedSessionChallengeURL(config, boot.ChallengeID)
}
if authURL != "" {
pendingAuthRequestsMu.Lock()
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
ExtensionID: r.extensionID,
AuthURL: authURL,
CallbackURL: config.CallbackURL,
}
pendingAuthRequestsMu.Unlock()
}
return authURL
}
func (r *extensionRuntime) buildSignedSessionChallengeURL(config SignedSessionConfig, challengeID string) string {
challengeURL, err := signedSessionURL(config, config.Endpoints.Challenge)
if err != nil {
return ""
}
parsed, err := url.Parse(challengeURL)
if err != nil {
return ""
}
callback, err := url.Parse(config.CallbackURL)
if err != nil {
return ""
}
q := callback.Query()
q.Set("cb_version", "v2grant")
q.Set("state", r.extensionID)
callback.RawQuery = q.Encode()
query := parsed.Query()
query.Set("id", challengeID)
query.Set("cb", callback.String())
parsed.RawQuery = query.Encode()
return parsed.String()
}
func signedSessionURL(config SignedSessionConfig, endpoint string) (string, error) {
base, err := url.Parse(strings.TrimRight(config.BaseURL, "/") + "/")
if err != nil || base.Scheme != "https" || base.Host == "" {
return "", fmt.Errorf("invalid signed session baseUrl")
}
endpoint = strings.TrimSpace(endpoint)
if endpoint == "" {
return "", fmt.Errorf("signed session endpoint is empty")
}
if strings.HasPrefix(endpoint, "https://") {
return endpoint, nil
}
endpoint = strings.TrimLeft(endpoint, "/")
ref, _ := url.Parse(endpoint)
return base.ResolveReference(ref).String(), nil
}
func (r *extensionRuntime) doSignedSessionRequest(
config SignedSessionConfig,
record *signedSessionRecord,
method string,
requestPath string,
body []byte,
extraHeaders map[string]string,
) (*http.Response, []byte, map[string]interface{}, error) {
fullURL, err := signedSessionURL(config, requestPath)
if err != nil {
return nil, nil, nil, err
}
parsed, err := url.Parse(fullURL)
if err != nil {
return nil, nil, nil, err
}
ts := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
nonce := randomHex(12)
bodyHashBytes := sha256.Sum256(body)
bodyHash := hex.EncodeToString(bodyHashBytes[:])
parsedTs, _ := time.Parse("2006-01-02T15:04:05.000Z", ts)
window := parsedTs.Unix() / int64(config.TimeWindowSeconds)
rollingInput := fmt.Sprintf("%d:%s", window, record.SessionID)
rk := base64.RawURLEncoding.EncodeToString(hmacSHA256Bytes([]byte(record.SessionSecret), []byte(rollingInput)))
signingInput := strings.Join([]string{
config.SchemeLabel,
method,
parsed.EscapedPath(),
"",
bodyHash,
ts,
nonce,
record.SessionID,
config.AppVersion,
config.Platform,
}, "\n")
sig := base64.RawURLEncoding.EncodeToString(hmacSHA256Bytes([]byte(rk), []byte(signingInput)))
req, err := http.NewRequest(method, fullURL, bytes.NewReader(body))
if err != nil {
return nil, nil, nil, err
}
req = r.bindDownloadCancelContext(req)
req.Header.Set("Accept", "application/json")
if len(body) > 0 {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
prefix := config.HeaderPrefix
req.Header.Set(prefix+"Session", record.SessionID)
req.Header.Set(prefix+"Timestamp", ts)
req.Header.Set(prefix+"Nonce", nonce)
req.Header.Set(prefix+"Body-SHA256", bodyHash)
req.Header.Set(prefix+"Signature", sig)
req.Header.Set(prefix+"App-Version", config.AppVersion)
req.Header.Set(prefix+"Platform", config.Platform)
for k, v := range extraHeaders {
req.Header.Set(k, v)
}
resp, err := r.httpClient.Do(req)
if err != nil {
return nil, nil, nil, err
}
defer resp.Body.Close()
respBody, err := readExtensionHTTPResponseBody(resp)
if err != nil {
return nil, nil, nil, err
}
headers := make(map[string]interface{})
for k, v := range resp.Header {
if len(v) == 1 {
headers[k] = v[0]
} else {
headers[k] = v
}
}
return resp, respBody, headers, nil
}
func signedSessionRetryAfterSeconds(resp *http.Response) int {
if resp == nil {
return 0
}
value := strings.TrimSpace(resp.Header.Get("Retry-After"))
if value == "" {
return 0
}
if seconds, err := strconv.Atoi(value); err == nil {
if seconds < 0 {
return 0
}
return seconds
}
if retryAt, err := http.ParseTime(value); err == nil {
seconds := int(time.Until(retryAt).Seconds())
if seconds < 0 {
return 0
}
return seconds
}
return 0
}
func hmacSHA256Bytes(key, message []byte) []byte {
mac := hmac.New(sha256.New, key)
mac.Write(message)
return mac.Sum(nil)
}
func setPendingSignedSessionGrant(extensionID, grant string) {
extensionID = strings.TrimSpace(extensionID)
grant = strings.TrimSpace(grant)
if extensionID == "" || grant == "" {
return
}
pendingSignedSessionGrantsMu.Lock()
pendingSignedSessionGrants[extensionID] = grant
pendingSignedSessionGrantsMu.Unlock()
}
+11 -7
View File
@@ -330,22 +330,26 @@ func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExte
return result, nil
}
func (s *extensionStore) downloadExtension(extensionID string, destPath string) error {
func (s *extensionStore) findExtension(extensionID string) (*storeExtension, error) {
registry, err := s.fetchRegistry(false)
if err != nil {
return err
return nil, err
}
var ext *storeExtension
for _, e := range registry.Extensions {
if e.ID == extensionID {
ext = &e
break
ext := e
return &ext, nil
}
}
if ext == nil {
return fmt.Errorf("extension %s not found in store", extensionID)
return nil, fmt.Errorf("extension %s not found in store", extensionID)
}
func (s *extensionStore) downloadExtension(extensionID string, destPath string) error {
ext, err := s.findExtension(extensionID)
if err != nil {
return err
}
if err := requireHTTPSURL(ext.getDownloadURL(), "extension download"); err != nil {
+15 -1
View File
@@ -13,7 +13,7 @@ import (
var (
invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
multiUnderscore = regexp.MustCompile(`_+`)
formattedNumberPlaceholderExpr = regexp.MustCompile(`\{(track|disc):([0-9]+)\}`)
formattedNumberPlaceholderExpr = regexp.MustCompile(`\{(track|disc|playlist_position|playlistPosition|position):([0-9]+)\}`)
dateFormatPlaceholderExpr = regexp.MustCompile(`\{date:([^{}]+)\}`)
yearPattern = regexp.MustCompile(`\d{4}`)
)
@@ -99,6 +99,11 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
"{album}": getString(metadata, "album"),
"{track}": formatTrackNumber(getInt(metadata, "track")),
"{track_raw}": formatRawNumber(getInt(metadata, "track")),
"{playlist_position}": formatTrackNumber(getPlaylistPosition(metadata)),
"{playlist position}": formatTrackNumber(getPlaylistPosition(metadata)),
"{playlistPosition}": formatTrackNumber(getPlaylistPosition(metadata)),
"{position}": formatTrackNumber(getPlaylistPosition(metadata)),
"{playlist_position_raw}": formatRawNumber(getPlaylistPosition(metadata)),
"{year}": yearValue,
"{date}": dateValue,
"{disc}": formatDiscNumber(getInt(metadata, "disc")),
@@ -120,6 +125,9 @@ func replaceFormattedNumberPlaceholders(template string, metadata map[string]int
}
number := getInt(metadata, parts[1])
if parts[1] == "playlist_position" || parts[1] == "playlistPosition" || parts[1] == "position" {
number = getPlaylistPosition(metadata)
}
width, err := strconv.Atoi(parts[2])
if err != nil {
return ""
@@ -177,6 +185,8 @@ func getInt(m map[string]interface{}, key string) int {
candidateKeys = append(candidateKeys, "track_number")
case "disc":
candidateKeys = append(candidateKeys, "disc_number")
case "playlist_position", "playlistPosition", "playlist position", "position":
candidateKeys = append(candidateKeys, "playlistPosition", "playlist position", "position")
}
for _, candidate := range candidateKeys {
@@ -200,6 +210,10 @@ func getInt(m map[string]interface{}, key string) int {
return 0
}
func getPlaylistPosition(metadata map[string]interface{}) int {
return getInt(metadata, "playlist_position")
}
func formatTrackNumber(n int) string {
if n <= 0 {
return ""
+17
View File
@@ -55,6 +55,23 @@ func TestBuildFilenameFromTemplate_InlineNumberFormatting(t *testing.T) {
}
}
func TestBuildFilenameFromTemplate_PlaylistPositionFormatting(t *testing.T) {
metadata := map[string]interface{}{
"playlist_position": 4,
"artist": "Artist Name",
"title": "Song Name",
}
formatted := buildFilenameFromTemplate(
"{playlist_position:02} - {artist} - {title}",
metadata,
)
expected := "04 - Artist Name - Song Name"
if formatted != expected {
t.Fatalf("expected %q, got %q", expected, formatted)
}
}
func TestBuildFilenameFromTemplate_DateStrftimeFormatting(t *testing.T) {
metadata := map[string]interface{}{
"artist": "Artist Name",
+4 -4
View File
@@ -5,25 +5,25 @@ go 1.25.0
toolchain go1.25.9
require (
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59
github.com/go-flac/flacpicture/v2 v2.0.2
github.com/go-flac/flacvorbis/v2 v2.0.2
github.com/go-flac/go-flac/v2 v2.0.4
github.com/refraction-networking/utls v1.8.2
golang.org/x/crypto v0.53.0
golang.org/x/mobile v0.0.0-20260602190626-68735029466e
golang.org/x/mobile v0.0.0-20260611195102-4dd8f1dbf5d2
golang.org/x/net v0.56.0
golang.org/x/text v0.38.0
)
require (
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
github.com/dlclark/regexp2/v2 v2.2.2 // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect
github.com/klauspost/compress v1.18.6 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/tools v0.45.0 // indirect
golang.org/x/tools v0.47.0 // indirect
)
+8 -8
View File
@@ -4,10 +4,10 @@ github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eT
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d h1:xbM5U2EvWKkHxzEQJ2DEn20FwolWZahuTnVHr6WL3Q4=
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d/go.mod h1:Sc+QOu1WruvaaeT/cxFez/pXHpI9ZDjg/E8QNfSVveI=
github.com/dlclark/regexp2/v2 v2.2.2 h1:MYWvNYw8okuqNhwTYO587EZMiDruVa2vhV6fsGpfya0=
github.com/dlclark/regexp2/v2 v2.2.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59 h1:DjKLmvKK9u15djHZ88N8M0DhgnHVgJJ8bnEe0h7Lga8=
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59/go.mod h1:Sc+QOu1WruvaaeT/cxFez/pXHpI9ZDjg/E8QNfSVveI=
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
@@ -34,8 +34,8 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/mobile v0.0.0-20260602190626-68735029466e h1:YxPXu/HWDTcSSrzSX+sCltsfcNCa/ZYVG43oslMouNU=
golang.org/x/mobile v0.0.0-20260602190626-68735029466e/go.mod h1:ltIbhcRzKgwHa4ZxKJeiv0nyzcXUUYCqMyO0Y+vPmXw=
golang.org/x/mobile v0.0.0-20260611195102-4dd8f1dbf5d2 h1:zoM1gIKhVkcQNm43kad8OHLgPNoJ12xIqmxHtKr8Mug=
golang.org/x/mobile v0.0.0-20260611195102-4dd8f1dbf5d2/go.mod h1:QGMqsqLn6orFQ/ksqYMf+Fa33Soa1vPoHEd0Pj7N+lQ=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
@@ -46,7 +46,7 @@ golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+117 -73
View File
@@ -1,7 +1,9 @@
package gobackend
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io"
@@ -437,101 +439,143 @@ func (e *ISPBlockingError) Error() string {
return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason)
}
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
// isTransientNetworkError reports retryable transport failures such as
// timeouts and temporary DNS errors. Permanent DNS misses are excluded.
func isTransientNetworkError(err error) bool {
if err == nil {
return nil
return false
}
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return true
}
var netErr net.Error
return errors.As(err, &netErr) && (netErr.Timeout() || netErr.Temporary())
}
// isConnectivityFailure reports DNS, dial, timeout, TLS, or truncated transport
// errors. Application-level API messages are excluded.
func isConnectivityFailure(err error) bool {
return connectivityFailureReason(err) != ""
}
func connectivityFailureReason(err error) string {
if err == nil {
return ""
}
if errors.Is(err, context.DeadlineExceeded) {
return "Request timed out - ISP may be throttling"
}
if errors.Is(err, io.ErrUnexpectedEOF) {
return "Connection closed unexpectedly - ISP may be blocking"
}
domain := extractDomain(requestURL)
errStr := strings.ToLower(err.Error())
var urlErr *url.Error
if errors.As(err, &urlErr) {
if urlErr.Timeout() {
return "Connection timed out - ISP may be blocking access"
}
if urlErr.Err != nil {
if reason := connectivityFailureReason(urlErr.Err); reason != "" {
return reason
}
}
}
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
if dnsErr.IsNotFound || dnsErr.IsTemporary {
return &ISPBlockingError{
Domain: domain,
Reason: "DNS resolution failed - domain may be blocked by ISP",
OriginalErr: err,
}
if dnsErr.IsNotFound || dnsErr.IsTimeout || dnsErr.IsTemporary {
return "DNS resolution failed - domain may be blocked by ISP"
}
}
var opErr *net.OpError
if errors.As(err, &opErr) {
if opErr.Op == "dial" {
var syscallErr syscall.Errno
if errors.As(opErr.Err, &syscallErr) {
switch syscallErr {
case syscall.ECONNREFUSED:
return &ISPBlockingError{
Domain: domain,
Reason: "Connection refused - port may be blocked by ISP/firewall",
OriginalErr: err,
}
case syscall.ECONNRESET:
return &ISPBlockingError{
Domain: domain,
Reason: "Connection reset - ISP may be intercepting traffic",
OriginalErr: err,
}
case syscall.ETIMEDOUT:
return &ISPBlockingError{
Domain: domain,
Reason: "Connection timed out - ISP may be blocking access",
OriginalErr: err,
}
case syscall.ENETUNREACH:
return &ISPBlockingError{
Domain: domain,
Reason: "Network unreachable - ISP may be blocking route",
OriginalErr: err,
}
case syscall.EHOSTUNREACH:
return &ISPBlockingError{
Domain: domain,
Reason: "Host unreachable - ISP may be blocking destination",
OriginalErr: err,
}
}
if opErr.Timeout() {
return "Connection timed out - ISP may be blocking access"
}
var errno syscall.Errno
if errors.As(opErr.Err, &errno) {
switch errno {
case syscall.ECONNREFUSED:
return "Connection refused - port may be blocked by ISP/firewall"
case syscall.ECONNRESET:
return "Connection reset - ISP may be intercepting traffic"
case syscall.ETIMEDOUT:
return "Connection timed out - ISP may be blocking access"
case syscall.ENETUNREACH:
return "Network unreachable - ISP may be blocking route"
case syscall.EHOSTUNREACH:
return "Host unreachable - ISP may be blocking destination"
}
}
}
var tlsErr *tls.RecordHeaderError
if errors.As(err, &tlsErr) {
return &ISPBlockingError{
Domain: domain,
Reason: "TLS handshake failed - ISP may be intercepting HTTPS traffic",
OriginalErr: err,
return "TLS handshake failed - ISP may be intercepting HTTPS traffic"
}
var certErr x509.CertificateInvalidError
if errors.As(err, &certErr) {
return "Certificate error - ISP may be using MITM proxy"
}
var hostnameErr x509.HostnameError
if errors.As(err, &hostnameErr) {
return "Certificate error - ISP may be using MITM proxy"
}
var unknownAuth x509.UnknownAuthorityError
if errors.As(err, &unknownAuth) {
return "Certificate error - ISP may be using MITM proxy"
}
return ""
}
// isTLSHandshakeOrResetError reports TLS handshake/cert failures and TCP resets
// that should trigger a Chrome fingerprint retry.
func isTLSHandshakeOrResetError(err error) bool {
if err == nil {
return false
}
var recordErr *tls.RecordHeaderError
if errors.As(err, &recordErr) {
return true
}
var certErr x509.CertificateInvalidError
if errors.As(err, &certErr) {
return true
}
var hostnameErr x509.HostnameError
if errors.As(err, &hostnameErr) {
return true
}
var unknownAuth x509.UnknownAuthorityError
if errors.As(err, &unknownAuth) {
return true
}
var opErr *net.OpError
if errors.As(err, &opErr) {
var errno syscall.Errno
if errors.As(opErr.Err, &errno) && errno == syscall.ECONNRESET {
return true
}
}
return false
}
blockingPatterns := []struct {
pattern string
reason string
}{
{"connection reset by peer", "Connection reset - ISP may be intercepting traffic"},
{"connection refused", "Connection refused - port may be blocked"},
{"no such host", "DNS lookup failed - domain may be blocked by ISP"},
{"i/o timeout", "Connection timed out - ISP may be blocking access"},
{"network is unreachable", "Network unreachable - ISP may be blocking route"},
{"tls: ", "TLS error - ISP may be intercepting HTTPS traffic"},
{"certificate", "Certificate error - ISP may be using MITM proxy"},
{"eof", "Connection closed unexpectedly - ISP may be blocking"},
{"context deadline exceeded", "Request timed out - ISP may be throttling"},
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
if err == nil {
return nil
}
for _, bp := range blockingPatterns {
if strings.Contains(errStr, bp.pattern) {
return &ISPBlockingError{
Domain: domain,
Reason: bp.reason,
OriginalErr: err,
}
}
reason := connectivityFailureReason(err)
if reason == "" {
return nil
}
return &ISPBlockingError{
Domain: extractDomain(requestURL),
Reason: reason,
OriginalErr: err,
}
return nil
}
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
+15 -4
View File
@@ -1,13 +1,15 @@
package gobackend
import (
"context"
"crypto/x509"
"encoding/pem"
"errors"
"io"
"net"
"net/http"
"net/url"
"strings"
"syscall"
"testing"
"time"
)
@@ -131,15 +133,24 @@ func TestHTTPUtilityHelpers(t *testing.T) {
if getRetryAfterDuration(&http.Response{Header: http.Header{"Retry-After": []string{"bad"}}}) != 0 {
t.Fatal("invalid retry-after should be zero")
}
if isp := IsISPBlocking(errors.New("connection reset by peer"), "https://example.com/x"); isp == nil || !strings.Contains(isp.Error(), "example.com") {
resetErr := &net.OpError{Op: "read", Err: syscall.ECONNRESET}
if isp := IsISPBlocking(resetErr, "https://example.com/x"); isp == nil || !strings.Contains(isp.Error(), "example.com") {
t.Fatalf("IsISPBlocking = %#v", isp)
}
if !CheckAndLogISPBlocking(errors.New("i/o timeout"), "https://timeout.example/x", "test") {
timeoutErr := &net.OpError{Op: "dial", Err: syscall.ETIMEDOUT}
if !CheckAndLogISPBlocking(timeoutErr, "https://timeout.example/x", "test") {
t.Fatal("expected logged ISP blocking")
}
if wrapped := WrapErrorWithISPCheck(errors.New("connection refused"), "https://refused.example/x", "test"); wrapped == nil || !strings.Contains(wrapped.Error(), "ISP blocking") {
refusedErr := &net.OpError{Op: "dial", Err: syscall.ECONNREFUSED}
if wrapped := WrapErrorWithISPCheck(refusedErr, "https://refused.example/x", "test"); wrapped == nil || !strings.Contains(wrapped.Error(), "ISP blocking") {
t.Fatalf("WrapErrorWithISPCheck = %v", wrapped)
}
if !isTransientNetworkError(context.DeadlineExceeded) || isTransientNetworkError(&net.DNSError{IsNotFound: true}) {
t.Fatal("isTransientNetworkError mismatch")
}
if !isConnectivityFailure(&net.DNSError{IsNotFound: true}) || !isConnectivityFailure(context.DeadlineExceeded) {
t.Fatal("isConnectivityFailure mismatch")
}
if WrapErrorWithISPCheck(nil, "", "test") != nil {
t.Fatal("nil wrap should stay nil")
}
+1 -7
View File
@@ -144,13 +144,7 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
return resp, nil
}
errStr := strings.ToLower(err.Error())
tlsRelated := strings.Contains(errStr, "tls") ||
strings.Contains(errStr, "handshake") ||
strings.Contains(errStr, "certificate") ||
strings.Contains(errStr, "connection reset")
if tlsRelated {
if isTLSHandshakeOrResetError(err) {
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
reqCopy := req.Clone(req.Context())
+196 -28
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
@@ -92,6 +93,18 @@ type scannedCueFileInfo struct {
audioPath string
}
type libraryScanTask struct {
index int
info libraryAudioFileInfo
}
type libraryScanTaskResult struct {
index int
path string
results []LibraryScanResult
err error
}
func isLibraryStagingFile(path string) bool {
name := strings.ToLower(filepath.Base(path))
if strings.HasSuffix(name, ".partial") {
@@ -150,6 +163,129 @@ func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]li
return files, nil
}
func libraryScanWorkerCount(taskCount int) int {
if taskCount < 16 {
return 1
}
workers := runtime.NumCPU()
if workers > 4 {
workers = 4
}
if workers < 2 {
workers = 2
}
if workers > taskCount {
workers = taskCount
}
return workers
}
func updateLibraryScanProgress(scannedFiles, totalFiles int, currentPath string) {
libraryScanProgressMu.Lock()
libraryScanProgress.ScannedFiles = scannedFiles
libraryScanProgress.CurrentFile = filepath.Base(currentPath)
if totalFiles > 0 {
libraryScanProgress.ProgressPct = float64(scannedFiles) / float64(totalFiles) * 100
}
libraryScanProgressMu.Unlock()
}
func scanLibraryAudioTasksParallel(tasks []libraryScanTask, scanTime string, cancelCh <-chan struct{}, totalFiles int, completed *int) (map[int][]LibraryScanResult, int, error) {
resultsByIndex := make(map[int][]LibraryScanResult, len(tasks))
if len(tasks) == 0 {
return resultsByIndex, 0, nil
}
workers := libraryScanWorkerCount(len(tasks))
if workers <= 1 {
errorCount := 0
for _, task := range tasks {
select {
case <-cancelCh:
return resultsByIndex, errorCount, fmt.Errorf("scan cancelled")
default:
}
result, err := scanAudioFileWithKnownModTime(task.info.path, scanTime, task.info.modTime)
*completed++
updateLibraryScanProgress(*completed, totalFiles, task.info.path)
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning %s: %v\n", task.info.path, err)
continue
}
resultsByIndex[task.index] = []LibraryScanResult{*result}
}
return resultsByIndex, errorCount, nil
}
taskCh := make(chan libraryScanTask)
resultCh := make(chan libraryScanTaskResult, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for task := range taskCh {
select {
case <-cancelCh:
return
default:
}
result, err := scanAudioFileWithKnownModTime(task.info.path, scanTime, task.info.modTime)
taskResult := libraryScanTaskResult{
index: task.index,
path: task.info.path,
err: err,
}
if err == nil && result != nil {
taskResult.results = []LibraryScanResult{*result}
}
select {
case <-cancelCh:
return
case resultCh <- taskResult:
}
}
}()
}
go func() {
defer close(taskCh)
for _, task := range tasks {
select {
case <-cancelCh:
return
case taskCh <- task:
}
}
}()
go func() {
wg.Wait()
close(resultCh)
}()
errorCount := 0
for taskResult := range resultCh {
*completed++
updateLibraryScanProgress(*completed, totalFiles, taskResult.path)
if taskResult.err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning %s: %v\n", taskResult.path, taskResult.err)
continue
}
resultsByIndex[taskResult.index] = taskResult.results
}
select {
case <-cancelCh:
return resultsByIndex, errorCount, fmt.Errorf("scan cancelled")
default:
}
return resultsByIndex, errorCount, nil
}
func SetLibraryCoverCacheDir(cacheDir string) {
libraryCoverCacheMu.Lock()
libraryCoverCacheDir = cacheDir
@@ -225,6 +361,10 @@ func ScanLibraryFolder(folderPath string) (string, error) {
}
}
resultsByIndex := make(map[int][]LibraryScanResult, totalFiles)
audioTasks := make([]libraryScanTask, 0, totalFiles)
completedFiles := 0
for i, fileInfo := range audioFileInfos {
filePath := fileInfo.path
select {
@@ -233,12 +373,6 @@ func ScanLibraryFolder(folderPath string) (string, error) {
default:
}
libraryScanProgressMu.Lock()
libraryScanProgress.ScannedFiles = i + 1
libraryScanProgress.CurrentFile = filepath.Base(filePath)
libraryScanProgress.ProgressPct = float64(i+1) / float64(totalFiles) * 100
libraryScanProgressMu.Unlock()
ext := strings.ToLower(filepath.Ext(filePath))
if ext == ".cue" {
@@ -260,26 +394,44 @@ func ScanLibraryFolder(folderPath string) (string, error) {
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning cue %s: %v\n", filePath, err)
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, filePath)
continue
}
results = append(results, cueResults...)
resultsByIndex[i] = cueResults
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, filePath)
GoLog("[LibraryScan] CUE sheet %s: %d tracks\n", filepath.Base(filePath), len(cueResults))
continue
}
if cueReferencedAudioFiles[filePath] {
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, filePath)
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
continue
}
result, err := scanAudioFileWithKnownModTime(filePath, scanTime, fileInfo.modTime)
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err)
continue
}
audioTasks = append(audioTasks, libraryScanTask{index: i, info: fileInfo})
}
results = append(results, *result)
audioResults, audioErrors, err := scanLibraryAudioTasksParallel(
audioTasks,
scanTime,
cancelCh,
totalFiles,
&completedFiles,
)
if err != nil {
return "[]", err
}
errorCount += audioErrors
for index, scanResults := range audioResults {
resultsByIndex[index] = scanResults
}
for i := range audioFileInfos {
results = append(results, resultsByIndex[i]...)
}
libraryScanProgressMu.Lock()
@@ -874,6 +1026,10 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
}
}
resultsByIndex := make(map[int][]LibraryScanResult, len(filesToScan))
audioTasks := make([]libraryScanTask, 0, len(filesToScan))
completedFiles := skippedCount
for i, f := range filesToScan {
select {
case <-cancelCh:
@@ -881,12 +1037,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
default:
}
libraryScanProgressMu.Lock()
libraryScanProgress.ScannedFiles = skippedCount + i + 1
libraryScanProgress.CurrentFile = filepath.Base(f.path)
libraryScanProgress.ProgressPct = float64(skippedCount+i+1) / float64(totalFiles) * 100
libraryScanProgressMu.Unlock()
ext := strings.ToLower(filepath.Ext(f.path))
if ext == ".cue" {
@@ -908,24 +1058,42 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err)
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
continue
}
results = append(results, cueResults...)
resultsByIndex[i] = cueResults
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
continue
}
if cueReferencedAudioFilesInc[f.path] {
completedFiles++
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
continue
}
result, err := scanAudioFileWithKnownModTime(f.path, scanTime, f.modTime)
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err)
continue
}
audioTasks = append(audioTasks, libraryScanTask{index: i, info: f})
}
results = append(results, *result)
audioResults, audioErrors, err := scanLibraryAudioTasksParallel(
audioTasks,
scanTime,
cancelCh,
totalFiles,
&completedFiles,
)
if err != nil {
return "{}", err
}
errorCount += audioErrors
for index, scanResults := range audioResults {
resultsByIndex[index] = scanResults
}
for i := range filesToScan {
results = append(results, resultsByIndex[i]...)
}
libraryScanProgressMu.Lock()
+502 -149
View File
@@ -20,6 +20,12 @@ const (
durationToleranceSec = 10.0
)
const (
lyricsProviderUnavailableCooldown = 10 * time.Minute
lyricsProviderParallelism = 3
lyricsProviderPriorityGrace = 5000 * time.Millisecond
)
const (
LyricsProviderLRCLIB = "lrclib"
LyricsProviderNetease = "netease"
@@ -46,6 +52,33 @@ var (
appVersion string
)
type lyricsProviderHealthEntry struct {
unavailableUntil time.Time
reason string
}
type lyricsProviderSearchRequest struct {
spotifyID string
trackName string
artistName string
primaryArtist string
simplifiedTrack string
durationSec float64
fetchOptions LyricsFetchOptions
}
type lyricsProviderSearchResult struct {
index int
providerName string
lyrics *LyricsResponse
err error
}
var (
lyricsProviderHealthMu sync.RWMutex
lyricsProviderHealth = make(map[string]lyricsProviderHealthEntry)
)
func SetAppVersion(version string) {
normalized := strings.TrimSpace(version)
@@ -99,6 +132,7 @@ func SetLyricsProviderOrder(providers []string) {
if len(providers) == 0 {
lyricsProviders = nil
clearLyricsProviderHealth()
return
}
@@ -125,9 +159,131 @@ func SetLyricsProviderOrder(providers []string) {
}
lyricsProviders = valid
clearLyricsProviderHealth()
GoLog("[Lyrics] Provider order set to: %v\n", valid)
}
func clearLyricsProviderHealth() {
lyricsProviderHealthMu.Lock()
defer lyricsProviderHealthMu.Unlock()
lyricsProviderHealth = make(map[string]lyricsProviderHealthEntry)
}
func lyricsProviderHealthKey(providerName string) string {
return strings.ToLower(strings.TrimSpace(providerName))
}
func shouldSkipLyricsProvider(providerName string) (bool, time.Duration, string) {
key := lyricsProviderHealthKey(providerName)
if key == "" {
return false, 0, ""
}
now := time.Now()
lyricsProviderHealthMu.RLock()
entry, ok := lyricsProviderHealth[key]
lyricsProviderHealthMu.RUnlock()
if !ok {
return false, 0, ""
}
if !now.Before(entry.unavailableUntil) {
lyricsProviderHealthMu.Lock()
if current, exists := lyricsProviderHealth[key]; exists && !now.Before(current.unavailableUntil) {
delete(lyricsProviderHealth, key)
}
lyricsProviderHealthMu.Unlock()
return false, 0, ""
}
return true, time.Until(entry.unavailableUntil), entry.reason
}
func markLyricsProviderAvailable(providerName string) {
key := lyricsProviderHealthKey(providerName)
if key == "" {
return
}
lyricsProviderHealthMu.Lock()
delete(lyricsProviderHealth, key)
lyricsProviderHealthMu.Unlock()
}
func markLyricsProviderUnavailable(providerName string, err error) {
if err == nil || !isLyricsProviderUnavailableError(err) {
return
}
key := lyricsProviderHealthKey(providerName)
if key == "" {
return
}
reason := strings.TrimSpace(err.Error())
if len(reason) > 160 {
reason = reason[:160]
}
unavailableUntil := time.Now().Add(lyricsProviderUnavailableCooldown)
lyricsProviderHealthMu.Lock()
lyricsProviderHealth[key] = lyricsProviderHealthEntry{
unavailableUntil: unavailableUntil,
reason: reason,
}
lyricsProviderHealthMu.Unlock()
GoLog("[Lyrics] Provider %s marked unavailable for %s: %s\n", providerName, lyricsProviderUnavailableCooldown, reason)
}
var lyricsNotFoundSignals = []string{
"lyrics not found",
"no lyrics found",
"no songs found",
"not found on",
"empty track",
"empty search query",
"needs a deezer id",
}
// Provider/API-level failures that should temporarily disable a lyrics source.
// Transport failures are handled by isConnectivityFailure via typed errors.
var lyricsServiceUnavailableSignals = []string{
"fetch failed",
"missing required parameters",
"request failed",
"request unsuccessful",
"search failed",
"search unavailable",
"rate limit",
"too many requests",
"operation too frequent",
"操作频繁",
"proxy returned http 429",
"proxy returned http 5",
"unexpected status code: 429",
"unexpected status code: 5",
"unexpected response code",
"returned http 429",
"returned http 5",
}
func isLyricsProviderUnavailableError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
for _, signal := range lyricsNotFoundSignals {
if strings.Contains(msg, signal) {
return false
}
}
if isConnectivityFailure(err) {
return true
}
for _, signal := range lyricsServiceUnavailableSignals {
if strings.Contains(msg, signal) {
return true
}
}
return false
}
func GetLyricsProviderOrder() []string {
lyricsProvidersMu.RLock()
defer lyricsProvidersMu.RUnlock()
@@ -474,15 +630,22 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
if len(extensionProviders) > 0 {
for _, provider := range extensionProviders {
providerName := "extension:" + provider.extension.ID
if skip, remaining, reason := shouldSkipLyricsProvider(providerName); skip {
GoLog("[Lyrics] Skipping unavailable extension lyrics provider %s for %s: %s\n", provider.extension.ID, remaining.Round(time.Second), reason)
continue
}
GoLog("[Lyrics] Trying extension lyrics provider: %s\n", provider.extension.ID)
lyrics, err := provider.FetchLyrics(trackName, artistName, "", durationSec)
if err == nil && isValidResult(lyrics) {
GoLog("[Lyrics] Got lyrics from extension: %s\n", provider.extension.ID)
markLyricsProviderAvailable(providerName)
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
if err != nil {
GoLog("[Lyrics] Extension %s failed: %v\n", provider.extension.ID, err)
markLyricsProviderUnavailable(providerName, err)
}
}
}
@@ -496,175 +659,338 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
providerOrder := GetLyricsProviderOrder()
simplifiedTrack := simplifyTrackName(trackName)
request := lyricsProviderSearchRequest{
spotifyID: spotifyID,
trackName: trackName,
artistName: artistName,
primaryArtist: primaryArtist,
simplifiedTrack: simplifiedTrack,
durationSec: durationSec,
fetchOptions: fetchOptions,
}
GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder)
for _, providerName := range providerOrder {
GoLog("[Lyrics] Trying provider: %s\n", providerName)
lyrics, err := fetchBuiltInLyricsProviders(providerOrder, request, c.fetchBuiltInLyricsProvider)
if err == nil && isValidResult(lyrics) {
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
}
var lyrics *LyricsResponse
var err error
return nil, fmt.Errorf("lyrics not found from any source")
}
switch providerName {
case LyricsProviderLRCLIB:
lyrics, err = c.tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack, durationSec)
func fetchBuiltInLyricsProviders(
providerOrder []string,
request lyricsProviderSearchRequest,
fetchProvider func(string, lyricsProviderSearchRequest) (*LyricsResponse, error, bool),
) (*LyricsResponse, error) {
type providerCandidate struct {
index int
name string
}
case LyricsProviderNetease:
neteaseClient := NewNeteaseClient()
lyrics, err = neteaseClient.FetchLyrics(
trackName,
primaryArtist,
durationSec,
fetchOptions.IncludeTranslationNetease,
fetchOptions.IncludeRomanizationNetease,
)
if err != nil && primaryArtist != artistName {
lyrics, err = neteaseClient.FetchLyrics(
trackName,
artistName,
durationSec,
fetchOptions.IncludeTranslationNetease,
fetchOptions.IncludeRomanizationNetease,
)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = neteaseClient.FetchLyrics(
simplifiedTrack,
primaryArtist,
durationSec,
fetchOptions.IncludeTranslationNetease,
fetchOptions.IncludeRomanizationNetease,
)
}
candidates := make([]providerCandidate, 0, len(providerOrder))
results := make(chan lyricsProviderSearchResult, len(providerOrder))
sem := make(chan struct{}, lyricsProviderParallelism)
var wg sync.WaitGroup
case LyricsProviderMusixmatch:
musixmatchClient := NewMusixmatchClient()
lyrics, err = musixmatchClient.FetchLyrics(
trackName,
primaryArtist,
durationSec,
fetchOptions.MusixmatchLanguage,
)
if err != nil && primaryArtist != artistName {
lyrics, err = musixmatchClient.FetchLyrics(
trackName,
artistName,
durationSec,
fetchOptions.MusixmatchLanguage,
)
}
for index, providerName := range providerOrder {
if skip, remaining, reason := shouldSkipLyricsProvider(providerName); skip {
GoLog("[Lyrics] Skipping unavailable provider %s for %s: %s\n", providerName, remaining.Round(time.Second), reason)
continue
}
case LyricsProviderAppleMusic:
appleClient := NewAppleMusicClient()
lyrics, err = appleClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord, fetchOptions.AppleElrcWordSync)
if err != nil && primaryArtist != artistName {
lyrics, err = appleClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord, fetchOptions.AppleElrcWordSync)
}
case LyricsProviderQQMusic:
qqClient := NewQQMusicClient()
lyrics, err = qqClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord)
if err != nil && primaryArtist != artistName {
lyrics, err = qqClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord)
}
case LyricsProviderSpotify:
spotifyClient := NewSpotifyLyricsClient()
lyrics, err = spotifyClient.FetchLyrics(spotifyID, trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = spotifyClient.FetchLyrics(spotifyID, trackName, artistName, durationSec)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = spotifyClient.FetchLyrics("", simplifiedTrack, primaryArtist, durationSec)
}
case LyricsProviderDeezer:
deezerClient := NewDeezerLyricsClient()
lyrics, err = deezerClient.FetchLyrics(spotifyID, trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = deezerClient.FetchLyrics(spotifyID, trackName, artistName, durationSec)
}
case LyricsProviderYouTube:
youtubeClient := NewYouTubeLyricsClient()
lyrics, err = youtubeClient.FetchLyrics(trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = youtubeClient.FetchLyrics(trackName, artistName, durationSec)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = youtubeClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec)
}
case LyricsProviderKugou:
kugouClient := NewKugouLyricsClient()
lyrics, err = kugouClient.FetchLyrics(trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = kugouClient.FetchLyrics(trackName, artistName, durationSec)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = kugouClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec)
}
case LyricsProviderGenius:
geniusClient := NewGeniusLyricsClient()
lyrics, err = geniusClient.FetchLyrics(trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = geniusClient.FetchLyrics(trackName, artistName, durationSec)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = geniusClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec)
}
case LyricsProviderLyricsPlus:
lyricsPlusClient := NewLyricsPlusClient()
lyrics, err = lyricsPlusClient.FetchLyrics(
trackName,
primaryArtist,
"",
durationSec,
fetchOptions.MultiPersonWordByWord,
fetchOptions.AppleElrcWordSync,
)
if err != nil && primaryArtist != artistName {
lyrics, err = lyricsPlusClient.FetchLyrics(
trackName,
artistName,
"",
durationSec,
fetchOptions.MultiPersonWordByWord,
fetchOptions.AppleElrcWordSync,
)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = lyricsPlusClient.FetchLyrics(
simplifiedTrack,
primaryArtist,
"",
durationSec,
fetchOptions.MultiPersonWordByWord,
fetchOptions.AppleElrcWordSync,
)
}
default:
knownProvider := isKnownBuiltInLyricsProvider(providerName)
if !knownProvider {
GoLog("[Lyrics] Unknown provider: %s, skipping\n", providerName)
continue
}
if err == nil && isValidResult(lyrics) {
GoLog("[Lyrics] Got lyrics from: %s\n", providerName)
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
return lyrics, nil
candidate := providerCandidate{index: index, name: providerName}
candidates = append(candidates, candidate)
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
GoLog("[Lyrics] Trying provider: %s\n", candidate.name)
lyrics, err, ok := fetchProvider(candidate.name, request)
if !ok {
results <- lyricsProviderSearchResult{index: candidate.index, providerName: candidate.name, err: fmt.Errorf("unknown provider")}
return
}
if err == nil && lyricsHasUsableText(lyrics) {
GoLog("[Lyrics] Got lyrics from: %s\n", candidate.name)
markLyricsProviderAvailable(candidate.name)
} else if err != nil {
GoLog("[Lyrics] Provider %s failed: %v\n", candidate.name, err)
markLyricsProviderUnavailable(candidate.name, err)
}
results <- lyricsProviderSearchResult{index: candidate.index, providerName: candidate.name, lyrics: lyrics, err: err}
}()
}
if len(candidates) == 0 {
return nil, fmt.Errorf("lyrics not found from any source")
}
go func() {
wg.Wait()
close(results)
}()
completed := make(map[int]bool, len(candidates))
var best *lyricsProviderSearchResult
var lastErr error
var graceTimer *time.Timer
var grace <-chan time.Time
stopGrace := func() {
if graceTimer != nil {
if !graceTimer.Stop() {
select {
case <-graceTimer.C:
default:
}
}
graceTimer = nil
grace = nil
}
}
defer stopGrace()
hasPendingEarlier := func(index int) bool {
for _, candidate := range candidates {
if candidate.index >= index {
return false
}
if !completed[candidate.index] {
return true
}
}
return false
}
for remaining := len(candidates); remaining > 0; {
if best != nil && !hasPendingEarlier(best.index) {
return best.lyrics, nil
}
if best != nil && graceTimer == nil {
graceTimer = time.NewTimer(lyricsProviderPriorityGrace)
grace = graceTimer.C
}
if err != nil {
GoLog("[Lyrics] Provider %s failed: %v\n", providerName, err)
select {
case result, ok := <-results:
if !ok {
remaining = 0
break
}
remaining--
completed[result.index] = true
if result.err != nil {
lastErr = result.err
}
if lyricsHasUsableText(result.lyrics) && (best == nil || result.index < best.index) {
copied := result
best = &copied
stopGrace()
}
case <-grace:
if best != nil {
GoLog("[Lyrics] Returning provider %s after %s priority grace\n", best.providerName, lyricsProviderPriorityGrace)
return best.lyrics, nil
}
}
}
if best != nil {
return best.lyrics, nil
}
if lastErr != nil {
return nil, lastErr
}
return nil, fmt.Errorf("lyrics not found from any source")
}
func isKnownBuiltInLyricsProvider(providerName string) bool {
switch providerName {
case LyricsProviderLRCLIB,
LyricsProviderNetease,
LyricsProviderMusixmatch,
LyricsProviderAppleMusic,
LyricsProviderQQMusic,
LyricsProviderSpotify,
LyricsProviderDeezer,
LyricsProviderYouTube,
LyricsProviderKugou,
LyricsProviderGenius,
LyricsProviderLyricsPlus:
return true
default:
return false
}
}
func (c *LyricsClient) fetchBuiltInLyricsProvider(providerName string, request lyricsProviderSearchRequest) (*LyricsResponse, error, bool) {
switch providerName {
case LyricsProviderLRCLIB:
lyrics, err := c.tryLRCLIB(request.primaryArtist, request.artistName, request.trackName, request.simplifiedTrack, request.durationSec)
return lyrics, err, true
case LyricsProviderNetease:
neteaseClient := NewNeteaseClient()
lyrics, err := neteaseClient.FetchLyrics(
request.trackName,
request.primaryArtist,
request.durationSec,
request.fetchOptions.IncludeTranslationNetease,
request.fetchOptions.IncludeRomanizationNetease,
)
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
lyrics, err = neteaseClient.FetchLyrics(
request.trackName,
request.artistName,
request.durationSec,
request.fetchOptions.IncludeTranslationNetease,
request.fetchOptions.IncludeRomanizationNetease,
)
}
if err != nil && !isLyricsProviderUnavailableError(err) && request.simplifiedTrack != request.trackName {
lyrics, err = neteaseClient.FetchLyrics(
request.simplifiedTrack,
request.primaryArtist,
request.durationSec,
request.fetchOptions.IncludeTranslationNetease,
request.fetchOptions.IncludeRomanizationNetease,
)
}
return lyrics, err, true
case LyricsProviderMusixmatch:
musixmatchClient := NewMusixmatchClient()
lyrics, err := musixmatchClient.FetchLyrics(
request.trackName,
request.primaryArtist,
request.durationSec,
request.fetchOptions.MusixmatchLanguage,
)
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
lyrics, err = musixmatchClient.FetchLyrics(
request.trackName,
request.artistName,
request.durationSec,
request.fetchOptions.MusixmatchLanguage,
)
}
return lyrics, err, true
case LyricsProviderAppleMusic:
appleClient := NewAppleMusicClient()
lyrics, err := appleClient.FetchLyrics(request.trackName, request.primaryArtist, request.durationSec, request.fetchOptions.MultiPersonWordByWord, request.fetchOptions.AppleElrcWordSync)
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
lyrics, err = appleClient.FetchLyrics(request.trackName, request.artistName, request.durationSec, request.fetchOptions.MultiPersonWordByWord, request.fetchOptions.AppleElrcWordSync)
}
return lyrics, err, true
case LyricsProviderQQMusic:
qqClient := NewQQMusicClient()
lyrics, err := qqClient.FetchLyrics(request.trackName, request.primaryArtist, request.durationSec, request.fetchOptions.MultiPersonWordByWord)
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
lyrics, err = qqClient.FetchLyrics(request.trackName, request.artistName, request.durationSec, request.fetchOptions.MultiPersonWordByWord)
}
return lyrics, err, true
case LyricsProviderSpotify:
spotifyClient := NewSpotifyLyricsClient()
lyrics, err := spotifyClient.FetchLyrics(request.spotifyID, request.trackName, request.primaryArtist, request.durationSec)
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
lyrics, err = spotifyClient.FetchLyrics(request.spotifyID, request.trackName, request.artistName, request.durationSec)
}
if err != nil && !isLyricsProviderUnavailableError(err) && request.simplifiedTrack != request.trackName {
lyrics, err = spotifyClient.FetchLyrics("", request.simplifiedTrack, request.primaryArtist, request.durationSec)
}
return lyrics, err, true
case LyricsProviderDeezer:
deezerClient := NewDeezerLyricsClient()
lyrics, err := deezerClient.FetchLyrics(request.spotifyID, request.trackName, request.primaryArtist, request.durationSec)
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
lyrics, err = deezerClient.FetchLyrics(request.spotifyID, request.trackName, request.artistName, request.durationSec)
}
return lyrics, err, true
case LyricsProviderYouTube:
youtubeClient := NewYouTubeLyricsClient()
lyrics, err := youtubeClient.FetchLyrics(request.trackName, request.primaryArtist, request.durationSec)
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
lyrics, err = youtubeClient.FetchLyrics(request.trackName, request.artistName, request.durationSec)
}
if err != nil && !isLyricsProviderUnavailableError(err) && request.simplifiedTrack != request.trackName {
lyrics, err = youtubeClient.FetchLyrics(request.simplifiedTrack, request.primaryArtist, request.durationSec)
}
return lyrics, err, true
case LyricsProviderKugou:
kugouClient := NewKugouLyricsClient()
lyrics, err := kugouClient.FetchLyrics(request.trackName, request.primaryArtist, request.durationSec)
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
lyrics, err = kugouClient.FetchLyrics(request.trackName, request.artistName, request.durationSec)
}
if err != nil && !isLyricsProviderUnavailableError(err) && request.simplifiedTrack != request.trackName {
lyrics, err = kugouClient.FetchLyrics(request.simplifiedTrack, request.primaryArtist, request.durationSec)
}
return lyrics, err, true
case LyricsProviderGenius:
geniusClient := NewGeniusLyricsClient()
lyrics, err := geniusClient.FetchLyrics(request.trackName, request.primaryArtist, request.durationSec)
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
lyrics, err = geniusClient.FetchLyrics(request.trackName, request.artistName, request.durationSec)
}
if err != nil && !isLyricsProviderUnavailableError(err) && request.simplifiedTrack != request.trackName {
lyrics, err = geniusClient.FetchLyrics(request.simplifiedTrack, request.primaryArtist, request.durationSec)
}
return lyrics, err, true
case LyricsProviderLyricsPlus:
lyricsPlusClient := NewLyricsPlusClient()
lyrics, err := lyricsPlusClient.FetchLyrics(
request.trackName,
request.primaryArtist,
"",
request.durationSec,
request.fetchOptions.MultiPersonWordByWord,
request.fetchOptions.AppleElrcWordSync,
)
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
lyrics, err = lyricsPlusClient.FetchLyrics(
request.trackName,
request.artistName,
"",
request.durationSec,
request.fetchOptions.MultiPersonWordByWord,
request.fetchOptions.AppleElrcWordSync,
)
}
if err != nil && !isLyricsProviderUnavailableError(err) && request.simplifiedTrack != request.trackName {
lyrics, err = lyricsPlusClient.FetchLyrics(
request.simplifiedTrack,
request.primaryArtist,
"",
request.durationSec,
request.fetchOptions.MultiPersonWordByWord,
request.fetchOptions.AppleElrcWordSync,
)
}
return lyrics, err, true
default:
return nil, fmt.Errorf("unknown provider: %s", providerName), false
}
}
func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack string, durationSec float64) (*LyricsResponse, error) {
var lyrics *LyricsResponse
var err error
@@ -674,6 +1000,9 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
lyrics.Source = "LRCLIB"
return lyrics, nil
}
if isLyricsProviderUnavailableError(err) {
return nil, err
}
if primaryArtist != artistName {
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
@@ -681,6 +1010,9 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
lyrics.Source = "LRCLIB"
return lyrics, nil
}
if isLyricsProviderUnavailableError(err) {
return nil, err
}
}
if simplifiedTrack != trackName {
@@ -689,6 +1021,9 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
lyrics.Source = "LRCLIB (simplified)"
return lyrics, nil
}
if isLyricsProviderUnavailableError(err) {
return nil, err
}
}
query := primaryArtist + " " + trackName
@@ -697,6 +1032,9 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
lyrics.Source = "LRCLIB Search"
return lyrics, nil
}
if isLyricsProviderUnavailableError(err) {
return nil, err
}
if simplifiedTrack != trackName {
query = primaryArtist + " " + simplifiedTrack
@@ -705,6 +1043,9 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
lyrics.Source = "LRCLIB Search (simplified)"
return lyrics, nil
}
if isLyricsProviderUnavailableError(err) {
return nil, err
}
}
return nil, fmt.Errorf("LRCLIB: no lyrics found")
@@ -848,6 +1189,18 @@ func detectLyricsErrorPayload(raw string) (string, bool) {
if success, ok := payload["success"].(bool); ok && !success && !hasLyricsKey {
return "request unsuccessful", true
}
if isError, ok := payload["isError"].(bool); ok && isError && !hasLyricsKey {
return "request unsuccessful", true
}
if code, ok := payload["code"].(float64); ok && code != 0 && code != 200 && !hasLyricsKey {
if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" {
return strings.TrimSpace(msg), true
}
if msg, ok := payload["msg"].(string); ok && strings.TrimSpace(msg) != "" {
return strings.TrimSpace(msg), true
}
return fmt.Sprintf("unexpected response code %.0f", code), true
}
return "", false
}
+6 -3
View File
@@ -2,6 +2,7 @@ package gobackend
import (
"encoding/json"
"errors"
"fmt"
"io"
"math"
@@ -13,6 +14,8 @@ import (
"time"
)
var errAppleMusicUnauthorized = errors.New("apple music catalog search unauthorized")
type AppleMusicClient struct {
httpClient *http.Client
}
@@ -188,7 +191,7 @@ func (c *AppleMusicClient) getAppleMusicToken() (string, error) {
return "", fmt.Errorf("failed to read apple music script: %w", err)
}
token := regexp.MustCompile(`eyJh[^"' <]+`).FindString(string(jsBody))
token := regexp.MustCompile(`eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+`).FindString(string(jsBody))
if token == "" {
return "", fmt.Errorf("apple music token not found")
}
@@ -235,7 +238,7 @@ func (c *AppleMusicClient) searchSongWithToken(token, query string) ([]appleMusi
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return nil, fmt.Errorf("apple music catalog search unauthorized")
return nil, errAppleMusicUnauthorized
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("apple music catalog search returned HTTP %d", resp.StatusCode)
@@ -281,7 +284,7 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec
}
searchResp, err := c.searchSongWithToken(token, strings.TrimSpace(query))
if err != nil && strings.Contains(strings.ToLower(err.Error()), "unauthorized") {
if errors.Is(err, errAppleMusicUnauthorized) {
clearAppleMusicToken()
token, tokenErr := c.getAppleMusicToken()
if tokenErr != nil {
+1 -5
View File
@@ -24,12 +24,8 @@ import (
// Public LyricsPlus / KPOE servers (mirrors). Tried in order with failover.
// Sourced from the upstream YouLy+ client server list.
var lyricsPlusServers = []string{
"https://lyricsplus.prjktla.my.id",
"https://lyricsplus.atomix.one",
"https://lyricsplus.binimum.org",
"https://lyricsplus.prjktla.workers.dev",
"https://lyricsplus-seven.vercel.app",
"https://lyrics-plus-backend.vercel.app",
"https://lyricsplus.binimum.org",
}
type LyricsPlusClient struct {
+14 -1
View File
@@ -24,7 +24,9 @@ type neteaseSearchResponse struct {
} `json:"songs"`
SongCount int `json:"songCount"`
} `json:"result"`
Code int `json:"code"`
Code int `json:"code"`
Message string `json:"message"`
Msg string `json:"msg"`
}
type neteaseLyricsResponse struct {
@@ -87,6 +89,17 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
return 0, fmt.Errorf("failed to decode netease search: %w", err)
}
if searchResp.Code != 0 && searchResp.Code != 200 {
message := strings.TrimSpace(searchResp.Message)
if message == "" {
message = strings.TrimSpace(searchResp.Msg)
}
if message == "" {
message = "unexpected response code"
}
return 0, fmt.Errorf("netease search unavailable: code %d: %s", searchResp.Code, message)
}
if searchResp.Result.SongCount == 0 || len(searchResp.Result.Songs) == 0 {
return 0, fmt.Errorf("no songs found on netease")
}
+1 -1
View File
@@ -463,7 +463,7 @@ func (c *GeniusLyricsClient) SearchSong(trackName, artistName string, durationSe
params := url.Values{}
params.Set("q", query)
params.Set("per_page", "10")
params.Set("per_page", "5")
raw, err := fetchPaxsenixBody(c.httpClient, "https://genius.com/api/search/multi", params)
if err != nil {
return "", fmt.Errorf("genius search failed: %w", err)
+131 -1
View File
@@ -1,6 +1,7 @@
package gobackend
import (
"errors"
"io"
"net/http"
"path/filepath"
@@ -54,6 +55,15 @@ func TestLyricsCacheParsingAndLRCLibClient(t *testing.T) {
if msg, ok := detectLyricsErrorPayload(`{"success":false,"message":"nope"}`); !ok || msg != "nope" {
t.Fatalf("error payload = %q/%v", msg, ok)
}
if msg, ok := detectLyricsErrorPayload(`{"isError":true,"error":"Missing required parameters"}`); !ok || msg != "Missing required parameters" {
t.Fatalf("isError payload = %q/%v", msg, ok)
}
if msg, ok := detectLyricsErrorPayload(`{"code":405,"message":"rate limited"}`); !ok || msg != "rate limited" {
t.Fatalf("coded error payload = %q/%v", msg, ok)
}
if !isLyricsProviderUnavailableError(errors.New("rate limit")) {
t.Fatal("expected rate-limit errors to mark provider unavailable")
}
if lrcTimestampToMs("01", "02", "345") != 62345 || msToLRCTimestamp(62340) != "[01:02.34]" {
t.Fatal("unexpected LRC timestamp conversion")
}
@@ -130,9 +140,120 @@ func TestLyricsCacheParsingAndLRCLibClient(t *testing.T) {
}
}
func TestLyricsProviderHealthSkipsUnavailableProvider(t *testing.T) {
SetLyricsProviderOrder([]string{LyricsProviderLRCLIB})
defer SetLyricsProviderOrder(nil)
globalLyricsCache.ClearAll()
clearLyricsProviderHealth()
defer clearLyricsProviderHealth()
calls := 0
downClient := &LyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
return &http.Response{StatusCode: 503, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`service unavailable`)), Request: req}, nil
})}}
if lyrics, err := downClient.FetchLyricsAllSources("", "Down Song", "Artist", 180); err == nil || lyrics != nil {
t.Fatalf("expected unavailable provider error, got %#v/%v", lyrics, err)
}
if calls != 1 {
t.Fatalf("expected one HTTP call before cooldown, got %d", calls)
}
if skip, _, _ := shouldSkipLyricsProvider(LyricsProviderLRCLIB); !skip {
t.Fatal("expected LRCLIB to be marked unavailable")
}
if lyrics, err := downClient.FetchLyricsAllSources("", "Another Song", "Artist", 180); err == nil || lyrics != nil {
t.Fatalf("expected skipped provider error, got %#v/%v", lyrics, err)
}
if calls != 1 {
t.Fatalf("provider was called while in cooldown, calls=%d", calls)
}
clearLyricsProviderHealth()
globalLyricsCache.ClearAll()
notFoundCalls := 0
notFoundClient := &LyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
notFoundCalls++
switch req.URL.Path {
case "/api/get":
return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
case "/api/search":
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[]`)), Request: req}, nil
default:
return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
}
})}}
if lyrics, err := notFoundClient.FetchLyricsAllSources("", "missing song", "Artist", 180); err == nil || lyrics != nil {
t.Fatalf("expected not found error, got %#v/%v", lyrics, err)
}
if skip, _, _ := shouldSkipLyricsProvider(LyricsProviderLRCLIB); skip {
t.Fatal("not-found result must not mark provider unavailable")
}
if lyrics, err := notFoundClient.FetchLyricsAllSources("", "missing song 2", "Artist", 180); err == nil || lyrics != nil {
t.Fatalf("expected second not found error, got %#v/%v", lyrics, err)
}
if notFoundCalls != 4 {
t.Fatalf("expected not-found provider to be retried, calls=%d", notFoundCalls)
}
}
func TestConcurrentLyricsProvidersReturnFastFallback(t *testing.T) {
clearLyricsProviderHealth()
defer clearLyricsProviderHealth()
start := time.Now()
lyrics, err := fetchBuiltInLyricsProviders(
[]string{LyricsProviderLRCLIB, LyricsProviderAppleMusic},
lyricsProviderSearchRequest{},
func(providerName string, _ lyricsProviderSearchRequest) (*LyricsResponse, error, bool) {
if providerName == LyricsProviderLRCLIB {
time.Sleep(lyricsProviderPriorityGrace + 800*time.Millisecond)
return &LyricsResponse{Provider: "LRCLIB", PlainLyrics: "slow"}, nil, true
}
return &LyricsResponse{Provider: "Apple Music", PlainLyrics: "fast"}, nil, true
},
)
if err != nil {
t.Fatalf("concurrent providers returned error: %v", err)
}
if lyrics == nil || lyrics.Provider != "Apple Music" {
t.Fatalf("expected fast fallback lyrics, got %#v", lyrics)
}
if elapsed := time.Since(start); elapsed >= lyricsProviderPriorityGrace+700*time.Millisecond {
t.Fatalf("fallback waited too long: %s", elapsed)
}
}
func TestConcurrentLyricsProvidersPreferEarlierProviderWithinGrace(t *testing.T) {
clearLyricsProviderHealth()
defer clearLyricsProviderHealth()
lyrics, err := fetchBuiltInLyricsProviders(
[]string{LyricsProviderLRCLIB, LyricsProviderAppleMusic},
lyricsProviderSearchRequest{},
func(providerName string, _ lyricsProviderSearchRequest) (*LyricsResponse, error, bool) {
if providerName == LyricsProviderLRCLIB {
time.Sleep(50 * time.Millisecond)
return &LyricsResponse{Provider: "LRCLIB", PlainLyrics: "preferred"}, nil, true
}
return &LyricsResponse{Provider: "Apple Music", PlainLyrics: "fast"}, nil, true
},
)
if err != nil {
t.Fatalf("concurrent providers returned error: %v", err)
}
if lyrics == nil || lyrics.Provider != "LRCLIB" {
t.Fatalf("expected preferred provider lyrics, got %#v", lyrics)
}
}
func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
clearAppleMusicToken()
defer clearAppleMusicToken()
if len(lyricsPlusServers) == 0 || lyricsPlusServers[0] != "https://lyricsplus.prjktla.workers.dev" {
t.Fatalf("unexpected LyricsPlus server order = %#v", lyricsPlusServers)
}
paxJSON := `{"type":"Syllable","content":[{"timestamp":1000,"oppositeTurn":true,"background":true,"text":[{"text":"Hel","part":true,"timestamp":1000},{"text":"lo","part":false,"timestamp":1200,"endtime":1500}],"backgroundText":[{"text":"bg","part":false,"timestamp":900}]}]}`
apple := &AppleMusicClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
@@ -140,7 +261,7 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
case req.URL.Host == "beta.music.apple.com" && (req.URL.Path == "" || req.URL.Path == "/"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`<script src="/assets/index~test.js"></script>`)), Request: req}, nil
case req.URL.Host == "beta.music.apple.com" && req.URL.Path == "/assets/index~test.js":
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`const token="eyJhbGci.test";`)), Request: req}, nil
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`const token="eyJ0eXAiOiJKV1Q.eyJpc3MiOiJ0ZXN0.c2ln";`)), Request: req}, nil
case req.URL.Host == "amp-api.music.apple.com" && strings.Contains(req.URL.Path, "/v1/catalog/us/search"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"results":{"songs":{"data":[{"id":"apple-2"},{"id":"apple-1"}]}},"resources":{"songs":{"apple-2":{"attributes":{"name":"Other","artistName":"Other","durationInMillis":1000}},"apple-1":{"attributes":{"name":"Song","artistName":"Artist","albumName":"Album","durationInMillis":180000}}}}}`)), Request: req}, nil
case strings.Contains(req.URL.Path, "/apple-music/lyrics"):
@@ -236,6 +357,12 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
if _, err := netease.SearchSong("", ""); err == nil {
t.Fatal("expected empty netease search error")
}
rateLimitedNetease := &NeteaseClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"msg":"操作频繁,请稍候再试","code":405,"message":"操作频繁,请稍候再试"}`)), Request: req}, nil
})}}
if _, err := rateLimitedNetease.SearchSong("Song", "Artist"); err == nil || !isLyricsProviderUnavailableError(err) {
t.Fatalf("expected unavailable netease rate-limit error, got %v", err)
}
qq := &QQMusicClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.Method != http.MethodPost {
@@ -311,6 +438,9 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
genius := &GeniusLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/api/search/multi"):
if got := req.URL.Query().Get("per_page"); got != "5" {
t.Fatalf("genius per_page = %q", got)
}
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"response":{"sections":[{"hits":[{"type":"song","result":{"title":"Song","primary_artist_names":"Artist","url":"https://genius.com/artist-song-lyrics"}}]}]}}`)), Request: req}, nil
case strings.Contains(req.URL.Path, "/genius/lyrics"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"error":false,"lyrics":"Genius line"}`)), Request: req}, nil
+68
View File
@@ -0,0 +1,68 @@
package gobackend
import (
"os"
"path/filepath"
"testing"
)
func TestEditM4AFreeformTextWritesISRCAndLabel(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "track.m4a")
ilst := buildM4ATextTag("\xa9nam", "Title")
if err := os.WriteFile(path, buildM4AFileWithIlst(ilst, true), 0600); err != nil {
t.Fatal(err)
}
if err := EditM4AFreeformText(path, map[string]string{
"isrc": "USRC17607839",
"label": "Some Label",
}); err != nil {
t.Fatalf("EditM4AFreeformText: %v", err)
}
meta, err := ReadM4ATags(path)
if err != nil {
t.Fatalf("ReadM4ATags: %v", err)
}
if meta.ISRC != "USRC17607839" {
t.Fatalf("ISRC = %q, want USRC17607839", meta.ISRC)
}
if meta.Label != "Some Label" {
t.Fatalf("Label = %q, want Some Label", meta.Label)
}
if meta.Title != "Title" {
t.Fatalf("Title = %q, want Title (existing tag must survive)", meta.Title)
}
}
func TestEditM4AFreeformTextReplacesExisting(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "track.m4a")
ilst := buildM4ATextTag("\xa9nam", "Title")
ilst = append(ilst, buildM4AFreeformAtom("ISRC", "OLDISRC00001")...)
ilst = append(ilst, buildM4AFreeformAtom("LABEL", "Old Label")...)
if err := os.WriteFile(path, buildM4AFileWithIlst(ilst, true), 0600); err != nil {
t.Fatal(err)
}
if err := EditM4AFreeformText(path, map[string]string{
"isrc": "NEWISRC00002",
"label": "",
}); err != nil {
t.Fatalf("EditM4AFreeformText: %v", err)
}
meta, err := ReadM4ATags(path)
if err != nil {
t.Fatalf("ReadM4ATags: %v", err)
}
if meta.ISRC != "NEWISRC00002" {
t.Fatalf("ISRC = %q, want NEWISRC00002", meta.ISRC)
}
if meta.Label != "" {
t.Fatalf("Label = %q, want empty (cleared)", meta.Label)
}
}
+187 -42
View File
@@ -6,7 +6,7 @@ import (
"fmt"
stdimage "image"
_ "image/gif"
_ "image/jpeg"
"image/jpeg"
_ "image/png"
"io"
"math"
@@ -71,11 +71,83 @@ func detectCoverMIME(coverPath string, coverData []byte) string {
return "image/jpeg"
}
// maxFlacPictureBytes keeps cover art below the 24-bit length field of a FLAC
// metadata block; go-flac silently truncates oversized blocks into a corrupt file.
const maxFlacPictureBytes = 16 * 1000 * 1000
// fitCoverForFlac returns cover bytes that fit inside a FLAC PICTURE block,
// re-encoding and downscaling when needed. Returns false if the data cannot be
// decoded as an image.
func fitCoverForFlac(coverData []byte) ([]byte, bool) {
if len(coverData) <= maxFlacPictureBytes {
return coverData, true
}
img, _, err := stdimage.Decode(bytes.NewReader(coverData))
if err != nil {
return nil, false
}
for _, quality := range []int{90, 80, 70, 60} {
if encoded, ok := encodeJPEGUnder(img, quality, maxFlacPictureBytes); ok {
return encoded, true
}
}
for _, maxDim := range []int{1500, 1200, 1000, 800} {
scaled := downscaleImage(img, maxDim)
if encoded, ok := encodeJPEGUnder(scaled, 85, maxFlacPictureBytes); ok {
return encoded, true
}
}
return nil, false
}
func encodeJPEGUnder(img stdimage.Image, quality, limit int) ([]byte, bool) {
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err != nil {
return nil, false
}
if buf.Len() > limit {
return nil, false
}
return buf.Bytes(), true
}
func downscaleImage(img stdimage.Image, maxDim int) stdimage.Image {
bounds := img.Bounds()
width, height := bounds.Dx(), bounds.Dy()
if width <= maxDim && height <= maxDim {
return img
}
scale := float64(maxDim) / float64(max(width, height))
newWidth := max(1, int(float64(width)*scale))
newHeight := max(1, int(float64(height)*scale))
dst := stdimage.NewRGBA(stdimage.Rect(0, 0, newWidth, newHeight))
for y := 0; y < newHeight; y++ {
srcY := bounds.Min.Y + int(float64(y)/scale)
for x := 0; x < newWidth; x++ {
srcX := bounds.Min.X + int(float64(x)/scale)
dst.Set(x, y, img.At(srcX, srcY))
}
}
return dst
}
func buildPictureBlock(coverPath string, coverData []byte) (flac.MetaDataBlock, error) {
if len(coverData) == 0 {
return flac.MetaDataBlock{}, fmt.Errorf("empty cover data")
}
fitted, ok := fitCoverForFlac(coverData)
if !ok {
return flac.MetaDataBlock{}, fmt.Errorf("cover too large for FLAC picture block and could not be resized")
}
coverData = fitted
mime := detectCoverMIME(coverPath, coverData)
picture := &flacpicture.MetadataBlockPicture{
PictureType: flacpicture.PictureTypeFrontCover,
@@ -175,10 +247,11 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
picBlock, err := buildPictureBlock(coverPath, coverData)
if err != nil {
return fmt.Errorf("failed to create picture block: %w", err)
fmt.Printf("[Metadata] Warning: skipping cover art: %v\n", err)
} else {
f.Meta = append(f.Meta, &picBlock)
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
}
f.Meta = append(f.Meta, &picBlock)
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
}
} else {
fmt.Printf("[Metadata] Warning: Cover file does not exist: %s\n", coverPath)
@@ -230,10 +303,11 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
picBlock, err := buildPictureBlock("", coverData)
if err != nil {
return fmt.Errorf("failed to create picture block: %w", err)
fmt.Printf("[Metadata] Warning: skipping cover art: %v\n", err)
} else {
f.Meta = append(f.Meta, &picBlock)
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
}
f.Meta = append(f.Meta, &picBlock)
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
}
return f.Save(filePath)
@@ -1123,9 +1197,7 @@ func findM4AIlstAtom(f *os.File, fileSize int64) (atomHeader, error) {
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 {
if ilst, ok3 := findIlstInMeta(f, meta, fileSize); ok3 {
return ilst, nil
}
}
@@ -1133,9 +1205,7 @@ func findM4AIlstAtom(f *os.File, fileSize int64) (atomHeader, error) {
// 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 {
if ilst, ok2 := findIlstInMeta(f, meta, fileSize); ok2 {
return ilst, nil
}
}
@@ -1143,6 +1213,26 @@ func findM4AIlstAtom(f *os.File, fileSize int64) (atomHeader, error) {
return atomHeader{}, fmt.Errorf("ilst not found (tried moov>udta>meta>ilst and moov>meta>ilst)")
}
// findIlstInMeta locates the ilst atom inside a meta atom, handling both
// layouts: ISO-BMFF (4-byte version/flags before the child atoms, written by
// FFmpeg's mp4 muxer) and QuickTime (no version/flags, written by the mov muxer
// used for AC-4 passthrough).
func findIlstInMeta(f *os.File, meta atomHeader, fileSize int64) (atomHeader, bool) {
// ISO-BMFF: skip the 4-byte version/flags that precede the child atoms.
isoStart := meta.offset + meta.headerSize + 4
isoSize := meta.size - meta.headerSize - 4
if ilst, ok, _ := findAtomInRange(f, isoStart, isoSize, "ilst", fileSize); ok {
return ilst, true
}
// QuickTime: child atoms begin immediately after the meta header.
qtStart := meta.offset + meta.headerSize
qtSize := meta.size - meta.headerSize
if ilst, ok, _ := findAtomInRange(f, qtStart, qtSize, "ilst", fileSize); ok {
return ilst, true
}
return atomHeader{}, false
}
func readM4ADataAtomPayload(f *os.File, dataAtom atomHeader) ([]byte, error) {
payloadStart := dataAtom.offset + dataAtom.headerSize + 8
payloadLen := dataAtom.size - dataAtom.headerSize - 8
@@ -1280,9 +1370,7 @@ func findM4AMetadataPath(f *os.File, fileSize int64) (m4aMetadataPath, error) {
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 {
if ilst, ok3 := findIlstInMeta(f, meta, fileSize); ok3 {
udtaCopy := udta
return m4aMetadataPath{
moov: moov,
@@ -1295,9 +1383,7 @@ func findM4AMetadataPath(f *os.File, fileSize int64) (m4aMetadataPath, error) {
}
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 {
if ilst, ok2 := findIlstInMeta(f, meta, fileSize); ok2 {
return m4aMetadataPath{
moov: moov,
meta: meta,
@@ -1432,6 +1518,51 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
return nil
}
remove := map[string]struct{}{
"REPLAYGAIN_TRACK_GAIN": {},
"REPLAYGAIN_TRACK_PEAK": {},
"REPLAYGAIN_ALBUM_GAIN": {},
"REPLAYGAIN_ALBUM_PEAK": {},
"ITUNNORM": {},
}
order := []string{
"replaygain_track_gain",
"replaygain_track_peak",
"replaygain_album_gain",
"replaygain_album_peak",
"iTunNORM",
}
tags := make([]m4aFreeformTag, 0, len(order))
for _, key := range order {
value := strings.TrimSpace(replayGainFields[key])
if value == "" {
continue
}
name := key
if key != "iTunNORM" {
name = strings.ToLower(key)
}
tags = append(tags, m4aFreeformTag{name: name, value: value})
}
return writeM4AFreeformTags(filePath, remove, tags)
}
type m4aFreeformTag struct {
name string
value string
}
// writeM4AFreeformTags rewrites the ilst atom in place: it drops every existing
// freeform ("----") atom whose uppercased name is in `remove`, then appends the
// supplied tags (empty values are skipped, which effectively clears the field).
// Atom sizes are fixed up along the ilst -> meta -> udta -> moov chain.
//
// FFmpeg's MP4 muxer only writes a fixed set of recognized keys to the ilst, so
// fields like ISRC and LABEL are silently dropped when written via -metadata.
// Writing them as iTunes freeform atoms natively is the only way they persist.
func writeM4AFreeformTags(filePath string, remove map[string]struct{}, tags []m4aFreeformTag) error {
f, err := os.Open(filePath)
if err != nil {
return err
@@ -1445,6 +1576,13 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
path, err := findM4AMetadataPath(f, info.Size())
if err != nil {
// MOV-style containers (e.g. AC-4 passthrough) store tags as QuickTime
// atoms under udta with no iTunes meta>ilst structure. There is nowhere
// to write freeform tags, so skip gracefully instead of failing.
if strings.Contains(err.Error(), "ilst not found") {
GoLog("[Metadata] No iTunes ilst container; skipping freeform tags")
return nil
}
return err
}
@@ -1456,13 +1594,6 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
bodyStart := path.ilst.offset + path.ilst.headerSize
bodyEnd := path.ilst.offset + path.ilst.size
newBody := make([]byte, 0, int(path.ilst.size))
targets := map[string]struct{}{
"REPLAYGAIN_TRACK_GAIN": {},
"REPLAYGAIN_TRACK_PEAK": {},
"REPLAYGAIN_ALBUM_GAIN": {},
"REPLAYGAIN_ALBUM_PEAK": {},
"ITUNNORM": {},
}
for pos := bodyStart; pos+8 <= bodyEnd; {
header, readErr := readAtomHeaderAt(f, pos, info.Size())
@@ -1480,7 +1611,7 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
if header.typ == "----" {
name, _, freeformErr := readM4AFreeformValue(f, header, info.Size())
if freeformErr == nil {
if _, ok := targets[strings.ToUpper(strings.TrimSpace(name))]; ok {
if _, ok := remove[strings.ToUpper(strings.TrimSpace(name))]; ok {
keep = false
}
}
@@ -1492,23 +1623,11 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
pos += header.size
}
order := []string{
"replaygain_track_gain",
"replaygain_track_peak",
"replaygain_album_gain",
"replaygain_album_peak",
"iTunNORM",
}
for _, key := range order {
value := strings.TrimSpace(replayGainFields[key])
if value == "" {
for _, tag := range tags {
if strings.TrimSpace(tag.value) == "" {
continue
}
name := key
if key != "iTunNORM" {
name = strings.ToLower(key)
}
newBody = append(newBody, buildM4AFreeformAtom(name, value)...)
newBody = append(newBody, buildM4AFreeformAtom(tag.name, tag.value)...)
}
newIlst := buildM4AAtom("ilst", newBody)
@@ -1535,6 +1654,32 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
return os.WriteFile(filePath, updated, 0o644)
}
// EditM4AFreeformText writes ISRC and label tags into an M4A/MP4 file as iTunes
// freeform atoms. These keys are not part of FFmpeg's MP4 metadata key set, so
// they must be written natively for the values to actually persist. An empty
// value clears the corresponding tag. Other (recognized) tags are left intact.
func EditM4AFreeformText(filePath string, fields map[string]string) error {
_, hasISRC := fields["isrc"]
_, hasLabel := fields["label"]
if !hasISRC && !hasLabel {
return nil
}
remove := map[string]struct{}{}
tags := make([]m4aFreeformTag, 0, 2)
if hasISRC {
remove["ISRC"] = struct{}{}
tags = append(tags, m4aFreeformTag{name: "ISRC", value: strings.TrimSpace(fields["isrc"])})
}
if hasLabel {
remove["LABEL"] = struct{}{}
remove["ORGANIZATION"] = struct{}{}
tags = append(tags, m4aFreeformTag{name: "LABEL", value: strings.TrimSpace(fields["label"])})
}
return writeM4AFreeformTags(filePath, remove, tags)
}
func extractLyricsFromSidecarLRC(filePath string) (string, error) {
ext := filepath.Ext(filePath)
base := strings.TrimSuffix(filePath, ext)
+1 -1
View File
@@ -35,7 +35,7 @@ func openOutputForWrite(outputPath string, outputFD int) (*os.File, error) {
if err == nil {
return file, nil
}
if strings.Contains(strings.ToLower(err.Error()), "permission denied") {
if os.IsPermission(err) {
return os.OpenFile(path, os.O_WRONLY, 0)
}
return nil, err
-16
View File
@@ -41,8 +41,6 @@ const (
wavFormatExtensn = 0xFFFE
)
// ---------- low-level chunk size helpers ----------
func putUint32(dst []byte, le bool, v uint32) {
if le {
binary.LittleEndian.PutUint32(dst, v)
@@ -95,8 +93,6 @@ func parseExtendedFloat80(b []byte) float64 {
return sign * float64(mantissa) * math.Pow(2, float64(exponent-16383-63))
}
// ---------- WAV (RIFF) ----------
type wavProbe struct {
sampleRate int
bitDepth int
@@ -289,8 +285,6 @@ func ReadWAVTags(filePath string) (*AudioMetadata, error) {
return meta, nil
}
// ---------- AIFF / AIFC ----------
type aiffProbe struct {
sampleRate int
bitDepth int
@@ -443,8 +437,6 @@ func ReadAIFFTags(filePath string) (*AudioMetadata, error) {
return meta, nil
}
// ---------- ID3v2 reading from a buffered chunk ----------
// readID3v2FromBytes parses an in-memory ID3v2 tag (the contents of a WAV "id3 "
// or AIFF "ID3 " chunk) by reusing the existing frame parsers.
func readID3v2FromBytes(data []byte) (*AudioMetadata, error) {
@@ -535,8 +527,6 @@ func extractAPICFromID3(tag []byte) ([]byte, string) {
return nil, ""
}
// ---------- ID3v2.4 building ----------
// buildID3v24Tag builds a UTF-8 ID3v2.4 tag from metadata plus optional cover.
func buildID3v24Tag(meta *AudioMetadata, coverData []byte, coverMIME string) []byte {
var frames bytes.Buffer
@@ -642,8 +632,6 @@ func buildID3v24Tag(meta *AudioMetadata, coverData []byte, coverMIME string) []b
return out.Bytes()
}
// ---------- tag writing (streaming chunk rewrite) ----------
// writeID3Chunk rewrites filePath, replacing any existing tag chunk (chunkID,
// matched case-insensitively) with a fresh ID3v2.4 chunk appended at the end.
// The audio data and all other chunks are preserved; container size is patched.
@@ -692,7 +680,6 @@ func writeID3Chunk(filePath, expectMagic, chunkID string, le bool, id3 []byte) e
pad := int64(size) & 1
if strings.EqualFold(id, chunkID) {
// Drop the existing tag chunk.
if _, err := in.Seek(int64(size)+pad, io.SeekCurrent); err != nil {
cleanup()
return err
@@ -711,7 +698,6 @@ func writeID3Chunk(filePath, expectMagic, chunkID string, le bool, id3 []byte) e
bodyLen += 8 + int64(size) + pad
}
// Append the new tag chunk.
newSize := len(id3)
chunkHdr := make([]byte, 8)
copy(chunkHdr[0:4], chunkID)
@@ -890,8 +876,6 @@ func WriteAIFFTags(filePath string, fields map[string]string) error {
return writeID3Chunk(filePath, "FORM", id3ChunkAIFF, false, tag)
}
// ---------- library scan integration ----------
func scanWAVFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
if metadata, err := ReadWAVTags(filePath); err == nil && metadata != nil {
applyAudioMetadataToScan(metadata, result)
+48 -19
View File
@@ -1,6 +1,6 @@
import Flutter
import UIKit
import Gobackend // Import Go framework
import Gobackend
@main
@objc class AppDelegate: FlutterAppDelegate {
@@ -17,6 +17,8 @@ import Gobackend // Import Go framework
private var libraryScanProgressTimer: DispatchSourceTimer?
private var libraryScanProgressEventSink: FlutterEventSink?
private var lastLibraryScanProgressPayload: String?
private var backendChannel: FlutterMethodChannel?
private var pendingSessionGrantEvents: [[String: Any]] = []
/// Currently accessed security-scoped URL for library folder
private var activeSecurityScopedURL: URL?
@@ -39,6 +41,14 @@ import Gobackend // Import Go framework
name: CHANNEL,
binaryMessenger: controller.binaryMessenger
)
backendChannel = channel
if !pendingSessionGrantEvents.isEmpty {
let events = pendingSessionGrantEvents
pendingSessionGrantEvents.removeAll()
for event in events {
channel.invokeMethod("extensionSessionGrantCompleted", arguments: event)
}
}
let downloadProgressEvents = FlutterEventChannel(
name: DOWNLOAD_PROGRESS_STREAM_CHANNEL,
binaryMessenger: controller.binaryMessenger
@@ -83,20 +93,25 @@ import Gobackend // Import Go framework
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
/// PKCE OAuth return URL: spotiflac://callback?code=...&state=<extension_id>
/// Extension return URLs:
/// - OAuth: spotiflac://callback?code=...&state=<extension_id>
/// - Signed session: spotiflac://session-grant?grant=...&state=<extension_id>
@discardableResult
private func handleExtensionOAuthRedirect(url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased(), scheme == "spotiflac" else { return false }
let host = (url.host ?? "").lowercased()
let path = url.path.lowercased()
let isSessionGrant = host == "session-grant"
let ok =
host == "callback" || host == "spotify-callback" || path.contains("callback")
isSessionGrant || host == "callback" || host == "spotify-callback" || path.contains("callback")
guard ok else { return false }
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return false
}
let q = components.queryItems ?? []
let code =
q.first { $0.name == (isSessionGrant ? "grant" : "code") }?.value?.trimmingCharacters(
in: .whitespacesAndNewlines) ??
q.first { $0.name == "code" }?.value?.trimmingCharacters(
in: .whitespacesAndNewlines) ?? ""
let state =
@@ -109,16 +124,37 @@ import Gobackend // Import Go framework
}
streamQueue.async {
var err: NSError?
GobackendSetExtensionAuthCodeByID(state, code)
_ = GobackendInvokeExtensionActionJSON(state, "completeSpotifyLogin", &err)
if isSessionGrant {
GobackendSetExtensionSessionGrantByID(state, code)
_ = GobackendInvokeExtensionActionJSON(state, "completeGrant", &err)
} else {
GobackendSetExtensionAuthCodeByID(state, code)
_ = GobackendInvokeExtensionActionJSON(state, "completeSpotifyLogin", &err)
}
if let err = err {
NSLog(
"SpotiFLAC: Extension OAuth complete failed: \(err.localizedDescription)")
"SpotiFLAC: Extension callback complete failed: \(err.localizedDescription)")
} else if isSessionGrant {
DispatchQueue.main.async { [weak self] in
self?.notifySessionGrantCompleted(extensionId: state)
}
}
}
return true
}
private func notifySessionGrantCompleted(extensionId: String) {
let payload: [String: Any] = [
"extension_id": extensionId,
"success": true,
]
if let channel = backendChannel {
channel.invokeMethod("extensionSessionGrantCompleted", arguments: payload)
} else {
pendingSessionGrantEvents.append(payload)
}
}
override func application(
_ app: UIApplication,
open url: URL,
@@ -357,6 +393,12 @@ import Gobackend // Import Go framework
let insecureTLS = args["insecure_tls"] as? Bool ?? false
GobackendSetNetworkCompatibilityOptions(allowHTTP, insecureTLS)
return nil
case "setAllowPrivateNetwork":
let args = call.arguments as! [String: Any]
let allowed = args["allowed"] as? Bool ?? false
GobackendSetAllowPrivateNetwork(allowed)
return nil
case "checkDuplicate":
let args = call.arguments as! [String: Any]
@@ -590,7 +632,6 @@ import Gobackend // Import Go framework
GobackendClearTrackCache()
return nil
// Log methods
case "getLogs":
let response = GobackendGetLogs()
return response
@@ -615,7 +656,6 @@ import Gobackend // Import Go framework
GobackendSetLoggingEnabled(enabled)
return nil
// Extension System methods
case "initExtensionSystem":
let args = call.arguments as! [String: Any]
let extensionsDir = args["extensions_dir"] as! String
@@ -780,7 +820,6 @@ import Gobackend // Import Go framework
GobackendCleanupExtensions()
return nil
// Extension Auth API
case "getExtensionPendingAuth":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
@@ -821,7 +860,6 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
// Extension FFmpeg API
case "getPendingFFmpegCommand":
let args = call.arguments as! [String: Any]
let commandId = args["command_id"] as! String
@@ -843,7 +881,6 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
// Extension Custom Search API
case "customSearchWithExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
@@ -865,7 +902,6 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
// Extension URL Handler API
case "handleURLWithExtension":
let args = call.arguments as! [String: Any]
let url = args["url"] as! String
@@ -884,7 +920,6 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
// Extension Post-Processing API
case "runPostProcessing":
let args = call.arguments as! [String: Any]
let filePath = args["file_path"] as! String
@@ -906,7 +941,6 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
// Extension Store
case "initExtensionStore":
let args = call.arguments as! [String: Any]
let cacheDir = args["cache_dir"] as! String
@@ -964,7 +998,6 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return nil
// Extension Home Feed API
case "getExtensionHomeFeed":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
@@ -980,7 +1013,6 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
// Local Library Scanning
case "setLibraryCoverCacheDir":
let args = call.arguments as! [String: Any]
let cacheDir = args["cache_dir"] as! String
@@ -1017,7 +1049,6 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
// iOS Security-Scoped Bookmark for Local Library
case "resolveIosBookmark":
let args = call.arguments as! [String: Any]
let bookmarkBase64 = args["bookmark"] as! String
@@ -1037,7 +1068,6 @@ import Gobackend // Import Go framework
let path = args["path"] as! String
return try createIosBookmarkFromPath(path)
// Lyrics Provider Settings
case "setLyricsProviders":
let args = call.arguments as! [String: Any]
let providersJson = args["providers_json"] as? String ?? "[]"
@@ -1067,7 +1097,6 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
// CUE Sheet Parsing
case "parseCueSheet":
let args = call.arguments as! [String: Any]
let cuePath = args["cue_path"] as! String
+2
View File
@@ -7,6 +7,7 @@ import 'package:spotiflac_android/screens/main_shell.dart';
import 'package:spotiflac_android/screens/setup_screen.dart';
import 'package:spotiflac_android/screens/tutorial_screen.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/app_navigation_service.dart';
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
@@ -28,6 +29,7 @@ final _routerProvider = Provider<GoRouter>((ref) {
}
return GoRouter(
navigatorKey: AppNavigationService.rootNavigatorKey,
initialLocation: initialLocation,
routes: [
GoRoute(path: '/', builder: (context, state) => const MainShell()),
+2 -2
View File
@@ -1,8 +1,8 @@
import 'package:flutter/foundation.dart';
class AppInfo {
static const String version = '4.6.0';
static const String buildNumber = '135';
static const String version = '4.7.1';
static const String buildNumber = '137';
static const String fullVersion = '$version+$buildNumber';
static String get displayVersion => kDebugMode ? 'Internal' : version;
+754
View File
@@ -1202,6 +1202,24 @@ abstract class AppLocalizations {
/// **'Download'**
String get dialogDownload;
/// Tooltip for the button that plays a short track preview snippet
///
/// In en, this message translates to:
/// **'Play preview'**
String get previewPlay;
/// Tooltip for the button that stops the playing track preview snippet
///
/// In en, this message translates to:
/// **'Stop preview'**
String get previewStop;
/// Snackbar shown when a track preview snippet cannot be played
///
/// In en, this message translates to:
/// **'Preview unavailable'**
String get previewUnavailable;
/// Dialog button - discard changes
///
/// In en, this message translates to:
@@ -2954,6 +2972,12 @@ abstract class AppLocalizations {
/// **'Album Folder Structure'**
String get downloadAlbumFolderStructure;
/// Album folder structure picker description
///
/// In en, this message translates to:
/// **'Choose how album folders are structured'**
String get albumFolderStructureDescription;
/// Setting - choose whether artist folders use Album Artist or Track Artist
///
/// In en, this message translates to:
@@ -4993,6 +5017,198 @@ abstract class AppLocalizations {
/// **'Buy the developer a coffee'**
String get settingsDonateSubtitle;
/// Settings menu item - backup and restore page
///
/// In en, this message translates to:
/// **'Backup & Restore'**
String get settingsBackup;
/// Subtitle for backup and restore settings item
///
/// In en, this message translates to:
/// **'Move your library, history and settings to a new device'**
String get settingsBackupSubtitle;
/// App bar title for the backup and restore page
///
/// In en, this message translates to:
/// **'Backup & Restore'**
String get backupTitle;
/// Section title for the export/backup card
///
/// In en, this message translates to:
/// **'Create backup'**
String get backupExportSectionTitle;
/// Description of what a backup contains
///
/// In en, this message translates to:
/// **'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.'**
String get backupExportSectionDescription;
/// Button to create and share a backup file
///
/// In en, this message translates to:
/// **'Create backup file'**
String get backupExportButton;
/// Section title for the import/restore card
///
/// In en, this message translates to:
/// **'Restore backup'**
String get backupImportSectionTitle;
/// Description for the restore action
///
/// In en, this message translates to:
/// **'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.'**
String get backupImportSectionDescription;
/// Button to pick a backup file to restore
///
/// In en, this message translates to:
/// **'Choose backup file'**
String get backupImportButton;
/// Progress text while a backup is being created
///
/// In en, this message translates to:
/// **'Creating backup...'**
String get backupCreating;
/// Snackbar after a backup file is created
///
/// In en, this message translates to:
/// **'Backup created'**
String get backupCreated;
/// Snackbar when backup creation fails
///
/// In en, this message translates to:
/// **'Failed to create backup'**
String get backupCreateFailed;
/// Snackbar when there is no data to back up
///
/// In en, this message translates to:
/// **'There is nothing to back up yet'**
String get backupEmpty;
/// Confirmation dialog title before restoring a backup
///
/// In en, this message translates to:
/// **'Restore this backup?'**
String get backupRestoreConfirmTitle;
/// Confirmation dialog message before restoring a backup
///
/// In en, this message translates to:
/// **'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.'**
String get backupRestoreConfirmMessage;
/// Confirm button to proceed with restore
///
/// In en, this message translates to:
/// **'Restore'**
String get backupRestoreConfirmButton;
/// Progress text while restoring a backup
///
/// In en, this message translates to:
/// **'Restoring backup...'**
String get backupRestoring;
/// Snackbar after a successful restore
///
/// In en, this message translates to:
/// **'Backup restored successfully'**
String get backupRestored;
/// Snackbar when restore fails
///
/// In en, this message translates to:
/// **'Failed to restore backup'**
String get backupRestoreFailed;
/// Snackbar when the chosen file is not a valid backup
///
/// In en, this message translates to:
/// **'This file is not a valid SpotiFLAC backup'**
String get backupInvalidFile;
/// Hint shown after restoring that an app restart is recommended
///
/// In en, this message translates to:
/// **'Restart the app to make sure every change is applied.'**
String get backupRestoreRestartHint;
/// Header above the list summarizing what the backup contains
///
/// In en, this message translates to:
/// **'Backup contents'**
String get backupContentsTitle;
/// Backup contents row label for settings
///
/// In en, this message translates to:
/// **'App settings'**
String get backupContentsSettings;
/// Backup contents row for history count
///
/// In en, this message translates to:
/// **'{count} history {count, plural, =1{item} other{items}}'**
String backupContentsHistory(int count);
/// Backup contents row for liked tracks count
///
/// In en, this message translates to:
/// **'{count} liked {count, plural, =1{track} other{tracks}}'**
String backupContentsLiked(int count);
/// Backup contents row for wishlist tracks count
///
/// In en, this message translates to:
/// **'{count} wishlist {count, plural, =1{track} other{tracks}}'**
String backupContentsWishlist(int count);
/// Backup contents row for playlist count
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 playlist} other{{count} playlists}}'**
String backupContentsPlaylists(int count);
/// Backup contents row for favorite artists count
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 favorite artist} other{{count} favorite artists}}'**
String backupContentsArtists(int count);
/// Backup contents row for installed extensions count
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 extension} other{{count} extensions}}'**
String backupContentsExtensions(int count);
/// Toggle to include secret extension settings (tokens, API keys) in the backup
///
/// In en, this message translates to:
/// **'Include extension credentials'**
String get backupIncludeSecrets;
/// Explanation for the include-credentials toggle
///
/// In en, this message translates to:
/// **'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.'**
String get backupIncludeSecretsDescription;
/// Snackbar/hint when some extensions failed to reinstall during restore
///
/// In en, this message translates to:
/// **'{count} {count, plural, =1{extension} other{extensions}} could not be reinstalled. Install them manually from the store.'**
String backupExtensionsRestoreFailed(int count);
/// Tooltip for the Love All button on album/playlist screens
///
/// In en, this message translates to:
@@ -5205,6 +5421,24 @@ abstract class AppLocalizations {
/// **'Using standard network settings'**
String get downloadNetworkCompatibilityModeDisabled;
/// Setting title for allowing requests to private/local network targets
///
/// In en, this message translates to:
/// **'Allow Local Network Access'**
String get downloadAllowLocalNetwork;
/// Subtitle when allow local network access is on
///
/// In en, this message translates to:
/// **'Requests to local/private addresses are allowed (for local proxy or custom DNS)'**
String get downloadAllowLocalNetworkEnabled;
/// Subtitle when allow local network access is off
///
/// In en, this message translates to:
/// **'Local/private addresses are blocked for security'**
String get downloadAllowLocalNetworkDisabled;
/// Subtitle when quality picker is disabled due to extension service
///
/// In en, this message translates to:
@@ -7116,6 +7350,526 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'{service} link copied'**
String shareSheetLinkCopied(Object service);
/// Section header for playback settings in library settings
///
/// In en, this message translates to:
/// **'Playback'**
String get libraryPlayback;
/// Setting option to use an external music player
///
/// In en, this message translates to:
/// **'External player'**
String get libraryExternalPlayer;
/// Subtitle for external player option
///
/// In en, this message translates to:
/// **'Recommended for listening, best quality, gapless playback, EQ, and wider format support'**
String get libraryExternalPlayerSubtitle;
/// Setting option to use the built-in preview player
///
/// In en, this message translates to:
/// **'Built-in preview player'**
String get libraryBuiltInPreviewPlayer;
/// Subtitle for built-in preview player option
///
/// In en, this message translates to:
/// **'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening'**
String get libraryBuiltInPreviewPlayerSubtitle;
/// Info note explaining the built-in player is for previews only
///
/// In en, this message translates to:
/// **'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.'**
String get libraryBuiltInPlayerInfo;
/// Title for the now playing screen
///
/// In en, this message translates to:
/// **'Now Playing'**
String get nowPlayingTitle;
/// Empty state when no track is currently playing
///
/// In en, this message translates to:
/// **'Nothing is playing'**
String get nowPlayingNothingPlaying;
/// Tooltip for minimizing the now playing screen
///
/// In en, this message translates to:
/// **'Minimize'**
String get nowPlayingMinimize;
/// Title for the playback queue sheet
///
/// In en, this message translates to:
/// **'Up next'**
String get nowPlayingUpNext;
/// Menu item and section title for track metadata details
///
/// In en, this message translates to:
/// **'Details'**
String get nowPlayingDetails;
/// Menu item to open the current track in an external player
///
/// In en, this message translates to:
/// **'Open in external player'**
String get nowPlayingOpenInExternalPlayer;
/// Tab label for the player view
///
/// In en, this message translates to:
/// **'Player'**
String get nowPlayingTabPlayer;
/// Tab label for the lyrics view
///
/// In en, this message translates to:
/// **'Lyrics'**
String get nowPlayingTabLyrics;
/// Empty state when the playing file has no embedded lyrics
///
/// In en, this message translates to:
/// **'No lyrics in this file'**
String get nowPlayingNoLyrics;
/// Snackbar when shuffle library is requested but library has no tracks
///
/// In en, this message translates to:
/// **'Your library is empty'**
String get nowPlayingLibraryEmpty;
/// Snackbar when shuffling the library fails
///
/// In en, this message translates to:
/// **'Could not shuffle library: {error}'**
String nowPlayingShuffleLibraryFailed(String error);
/// Tooltip when shuffle mode is enabled
///
/// In en, this message translates to:
/// **'Shuffle on'**
String get nowPlayingShuffleOn;
/// Tooltip when shuffle mode is disabled
///
/// In en, this message translates to:
/// **'Play in order'**
String get nowPlayingPlayInOrder;
/// Button label to shuffle and play the entire local library
///
/// In en, this message translates to:
/// **'Shuffle library'**
String get nowPlayingShuffleLibrary;
/// Empty state when the playback queue has no items
///
/// In en, this message translates to:
/// **'Queue is empty'**
String get nowPlayingQueueEmpty;
/// Empty state when track metadata cannot be loaded
///
/// In en, this message translates to:
/// **'No metadata available'**
String get nowPlayingNoMetadata;
/// Snackbar shown when an announcement CTA link cannot be opened
///
/// In en, this message translates to:
/// **'Unable to open link. Please try again.'**
String get announcementUnableToOpenLink;
/// Hint shown when lossless conversion will cap bit depth or sample rate
///
/// In en, this message translates to:
/// **'Lossless output with {quality} cap'**
String trackConvertLosslessOutputWithCap(String quality);
/// Confirmation dialog message for capped lossless conversion of a single file
///
/// In en, this message translates to:
/// **'Convert from {sourceFormat} to {targetFormat} ({quality})?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.'**
String trackConvertConfirmMessageLosslessCapped(
String sourceFormat,
String targetFormat,
String quality,
);
/// Confirmation dialog message for capped lossless batch conversion
///
/// In en, this message translates to:
/// **'Convert {count} {count, plural, =1{track} other{tracks}} to {format} ({quality})?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.'**
String selectionBatchConvertConfirmMessageLosslessCapped(
int count,
String format,
String quality,
);
/// Convert button label for lossless conversion with quality cap
///
/// In en, this message translates to:
/// **'{sourceFormat} → {targetFormat} ({quality})'**
String trackConvertActionLabelLossless(
String sourceFormat,
String targetFormat,
String quality,
);
/// Convert button label for lossy conversion
///
/// In en, this message translates to:
/// **'{sourceFormat} → {targetFormat} @ {bitrate}'**
String trackConvertActionLabelLossy(
String sourceFormat,
String targetFormat,
String bitrate,
);
/// Subtitle for Paxsenix special thanks entry on the about page
///
/// In en, this message translates to:
/// **'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius'**
String get aboutPaxsenixSubtitle;
/// Snackbar when a track is inserted as the next queue item
///
/// In en, this message translates to:
/// **'Playing next'**
String get snackbarPlayingNext;
/// Snackbar when a track is added to the playback queue without naming it
///
/// In en, this message translates to:
/// **'Added to queue'**
String get snackbarAddedToQueueGeneric;
/// Button label for deleting multiple selected playlists
///
/// In en, this message translates to:
/// **'Delete {count} {count, plural, =1{playlist} other{playlists}}'**
String selectionDeletePlaylistsCount(int count);
/// Tooltip for shuffle playback action
///
/// In en, this message translates to:
/// **'Shuffle'**
String get actionShuffle;
/// Status label when primary-artist-only folder naming is enabled
///
/// In en, this message translates to:
/// **'Primary only: On'**
String get downloadPrimaryArtistOnlyOn;
/// Status label when primary-artist-only folder naming is disabled
///
/// In en, this message translates to:
/// **'Primary only: Off'**
String get downloadPrimaryArtistOnlyOff;
/// Status label when album-artist folder filtering uses primary artist only
///
/// In en, this message translates to:
/// **'Album Artist metadata: Primary only'**
String get downloadAlbumArtistMetadataPrimaryOnly;
/// Status label when album-artist folder filtering uses full metadata
///
/// In en, this message translates to:
/// **'Album Artist metadata: Full'**
String get downloadAlbumArtistMetadataFull;
/// Label for keeping original bit depth or sample rate during conversion
///
/// In en, this message translates to:
/// **'Original'**
String get trackConvertOriginal;
/// Label when no bit depth or sample rate cap is applied during lossless conversion
///
/// In en, this message translates to:
/// **'Original quality'**
String get trackConvertOriginalQuality;
/// Suffix used in converted lossless quality labels
///
/// In en, this message translates to:
/// **'Lossless'**
String get trackConvertLosslessSuffix;
/// Section label for lossless conversion dithering options
///
/// In en, this message translates to:
/// **'Dithering'**
String get trackConvertDithering;
/// Section label for lossless conversion resampler options
///
/// In en, this message translates to:
/// **'Resampler'**
String get trackConvertResampler;
/// Lossless conversion dither option with no dithering applied
///
/// In en, this message translates to:
/// **'None'**
String get trackConvertDitherNone;
/// Lossless conversion triangular probability density function dither option
///
/// In en, this message translates to:
/// **'TPDF'**
String get trackConvertDitherTriangular;
/// Lossless conversion high-pass triangular dither option
///
/// In en, this message translates to:
/// **'Triangular HP'**
String get trackConvertDitherTriangularHp;
/// Lossless conversion default FFmpeg swresample resampler option
///
/// In en, this message translates to:
/// **'SWR'**
String get trackConvertResamplerSwr;
/// Lossless conversion SoX resampler option
///
/// In en, this message translates to:
/// **'SoXr'**
String get trackConvertResamplerSoxr;
/// Fallback changelog text when release notes cannot be parsed
///
/// In en, this message translates to:
/// **'See release notes for details.'**
String get updateSeeReleaseNotes;
/// Fallback track title when metadata is missing
///
/// In en, this message translates to:
/// **'Unknown title'**
String get unknownTitle;
/// Menu action to play a track as the next queue item
///
/// In en, this message translates to:
/// **'Play next'**
String get trackPlayNext;
/// Menu action to add a track to the playback queue
///
/// In en, this message translates to:
/// **'Add to queue'**
String get trackAddToQueue;
/// Snackbar after installing an extension from the repo tab
///
/// In en, this message translates to:
/// **'{extensionName} installed. Enable it in Settings > Extensions'**
String snackbarExtensionInstalledEnable(String extensionName);
/// Snackbar after updating an extension from the repo tab
///
/// In en, this message translates to:
/// **'{extensionName} updated to v{version}'**
String snackbarExtensionUpdatedVersion(String extensionName, String version);
/// Snackbar when extension install fails in the repo tab
///
/// In en, this message translates to:
/// **'Failed to install {extensionName}'**
String snackbarFailedToInstallNamed(String extensionName);
/// Snackbar when extension update fails in the repo tab
///
/// In en, this message translates to:
/// **'Failed to update {extensionName}'**
String snackbarFailedToUpdateNamed(String extensionName);
/// Badge label for EP releases
///
/// In en, this message translates to:
/// **'EP'**
String get releaseTypeEp;
/// Badge label for single releases
///
/// In en, this message translates to:
/// **'Single'**
String get releaseTypeSingle;
/// Label shown when metadata autofill downloaded cover art from the internet
///
/// In en, this message translates to:
/// **'Online cover'**
String get trackCoverOnline;
/// Country name for SongLink region picker
///
/// In en, this message translates to:
/// **'United States'**
String get regionCountryUS;
/// Country name for SongLink region picker
///
/// In en, this message translates to:
/// **'United Kingdom'**
String get regionCountryGB;
/// Country name for SongLink region picker
///
/// In en, this message translates to:
/// **'France'**
String get regionCountryFR;
/// Country name for SongLink region picker
///
/// In en, this message translates to:
/// **'Germany'**
String get regionCountryDE;
/// Country name for SongLink region picker
///
/// In en, this message translates to:
/// **'Japan'**
String get regionCountryJP;
/// Country name for SongLink region picker
///
/// In en, this message translates to:
/// **'South Korea'**
String get regionCountryKR;
/// Country name for SongLink region picker
///
/// In en, this message translates to:
/// **'India'**
String get regionCountryIN;
/// Country name for SongLink region picker
///
/// In en, this message translates to:
/// **'Indonesia'**
String get regionCountryID;
/// Country name for SongLink region picker
///
/// In en, this message translates to:
/// **'Brazil'**
String get regionCountryBR;
/// Country name for SongLink region picker
///
/// In en, this message translates to:
/// **'Mexico'**
String get regionCountryMX;
/// Country name for SongLink region picker
///
/// In en, this message translates to:
/// **'Australia'**
String get regionCountryAU;
/// Country name for SongLink region picker
///
/// In en, this message translates to:
/// **'Canada'**
String get regionCountryCA;
/// Country name for SongLink region picker
///
/// In en, this message translates to:
/// **'Kosovo'**
String get regionCountryXK;
/// Settings option title for extension verification browser preference
///
/// In en, this message translates to:
/// **'Verification browser'**
String get extensionVerificationBrowserTitle;
/// Subtitle when external browser is preferred for extension verification
///
/// In en, this message translates to:
/// **'Open challenges in the default browser first'**
String get extensionVerificationBrowserSubtitleExternal;
/// Subtitle when in-app browser is preferred for extension verification
///
/// In en, this message translates to:
/// **'Open challenges in the in-app browser first'**
String get extensionVerificationBrowserSubtitleInApp;
/// Chip label for external browser verification mode
///
/// In en, this message translates to:
/// **'External'**
String get extensionVerificationBrowserExternal;
/// Chip label for in-app browser verification mode
///
/// In en, this message translates to:
/// **'In-app'**
String get extensionVerificationBrowserInApp;
/// Dialog title when automatic browser launch for verification fails
///
/// In en, this message translates to:
/// **'Open verification manually'**
String get extensionVerificationHelpTitleManual;
/// Dialog title when verification is taking longer than expected
///
/// In en, this message translates to:
/// **'Verification still waiting'**
String get extensionVerificationHelpTitleWaiting;
/// Dialog message when automatic browser launch for verification fails
///
/// In en, this message translates to:
/// **'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.'**
String get extensionVerificationHelpMessageManual;
/// Dialog message when verification may need manual browser help
///
/// In en, this message translates to:
/// **'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.'**
String get extensionVerificationHelpMessageWaiting;
/// Button to dismiss the extension verification help dialog
///
/// In en, this message translates to:
/// **'Close'**
String get extensionVerificationClose;
/// Button to copy the extension verification URL
///
/// In en, this message translates to:
/// **'Copy link'**
String get extensionVerificationCopyLink;
/// Snackbar after copying the extension verification URL
///
/// In en, this message translates to:
/// **'Verification link copied'**
String get extensionVerificationLinkCopied;
/// Button to open the extension verification URL in a browser
///
/// In en, this message translates to:
/// **'Open browser'**
String get extensionVerificationOpenBrowser;
}
class _AppLocalizationsDelegate
+496
View File
@@ -603,6 +603,15 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Discard';
@@ -1592,6 +1601,10 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get albumFolderStructureDescription =>
'Choose how album folders are structured';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@@ -2885,6 +2898,164 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get settingsDonateSubtitle => 'Buy the developer a coffee';
@override
String get settingsBackup => 'Backup & Restore';
@override
String get settingsBackupSubtitle =>
'Move your library, history and settings to a new device';
@override
String get backupTitle => 'Backup & Restore';
@override
String get backupExportSectionTitle => 'Create backup';
@override
String get backupExportSectionDescription =>
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
@override
String get backupExportButton => 'Create backup file';
@override
String get backupImportSectionTitle => 'Restore backup';
@override
String get backupImportSectionDescription =>
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
@override
String get backupImportButton => 'Choose backup file';
@override
String get backupCreating => 'Creating backup...';
@override
String get backupCreated => 'Backup created';
@override
String get backupCreateFailed => 'Failed to create backup';
@override
String get backupEmpty => 'There is nothing to back up yet';
@override
String get backupRestoreConfirmTitle => 'Restore this backup?';
@override
String get backupRestoreConfirmMessage =>
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
@override
String get backupRestoreConfirmButton => 'Restore';
@override
String get backupRestoring => 'Restoring backup...';
@override
String get backupRestored => 'Backup restored successfully';
@override
String get backupRestoreFailed => 'Failed to restore backup';
@override
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
@override
String get backupRestoreRestartHint =>
'Restart the app to make sure every change is applied.';
@override
String get backupContentsTitle => 'Backup contents';
@override
String get backupContentsSettings => 'App settings';
@override
String backupContentsHistory(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count history $_temp0';
}
@override
String backupContentsLiked(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count liked $_temp0';
}
@override
String backupContentsWishlist(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count wishlist $_temp0';
}
@override
String backupContentsPlaylists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String backupContentsArtists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count favorite artists',
one: '1 favorite artist',
);
return '$_temp0';
}
@override
String backupContentsExtensions(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count extensions',
one: '1 extension',
);
return '$_temp0';
}
@override
String get backupIncludeSecrets => 'Include extension credentials';
@override
String get backupIncludeSecretsDescription =>
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
@override
String backupExtensionsRestoreFailed(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
}
@override
String get tooltipLoveAll => 'Love All';
@@ -3014,6 +3185,17 @@ class AppLocalizationsAr extends AppLocalizations {
String get downloadNetworkCompatibilityModeDisabled =>
'Using standard network settings';
@override
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
@override
String get downloadAllowLocalNetworkEnabled =>
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
@override
String get downloadAllowLocalNetworkDisabled =>
'Local/private addresses are blocked for security';
@override
String get downloadSelectServiceToEnable =>
'Select a provider with quality options to enable this option';
@@ -4274,4 +4456,318 @@ class AppLocalizationsAr extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
@override
String get libraryPlayback => 'Playback';
@override
String get libraryExternalPlayer => 'External player';
@override
String get libraryExternalPlayerSubtitle =>
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
@override
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
@override
String get libraryBuiltInPreviewPlayerSubtitle =>
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
@override
String get libraryBuiltInPlayerInfo =>
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
@override
String get nowPlayingTitle => 'Now Playing';
@override
String get nowPlayingNothingPlaying => 'Nothing is playing';
@override
String get nowPlayingMinimize => 'Minimize';
@override
String get nowPlayingUpNext => 'Up next';
@override
String get nowPlayingDetails => 'Details';
@override
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
@override
String get nowPlayingTabPlayer => 'Player';
@override
String get nowPlayingTabLyrics => 'Lyrics';
@override
String get nowPlayingNoLyrics => 'No lyrics in this file';
@override
String get nowPlayingLibraryEmpty => 'Your library is empty';
@override
String nowPlayingShuffleLibraryFailed(String error) {
return 'Could not shuffle library: $error';
}
@override
String get nowPlayingShuffleOn => 'Shuffle on';
@override
String get nowPlayingPlayInOrder => 'Play in order';
@override
String get nowPlayingShuffleLibrary => 'Shuffle library';
@override
String get nowPlayingQueueEmpty => 'Queue is empty';
@override
String get nowPlayingNoMetadata => 'No metadata available';
@override
String get announcementUnableToOpenLink =>
'Unable to open link. Please try again.';
@override
String trackConvertLosslessOutputWithCap(String quality) {
return 'Lossless output with $quality cap';
}
@override
String trackConvertConfirmMessageLosslessCapped(
String sourceFormat,
String targetFormat,
String quality,
) {
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLosslessCapped(
int count,
String format,
String quality,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
}
@override
String trackConvertActionLabelLossless(
String sourceFormat,
String targetFormat,
String quality,
) {
return '$sourceFormat$targetFormat ($quality)';
}
@override
String trackConvertActionLabelLossy(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return '$sourceFormat$targetFormat @ $bitrate';
}
@override
String get aboutPaxsenixSubtitle =>
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
@override
String get snackbarPlayingNext => 'Playing next';
@override
String get snackbarAddedToQueueGeneric => 'Added to queue';
@override
String selectionDeletePlaylistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0';
}
@override
String get actionShuffle => 'Shuffle';
@override
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
@override
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
@override
String get downloadAlbumArtistMetadataPrimaryOnly =>
'Album Artist metadata: Primary only';
@override
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
@override
String get trackConvertOriginal => 'Original';
@override
String get trackConvertOriginalQuality => 'Original quality';
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@override
String get unknownTitle => 'Unknown title';
@override
String get trackPlayNext => 'Play next';
@override
String get trackAddToQueue => 'Add to queue';
@override
String snackbarExtensionInstalledEnable(String extensionName) {
return '$extensionName installed. Enable it in Settings > Extensions';
}
@override
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
return '$extensionName updated to v$version';
}
@override
String snackbarFailedToInstallNamed(String extensionName) {
return 'Failed to install $extensionName';
}
@override
String snackbarFailedToUpdateNamed(String extensionName) {
return 'Failed to update $extensionName';
}
@override
String get releaseTypeEp => 'EP';
@override
String get releaseTypeSingle => 'Single';
@override
String get trackCoverOnline => 'Online cover';
@override
String get regionCountryUS => 'United States';
@override
String get regionCountryGB => 'United Kingdom';
@override
String get regionCountryFR => 'France';
@override
String get regionCountryDE => 'Germany';
@override
String get regionCountryJP => 'Japan';
@override
String get regionCountryKR => 'South Korea';
@override
String get regionCountryIN => 'India';
@override
String get regionCountryID => 'Indonesia';
@override
String get regionCountryBR => 'Brazil';
@override
String get regionCountryMX => 'Mexico';
@override
String get regionCountryAU => 'Australia';
@override
String get regionCountryCA => 'Canada';
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
+496
View File
@@ -612,6 +612,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get dialogDownload => 'Herunterladen';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Verwerfen';
@@ -1615,6 +1624,10 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album-Ordnerstruktur';
@override
String get albumFolderStructureDescription =>
'Ordnerstruktur für Alben festlegen';
@override
String get downloadUseAlbumArtistForFolders =>
'Album-Künstler für Ordner verwenden';
@@ -2922,6 +2935,164 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get settingsDonateSubtitle => 'Kaufe dem Entwickler einen Kaffee';
@override
String get settingsBackup => 'Backup & Restore';
@override
String get settingsBackupSubtitle =>
'Move your library, history and settings to a new device';
@override
String get backupTitle => 'Backup & Restore';
@override
String get backupExportSectionTitle => 'Create backup';
@override
String get backupExportSectionDescription =>
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
@override
String get backupExportButton => 'Create backup file';
@override
String get backupImportSectionTitle => 'Restore backup';
@override
String get backupImportSectionDescription =>
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
@override
String get backupImportButton => 'Choose backup file';
@override
String get backupCreating => 'Creating backup...';
@override
String get backupCreated => 'Backup created';
@override
String get backupCreateFailed => 'Failed to create backup';
@override
String get backupEmpty => 'There is nothing to back up yet';
@override
String get backupRestoreConfirmTitle => 'Restore this backup?';
@override
String get backupRestoreConfirmMessage =>
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
@override
String get backupRestoreConfirmButton => 'Restore';
@override
String get backupRestoring => 'Restoring backup...';
@override
String get backupRestored => 'Backup restored successfully';
@override
String get backupRestoreFailed => 'Failed to restore backup';
@override
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
@override
String get backupRestoreRestartHint =>
'Restart the app to make sure every change is applied.';
@override
String get backupContentsTitle => 'Backup contents';
@override
String get backupContentsSettings => 'App settings';
@override
String backupContentsHistory(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count history $_temp0';
}
@override
String backupContentsLiked(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count liked $_temp0';
}
@override
String backupContentsWishlist(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count wishlist $_temp0';
}
@override
String backupContentsPlaylists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String backupContentsArtists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count favorite artists',
one: '1 favorite artist',
);
return '$_temp0';
}
@override
String backupContentsExtensions(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count extensions',
one: '1 extension',
);
return '$_temp0';
}
@override
String get backupIncludeSecrets => 'Include extension credentials';
@override
String get backupIncludeSecretsDescription =>
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
@override
String backupExtensionsRestoreFailed(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
}
@override
String get tooltipLoveAll => 'Alle lieben';
@@ -3054,6 +3225,17 @@ class AppLocalizationsDe extends AppLocalizations {
String get downloadNetworkCompatibilityModeDisabled =>
'Standard-Netzwerkeinstellungen verwenden';
@override
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
@override
String get downloadAllowLocalNetworkEnabled =>
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
@override
String get downloadAllowLocalNetworkDisabled =>
'Local/private addresses are blocked for security';
@override
String get downloadSelectServiceToEnable =>
'Select a provider with quality options to enable this option';
@@ -4323,4 +4505,318 @@ class AppLocalizationsDe extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
@override
String get libraryPlayback => 'Playback';
@override
String get libraryExternalPlayer => 'External player';
@override
String get libraryExternalPlayerSubtitle =>
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
@override
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
@override
String get libraryBuiltInPreviewPlayerSubtitle =>
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
@override
String get libraryBuiltInPlayerInfo =>
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
@override
String get nowPlayingTitle => 'Now Playing';
@override
String get nowPlayingNothingPlaying => 'Nothing is playing';
@override
String get nowPlayingMinimize => 'Minimize';
@override
String get nowPlayingUpNext => 'Up next';
@override
String get nowPlayingDetails => 'Details';
@override
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
@override
String get nowPlayingTabPlayer => 'Player';
@override
String get nowPlayingTabLyrics => 'Lyrics';
@override
String get nowPlayingNoLyrics => 'No lyrics in this file';
@override
String get nowPlayingLibraryEmpty => 'Your library is empty';
@override
String nowPlayingShuffleLibraryFailed(String error) {
return 'Could not shuffle library: $error';
}
@override
String get nowPlayingShuffleOn => 'Shuffle on';
@override
String get nowPlayingPlayInOrder => 'Play in order';
@override
String get nowPlayingShuffleLibrary => 'Shuffle library';
@override
String get nowPlayingQueueEmpty => 'Queue is empty';
@override
String get nowPlayingNoMetadata => 'No metadata available';
@override
String get announcementUnableToOpenLink =>
'Unable to open link. Please try again.';
@override
String trackConvertLosslessOutputWithCap(String quality) {
return 'Lossless output with $quality cap';
}
@override
String trackConvertConfirmMessageLosslessCapped(
String sourceFormat,
String targetFormat,
String quality,
) {
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLosslessCapped(
int count,
String format,
String quality,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
}
@override
String trackConvertActionLabelLossless(
String sourceFormat,
String targetFormat,
String quality,
) {
return '$sourceFormat$targetFormat ($quality)';
}
@override
String trackConvertActionLabelLossy(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return '$sourceFormat$targetFormat @ $bitrate';
}
@override
String get aboutPaxsenixSubtitle =>
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
@override
String get snackbarPlayingNext => 'Playing next';
@override
String get snackbarAddedToQueueGeneric => 'Added to queue';
@override
String selectionDeletePlaylistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0';
}
@override
String get actionShuffle => 'Shuffle';
@override
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
@override
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
@override
String get downloadAlbumArtistMetadataPrimaryOnly =>
'Album Artist metadata: Primary only';
@override
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
@override
String get trackConvertOriginal => 'Original';
@override
String get trackConvertOriginalQuality => 'Original quality';
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@override
String get unknownTitle => 'Unknown title';
@override
String get trackPlayNext => 'Play next';
@override
String get trackAddToQueue => 'Add to queue';
@override
String snackbarExtensionInstalledEnable(String extensionName) {
return '$extensionName installed. Enable it in Settings > Extensions';
}
@override
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
return '$extensionName updated to v$version';
}
@override
String snackbarFailedToInstallNamed(String extensionName) {
return 'Failed to install $extensionName';
}
@override
String snackbarFailedToUpdateNamed(String extensionName) {
return 'Failed to update $extensionName';
}
@override
String get releaseTypeEp => 'EP';
@override
String get releaseTypeSingle => 'Single';
@override
String get trackCoverOnline => 'Online cover';
@override
String get regionCountryUS => 'United States';
@override
String get regionCountryGB => 'United Kingdom';
@override
String get regionCountryFR => 'France';
@override
String get regionCountryDE => 'Germany';
@override
String get regionCountryJP => 'Japan';
@override
String get regionCountryKR => 'South Korea';
@override
String get regionCountryIN => 'India';
@override
String get regionCountryID => 'Indonesia';
@override
String get regionCountryBR => 'Brazil';
@override
String get regionCountryMX => 'Mexico';
@override
String get regionCountryAU => 'Australia';
@override
String get regionCountryCA => 'Canada';
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
+496
View File
@@ -603,6 +603,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Discard';
@@ -1592,6 +1601,10 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get albumFolderStructureDescription =>
'Choose how album folders are structured';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@@ -2885,6 +2898,164 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settingsDonateSubtitle => 'Buy the developer a coffee';
@override
String get settingsBackup => 'Backup & Restore';
@override
String get settingsBackupSubtitle =>
'Move your library, history and settings to a new device';
@override
String get backupTitle => 'Backup & Restore';
@override
String get backupExportSectionTitle => 'Create backup';
@override
String get backupExportSectionDescription =>
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
@override
String get backupExportButton => 'Create backup file';
@override
String get backupImportSectionTitle => 'Restore backup';
@override
String get backupImportSectionDescription =>
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
@override
String get backupImportButton => 'Choose backup file';
@override
String get backupCreating => 'Creating backup...';
@override
String get backupCreated => 'Backup created';
@override
String get backupCreateFailed => 'Failed to create backup';
@override
String get backupEmpty => 'There is nothing to back up yet';
@override
String get backupRestoreConfirmTitle => 'Restore this backup?';
@override
String get backupRestoreConfirmMessage =>
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
@override
String get backupRestoreConfirmButton => 'Restore';
@override
String get backupRestoring => 'Restoring backup...';
@override
String get backupRestored => 'Backup restored successfully';
@override
String get backupRestoreFailed => 'Failed to restore backup';
@override
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
@override
String get backupRestoreRestartHint =>
'Restart the app to make sure every change is applied.';
@override
String get backupContentsTitle => 'Backup contents';
@override
String get backupContentsSettings => 'App settings';
@override
String backupContentsHistory(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count history $_temp0';
}
@override
String backupContentsLiked(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count liked $_temp0';
}
@override
String backupContentsWishlist(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count wishlist $_temp0';
}
@override
String backupContentsPlaylists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String backupContentsArtists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count favorite artists',
one: '1 favorite artist',
);
return '$_temp0';
}
@override
String backupContentsExtensions(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count extensions',
one: '1 extension',
);
return '$_temp0';
}
@override
String get backupIncludeSecrets => 'Include extension credentials';
@override
String get backupIncludeSecretsDescription =>
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
@override
String backupExtensionsRestoreFailed(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
}
@override
String get tooltipLoveAll => 'Love All';
@@ -3014,6 +3185,17 @@ class AppLocalizationsEn extends AppLocalizations {
String get downloadNetworkCompatibilityModeDisabled =>
'Using standard network settings';
@override
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
@override
String get downloadAllowLocalNetworkEnabled =>
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
@override
String get downloadAllowLocalNetworkDisabled =>
'Local/private addresses are blocked for security';
@override
String get downloadSelectServiceToEnable =>
'Select a provider with quality options to enable this option';
@@ -4274,4 +4456,318 @@ class AppLocalizationsEn extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
@override
String get libraryPlayback => 'Playback';
@override
String get libraryExternalPlayer => 'External player';
@override
String get libraryExternalPlayerSubtitle =>
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
@override
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
@override
String get libraryBuiltInPreviewPlayerSubtitle =>
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
@override
String get libraryBuiltInPlayerInfo =>
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
@override
String get nowPlayingTitle => 'Now Playing';
@override
String get nowPlayingNothingPlaying => 'Nothing is playing';
@override
String get nowPlayingMinimize => 'Minimize';
@override
String get nowPlayingUpNext => 'Up next';
@override
String get nowPlayingDetails => 'Details';
@override
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
@override
String get nowPlayingTabPlayer => 'Player';
@override
String get nowPlayingTabLyrics => 'Lyrics';
@override
String get nowPlayingNoLyrics => 'No lyrics in this file';
@override
String get nowPlayingLibraryEmpty => 'Your library is empty';
@override
String nowPlayingShuffleLibraryFailed(String error) {
return 'Could not shuffle library: $error';
}
@override
String get nowPlayingShuffleOn => 'Shuffle on';
@override
String get nowPlayingPlayInOrder => 'Play in order';
@override
String get nowPlayingShuffleLibrary => 'Shuffle library';
@override
String get nowPlayingQueueEmpty => 'Queue is empty';
@override
String get nowPlayingNoMetadata => 'No metadata available';
@override
String get announcementUnableToOpenLink =>
'Unable to open link. Please try again.';
@override
String trackConvertLosslessOutputWithCap(String quality) {
return 'Lossless output with $quality cap';
}
@override
String trackConvertConfirmMessageLosslessCapped(
String sourceFormat,
String targetFormat,
String quality,
) {
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLosslessCapped(
int count,
String format,
String quality,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
}
@override
String trackConvertActionLabelLossless(
String sourceFormat,
String targetFormat,
String quality,
) {
return '$sourceFormat$targetFormat ($quality)';
}
@override
String trackConvertActionLabelLossy(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return '$sourceFormat$targetFormat @ $bitrate';
}
@override
String get aboutPaxsenixSubtitle =>
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
@override
String get snackbarPlayingNext => 'Playing next';
@override
String get snackbarAddedToQueueGeneric => 'Added to queue';
@override
String selectionDeletePlaylistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0';
}
@override
String get actionShuffle => 'Shuffle';
@override
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
@override
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
@override
String get downloadAlbumArtistMetadataPrimaryOnly =>
'Album Artist metadata: Primary only';
@override
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
@override
String get trackConvertOriginal => 'Original';
@override
String get trackConvertOriginalQuality => 'Original quality';
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@override
String get unknownTitle => 'Unknown title';
@override
String get trackPlayNext => 'Play next';
@override
String get trackAddToQueue => 'Add to queue';
@override
String snackbarExtensionInstalledEnable(String extensionName) {
return '$extensionName installed. Enable it in Settings > Extensions';
}
@override
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
return '$extensionName updated to v$version';
}
@override
String snackbarFailedToInstallNamed(String extensionName) {
return 'Failed to install $extensionName';
}
@override
String snackbarFailedToUpdateNamed(String extensionName) {
return 'Failed to update $extensionName';
}
@override
String get releaseTypeEp => 'EP';
@override
String get releaseTypeSingle => 'Single';
@override
String get trackCoverOnline => 'Online cover';
@override
String get regionCountryUS => 'United States';
@override
String get regionCountryGB => 'United Kingdom';
@override
String get regionCountryFR => 'France';
@override
String get regionCountryDE => 'Germany';
@override
String get regionCountryJP => 'Japan';
@override
String get regionCountryKR => 'South Korea';
@override
String get regionCountryIN => 'India';
@override
String get regionCountryID => 'Indonesia';
@override
String get regionCountryBR => 'Brazil';
@override
String get regionCountryMX => 'Mexico';
@override
String get regionCountryAU => 'Australia';
@override
String get regionCountryCA => 'Canada';
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
+500
View File
@@ -603,6 +603,15 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Discard';
@@ -1592,6 +1601,10 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get albumFolderStructureDescription =>
'Choose how album folders are structured';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@@ -2885,6 +2898,164 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get settingsDonateSubtitle => 'Buy the developer a coffee';
@override
String get settingsBackup => 'Backup & Restore';
@override
String get settingsBackupSubtitle =>
'Move your library, history and settings to a new device';
@override
String get backupTitle => 'Backup & Restore';
@override
String get backupExportSectionTitle => 'Create backup';
@override
String get backupExportSectionDescription =>
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
@override
String get backupExportButton => 'Create backup file';
@override
String get backupImportSectionTitle => 'Restore backup';
@override
String get backupImportSectionDescription =>
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
@override
String get backupImportButton => 'Choose backup file';
@override
String get backupCreating => 'Creating backup...';
@override
String get backupCreated => 'Backup created';
@override
String get backupCreateFailed => 'Failed to create backup';
@override
String get backupEmpty => 'There is nothing to back up yet';
@override
String get backupRestoreConfirmTitle => 'Restore this backup?';
@override
String get backupRestoreConfirmMessage =>
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
@override
String get backupRestoreConfirmButton => 'Restore';
@override
String get backupRestoring => 'Restoring backup...';
@override
String get backupRestored => 'Backup restored successfully';
@override
String get backupRestoreFailed => 'Failed to restore backup';
@override
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
@override
String get backupRestoreRestartHint =>
'Restart the app to make sure every change is applied.';
@override
String get backupContentsTitle => 'Backup contents';
@override
String get backupContentsSettings => 'App settings';
@override
String backupContentsHistory(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count history $_temp0';
}
@override
String backupContentsLiked(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count liked $_temp0';
}
@override
String backupContentsWishlist(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count wishlist $_temp0';
}
@override
String backupContentsPlaylists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String backupContentsArtists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count favorite artists',
one: '1 favorite artist',
);
return '$_temp0';
}
@override
String backupContentsExtensions(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count extensions',
one: '1 extension',
);
return '$_temp0';
}
@override
String get backupIncludeSecrets => 'Include extension credentials';
@override
String get backupIncludeSecretsDescription =>
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
@override
String backupExtensionsRestoreFailed(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
}
@override
String get tooltipLoveAll => 'Love All';
@@ -3014,6 +3185,17 @@ class AppLocalizationsEs extends AppLocalizations {
String get downloadNetworkCompatibilityModeDisabled =>
'Using standard network settings';
@override
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
@override
String get downloadAllowLocalNetworkEnabled =>
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
@override
String get downloadAllowLocalNetworkDisabled =>
'Local/private addresses are blocked for security';
@override
String get downloadSelectServiceToEnable =>
'Select a provider with quality options to enable this option';
@@ -4268,6 +4450,320 @@ class AppLocalizationsEs extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
@override
String get libraryPlayback => 'Playback';
@override
String get libraryExternalPlayer => 'External player';
@override
String get libraryExternalPlayerSubtitle =>
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
@override
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
@override
String get libraryBuiltInPreviewPlayerSubtitle =>
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
@override
String get libraryBuiltInPlayerInfo =>
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
@override
String get nowPlayingTitle => 'Now Playing';
@override
String get nowPlayingNothingPlaying => 'Nothing is playing';
@override
String get nowPlayingMinimize => 'Minimize';
@override
String get nowPlayingUpNext => 'Up next';
@override
String get nowPlayingDetails => 'Details';
@override
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
@override
String get nowPlayingTabPlayer => 'Player';
@override
String get nowPlayingTabLyrics => 'Lyrics';
@override
String get nowPlayingNoLyrics => 'No lyrics in this file';
@override
String get nowPlayingLibraryEmpty => 'Your library is empty';
@override
String nowPlayingShuffleLibraryFailed(String error) {
return 'Could not shuffle library: $error';
}
@override
String get nowPlayingShuffleOn => 'Shuffle on';
@override
String get nowPlayingPlayInOrder => 'Play in order';
@override
String get nowPlayingShuffleLibrary => 'Shuffle library';
@override
String get nowPlayingQueueEmpty => 'Queue is empty';
@override
String get nowPlayingNoMetadata => 'No metadata available';
@override
String get announcementUnableToOpenLink =>
'Unable to open link. Please try again.';
@override
String trackConvertLosslessOutputWithCap(String quality) {
return 'Lossless output with $quality cap';
}
@override
String trackConvertConfirmMessageLosslessCapped(
String sourceFormat,
String targetFormat,
String quality,
) {
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLosslessCapped(
int count,
String format,
String quality,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
}
@override
String trackConvertActionLabelLossless(
String sourceFormat,
String targetFormat,
String quality,
) {
return '$sourceFormat$targetFormat ($quality)';
}
@override
String trackConvertActionLabelLossy(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return '$sourceFormat$targetFormat @ $bitrate';
}
@override
String get aboutPaxsenixSubtitle =>
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
@override
String get snackbarPlayingNext => 'Playing next';
@override
String get snackbarAddedToQueueGeneric => 'Added to queue';
@override
String selectionDeletePlaylistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0';
}
@override
String get actionShuffle => 'Shuffle';
@override
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
@override
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
@override
String get downloadAlbumArtistMetadataPrimaryOnly =>
'Album Artist metadata: Primary only';
@override
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
@override
String get trackConvertOriginal => 'Original';
@override
String get trackConvertOriginalQuality => 'Original quality';
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@override
String get unknownTitle => 'Unknown title';
@override
String get trackPlayNext => 'Play next';
@override
String get trackAddToQueue => 'Add to queue';
@override
String snackbarExtensionInstalledEnable(String extensionName) {
return '$extensionName installed. Enable it in Settings > Extensions';
}
@override
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
return '$extensionName updated to v$version';
}
@override
String snackbarFailedToInstallNamed(String extensionName) {
return 'Failed to install $extensionName';
}
@override
String snackbarFailedToUpdateNamed(String extensionName) {
return 'Failed to update $extensionName';
}
@override
String get releaseTypeEp => 'EP';
@override
String get releaseTypeSingle => 'Single';
@override
String get trackCoverOnline => 'Online cover';
@override
String get regionCountryUS => 'United States';
@override
String get regionCountryGB => 'United Kingdom';
@override
String get regionCountryFR => 'France';
@override
String get regionCountryDE => 'Germany';
@override
String get regionCountryJP => 'Japan';
@override
String get regionCountryKR => 'South Korea';
@override
String get regionCountryIN => 'India';
@override
String get regionCountryID => 'Indonesia';
@override
String get regionCountryBR => 'Brazil';
@override
String get regionCountryMX => 'Mexico';
@override
String get regionCountryAU => 'Australia';
@override
String get regionCountryCA => 'Canada';
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
@@ -5842,6 +6338,10 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
@override
String get downloadAlbumFolderStructure => 'Estructura de carpeta del álbum';
@override
String get albumFolderStructureDescription =>
'Elige cómo se estructuran las carpetas de los álbumes';
@override
String get downloadUseAlbumArtistForFolders =>
'Usar álbum de artista cómo carpeta';
+496
View File
@@ -620,6 +620,15 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get dialogDownload => 'Télécharger';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Ignorer';
@@ -1637,6 +1646,10 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Structure du dossier de l\'album';
@override
String get albumFolderStructureDescription =>
'Choisir la structure des dossiers d\'album';
@override
String get downloadUseAlbumArtistForFolders =>
'Utilisez l\'artiste de l\'album pour les dossiers';
@@ -2962,6 +2975,164 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get settingsDonateSubtitle => 'Offrez un café au développeur';
@override
String get settingsBackup => 'Backup & Restore';
@override
String get settingsBackupSubtitle =>
'Move your library, history and settings to a new device';
@override
String get backupTitle => 'Backup & Restore';
@override
String get backupExportSectionTitle => 'Create backup';
@override
String get backupExportSectionDescription =>
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
@override
String get backupExportButton => 'Create backup file';
@override
String get backupImportSectionTitle => 'Restore backup';
@override
String get backupImportSectionDescription =>
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
@override
String get backupImportButton => 'Choose backup file';
@override
String get backupCreating => 'Creating backup...';
@override
String get backupCreated => 'Backup created';
@override
String get backupCreateFailed => 'Failed to create backup';
@override
String get backupEmpty => 'There is nothing to back up yet';
@override
String get backupRestoreConfirmTitle => 'Restore this backup?';
@override
String get backupRestoreConfirmMessage =>
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
@override
String get backupRestoreConfirmButton => 'Restore';
@override
String get backupRestoring => 'Restoring backup...';
@override
String get backupRestored => 'Backup restored successfully';
@override
String get backupRestoreFailed => 'Failed to restore backup';
@override
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
@override
String get backupRestoreRestartHint =>
'Restart the app to make sure every change is applied.';
@override
String get backupContentsTitle => 'Backup contents';
@override
String get backupContentsSettings => 'App settings';
@override
String backupContentsHistory(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count history $_temp0';
}
@override
String backupContentsLiked(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count liked $_temp0';
}
@override
String backupContentsWishlist(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count wishlist $_temp0';
}
@override
String backupContentsPlaylists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String backupContentsArtists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count favorite artists',
one: '1 favorite artist',
);
return '$_temp0';
}
@override
String backupContentsExtensions(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count extensions',
one: '1 extension',
);
return '$_temp0';
}
@override
String get backupIncludeSecrets => 'Include extension credentials';
@override
String get backupIncludeSecretsDescription =>
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
@override
String backupExtensionsRestoreFailed(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
}
@override
String get tooltipLoveAll => 'Tout aimer';
@@ -3099,6 +3270,17 @@ class AppLocalizationsFr extends AppLocalizations {
String get downloadNetworkCompatibilityModeDisabled =>
'Utilisation des paramètres réseau par défaut';
@override
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
@override
String get downloadAllowLocalNetworkEnabled =>
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
@override
String get downloadAllowLocalNetworkDisabled =>
'Local/private addresses are blocked for security';
@override
String get downloadSelectServiceToEnable =>
'Select a provider with quality options to enable this option';
@@ -4388,4 +4570,318 @@ class AppLocalizationsFr extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
return 'Lien $service copié';
}
@override
String get libraryPlayback => 'Playback';
@override
String get libraryExternalPlayer => 'External player';
@override
String get libraryExternalPlayerSubtitle =>
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
@override
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
@override
String get libraryBuiltInPreviewPlayerSubtitle =>
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
@override
String get libraryBuiltInPlayerInfo =>
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
@override
String get nowPlayingTitle => 'Now Playing';
@override
String get nowPlayingNothingPlaying => 'Nothing is playing';
@override
String get nowPlayingMinimize => 'Minimize';
@override
String get nowPlayingUpNext => 'Up next';
@override
String get nowPlayingDetails => 'Details';
@override
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
@override
String get nowPlayingTabPlayer => 'Player';
@override
String get nowPlayingTabLyrics => 'Lyrics';
@override
String get nowPlayingNoLyrics => 'No lyrics in this file';
@override
String get nowPlayingLibraryEmpty => 'Your library is empty';
@override
String nowPlayingShuffleLibraryFailed(String error) {
return 'Could not shuffle library: $error';
}
@override
String get nowPlayingShuffleOn => 'Shuffle on';
@override
String get nowPlayingPlayInOrder => 'Play in order';
@override
String get nowPlayingShuffleLibrary => 'Shuffle library';
@override
String get nowPlayingQueueEmpty => 'Queue is empty';
@override
String get nowPlayingNoMetadata => 'No metadata available';
@override
String get announcementUnableToOpenLink =>
'Unable to open link. Please try again.';
@override
String trackConvertLosslessOutputWithCap(String quality) {
return 'Lossless output with $quality cap';
}
@override
String trackConvertConfirmMessageLosslessCapped(
String sourceFormat,
String targetFormat,
String quality,
) {
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLosslessCapped(
int count,
String format,
String quality,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
}
@override
String trackConvertActionLabelLossless(
String sourceFormat,
String targetFormat,
String quality,
) {
return '$sourceFormat$targetFormat ($quality)';
}
@override
String trackConvertActionLabelLossy(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return '$sourceFormat$targetFormat @ $bitrate';
}
@override
String get aboutPaxsenixSubtitle =>
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
@override
String get snackbarPlayingNext => 'Playing next';
@override
String get snackbarAddedToQueueGeneric => 'Added to queue';
@override
String selectionDeletePlaylistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0';
}
@override
String get actionShuffle => 'Shuffle';
@override
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
@override
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
@override
String get downloadAlbumArtistMetadataPrimaryOnly =>
'Album Artist metadata: Primary only';
@override
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
@override
String get trackConvertOriginal => 'Original';
@override
String get trackConvertOriginalQuality => 'Original quality';
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@override
String get unknownTitle => 'Unknown title';
@override
String get trackPlayNext => 'Play next';
@override
String get trackAddToQueue => 'Add to queue';
@override
String snackbarExtensionInstalledEnable(String extensionName) {
return '$extensionName installed. Enable it in Settings > Extensions';
}
@override
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
return '$extensionName updated to v$version';
}
@override
String snackbarFailedToInstallNamed(String extensionName) {
return 'Failed to install $extensionName';
}
@override
String snackbarFailedToUpdateNamed(String extensionName) {
return 'Failed to update $extensionName';
}
@override
String get releaseTypeEp => 'EP';
@override
String get releaseTypeSingle => 'Single';
@override
String get trackCoverOnline => 'Online cover';
@override
String get regionCountryUS => 'United States';
@override
String get regionCountryGB => 'United Kingdom';
@override
String get regionCountryFR => 'France';
@override
String get regionCountryDE => 'Germany';
@override
String get regionCountryJP => 'Japan';
@override
String get regionCountryKR => 'South Korea';
@override
String get regionCountryIN => 'India';
@override
String get regionCountryID => 'Indonesia';
@override
String get regionCountryBR => 'Brazil';
@override
String get regionCountryMX => 'Mexico';
@override
String get regionCountryAU => 'Australia';
@override
String get regionCountryCA => 'Canada';
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
+496
View File
@@ -603,6 +603,15 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Discard';
@@ -1592,6 +1601,10 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get albumFolderStructureDescription =>
'Choose how album folders are structured';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@@ -2885,6 +2898,164 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get settingsDonateSubtitle => 'Buy the developer a coffee';
@override
String get settingsBackup => 'Backup & Restore';
@override
String get settingsBackupSubtitle =>
'Move your library, history and settings to a new device';
@override
String get backupTitle => 'Backup & Restore';
@override
String get backupExportSectionTitle => 'Create backup';
@override
String get backupExportSectionDescription =>
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
@override
String get backupExportButton => 'Create backup file';
@override
String get backupImportSectionTitle => 'Restore backup';
@override
String get backupImportSectionDescription =>
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
@override
String get backupImportButton => 'Choose backup file';
@override
String get backupCreating => 'Creating backup...';
@override
String get backupCreated => 'Backup created';
@override
String get backupCreateFailed => 'Failed to create backup';
@override
String get backupEmpty => 'There is nothing to back up yet';
@override
String get backupRestoreConfirmTitle => 'Restore this backup?';
@override
String get backupRestoreConfirmMessage =>
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
@override
String get backupRestoreConfirmButton => 'Restore';
@override
String get backupRestoring => 'Restoring backup...';
@override
String get backupRestored => 'Backup restored successfully';
@override
String get backupRestoreFailed => 'Failed to restore backup';
@override
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
@override
String get backupRestoreRestartHint =>
'Restart the app to make sure every change is applied.';
@override
String get backupContentsTitle => 'Backup contents';
@override
String get backupContentsSettings => 'App settings';
@override
String backupContentsHistory(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count history $_temp0';
}
@override
String backupContentsLiked(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count liked $_temp0';
}
@override
String backupContentsWishlist(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count wishlist $_temp0';
}
@override
String backupContentsPlaylists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String backupContentsArtists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count favorite artists',
one: '1 favorite artist',
);
return '$_temp0';
}
@override
String backupContentsExtensions(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count extensions',
one: '1 extension',
);
return '$_temp0';
}
@override
String get backupIncludeSecrets => 'Include extension credentials';
@override
String get backupIncludeSecretsDescription =>
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
@override
String backupExtensionsRestoreFailed(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
}
@override
String get tooltipLoveAll => 'Love All';
@@ -3014,6 +3185,17 @@ class AppLocalizationsHi extends AppLocalizations {
String get downloadNetworkCompatibilityModeDisabled =>
'Using standard network settings';
@override
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
@override
String get downloadAllowLocalNetworkEnabled =>
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
@override
String get downloadAllowLocalNetworkDisabled =>
'Local/private addresses are blocked for security';
@override
String get downloadSelectServiceToEnable =>
'Select a provider with quality options to enable this option';
@@ -4274,4 +4456,318 @@ class AppLocalizationsHi extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
@override
String get libraryPlayback => 'Playback';
@override
String get libraryExternalPlayer => 'External player';
@override
String get libraryExternalPlayerSubtitle =>
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
@override
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
@override
String get libraryBuiltInPreviewPlayerSubtitle =>
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
@override
String get libraryBuiltInPlayerInfo =>
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
@override
String get nowPlayingTitle => 'Now Playing';
@override
String get nowPlayingNothingPlaying => 'Nothing is playing';
@override
String get nowPlayingMinimize => 'Minimize';
@override
String get nowPlayingUpNext => 'Up next';
@override
String get nowPlayingDetails => 'Details';
@override
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
@override
String get nowPlayingTabPlayer => 'Player';
@override
String get nowPlayingTabLyrics => 'Lyrics';
@override
String get nowPlayingNoLyrics => 'No lyrics in this file';
@override
String get nowPlayingLibraryEmpty => 'Your library is empty';
@override
String nowPlayingShuffleLibraryFailed(String error) {
return 'Could not shuffle library: $error';
}
@override
String get nowPlayingShuffleOn => 'Shuffle on';
@override
String get nowPlayingPlayInOrder => 'Play in order';
@override
String get nowPlayingShuffleLibrary => 'Shuffle library';
@override
String get nowPlayingQueueEmpty => 'Queue is empty';
@override
String get nowPlayingNoMetadata => 'No metadata available';
@override
String get announcementUnableToOpenLink =>
'Unable to open link. Please try again.';
@override
String trackConvertLosslessOutputWithCap(String quality) {
return 'Lossless output with $quality cap';
}
@override
String trackConvertConfirmMessageLosslessCapped(
String sourceFormat,
String targetFormat,
String quality,
) {
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLosslessCapped(
int count,
String format,
String quality,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
}
@override
String trackConvertActionLabelLossless(
String sourceFormat,
String targetFormat,
String quality,
) {
return '$sourceFormat$targetFormat ($quality)';
}
@override
String trackConvertActionLabelLossy(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return '$sourceFormat$targetFormat @ $bitrate';
}
@override
String get aboutPaxsenixSubtitle =>
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
@override
String get snackbarPlayingNext => 'Playing next';
@override
String get snackbarAddedToQueueGeneric => 'Added to queue';
@override
String selectionDeletePlaylistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0';
}
@override
String get actionShuffle => 'Shuffle';
@override
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
@override
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
@override
String get downloadAlbumArtistMetadataPrimaryOnly =>
'Album Artist metadata: Primary only';
@override
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
@override
String get trackConvertOriginal => 'Original';
@override
String get trackConvertOriginalQuality => 'Original quality';
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@override
String get unknownTitle => 'Unknown title';
@override
String get trackPlayNext => 'Play next';
@override
String get trackAddToQueue => 'Add to queue';
@override
String snackbarExtensionInstalledEnable(String extensionName) {
return '$extensionName installed. Enable it in Settings > Extensions';
}
@override
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
return '$extensionName updated to v$version';
}
@override
String snackbarFailedToInstallNamed(String extensionName) {
return 'Failed to install $extensionName';
}
@override
String snackbarFailedToUpdateNamed(String extensionName) {
return 'Failed to update $extensionName';
}
@override
String get releaseTypeEp => 'EP';
@override
String get releaseTypeSingle => 'Single';
@override
String get trackCoverOnline => 'Online cover';
@override
String get regionCountryUS => 'United States';
@override
String get regionCountryGB => 'United Kingdom';
@override
String get regionCountryFR => 'France';
@override
String get regionCountryDE => 'Germany';
@override
String get regionCountryJP => 'Japan';
@override
String get regionCountryKR => 'South Korea';
@override
String get regionCountryIN => 'India';
@override
String get regionCountryID => 'Indonesia';
@override
String get regionCountryBR => 'Brazil';
@override
String get regionCountryMX => 'Mexico';
@override
String get regionCountryAU => 'Australia';
@override
String get regionCountryCA => 'Canada';
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
+473
View File
@@ -604,6 +604,15 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Buang';
@@ -1598,6 +1607,9 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Struktur Folder Album';
@override
String get albumFolderStructureDescription => 'Pilih struktur folder album';
@override
String get downloadUseAlbumArtistForFolders =>
'Gunakan Artis Album untuk folder';
@@ -2892,6 +2904,141 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get settingsDonateSubtitle => 'Buy the developer a coffee';
@override
String get settingsBackup => 'Cadangkan & Pulihkan';
@override
String get settingsBackupSubtitle =>
'Pindahkan pustaka, riwayat, dan pengaturan ke perangkat baru';
@override
String get backupTitle => 'Cadangkan & Pulihkan';
@override
String get backupExportSectionTitle => 'Buat cadangan';
@override
String get backupExportSectionDescription =>
'Simpan pengaturan, riwayat unduhan, lagu disukai, wishlist, artis favorit, dan playlist ke dalam satu file yang bisa kamu simpan atau pindahkan ke ponsel lain.';
@override
String get backupExportButton => 'Buat file cadangan';
@override
String get backupImportSectionTitle => 'Pulihkan cadangan';
@override
String get backupImportSectionDescription =>
'Pilih file cadangan untuk memulihkan data. Ini akan menggantikan pengaturan, riwayat, dan pustaka di perangkat ini.';
@override
String get backupImportButton => 'Pilih file cadangan';
@override
String get backupCreating => 'Membuat cadangan...';
@override
String get backupCreated => 'Cadangan berhasil dibuat';
@override
String get backupCreateFailed => 'Gagal membuat cadangan';
@override
String get backupEmpty => 'Belum ada data untuk dicadangkan';
@override
String get backupRestoreConfirmTitle => 'Pulihkan cadangan ini?';
@override
String get backupRestoreConfirmMessage =>
'Ini akan menggantikan pengaturan, riwayat unduhan, lagu disukai, wishlist, dan playlist saat ini dengan isi cadangan. Tindakan ini tidak bisa dibatalkan.';
@override
String get backupRestoreConfirmButton => 'Pulihkan';
@override
String get backupRestoring => 'Memulihkan cadangan...';
@override
String get backupRestored => 'Cadangan berhasil dipulihkan';
@override
String get backupRestoreFailed => 'Gagal memulihkan cadangan';
@override
String get backupInvalidFile =>
'File ini bukan cadangan SpotiFLAC yang valid';
@override
String get backupRestoreRestartHint =>
'Mulai ulang aplikasi untuk memastikan semua perubahan diterapkan.';
@override
String get backupContentsTitle => 'Isi cadangan';
@override
String get backupContentsSettings => 'Pengaturan aplikasi';
@override
String backupContentsHistory(int count) {
return '$count item riwayat';
}
@override
String backupContentsLiked(int count) {
return '$count lagu disukai';
}
@override
String backupContentsWishlist(int count) {
return '$count lagu di wishlist';
}
@override
String backupContentsPlaylists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlist',
one: '1 playlist',
);
return '$_temp0';
}
@override
String backupContentsArtists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count artis favorit',
one: '1 artis favorit',
);
return '$_temp0';
}
@override
String backupContentsExtensions(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count extension',
one: '1 extension',
);
return '$_temp0';
}
@override
String get backupIncludeSecrets => 'Sertakan kredensial extension';
@override
String get backupIncludeSecretsDescription =>
'Token dan API key dari extension akan ikut disimpan ke file cadangan. Jaga kerahasiaan file-nya. Jika dimatikan, kamu perlu memasukkannya lagi setelah pemulihan.';
@override
String backupExtensionsRestoreFailed(int count) {
return '$count extension gagal dipasang ulang. Pasang manual dari store.';
}
@override
String get tooltipLoveAll => 'Love All';
@@ -3021,6 +3168,17 @@ class AppLocalizationsId extends AppLocalizations {
String get downloadNetworkCompatibilityModeDisabled =>
'Using standard network settings';
@override
String get downloadAllowLocalNetwork => 'Izinkan Akses Jaringan Lokal';
@override
String get downloadAllowLocalNetworkEnabled =>
'Permintaan ke alamat lokal/privat diizinkan (untuk proxy lokal atau DNS kustom)';
@override
String get downloadAllowLocalNetworkDisabled =>
'Alamat lokal/privat diblokir demi keamanan';
@override
String get downloadSelectServiceToEnable =>
'Select a provider with quality options to enable this option';
@@ -4281,4 +4439,319 @@ class AppLocalizationsId extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
@override
String get libraryPlayback => 'Pemutaran';
@override
String get libraryExternalPlayer => 'Pemutar eksternal';
@override
String get libraryExternalPlayerSubtitle =>
'Disarankan untuk mendengarkan, kualitas terbaik, pemutaran tanpa jeda, EQ, dan dukungan format lebih luas';
@override
String get libraryBuiltInPreviewPlayer => 'Pemutar pratinjau bawaan';
@override
String get libraryBuiltInPreviewPlayerSubtitle =>
'Hanya untuk pratinjau lokal cepat di dalam SpotiFLAC Mobile, tidak disarankan untuk mendengarkan secara rutin';
@override
String get libraryBuiltInPlayerInfo =>
'Pemutar bawaan adalah alat pratinjau untuk memeriksa trek lokal dengan cepat. Gunakan pemutar musik eksternal untuk mendengarkan sebenarnya.';
@override
String get nowPlayingTitle => 'Sedang Diputar';
@override
String get nowPlayingNothingPlaying => 'Tidak ada yang diputar';
@override
String get nowPlayingMinimize => 'Minimalkan';
@override
String get nowPlayingUpNext => 'Berikutnya';
@override
String get nowPlayingDetails => 'Detail';
@override
String get nowPlayingOpenInExternalPlayer => 'Buka di pemutar eksternal';
@override
String get nowPlayingTabPlayer => 'Pemutar';
@override
String get nowPlayingTabLyrics => 'Lirik';
@override
String get nowPlayingNoLyrics => 'Tidak ada lirik di file ini';
@override
String get nowPlayingLibraryEmpty => 'Perpustakaan Anda kosong';
@override
String nowPlayingShuffleLibraryFailed(String error) {
return 'Tidak dapat mengacak perpustakaan: $error';
}
@override
String get nowPlayingShuffleOn => 'Acak aktif';
@override
String get nowPlayingPlayInOrder => 'Putar berurutan';
@override
String get nowPlayingShuffleLibrary => 'Acak perpustakaan';
@override
String get nowPlayingQueueEmpty => 'Antrean kosong';
@override
String get nowPlayingNoMetadata => 'Metadata tidak tersedia';
@override
String get announcementUnableToOpenLink =>
'Tidak dapat membuka tautan. Silakan coba lagi.';
@override
String trackConvertLosslessOutputWithCap(String quality) {
return 'Output lossless dengan batas $quality';
}
@override
String trackConvertConfirmMessageLosslessCapped(
String sourceFormat,
String targetFormat,
String quality,
) {
return 'Konversi dari $sourceFormat ke $targetFormat ($quality)?\n\nOutput tetap codec lossless, tetapi kedalaman bit/sample rate akan dibatasi. File asli akan dihapus setelah konversi.';
}
@override
String selectionBatchConvertConfirmMessageLosslessCapped(
int count,
String format,
String quality,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'trek',
one: 'trek',
);
return 'Konversi $count $_temp0 ke $format ($quality)?\n\nOutput tetap codec lossless, tetapi kedalaman bit/sample rate akan dibatasi. File asli akan dihapus setelah konversi.';
}
@override
String trackConvertActionLabelLossless(
String sourceFormat,
String targetFormat,
String quality,
) {
return '$sourceFormat$targetFormat ($quality)';
}
@override
String trackConvertActionLabelLossy(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return '$sourceFormat$targetFormat @ $bitrate';
}
@override
String get aboutPaxsenixSubtitle =>
'Proxy lirik untuk Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, dan Genius';
@override
String get snackbarPlayingNext => 'Memutar berikutnya';
@override
String get snackbarAddedToQueueGeneric => 'Ditambahkan ke antrean';
@override
String selectionDeletePlaylistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlist',
one: 'playlist',
);
return 'Hapus $count $_temp0';
}
@override
String get actionShuffle => 'Acak';
@override
String get downloadPrimaryArtistOnlyOn => 'Hanya utama: Aktif';
@override
String get downloadPrimaryArtistOnlyOff => 'Hanya utama: Nonaktif';
@override
String get downloadAlbumArtistMetadataPrimaryOnly =>
'Metadata Album Artist: Hanya utama';
@override
String get downloadAlbumArtistMetadataFull =>
'Metadata Album Artist: Lengkap';
@override
String get trackConvertOriginal => 'Asli';
@override
String get trackConvertOriginalQuality => 'Kualitas asli';
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'Tidak ada';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'Lihat catatan rilis untuk detail.';
@override
String get unknownTitle => 'Judul tidak diketahui';
@override
String get trackPlayNext => 'Putar berikutnya';
@override
String get trackAddToQueue => 'Tambah ke antrean';
@override
String snackbarExtensionInstalledEnable(String extensionName) {
return '$extensionName terpasang. Aktifkan di Pengaturan > Ekstensi';
}
@override
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
return '$extensionName diperbarui ke v$version';
}
@override
String snackbarFailedToInstallNamed(String extensionName) {
return 'Gagal memasang $extensionName';
}
@override
String snackbarFailedToUpdateNamed(String extensionName) {
return 'Gagal memperbarui $extensionName';
}
@override
String get releaseTypeEp => 'EP';
@override
String get releaseTypeSingle => 'Single';
@override
String get trackCoverOnline => 'Sampul daring';
@override
String get regionCountryUS => 'Amerika Serikat';
@override
String get regionCountryGB => 'Britania Raya';
@override
String get regionCountryFR => 'Prancis';
@override
String get regionCountryDE => 'Jerman';
@override
String get regionCountryJP => 'Jepang';
@override
String get regionCountryKR => 'Korea Selatan';
@override
String get regionCountryIN => 'India';
@override
String get regionCountryID => 'Indonesia';
@override
String get regionCountryBR => 'Brasil';
@override
String get regionCountryMX => 'Meksiko';
@override
String get regionCountryAU => 'Australia';
@override
String get regionCountryCA => 'Kanada';
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Browser verifikasi';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Buka tantangan di browser default terlebih dahulu';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Buka tantangan di browser dalam aplikasi terlebih dahulu';
@override
String get extensionVerificationBrowserExternal => 'Eksternal';
@override
String get extensionVerificationBrowserInApp => 'Dalam aplikasi';
@override
String get extensionVerificationHelpTitleManual =>
'Buka verifikasi secara manual';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verifikasi masih menunggu';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile tidak dapat membuka browser secara otomatis. Buka tautan ini di browser Anda, atau salin secara manual.';
@override
String get extensionVerificationHelpMessageWaiting =>
'Jika browser tidak terbuka, atau verifikasi selesai tetapi tidak kembali ke SpotiFLAC Mobile, buka tautan ini lagi atau salin secara manual.';
@override
String get extensionVerificationClose => 'Tutup';
@override
String get extensionVerificationCopyLink => 'Salin tautan';
@override
String get extensionVerificationLinkCopied => 'Tautan verifikasi disalin';
@override
String get extensionVerificationOpenBrowser => 'Buka browser';
}
+495
View File
@@ -600,6 +600,15 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => '破棄';
@@ -1582,6 +1591,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'アルバムフォルダの構造';
@override
String get albumFolderStructureDescription => 'アルバムフォルダの構成を選択';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@@ -2873,6 +2885,164 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get settingsDonateSubtitle => 'Buy the developer a coffee';
@override
String get settingsBackup => 'Backup & Restore';
@override
String get settingsBackupSubtitle =>
'Move your library, history and settings to a new device';
@override
String get backupTitle => 'Backup & Restore';
@override
String get backupExportSectionTitle => 'Create backup';
@override
String get backupExportSectionDescription =>
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
@override
String get backupExportButton => 'Create backup file';
@override
String get backupImportSectionTitle => 'Restore backup';
@override
String get backupImportSectionDescription =>
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
@override
String get backupImportButton => 'Choose backup file';
@override
String get backupCreating => 'Creating backup...';
@override
String get backupCreated => 'Backup created';
@override
String get backupCreateFailed => 'Failed to create backup';
@override
String get backupEmpty => 'There is nothing to back up yet';
@override
String get backupRestoreConfirmTitle => 'Restore this backup?';
@override
String get backupRestoreConfirmMessage =>
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
@override
String get backupRestoreConfirmButton => 'Restore';
@override
String get backupRestoring => 'Restoring backup...';
@override
String get backupRestored => 'Backup restored successfully';
@override
String get backupRestoreFailed => 'Failed to restore backup';
@override
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
@override
String get backupRestoreRestartHint =>
'Restart the app to make sure every change is applied.';
@override
String get backupContentsTitle => 'Backup contents';
@override
String get backupContentsSettings => 'App settings';
@override
String backupContentsHistory(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count history $_temp0';
}
@override
String backupContentsLiked(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count liked $_temp0';
}
@override
String backupContentsWishlist(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count wishlist $_temp0';
}
@override
String backupContentsPlaylists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String backupContentsArtists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count favorite artists',
one: '1 favorite artist',
);
return '$_temp0';
}
@override
String backupContentsExtensions(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count extensions',
one: '1 extension',
);
return '$_temp0';
}
@override
String get backupIncludeSecrets => 'Include extension credentials';
@override
String get backupIncludeSecretsDescription =>
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
@override
String backupExtensionsRestoreFailed(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
}
@override
String get tooltipLoveAll => 'Love All';
@@ -3002,6 +3172,17 @@ class AppLocalizationsJa extends AppLocalizations {
String get downloadNetworkCompatibilityModeDisabled =>
'Using standard network settings';
@override
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
@override
String get downloadAllowLocalNetworkEnabled =>
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
@override
String get downloadAllowLocalNetworkDisabled =>
'Local/private addresses are blocked for security';
@override
String get downloadSelectServiceToEnable =>
'Select a provider with quality options to enable this option';
@@ -4262,4 +4443,318 @@ class AppLocalizationsJa extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
@override
String get libraryPlayback => 'Playback';
@override
String get libraryExternalPlayer => 'External player';
@override
String get libraryExternalPlayerSubtitle =>
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
@override
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
@override
String get libraryBuiltInPreviewPlayerSubtitle =>
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
@override
String get libraryBuiltInPlayerInfo =>
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
@override
String get nowPlayingTitle => 'Now Playing';
@override
String get nowPlayingNothingPlaying => 'Nothing is playing';
@override
String get nowPlayingMinimize => 'Minimize';
@override
String get nowPlayingUpNext => 'Up next';
@override
String get nowPlayingDetails => 'Details';
@override
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
@override
String get nowPlayingTabPlayer => 'Player';
@override
String get nowPlayingTabLyrics => 'Lyrics';
@override
String get nowPlayingNoLyrics => 'No lyrics in this file';
@override
String get nowPlayingLibraryEmpty => 'Your library is empty';
@override
String nowPlayingShuffleLibraryFailed(String error) {
return 'Could not shuffle library: $error';
}
@override
String get nowPlayingShuffleOn => 'Shuffle on';
@override
String get nowPlayingPlayInOrder => 'Play in order';
@override
String get nowPlayingShuffleLibrary => 'Shuffle library';
@override
String get nowPlayingQueueEmpty => 'Queue is empty';
@override
String get nowPlayingNoMetadata => 'No metadata available';
@override
String get announcementUnableToOpenLink =>
'Unable to open link. Please try again.';
@override
String trackConvertLosslessOutputWithCap(String quality) {
return 'Lossless output with $quality cap';
}
@override
String trackConvertConfirmMessageLosslessCapped(
String sourceFormat,
String targetFormat,
String quality,
) {
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLosslessCapped(
int count,
String format,
String quality,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
}
@override
String trackConvertActionLabelLossless(
String sourceFormat,
String targetFormat,
String quality,
) {
return '$sourceFormat$targetFormat ($quality)';
}
@override
String trackConvertActionLabelLossy(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return '$sourceFormat$targetFormat @ $bitrate';
}
@override
String get aboutPaxsenixSubtitle =>
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
@override
String get snackbarPlayingNext => 'Playing next';
@override
String get snackbarAddedToQueueGeneric => 'Added to queue';
@override
String selectionDeletePlaylistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0';
}
@override
String get actionShuffle => 'Shuffle';
@override
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
@override
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
@override
String get downloadAlbumArtistMetadataPrimaryOnly =>
'Album Artist metadata: Primary only';
@override
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
@override
String get trackConvertOriginal => 'Original';
@override
String get trackConvertOriginalQuality => 'Original quality';
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@override
String get unknownTitle => 'Unknown title';
@override
String get trackPlayNext => 'Play next';
@override
String get trackAddToQueue => 'Add to queue';
@override
String snackbarExtensionInstalledEnable(String extensionName) {
return '$extensionName installed. Enable it in Settings > Extensions';
}
@override
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
return '$extensionName updated to v$version';
}
@override
String snackbarFailedToInstallNamed(String extensionName) {
return 'Failed to install $extensionName';
}
@override
String snackbarFailedToUpdateNamed(String extensionName) {
return 'Failed to update $extensionName';
}
@override
String get releaseTypeEp => 'EP';
@override
String get releaseTypeSingle => 'Single';
@override
String get trackCoverOnline => 'Online cover';
@override
String get regionCountryUS => 'United States';
@override
String get regionCountryGB => 'United Kingdom';
@override
String get regionCountryFR => 'France';
@override
String get regionCountryDE => 'Germany';
@override
String get regionCountryJP => 'Japan';
@override
String get regionCountryKR => 'South Korea';
@override
String get regionCountryIN => 'India';
@override
String get regionCountryID => 'Indonesia';
@override
String get regionCountryBR => 'Brazil';
@override
String get regionCountryMX => 'Mexico';
@override
String get regionCountryAU => 'Australia';
@override
String get regionCountryCA => 'Canada';
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
+496
View File
@@ -593,6 +593,15 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => '취소';
@@ -1577,6 +1586,10 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get albumFolderStructureDescription =>
'Choose how album folders are structured';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@@ -2870,6 +2883,164 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get settingsDonateSubtitle => 'Buy the developer a coffee';
@override
String get settingsBackup => 'Backup & Restore';
@override
String get settingsBackupSubtitle =>
'Move your library, history and settings to a new device';
@override
String get backupTitle => 'Backup & Restore';
@override
String get backupExportSectionTitle => 'Create backup';
@override
String get backupExportSectionDescription =>
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
@override
String get backupExportButton => 'Create backup file';
@override
String get backupImportSectionTitle => 'Restore backup';
@override
String get backupImportSectionDescription =>
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
@override
String get backupImportButton => 'Choose backup file';
@override
String get backupCreating => 'Creating backup...';
@override
String get backupCreated => 'Backup created';
@override
String get backupCreateFailed => 'Failed to create backup';
@override
String get backupEmpty => 'There is nothing to back up yet';
@override
String get backupRestoreConfirmTitle => 'Restore this backup?';
@override
String get backupRestoreConfirmMessage =>
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
@override
String get backupRestoreConfirmButton => 'Restore';
@override
String get backupRestoring => 'Restoring backup...';
@override
String get backupRestored => 'Backup restored successfully';
@override
String get backupRestoreFailed => 'Failed to restore backup';
@override
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
@override
String get backupRestoreRestartHint =>
'Restart the app to make sure every change is applied.';
@override
String get backupContentsTitle => 'Backup contents';
@override
String get backupContentsSettings => 'App settings';
@override
String backupContentsHistory(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count history $_temp0';
}
@override
String backupContentsLiked(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count liked $_temp0';
}
@override
String backupContentsWishlist(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count wishlist $_temp0';
}
@override
String backupContentsPlaylists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String backupContentsArtists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count favorite artists',
one: '1 favorite artist',
);
return '$_temp0';
}
@override
String backupContentsExtensions(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count extensions',
one: '1 extension',
);
return '$_temp0';
}
@override
String get backupIncludeSecrets => 'Include extension credentials';
@override
String get backupIncludeSecretsDescription =>
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
@override
String backupExtensionsRestoreFailed(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
}
@override
String get tooltipLoveAll => 'Love All';
@@ -2999,6 +3170,17 @@ class AppLocalizationsKo extends AppLocalizations {
String get downloadNetworkCompatibilityModeDisabled =>
'Using standard network settings';
@override
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
@override
String get downloadAllowLocalNetworkEnabled =>
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
@override
String get downloadAllowLocalNetworkDisabled =>
'Local/private addresses are blocked for security';
@override
String get downloadSelectServiceToEnable =>
'Select a provider with quality options to enable this option';
@@ -4259,4 +4441,318 @@ class AppLocalizationsKo extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
@override
String get libraryPlayback => 'Playback';
@override
String get libraryExternalPlayer => 'External player';
@override
String get libraryExternalPlayerSubtitle =>
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
@override
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
@override
String get libraryBuiltInPreviewPlayerSubtitle =>
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
@override
String get libraryBuiltInPlayerInfo =>
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
@override
String get nowPlayingTitle => 'Now Playing';
@override
String get nowPlayingNothingPlaying => 'Nothing is playing';
@override
String get nowPlayingMinimize => 'Minimize';
@override
String get nowPlayingUpNext => 'Up next';
@override
String get nowPlayingDetails => 'Details';
@override
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
@override
String get nowPlayingTabPlayer => 'Player';
@override
String get nowPlayingTabLyrics => 'Lyrics';
@override
String get nowPlayingNoLyrics => 'No lyrics in this file';
@override
String get nowPlayingLibraryEmpty => 'Your library is empty';
@override
String nowPlayingShuffleLibraryFailed(String error) {
return 'Could not shuffle library: $error';
}
@override
String get nowPlayingShuffleOn => 'Shuffle on';
@override
String get nowPlayingPlayInOrder => 'Play in order';
@override
String get nowPlayingShuffleLibrary => 'Shuffle library';
@override
String get nowPlayingQueueEmpty => 'Queue is empty';
@override
String get nowPlayingNoMetadata => 'No metadata available';
@override
String get announcementUnableToOpenLink =>
'Unable to open link. Please try again.';
@override
String trackConvertLosslessOutputWithCap(String quality) {
return 'Lossless output with $quality cap';
}
@override
String trackConvertConfirmMessageLosslessCapped(
String sourceFormat,
String targetFormat,
String quality,
) {
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLosslessCapped(
int count,
String format,
String quality,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
}
@override
String trackConvertActionLabelLossless(
String sourceFormat,
String targetFormat,
String quality,
) {
return '$sourceFormat$targetFormat ($quality)';
}
@override
String trackConvertActionLabelLossy(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return '$sourceFormat$targetFormat @ $bitrate';
}
@override
String get aboutPaxsenixSubtitle =>
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
@override
String get snackbarPlayingNext => 'Playing next';
@override
String get snackbarAddedToQueueGeneric => 'Added to queue';
@override
String selectionDeletePlaylistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0';
}
@override
String get actionShuffle => 'Shuffle';
@override
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
@override
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
@override
String get downloadAlbumArtistMetadataPrimaryOnly =>
'Album Artist metadata: Primary only';
@override
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
@override
String get trackConvertOriginal => 'Original';
@override
String get trackConvertOriginalQuality => 'Original quality';
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@override
String get unknownTitle => 'Unknown title';
@override
String get trackPlayNext => 'Play next';
@override
String get trackAddToQueue => 'Add to queue';
@override
String snackbarExtensionInstalledEnable(String extensionName) {
return '$extensionName installed. Enable it in Settings > Extensions';
}
@override
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
return '$extensionName updated to v$version';
}
@override
String snackbarFailedToInstallNamed(String extensionName) {
return 'Failed to install $extensionName';
}
@override
String snackbarFailedToUpdateNamed(String extensionName) {
return 'Failed to update $extensionName';
}
@override
String get releaseTypeEp => 'EP';
@override
String get releaseTypeSingle => 'Single';
@override
String get trackCoverOnline => 'Online cover';
@override
String get regionCountryUS => 'United States';
@override
String get regionCountryGB => 'United Kingdom';
@override
String get regionCountryFR => 'France';
@override
String get regionCountryDE => 'Germany';
@override
String get regionCountryJP => 'Japan';
@override
String get regionCountryKR => 'South Korea';
@override
String get regionCountryIN => 'India';
@override
String get regionCountryID => 'Indonesia';
@override
String get regionCountryBR => 'Brazil';
@override
String get regionCountryMX => 'Mexico';
@override
String get regionCountryAU => 'Australia';
@override
String get regionCountryCA => 'Canada';
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
+496
View File
@@ -603,6 +603,15 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Discard';
@@ -1592,6 +1601,10 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get albumFolderStructureDescription =>
'Choose how album folders are structured';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@@ -2885,6 +2898,164 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get settingsDonateSubtitle => 'Buy the developer a coffee';
@override
String get settingsBackup => 'Backup & Restore';
@override
String get settingsBackupSubtitle =>
'Move your library, history and settings to a new device';
@override
String get backupTitle => 'Backup & Restore';
@override
String get backupExportSectionTitle => 'Create backup';
@override
String get backupExportSectionDescription =>
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
@override
String get backupExportButton => 'Create backup file';
@override
String get backupImportSectionTitle => 'Restore backup';
@override
String get backupImportSectionDescription =>
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
@override
String get backupImportButton => 'Choose backup file';
@override
String get backupCreating => 'Creating backup...';
@override
String get backupCreated => 'Backup created';
@override
String get backupCreateFailed => 'Failed to create backup';
@override
String get backupEmpty => 'There is nothing to back up yet';
@override
String get backupRestoreConfirmTitle => 'Restore this backup?';
@override
String get backupRestoreConfirmMessage =>
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
@override
String get backupRestoreConfirmButton => 'Restore';
@override
String get backupRestoring => 'Restoring backup...';
@override
String get backupRestored => 'Backup restored successfully';
@override
String get backupRestoreFailed => 'Failed to restore backup';
@override
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
@override
String get backupRestoreRestartHint =>
'Restart the app to make sure every change is applied.';
@override
String get backupContentsTitle => 'Backup contents';
@override
String get backupContentsSettings => 'App settings';
@override
String backupContentsHistory(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count history $_temp0';
}
@override
String backupContentsLiked(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count liked $_temp0';
}
@override
String backupContentsWishlist(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count wishlist $_temp0';
}
@override
String backupContentsPlaylists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String backupContentsArtists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count favorite artists',
one: '1 favorite artist',
);
return '$_temp0';
}
@override
String backupContentsExtensions(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count extensions',
one: '1 extension',
);
return '$_temp0';
}
@override
String get backupIncludeSecrets => 'Include extension credentials';
@override
String get backupIncludeSecretsDescription =>
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
@override
String backupExtensionsRestoreFailed(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
}
@override
String get tooltipLoveAll => 'Love All';
@@ -3014,6 +3185,17 @@ class AppLocalizationsNl extends AppLocalizations {
String get downloadNetworkCompatibilityModeDisabled =>
'Using standard network settings';
@override
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
@override
String get downloadAllowLocalNetworkEnabled =>
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
@override
String get downloadAllowLocalNetworkDisabled =>
'Local/private addresses are blocked for security';
@override
String get downloadSelectServiceToEnable =>
'Select a provider with quality options to enable this option';
@@ -4274,4 +4456,318 @@ class AppLocalizationsNl extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
@override
String get libraryPlayback => 'Playback';
@override
String get libraryExternalPlayer => 'External player';
@override
String get libraryExternalPlayerSubtitle =>
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
@override
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
@override
String get libraryBuiltInPreviewPlayerSubtitle =>
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
@override
String get libraryBuiltInPlayerInfo =>
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
@override
String get nowPlayingTitle => 'Now Playing';
@override
String get nowPlayingNothingPlaying => 'Nothing is playing';
@override
String get nowPlayingMinimize => 'Minimize';
@override
String get nowPlayingUpNext => 'Up next';
@override
String get nowPlayingDetails => 'Details';
@override
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
@override
String get nowPlayingTabPlayer => 'Player';
@override
String get nowPlayingTabLyrics => 'Lyrics';
@override
String get nowPlayingNoLyrics => 'No lyrics in this file';
@override
String get nowPlayingLibraryEmpty => 'Your library is empty';
@override
String nowPlayingShuffleLibraryFailed(String error) {
return 'Could not shuffle library: $error';
}
@override
String get nowPlayingShuffleOn => 'Shuffle on';
@override
String get nowPlayingPlayInOrder => 'Play in order';
@override
String get nowPlayingShuffleLibrary => 'Shuffle library';
@override
String get nowPlayingQueueEmpty => 'Queue is empty';
@override
String get nowPlayingNoMetadata => 'No metadata available';
@override
String get announcementUnableToOpenLink =>
'Unable to open link. Please try again.';
@override
String trackConvertLosslessOutputWithCap(String quality) {
return 'Lossless output with $quality cap';
}
@override
String trackConvertConfirmMessageLosslessCapped(
String sourceFormat,
String targetFormat,
String quality,
) {
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLosslessCapped(
int count,
String format,
String quality,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
}
@override
String trackConvertActionLabelLossless(
String sourceFormat,
String targetFormat,
String quality,
) {
return '$sourceFormat$targetFormat ($quality)';
}
@override
String trackConvertActionLabelLossy(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return '$sourceFormat$targetFormat @ $bitrate';
}
@override
String get aboutPaxsenixSubtitle =>
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
@override
String get snackbarPlayingNext => 'Playing next';
@override
String get snackbarAddedToQueueGeneric => 'Added to queue';
@override
String selectionDeletePlaylistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0';
}
@override
String get actionShuffle => 'Shuffle';
@override
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
@override
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
@override
String get downloadAlbumArtistMetadataPrimaryOnly =>
'Album Artist metadata: Primary only';
@override
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
@override
String get trackConvertOriginal => 'Original';
@override
String get trackConvertOriginalQuality => 'Original quality';
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@override
String get unknownTitle => 'Unknown title';
@override
String get trackPlayNext => 'Play next';
@override
String get trackAddToQueue => 'Add to queue';
@override
String snackbarExtensionInstalledEnable(String extensionName) {
return '$extensionName installed. Enable it in Settings > Extensions';
}
@override
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
return '$extensionName updated to v$version';
}
@override
String snackbarFailedToInstallNamed(String extensionName) {
return 'Failed to install $extensionName';
}
@override
String snackbarFailedToUpdateNamed(String extensionName) {
return 'Failed to update $extensionName';
}
@override
String get releaseTypeEp => 'EP';
@override
String get releaseTypeSingle => 'Single';
@override
String get trackCoverOnline => 'Online cover';
@override
String get regionCountryUS => 'United States';
@override
String get regionCountryGB => 'United Kingdom';
@override
String get regionCountryFR => 'France';
@override
String get regionCountryDE => 'Germany';
@override
String get regionCountryJP => 'Japan';
@override
String get regionCountryKR => 'South Korea';
@override
String get regionCountryIN => 'India';
@override
String get regionCountryID => 'Indonesia';
@override
String get regionCountryBR => 'Brazil';
@override
String get regionCountryMX => 'Mexico';
@override
String get regionCountryAU => 'Australia';
@override
String get regionCountryCA => 'Canada';
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
+500
View File
@@ -603,6 +603,15 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Discard';
@@ -1592,6 +1601,10 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get albumFolderStructureDescription =>
'Choose how album folders are structured';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@@ -2885,6 +2898,164 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get settingsDonateSubtitle => 'Buy the developer a coffee';
@override
String get settingsBackup => 'Backup & Restore';
@override
String get settingsBackupSubtitle =>
'Move your library, history and settings to a new device';
@override
String get backupTitle => 'Backup & Restore';
@override
String get backupExportSectionTitle => 'Create backup';
@override
String get backupExportSectionDescription =>
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
@override
String get backupExportButton => 'Create backup file';
@override
String get backupImportSectionTitle => 'Restore backup';
@override
String get backupImportSectionDescription =>
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
@override
String get backupImportButton => 'Choose backup file';
@override
String get backupCreating => 'Creating backup...';
@override
String get backupCreated => 'Backup created';
@override
String get backupCreateFailed => 'Failed to create backup';
@override
String get backupEmpty => 'There is nothing to back up yet';
@override
String get backupRestoreConfirmTitle => 'Restore this backup?';
@override
String get backupRestoreConfirmMessage =>
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
@override
String get backupRestoreConfirmButton => 'Restore';
@override
String get backupRestoring => 'Restoring backup...';
@override
String get backupRestored => 'Backup restored successfully';
@override
String get backupRestoreFailed => 'Failed to restore backup';
@override
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
@override
String get backupRestoreRestartHint =>
'Restart the app to make sure every change is applied.';
@override
String get backupContentsTitle => 'Backup contents';
@override
String get backupContentsSettings => 'App settings';
@override
String backupContentsHistory(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count history $_temp0';
}
@override
String backupContentsLiked(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count liked $_temp0';
}
@override
String backupContentsWishlist(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count wishlist $_temp0';
}
@override
String backupContentsPlaylists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String backupContentsArtists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count favorite artists',
one: '1 favorite artist',
);
return '$_temp0';
}
@override
String backupContentsExtensions(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count extensions',
one: '1 extension',
);
return '$_temp0';
}
@override
String get backupIncludeSecrets => 'Include extension credentials';
@override
String get backupIncludeSecretsDescription =>
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
@override
String backupExtensionsRestoreFailed(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
}
@override
String get tooltipLoveAll => 'Love All';
@@ -3014,6 +3185,17 @@ class AppLocalizationsPt extends AppLocalizations {
String get downloadNetworkCompatibilityModeDisabled =>
'Using standard network settings';
@override
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
@override
String get downloadAllowLocalNetworkEnabled =>
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
@override
String get downloadAllowLocalNetworkDisabled =>
'Local/private addresses are blocked for security';
@override
String get downloadSelectServiceToEnable =>
'Select a provider with quality options to enable this option';
@@ -4268,6 +4450,320 @@ class AppLocalizationsPt extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
@override
String get libraryPlayback => 'Playback';
@override
String get libraryExternalPlayer => 'External player';
@override
String get libraryExternalPlayerSubtitle =>
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
@override
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
@override
String get libraryBuiltInPreviewPlayerSubtitle =>
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
@override
String get libraryBuiltInPlayerInfo =>
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
@override
String get nowPlayingTitle => 'Now Playing';
@override
String get nowPlayingNothingPlaying => 'Nothing is playing';
@override
String get nowPlayingMinimize => 'Minimize';
@override
String get nowPlayingUpNext => 'Up next';
@override
String get nowPlayingDetails => 'Details';
@override
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
@override
String get nowPlayingTabPlayer => 'Player';
@override
String get nowPlayingTabLyrics => 'Lyrics';
@override
String get nowPlayingNoLyrics => 'No lyrics in this file';
@override
String get nowPlayingLibraryEmpty => 'Your library is empty';
@override
String nowPlayingShuffleLibraryFailed(String error) {
return 'Could not shuffle library: $error';
}
@override
String get nowPlayingShuffleOn => 'Shuffle on';
@override
String get nowPlayingPlayInOrder => 'Play in order';
@override
String get nowPlayingShuffleLibrary => 'Shuffle library';
@override
String get nowPlayingQueueEmpty => 'Queue is empty';
@override
String get nowPlayingNoMetadata => 'No metadata available';
@override
String get announcementUnableToOpenLink =>
'Unable to open link. Please try again.';
@override
String trackConvertLosslessOutputWithCap(String quality) {
return 'Lossless output with $quality cap';
}
@override
String trackConvertConfirmMessageLosslessCapped(
String sourceFormat,
String targetFormat,
String quality,
) {
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLosslessCapped(
int count,
String format,
String quality,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
}
@override
String trackConvertActionLabelLossless(
String sourceFormat,
String targetFormat,
String quality,
) {
return '$sourceFormat$targetFormat ($quality)';
}
@override
String trackConvertActionLabelLossy(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return '$sourceFormat$targetFormat @ $bitrate';
}
@override
String get aboutPaxsenixSubtitle =>
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
@override
String get snackbarPlayingNext => 'Playing next';
@override
String get snackbarAddedToQueueGeneric => 'Added to queue';
@override
String selectionDeletePlaylistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0';
}
@override
String get actionShuffle => 'Shuffle';
@override
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
@override
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
@override
String get downloadAlbumArtistMetadataPrimaryOnly =>
'Album Artist metadata: Primary only';
@override
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
@override
String get trackConvertOriginal => 'Original';
@override
String get trackConvertOriginalQuality => 'Original quality';
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@override
String get unknownTitle => 'Unknown title';
@override
String get trackPlayNext => 'Play next';
@override
String get trackAddToQueue => 'Add to queue';
@override
String snackbarExtensionInstalledEnable(String extensionName) {
return '$extensionName installed. Enable it in Settings > Extensions';
}
@override
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
return '$extensionName updated to v$version';
}
@override
String snackbarFailedToInstallNamed(String extensionName) {
return 'Failed to install $extensionName';
}
@override
String snackbarFailedToUpdateNamed(String extensionName) {
return 'Failed to update $extensionName';
}
@override
String get releaseTypeEp => 'EP';
@override
String get releaseTypeSingle => 'Single';
@override
String get trackCoverOnline => 'Online cover';
@override
String get regionCountryUS => 'United States';
@override
String get regionCountryGB => 'United Kingdom';
@override
String get regionCountryFR => 'France';
@override
String get regionCountryDE => 'Germany';
@override
String get regionCountryJP => 'Japan';
@override
String get regionCountryKR => 'South Korea';
@override
String get regionCountryIN => 'India';
@override
String get regionCountryID => 'Indonesia';
@override
String get regionCountryBR => 'Brazil';
@override
String get regionCountryMX => 'Mexico';
@override
String get regionCountryAU => 'Australia';
@override
String get regionCountryCA => 'Canada';
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
@@ -5837,6 +6333,10 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
@override
String get downloadAlbumFolderStructure => 'Estrutura da Pasta de Álbum';
@override
String get albumFolderStructureDescription =>
'Escolher a estrutura das pastas dos álbuns';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
+496
View File
@@ -609,6 +609,15 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get dialogDownload => 'Скачать';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Отменить';
@@ -1613,6 +1622,10 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Структура папок альбома';
@override
String get albumFolderStructureDescription =>
'Выберите структуру папок альбомов';
@override
String get downloadUseAlbumArtistForFolders =>
'Использовать исполнителя альбома для папок';
@@ -2940,6 +2953,164 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get settingsDonateSubtitle => 'Buy the developer a coffee';
@override
String get settingsBackup => 'Backup & Restore';
@override
String get settingsBackupSubtitle =>
'Move your library, history and settings to a new device';
@override
String get backupTitle => 'Backup & Restore';
@override
String get backupExportSectionTitle => 'Create backup';
@override
String get backupExportSectionDescription =>
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
@override
String get backupExportButton => 'Create backup file';
@override
String get backupImportSectionTitle => 'Restore backup';
@override
String get backupImportSectionDescription =>
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
@override
String get backupImportButton => 'Choose backup file';
@override
String get backupCreating => 'Creating backup...';
@override
String get backupCreated => 'Backup created';
@override
String get backupCreateFailed => 'Failed to create backup';
@override
String get backupEmpty => 'There is nothing to back up yet';
@override
String get backupRestoreConfirmTitle => 'Restore this backup?';
@override
String get backupRestoreConfirmMessage =>
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
@override
String get backupRestoreConfirmButton => 'Restore';
@override
String get backupRestoring => 'Restoring backup...';
@override
String get backupRestored => 'Backup restored successfully';
@override
String get backupRestoreFailed => 'Failed to restore backup';
@override
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
@override
String get backupRestoreRestartHint =>
'Restart the app to make sure every change is applied.';
@override
String get backupContentsTitle => 'Backup contents';
@override
String get backupContentsSettings => 'App settings';
@override
String backupContentsHistory(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count history $_temp0';
}
@override
String backupContentsLiked(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count liked $_temp0';
}
@override
String backupContentsWishlist(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count wishlist $_temp0';
}
@override
String backupContentsPlaylists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String backupContentsArtists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count favorite artists',
one: '1 favorite artist',
);
return '$_temp0';
}
@override
String backupContentsExtensions(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count extensions',
one: '1 extension',
);
return '$_temp0';
}
@override
String get backupIncludeSecrets => 'Include extension credentials';
@override
String get backupIncludeSecretsDescription =>
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
@override
String backupExtensionsRestoreFailed(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
}
@override
String get tooltipLoveAll => 'Love All';
@@ -3069,6 +3240,17 @@ class AppLocalizationsRu extends AppLocalizations {
String get downloadNetworkCompatibilityModeDisabled =>
'Using standard network settings';
@override
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
@override
String get downloadAllowLocalNetworkEnabled =>
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
@override
String get downloadAllowLocalNetworkDisabled =>
'Local/private addresses are blocked for security';
@override
String get downloadSelectServiceToEnable =>
'Select a provider with quality options to enable this option';
@@ -4330,4 +4512,318 @@ class AppLocalizationsRu extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
@override
String get libraryPlayback => 'Playback';
@override
String get libraryExternalPlayer => 'External player';
@override
String get libraryExternalPlayerSubtitle =>
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
@override
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
@override
String get libraryBuiltInPreviewPlayerSubtitle =>
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
@override
String get libraryBuiltInPlayerInfo =>
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
@override
String get nowPlayingTitle => 'Now Playing';
@override
String get nowPlayingNothingPlaying => 'Nothing is playing';
@override
String get nowPlayingMinimize => 'Minimize';
@override
String get nowPlayingUpNext => 'Up next';
@override
String get nowPlayingDetails => 'Details';
@override
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
@override
String get nowPlayingTabPlayer => 'Player';
@override
String get nowPlayingTabLyrics => 'Lyrics';
@override
String get nowPlayingNoLyrics => 'No lyrics in this file';
@override
String get nowPlayingLibraryEmpty => 'Your library is empty';
@override
String nowPlayingShuffleLibraryFailed(String error) {
return 'Could not shuffle library: $error';
}
@override
String get nowPlayingShuffleOn => 'Shuffle on';
@override
String get nowPlayingPlayInOrder => 'Play in order';
@override
String get nowPlayingShuffleLibrary => 'Shuffle library';
@override
String get nowPlayingQueueEmpty => 'Queue is empty';
@override
String get nowPlayingNoMetadata => 'No metadata available';
@override
String get announcementUnableToOpenLink =>
'Unable to open link. Please try again.';
@override
String trackConvertLosslessOutputWithCap(String quality) {
return 'Lossless output with $quality cap';
}
@override
String trackConvertConfirmMessageLosslessCapped(
String sourceFormat,
String targetFormat,
String quality,
) {
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLosslessCapped(
int count,
String format,
String quality,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
}
@override
String trackConvertActionLabelLossless(
String sourceFormat,
String targetFormat,
String quality,
) {
return '$sourceFormat$targetFormat ($quality)';
}
@override
String trackConvertActionLabelLossy(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return '$sourceFormat$targetFormat @ $bitrate';
}
@override
String get aboutPaxsenixSubtitle =>
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
@override
String get snackbarPlayingNext => 'Playing next';
@override
String get snackbarAddedToQueueGeneric => 'Added to queue';
@override
String selectionDeletePlaylistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0';
}
@override
String get actionShuffle => 'Shuffle';
@override
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
@override
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
@override
String get downloadAlbumArtistMetadataPrimaryOnly =>
'Album Artist metadata: Primary only';
@override
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
@override
String get trackConvertOriginal => 'Original';
@override
String get trackConvertOriginalQuality => 'Original quality';
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@override
String get unknownTitle => 'Unknown title';
@override
String get trackPlayNext => 'Play next';
@override
String get trackAddToQueue => 'Add to queue';
@override
String snackbarExtensionInstalledEnable(String extensionName) {
return '$extensionName installed. Enable it in Settings > Extensions';
}
@override
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
return '$extensionName updated to v$version';
}
@override
String snackbarFailedToInstallNamed(String extensionName) {
return 'Failed to install $extensionName';
}
@override
String snackbarFailedToUpdateNamed(String extensionName) {
return 'Failed to update $extensionName';
}
@override
String get releaseTypeEp => 'EP';
@override
String get releaseTypeSingle => 'Single';
@override
String get trackCoverOnline => 'Online cover';
@override
String get regionCountryUS => 'United States';
@override
String get regionCountryGB => 'United Kingdom';
@override
String get regionCountryFR => 'France';
@override
String get regionCountryDE => 'Germany';
@override
String get regionCountryJP => 'Japan';
@override
String get regionCountryKR => 'South Korea';
@override
String get regionCountryIN => 'India';
@override
String get regionCountryID => 'Indonesia';
@override
String get regionCountryBR => 'Brazil';
@override
String get regionCountryMX => 'Mexico';
@override
String get regionCountryAU => 'Australia';
@override
String get regionCountryCA => 'Canada';
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
+495
View File
@@ -610,6 +610,15 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get dialogDownload => 'İndir';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Vazgeç';
@@ -1609,6 +1618,9 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Albüm Klasör Yapısı';
@override
String get albumFolderStructureDescription => 'Albüm klasör yapısını seçin';
@override
String get downloadUseAlbumArtistForFolders =>
'Klasörler için Albüm Sanatçısı\'nı kullan';
@@ -2914,6 +2926,164 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get settingsDonateSubtitle => 'Buy the developer a coffee';
@override
String get settingsBackup => 'Backup & Restore';
@override
String get settingsBackupSubtitle =>
'Move your library, history and settings to a new device';
@override
String get backupTitle => 'Backup & Restore';
@override
String get backupExportSectionTitle => 'Create backup';
@override
String get backupExportSectionDescription =>
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
@override
String get backupExportButton => 'Create backup file';
@override
String get backupImportSectionTitle => 'Restore backup';
@override
String get backupImportSectionDescription =>
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
@override
String get backupImportButton => 'Choose backup file';
@override
String get backupCreating => 'Creating backup...';
@override
String get backupCreated => 'Backup created';
@override
String get backupCreateFailed => 'Failed to create backup';
@override
String get backupEmpty => 'There is nothing to back up yet';
@override
String get backupRestoreConfirmTitle => 'Restore this backup?';
@override
String get backupRestoreConfirmMessage =>
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
@override
String get backupRestoreConfirmButton => 'Restore';
@override
String get backupRestoring => 'Restoring backup...';
@override
String get backupRestored => 'Backup restored successfully';
@override
String get backupRestoreFailed => 'Failed to restore backup';
@override
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
@override
String get backupRestoreRestartHint =>
'Restart the app to make sure every change is applied.';
@override
String get backupContentsTitle => 'Backup contents';
@override
String get backupContentsSettings => 'App settings';
@override
String backupContentsHistory(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count history $_temp0';
}
@override
String backupContentsLiked(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count liked $_temp0';
}
@override
String backupContentsWishlist(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count wishlist $_temp0';
}
@override
String backupContentsPlaylists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String backupContentsArtists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count favorite artists',
one: '1 favorite artist',
);
return '$_temp0';
}
@override
String backupContentsExtensions(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count extensions',
one: '1 extension',
);
return '$_temp0';
}
@override
String get backupIncludeSecrets => 'Include extension credentials';
@override
String get backupIncludeSecretsDescription =>
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
@override
String backupExtensionsRestoreFailed(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
}
@override
String get tooltipLoveAll => 'Love All';
@@ -3046,6 +3216,17 @@ class AppLocalizationsTr extends AppLocalizations {
String get downloadNetworkCompatibilityModeDisabled =>
'Using standard network settings';
@override
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
@override
String get downloadAllowLocalNetworkEnabled =>
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
@override
String get downloadAllowLocalNetworkDisabled =>
'Local/private addresses are blocked for security';
@override
String get downloadSelectServiceToEnable =>
'Select a provider with quality options to enable this option';
@@ -4306,4 +4487,318 @@ class AppLocalizationsTr extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
@override
String get libraryPlayback => 'Playback';
@override
String get libraryExternalPlayer => 'External player';
@override
String get libraryExternalPlayerSubtitle =>
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
@override
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
@override
String get libraryBuiltInPreviewPlayerSubtitle =>
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
@override
String get libraryBuiltInPlayerInfo =>
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
@override
String get nowPlayingTitle => 'Now Playing';
@override
String get nowPlayingNothingPlaying => 'Nothing is playing';
@override
String get nowPlayingMinimize => 'Minimize';
@override
String get nowPlayingUpNext => 'Up next';
@override
String get nowPlayingDetails => 'Details';
@override
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
@override
String get nowPlayingTabPlayer => 'Player';
@override
String get nowPlayingTabLyrics => 'Lyrics';
@override
String get nowPlayingNoLyrics => 'No lyrics in this file';
@override
String get nowPlayingLibraryEmpty => 'Your library is empty';
@override
String nowPlayingShuffleLibraryFailed(String error) {
return 'Could not shuffle library: $error';
}
@override
String get nowPlayingShuffleOn => 'Shuffle on';
@override
String get nowPlayingPlayInOrder => 'Play in order';
@override
String get nowPlayingShuffleLibrary => 'Shuffle library';
@override
String get nowPlayingQueueEmpty => 'Queue is empty';
@override
String get nowPlayingNoMetadata => 'No metadata available';
@override
String get announcementUnableToOpenLink =>
'Unable to open link. Please try again.';
@override
String trackConvertLosslessOutputWithCap(String quality) {
return 'Lossless output with $quality cap';
}
@override
String trackConvertConfirmMessageLosslessCapped(
String sourceFormat,
String targetFormat,
String quality,
) {
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLosslessCapped(
int count,
String format,
String quality,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
}
@override
String trackConvertActionLabelLossless(
String sourceFormat,
String targetFormat,
String quality,
) {
return '$sourceFormat$targetFormat ($quality)';
}
@override
String trackConvertActionLabelLossy(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return '$sourceFormat$targetFormat @ $bitrate';
}
@override
String get aboutPaxsenixSubtitle =>
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
@override
String get snackbarPlayingNext => 'Playing next';
@override
String get snackbarAddedToQueueGeneric => 'Added to queue';
@override
String selectionDeletePlaylistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0';
}
@override
String get actionShuffle => 'Shuffle';
@override
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
@override
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
@override
String get downloadAlbumArtistMetadataPrimaryOnly =>
'Album Artist metadata: Primary only';
@override
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
@override
String get trackConvertOriginal => 'Original';
@override
String get trackConvertOriginalQuality => 'Original quality';
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@override
String get unknownTitle => 'Unknown title';
@override
String get trackPlayNext => 'Play next';
@override
String get trackAddToQueue => 'Add to queue';
@override
String snackbarExtensionInstalledEnable(String extensionName) {
return '$extensionName installed. Enable it in Settings > Extensions';
}
@override
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
return '$extensionName updated to v$version';
}
@override
String snackbarFailedToInstallNamed(String extensionName) {
return 'Failed to install $extensionName';
}
@override
String snackbarFailedToUpdateNamed(String extensionName) {
return 'Failed to update $extensionName';
}
@override
String get releaseTypeEp => 'EP';
@override
String get releaseTypeSingle => 'Single';
@override
String get trackCoverOnline => 'Online cover';
@override
String get regionCountryUS => 'United States';
@override
String get regionCountryGB => 'United Kingdom';
@override
String get regionCountryFR => 'France';
@override
String get regionCountryDE => 'Germany';
@override
String get regionCountryJP => 'Japan';
@override
String get regionCountryKR => 'South Korea';
@override
String get regionCountryIN => 'India';
@override
String get regionCountryID => 'Indonesia';
@override
String get regionCountryBR => 'Brazil';
@override
String get regionCountryMX => 'Mexico';
@override
String get regionCountryAU => 'Australia';
@override
String get regionCountryCA => 'Canada';
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
+496
View File
@@ -612,6 +612,15 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get dialogDownload => 'Завантажити';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Відхилити';
@@ -1615,6 +1624,10 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Структура папок альбому';
@override
String get albumFolderStructureDescription =>
'Виберіть структуру папок альбомів';
@override
String get downloadUseAlbumArtistForFolders =>
'Використовувати виконавця альбому для папок';
@@ -2929,6 +2942,164 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get settingsDonateSubtitle => 'Buy the developer a coffee';
@override
String get settingsBackup => 'Backup & Restore';
@override
String get settingsBackupSubtitle =>
'Move your library, history and settings to a new device';
@override
String get backupTitle => 'Backup & Restore';
@override
String get backupExportSectionTitle => 'Create backup';
@override
String get backupExportSectionDescription =>
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
@override
String get backupExportButton => 'Create backup file';
@override
String get backupImportSectionTitle => 'Restore backup';
@override
String get backupImportSectionDescription =>
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
@override
String get backupImportButton => 'Choose backup file';
@override
String get backupCreating => 'Creating backup...';
@override
String get backupCreated => 'Backup created';
@override
String get backupCreateFailed => 'Failed to create backup';
@override
String get backupEmpty => 'There is nothing to back up yet';
@override
String get backupRestoreConfirmTitle => 'Restore this backup?';
@override
String get backupRestoreConfirmMessage =>
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
@override
String get backupRestoreConfirmButton => 'Restore';
@override
String get backupRestoring => 'Restoring backup...';
@override
String get backupRestored => 'Backup restored successfully';
@override
String get backupRestoreFailed => 'Failed to restore backup';
@override
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
@override
String get backupRestoreRestartHint =>
'Restart the app to make sure every change is applied.';
@override
String get backupContentsTitle => 'Backup contents';
@override
String get backupContentsSettings => 'App settings';
@override
String backupContentsHistory(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count history $_temp0';
}
@override
String backupContentsLiked(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count liked $_temp0';
}
@override
String backupContentsWishlist(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count wishlist $_temp0';
}
@override
String backupContentsPlaylists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String backupContentsArtists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count favorite artists',
one: '1 favorite artist',
);
return '$_temp0';
}
@override
String backupContentsExtensions(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count extensions',
one: '1 extension',
);
return '$_temp0';
}
@override
String get backupIncludeSecrets => 'Include extension credentials';
@override
String get backupIncludeSecretsDescription =>
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
@override
String backupExtensionsRestoreFailed(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
}
@override
String get tooltipLoveAll => 'Уподобати всіх';
@@ -3061,6 +3232,17 @@ class AppLocalizationsUk extends AppLocalizations {
String get downloadNetworkCompatibilityModeDisabled =>
'Using standard network settings';
@override
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
@override
String get downloadAllowLocalNetworkEnabled =>
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
@override
String get downloadAllowLocalNetworkDisabled =>
'Local/private addresses are blocked for security';
@override
String get downloadSelectServiceToEnable =>
'Select a provider with quality options to enable this option';
@@ -4327,4 +4509,318 @@ class AppLocalizationsUk extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
@override
String get libraryPlayback => 'Playback';
@override
String get libraryExternalPlayer => 'External player';
@override
String get libraryExternalPlayerSubtitle =>
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
@override
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
@override
String get libraryBuiltInPreviewPlayerSubtitle =>
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
@override
String get libraryBuiltInPlayerInfo =>
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
@override
String get nowPlayingTitle => 'Now Playing';
@override
String get nowPlayingNothingPlaying => 'Nothing is playing';
@override
String get nowPlayingMinimize => 'Minimize';
@override
String get nowPlayingUpNext => 'Up next';
@override
String get nowPlayingDetails => 'Details';
@override
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
@override
String get nowPlayingTabPlayer => 'Player';
@override
String get nowPlayingTabLyrics => 'Lyrics';
@override
String get nowPlayingNoLyrics => 'No lyrics in this file';
@override
String get nowPlayingLibraryEmpty => 'Your library is empty';
@override
String nowPlayingShuffleLibraryFailed(String error) {
return 'Could not shuffle library: $error';
}
@override
String get nowPlayingShuffleOn => 'Shuffle on';
@override
String get nowPlayingPlayInOrder => 'Play in order';
@override
String get nowPlayingShuffleLibrary => 'Shuffle library';
@override
String get nowPlayingQueueEmpty => 'Queue is empty';
@override
String get nowPlayingNoMetadata => 'No metadata available';
@override
String get announcementUnableToOpenLink =>
'Unable to open link. Please try again.';
@override
String trackConvertLosslessOutputWithCap(String quality) {
return 'Lossless output with $quality cap';
}
@override
String trackConvertConfirmMessageLosslessCapped(
String sourceFormat,
String targetFormat,
String quality,
) {
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLosslessCapped(
int count,
String format,
String quality,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
}
@override
String trackConvertActionLabelLossless(
String sourceFormat,
String targetFormat,
String quality,
) {
return '$sourceFormat$targetFormat ($quality)';
}
@override
String trackConvertActionLabelLossy(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return '$sourceFormat$targetFormat @ $bitrate';
}
@override
String get aboutPaxsenixSubtitle =>
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
@override
String get snackbarPlayingNext => 'Playing next';
@override
String get snackbarAddedToQueueGeneric => 'Added to queue';
@override
String selectionDeletePlaylistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0';
}
@override
String get actionShuffle => 'Shuffle';
@override
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
@override
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
@override
String get downloadAlbumArtistMetadataPrimaryOnly =>
'Album Artist metadata: Primary only';
@override
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
@override
String get trackConvertOriginal => 'Original';
@override
String get trackConvertOriginalQuality => 'Original quality';
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@override
String get unknownTitle => 'Unknown title';
@override
String get trackPlayNext => 'Play next';
@override
String get trackAddToQueue => 'Add to queue';
@override
String snackbarExtensionInstalledEnable(String extensionName) {
return '$extensionName installed. Enable it in Settings > Extensions';
}
@override
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
return '$extensionName updated to v$version';
}
@override
String snackbarFailedToInstallNamed(String extensionName) {
return 'Failed to install $extensionName';
}
@override
String snackbarFailedToUpdateNamed(String extensionName) {
return 'Failed to update $extensionName';
}
@override
String get releaseTypeEp => 'EP';
@override
String get releaseTypeSingle => 'Single';
@override
String get trackCoverOnline => 'Online cover';
@override
String get regionCountryUS => 'United States';
@override
String get regionCountryGB => 'United Kingdom';
@override
String get regionCountryFR => 'France';
@override
String get regionCountryDE => 'Germany';
@override
String get regionCountryJP => 'Japan';
@override
String get regionCountryKR => 'South Korea';
@override
String get regionCountryIN => 'India';
@override
String get regionCountryID => 'Indonesia';
@override
String get regionCountryBR => 'Brazil';
@override
String get regionCountryMX => 'Mexico';
@override
String get regionCountryAU => 'Australia';
@override
String get regionCountryCA => 'Canada';
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
+504
View File
@@ -603,6 +603,15 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get dialogDownload => 'Download';
@override
String get previewPlay => 'Play preview';
@override
String get previewStop => 'Stop preview';
@override
String get previewUnavailable => 'Preview unavailable';
@override
String get dialogDiscard => 'Discard';
@@ -1592,6 +1601,10 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get albumFolderStructureDescription =>
'Choose how album folders are structured';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@@ -2885,6 +2898,164 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get settingsDonateSubtitle => 'Buy the developer a coffee';
@override
String get settingsBackup => 'Backup & Restore';
@override
String get settingsBackupSubtitle =>
'Move your library, history and settings to a new device';
@override
String get backupTitle => 'Backup & Restore';
@override
String get backupExportSectionTitle => 'Create backup';
@override
String get backupExportSectionDescription =>
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
@override
String get backupExportButton => 'Create backup file';
@override
String get backupImportSectionTitle => 'Restore backup';
@override
String get backupImportSectionDescription =>
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
@override
String get backupImportButton => 'Choose backup file';
@override
String get backupCreating => 'Creating backup...';
@override
String get backupCreated => 'Backup created';
@override
String get backupCreateFailed => 'Failed to create backup';
@override
String get backupEmpty => 'There is nothing to back up yet';
@override
String get backupRestoreConfirmTitle => 'Restore this backup?';
@override
String get backupRestoreConfirmMessage =>
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
@override
String get backupRestoreConfirmButton => 'Restore';
@override
String get backupRestoring => 'Restoring backup...';
@override
String get backupRestored => 'Backup restored successfully';
@override
String get backupRestoreFailed => 'Failed to restore backup';
@override
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
@override
String get backupRestoreRestartHint =>
'Restart the app to make sure every change is applied.';
@override
String get backupContentsTitle => 'Backup contents';
@override
String get backupContentsSettings => 'App settings';
@override
String backupContentsHistory(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'items',
one: 'item',
);
return '$count history $_temp0';
}
@override
String backupContentsLiked(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count liked $_temp0';
}
@override
String backupContentsWishlist(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$count wishlist $_temp0';
}
@override
String backupContentsPlaylists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String backupContentsArtists(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count favorite artists',
one: '1 favorite artist',
);
return '$_temp0';
}
@override
String backupContentsExtensions(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count extensions',
one: '1 extension',
);
return '$_temp0';
}
@override
String get backupIncludeSecrets => 'Include extension credentials';
@override
String get backupIncludeSecretsDescription =>
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
@override
String backupExtensionsRestoreFailed(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'extensions',
one: 'extension',
);
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
}
@override
String get tooltipLoveAll => 'Love All';
@@ -3014,6 +3185,17 @@ class AppLocalizationsZh extends AppLocalizations {
String get downloadNetworkCompatibilityModeDisabled =>
'Using standard network settings';
@override
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
@override
String get downloadAllowLocalNetworkEnabled =>
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
@override
String get downloadAllowLocalNetworkDisabled =>
'Local/private addresses are blocked for security';
@override
String get downloadSelectServiceToEnable =>
'Select a provider with quality options to enable this option';
@@ -4268,6 +4450,320 @@ class AppLocalizationsZh extends AppLocalizations {
String shareSheetLinkCopied(Object service) {
return '$service link copied';
}
@override
String get libraryPlayback => 'Playback';
@override
String get libraryExternalPlayer => 'External player';
@override
String get libraryExternalPlayerSubtitle =>
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
@override
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
@override
String get libraryBuiltInPreviewPlayerSubtitle =>
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
@override
String get libraryBuiltInPlayerInfo =>
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
@override
String get nowPlayingTitle => 'Now Playing';
@override
String get nowPlayingNothingPlaying => 'Nothing is playing';
@override
String get nowPlayingMinimize => 'Minimize';
@override
String get nowPlayingUpNext => 'Up next';
@override
String get nowPlayingDetails => 'Details';
@override
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
@override
String get nowPlayingTabPlayer => 'Player';
@override
String get nowPlayingTabLyrics => 'Lyrics';
@override
String get nowPlayingNoLyrics => 'No lyrics in this file';
@override
String get nowPlayingLibraryEmpty => 'Your library is empty';
@override
String nowPlayingShuffleLibraryFailed(String error) {
return 'Could not shuffle library: $error';
}
@override
String get nowPlayingShuffleOn => 'Shuffle on';
@override
String get nowPlayingPlayInOrder => 'Play in order';
@override
String get nowPlayingShuffleLibrary => 'Shuffle library';
@override
String get nowPlayingQueueEmpty => 'Queue is empty';
@override
String get nowPlayingNoMetadata => 'No metadata available';
@override
String get announcementUnableToOpenLink =>
'Unable to open link. Please try again.';
@override
String trackConvertLosslessOutputWithCap(String quality) {
return 'Lossless output with $quality cap';
}
@override
String trackConvertConfirmMessageLosslessCapped(
String sourceFormat,
String targetFormat,
String quality,
) {
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLosslessCapped(
int count,
String format,
String quality,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
}
@override
String trackConvertActionLabelLossless(
String sourceFormat,
String targetFormat,
String quality,
) {
return '$sourceFormat$targetFormat ($quality)';
}
@override
String trackConvertActionLabelLossy(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return '$sourceFormat$targetFormat @ $bitrate';
}
@override
String get aboutPaxsenixSubtitle =>
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
@override
String get snackbarPlayingNext => 'Playing next';
@override
String get snackbarAddedToQueueGeneric => 'Added to queue';
@override
String selectionDeletePlaylistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Delete $count $_temp0';
}
@override
String get actionShuffle => 'Shuffle';
@override
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
@override
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
@override
String get downloadAlbumArtistMetadataPrimaryOnly =>
'Album Artist metadata: Primary only';
@override
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
@override
String get trackConvertOriginal => 'Original';
@override
String get trackConvertOriginalQuality => 'Original quality';
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@override
String get unknownTitle => 'Unknown title';
@override
String get trackPlayNext => 'Play next';
@override
String get trackAddToQueue => 'Add to queue';
@override
String snackbarExtensionInstalledEnable(String extensionName) {
return '$extensionName installed. Enable it in Settings > Extensions';
}
@override
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
return '$extensionName updated to v$version';
}
@override
String snackbarFailedToInstallNamed(String extensionName) {
return 'Failed to install $extensionName';
}
@override
String snackbarFailedToUpdateNamed(String extensionName) {
return 'Failed to update $extensionName';
}
@override
String get releaseTypeEp => 'EP';
@override
String get releaseTypeSingle => 'Single';
@override
String get trackCoverOnline => 'Online cover';
@override
String get regionCountryUS => 'United States';
@override
String get regionCountryGB => 'United Kingdom';
@override
String get regionCountryFR => 'France';
@override
String get regionCountryDE => 'Germany';
@override
String get regionCountryJP => 'Japan';
@override
String get regionCountryKR => 'South Korea';
@override
String get regionCountryIN => 'India';
@override
String get regionCountryID => 'Indonesia';
@override
String get regionCountryBR => 'Brazil';
@override
String get regionCountryMX => 'Mexico';
@override
String get regionCountryAU => 'Australia';
@override
String get regionCountryCA => 'Canada';
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
/// The translations for Chinese, as used in China (`zh_CN`).
@@ -5807,6 +6303,10 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get albumFolderStructureDescription =>
'Choose how album folders are structured';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@@ -10038,6 +10538,10 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get albumFolderStructureDescription =>
'Choose how album folders are structured';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
+4
View File
@@ -2037,6 +2037,10 @@
"@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization"
},
"albumFolderStructureDescription": "Choose how album folders are structured",
"@albumFolderStructureDescription": {
"description": "Album folder structure picker description"
},
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
"@downloadUseAlbumArtistForFolders": {
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
+4
View File
@@ -1980,6 +1980,10 @@
"@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization"
},
"albumFolderStructureDescription": "Ordnerstruktur für Alben festlegen",
"@albumFolderStructureDescription": {
"description": "Album folder structure picker description"
},
"downloadUseAlbumArtistForFolders": "Album-Künstler für Ordner verwenden",
"@downloadUseAlbumArtistForFolders": {
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
+609
View File
@@ -772,6 +772,18 @@
"@dialogDownload": {
"description": "Confirm button in Download All dialog"
},
"previewPlay": "Play preview",
"@previewPlay": {
"description": "Tooltip for the button that plays a short track preview snippet"
},
"previewStop": "Stop preview",
"@previewStop": {
"description": "Tooltip for the button that stops the playing track preview snippet"
},
"previewUnavailable": "Preview unavailable",
"@previewUnavailable": {
"description": "Snackbar shown when a track preview snippet cannot be played"
},
"dialogDiscard": "Discard",
"@dialogDiscard": {
"description": "Dialog button - discard changes"
@@ -2095,6 +2107,10 @@
"@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization"
},
"albumFolderStructureDescription": "Choose how album folders are structured",
"@albumFolderStructureDescription": {
"description": "Album folder structure picker description"
},
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
"@downloadUseAlbumArtistForFolders": {
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
@@ -3827,6 +3843,169 @@
"@settingsDonateSubtitle": {
"description": "Subtitle for donate menu item"
},
"settingsBackup": "Backup & Restore",
"@settingsBackup": {
"description": "Settings menu item - backup and restore page"
},
"settingsBackupSubtitle": "Move your library, history and settings to a new device",
"@settingsBackupSubtitle": {
"description": "Subtitle for backup and restore settings item"
},
"backupTitle": "Backup & Restore",
"@backupTitle": {
"description": "App bar title for the backup and restore page"
},
"backupExportSectionTitle": "Create backup",
"@backupExportSectionTitle": {
"description": "Section title for the export/backup card"
},
"backupExportSectionDescription": "Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.",
"@backupExportSectionDescription": {
"description": "Description of what a backup contains"
},
"backupExportButton": "Create backup file",
"@backupExportButton": {
"description": "Button to create and share a backup file"
},
"backupImportSectionTitle": "Restore backup",
"@backupImportSectionTitle": {
"description": "Section title for the import/restore card"
},
"backupImportSectionDescription": "Pick a backup file to restore your data. This replaces the current settings, history and library on this device.",
"@backupImportSectionDescription": {
"description": "Description for the restore action"
},
"backupImportButton": "Choose backup file",
"@backupImportButton": {
"description": "Button to pick a backup file to restore"
},
"backupCreating": "Creating backup...",
"@backupCreating": {
"description": "Progress text while a backup is being created"
},
"backupCreated": "Backup created",
"@backupCreated": {
"description": "Snackbar after a backup file is created"
},
"backupCreateFailed": "Failed to create backup",
"@backupCreateFailed": {
"description": "Snackbar when backup creation fails"
},
"backupEmpty": "There is nothing to back up yet",
"@backupEmpty": {
"description": "Snackbar when there is no data to back up"
},
"backupRestoreConfirmTitle": "Restore this backup?",
"@backupRestoreConfirmTitle": {
"description": "Confirmation dialog title before restoring a backup"
},
"backupRestoreConfirmMessage": "This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.",
"@backupRestoreConfirmMessage": {
"description": "Confirmation dialog message before restoring a backup"
},
"backupRestoreConfirmButton": "Restore",
"@backupRestoreConfirmButton": {
"description": "Confirm button to proceed with restore"
},
"backupRestoring": "Restoring backup...",
"@backupRestoring": {
"description": "Progress text while restoring a backup"
},
"backupRestored": "Backup restored successfully",
"@backupRestored": {
"description": "Snackbar after a successful restore"
},
"backupRestoreFailed": "Failed to restore backup",
"@backupRestoreFailed": {
"description": "Snackbar when restore fails"
},
"backupInvalidFile": "This file is not a valid SpotiFLAC backup",
"@backupInvalidFile": {
"description": "Snackbar when the chosen file is not a valid backup"
},
"backupRestoreRestartHint": "Restart the app to make sure every change is applied.",
"@backupRestoreRestartHint": {
"description": "Hint shown after restoring that an app restart is recommended"
},
"backupContentsTitle": "Backup contents",
"@backupContentsTitle": {
"description": "Header above the list summarizing what the backup contains"
},
"backupContentsSettings": "App settings",
"@backupContentsSettings": {
"description": "Backup contents row label for settings"
},
"backupContentsHistory": "{count} history {count, plural, =1{item} other{items}}",
"@backupContentsHistory": {
"description": "Backup contents row for history count",
"placeholders": {
"count": {
"type": "int"
}
}
},
"backupContentsLiked": "{count} liked {count, plural, =1{track} other{tracks}}",
"@backupContentsLiked": {
"description": "Backup contents row for liked tracks count",
"placeholders": {
"count": {
"type": "int"
}
}
},
"backupContentsWishlist": "{count} wishlist {count, plural, =1{track} other{tracks}}",
"@backupContentsWishlist": {
"description": "Backup contents row for wishlist tracks count",
"placeholders": {
"count": {
"type": "int"
}
}
},
"backupContentsPlaylists": "{count, plural, =1{1 playlist} other{{count} playlists}}",
"@backupContentsPlaylists": {
"description": "Backup contents row for playlist count",
"placeholders": {
"count": {
"type": "int"
}
}
},
"backupContentsArtists": "{count, plural, =1{1 favorite artist} other{{count} favorite artists}}",
"@backupContentsArtists": {
"description": "Backup contents row for favorite artists count",
"placeholders": {
"count": {
"type": "int"
}
}
},
"backupContentsExtensions": "{count, plural, =1{1 extension} other{{count} extensions}}",
"@backupContentsExtensions": {
"description": "Backup contents row for installed extensions count",
"placeholders": {
"count": {
"type": "int"
}
}
},
"backupIncludeSecrets": "Include extension credentials",
"@backupIncludeSecrets": {
"description": "Toggle to include secret extension settings (tokens, API keys) in the backup"
},
"backupIncludeSecretsDescription": "Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.",
"@backupIncludeSecretsDescription": {
"description": "Explanation for the include-credentials toggle"
},
"backupExtensionsRestoreFailed": "{count} {count, plural, =1{extension} other{extensions}} could not be reinstalled. Install them manually from the store.",
"@backupExtensionsRestoreFailed": {
"description": "Snackbar/hint when some extensions failed to reinstall during restore",
"placeholders": {
"count": {
"type": "int"
}
}
},
"tooltipLoveAll": "Love All",
"@tooltipLoveAll": {
"description": "Tooltip for the Love All button on album/playlist screens"
@@ -3983,6 +4162,18 @@
"@downloadNetworkCompatibilityModeDisabled": {
"description": "Subtitle when network compatibility mode is off"
},
"downloadAllowLocalNetwork": "Allow Local Network Access",
"@downloadAllowLocalNetwork": {
"description": "Setting title for allowing requests to private/local network targets"
},
"downloadAllowLocalNetworkEnabled": "Requests to local/private addresses are allowed (for local proxy or custom DNS)",
"@downloadAllowLocalNetworkEnabled": {
"description": "Subtitle when allow local network access is on"
},
"downloadAllowLocalNetworkDisabled": "Local/private addresses are blocked for security",
"@downloadAllowLocalNetworkDisabled": {
"description": "Subtitle when allow local network access is off"
},
"downloadSelectServiceToEnable": "Select a provider with quality options to enable this option",
"@downloadSelectServiceToEnable": {
"description": "Subtitle when quality picker is disabled due to extension service"
@@ -5595,5 +5786,423 @@
"placeholders": {
"service": {}
}
},
"libraryPlayback": "Playback",
"@libraryPlayback": {
"description": "Section header for playback settings in library settings"
},
"libraryExternalPlayer": "External player",
"@libraryExternalPlayer": {
"description": "Setting option to use an external music player"
},
"libraryExternalPlayerSubtitle": "Recommended for listening, best quality, gapless playback, EQ, and wider format support",
"@libraryExternalPlayerSubtitle": {
"description": "Subtitle for external player option"
},
"libraryBuiltInPreviewPlayer": "Built-in preview player",
"@libraryBuiltInPreviewPlayer": {
"description": "Setting option to use the built-in preview player"
},
"libraryBuiltInPreviewPlayerSubtitle": "Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening",
"@libraryBuiltInPreviewPlayerSubtitle": {
"description": "Subtitle for built-in preview player option"
},
"libraryBuiltInPlayerInfo": "The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.",
"@libraryBuiltInPlayerInfo": {
"description": "Info note explaining the built-in player is for previews only"
},
"nowPlayingTitle": "Now Playing",
"@nowPlayingTitle": {
"description": "Title for the now playing screen"
},
"nowPlayingNothingPlaying": "Nothing is playing",
"@nowPlayingNothingPlaying": {
"description": "Empty state when no track is currently playing"
},
"nowPlayingMinimize": "Minimize",
"@nowPlayingMinimize": {
"description": "Tooltip for minimizing the now playing screen"
},
"nowPlayingUpNext": "Up next",
"@nowPlayingUpNext": {
"description": "Title for the playback queue sheet"
},
"nowPlayingDetails": "Details",
"@nowPlayingDetails": {
"description": "Menu item and section title for track metadata details"
},
"nowPlayingOpenInExternalPlayer": "Open in external player",
"@nowPlayingOpenInExternalPlayer": {
"description": "Menu item to open the current track in an external player"
},
"nowPlayingTabPlayer": "Player",
"@nowPlayingTabPlayer": {
"description": "Tab label for the player view"
},
"nowPlayingTabLyrics": "Lyrics",
"@nowPlayingTabLyrics": {
"description": "Tab label for the lyrics view"
},
"nowPlayingNoLyrics": "No lyrics in this file",
"@nowPlayingNoLyrics": {
"description": "Empty state when the playing file has no embedded lyrics"
},
"nowPlayingLibraryEmpty": "Your library is empty",
"@nowPlayingLibraryEmpty": {
"description": "Snackbar when shuffle library is requested but library has no tracks"
},
"nowPlayingShuffleLibraryFailed": "Could not shuffle library: {error}",
"@nowPlayingShuffleLibraryFailed": {
"description": "Snackbar when shuffling the library fails",
"placeholders": {
"error": {
"type": "String"
}
}
},
"nowPlayingShuffleOn": "Shuffle on",
"@nowPlayingShuffleOn": {
"description": "Tooltip when shuffle mode is enabled"
},
"nowPlayingPlayInOrder": "Play in order",
"@nowPlayingPlayInOrder": {
"description": "Tooltip when shuffle mode is disabled"
},
"nowPlayingShuffleLibrary": "Shuffle library",
"@nowPlayingShuffleLibrary": {
"description": "Button label to shuffle and play the entire local library"
},
"nowPlayingQueueEmpty": "Queue is empty",
"@nowPlayingQueueEmpty": {
"description": "Empty state when the playback queue has no items"
},
"nowPlayingNoMetadata": "No metadata available",
"@nowPlayingNoMetadata": {
"description": "Empty state when track metadata cannot be loaded"
},
"announcementUnableToOpenLink": "Unable to open link. Please try again.",
"@announcementUnableToOpenLink": {
"description": "Snackbar shown when an announcement CTA link cannot be opened"
},
"trackConvertLosslessOutputWithCap": "Lossless output with {quality} cap",
"@trackConvertLosslessOutputWithCap": {
"description": "Hint shown when lossless conversion will cap bit depth or sample rate",
"placeholders": {
"quality": {
"type": "String"
}
}
},
"trackConvertConfirmMessageLosslessCapped": "Convert from {sourceFormat} to {targetFormat} ({quality})?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.",
"@trackConvertConfirmMessageLosslessCapped": {
"description": "Confirmation dialog message for capped lossless conversion of a single file",
"placeholders": {
"sourceFormat": {
"type": "String"
},
"targetFormat": {
"type": "String"
},
"quality": {
"type": "String"
}
}
},
"selectionBatchConvertConfirmMessageLosslessCapped": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} ({quality})?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.",
"@selectionBatchConvertConfirmMessageLosslessCapped": {
"description": "Confirmation dialog message for capped lossless batch conversion",
"placeholders": {
"count": {
"type": "int"
},
"format": {
"type": "String"
},
"quality": {
"type": "String"
}
}
},
"trackConvertActionLabelLossless": "{sourceFormat} → {targetFormat} ({quality})",
"@trackConvertActionLabelLossless": {
"description": "Convert button label for lossless conversion with quality cap",
"placeholders": {
"sourceFormat": {
"type": "String"
},
"targetFormat": {
"type": "String"
},
"quality": {
"type": "String"
}
}
},
"trackConvertActionLabelLossy": "{sourceFormat} → {targetFormat} @ {bitrate}",
"@trackConvertActionLabelLossy": {
"description": "Convert button label for lossy conversion",
"placeholders": {
"sourceFormat": {
"type": "String"
},
"targetFormat": {
"type": "String"
},
"bitrate": {
"type": "String"
}
}
},
"aboutPaxsenixSubtitle": "Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius",
"@aboutPaxsenixSubtitle": {
"description": "Subtitle for Paxsenix special thanks entry on the about page"
},
"snackbarPlayingNext": "Playing next",
"@snackbarPlayingNext": {
"description": "Snackbar when a track is inserted as the next queue item"
},
"snackbarAddedToQueueGeneric": "Added to queue",
"@snackbarAddedToQueueGeneric": {
"description": "Snackbar when a track is added to the playback queue without naming it"
},
"selectionDeletePlaylistsCount": "Delete {count} {count, plural, =1{playlist} other{playlists}}",
"@selectionDeletePlaylistsCount": {
"description": "Button label for deleting multiple selected playlists",
"placeholders": {
"count": {
"type": "int"
}
}
},
"actionShuffle": "Shuffle",
"@actionShuffle": {
"description": "Tooltip for shuffle playback action"
},
"downloadPrimaryArtistOnlyOn": "Primary only: On",
"@downloadPrimaryArtistOnlyOn": {
"description": "Status label when primary-artist-only folder naming is enabled"
},
"downloadPrimaryArtistOnlyOff": "Primary only: Off",
"@downloadPrimaryArtistOnlyOff": {
"description": "Status label when primary-artist-only folder naming is disabled"
},
"downloadAlbumArtistMetadataPrimaryOnly": "Album Artist metadata: Primary only",
"@downloadAlbumArtistMetadataPrimaryOnly": {
"description": "Status label when album-artist folder filtering uses primary artist only"
},
"downloadAlbumArtistMetadataFull": "Album Artist metadata: Full",
"@downloadAlbumArtistMetadataFull": {
"description": "Status label when album-artist folder filtering uses full metadata"
},
"trackConvertOriginal": "Original",
"@trackConvertOriginal": {
"description": "Label for keeping original bit depth or sample rate during conversion"
},
"trackConvertOriginalQuality": "Original quality",
"@trackConvertOriginalQuality": {
"description": "Label when no bit depth or sample rate cap is applied during lossless conversion"
},
"trackConvertLosslessSuffix": "Lossless",
"@trackConvertLosslessSuffix": {
"description": "Suffix used in converted lossless quality labels"
},
"trackConvertDithering": "Dithering",
"@trackConvertDithering": {
"description": "Section label for lossless conversion dithering options"
},
"trackConvertResampler": "Resampler",
"@trackConvertResampler": {
"description": "Section label for lossless conversion resampler options"
},
"trackConvertDitherNone": "None",
"@trackConvertDitherNone": {
"description": "Lossless conversion dither option with no dithering applied"
},
"trackConvertDitherTriangular": "TPDF",
"@trackConvertDitherTriangular": {
"description": "Lossless conversion triangular probability density function dither option"
},
"trackConvertDitherTriangularHp": "Triangular HP",
"@trackConvertDitherTriangularHp": {
"description": "Lossless conversion high-pass triangular dither option"
},
"trackConvertResamplerSwr": "SWR",
"@trackConvertResamplerSwr": {
"description": "Lossless conversion default FFmpeg swresample resampler option"
},
"trackConvertResamplerSoxr": "SoXr",
"@trackConvertResamplerSoxr": {
"description": "Lossless conversion SoX resampler option"
},
"updateSeeReleaseNotes": "See release notes for details.",
"@updateSeeReleaseNotes": {
"description": "Fallback changelog text when release notes cannot be parsed"
},
"unknownTitle": "Unknown title",
"@unknownTitle": {
"description": "Fallback track title when metadata is missing"
},
"trackPlayNext": "Play next",
"@trackPlayNext": {
"description": "Menu action to play a track as the next queue item"
},
"trackAddToQueue": "Add to queue",
"@trackAddToQueue": {
"description": "Menu action to add a track to the playback queue"
},
"snackbarExtensionInstalledEnable": "{extensionName} installed. Enable it in Settings > Extensions",
"@snackbarExtensionInstalledEnable": {
"description": "Snackbar after installing an extension from the repo tab",
"placeholders": {
"extensionName": {
"type": "String"
}
}
},
"snackbarExtensionUpdatedVersion": "{extensionName} updated to v{version}",
"@snackbarExtensionUpdatedVersion": {
"description": "Snackbar after updating an extension from the repo tab",
"placeholders": {
"extensionName": {
"type": "String"
},
"version": {
"type": "String"
}
}
},
"snackbarFailedToInstallNamed": "Failed to install {extensionName}",
"@snackbarFailedToInstallNamed": {
"description": "Snackbar when extension install fails in the repo tab",
"placeholders": {
"extensionName": {
"type": "String"
}
}
},
"snackbarFailedToUpdateNamed": "Failed to update {extensionName}",
"@snackbarFailedToUpdateNamed": {
"description": "Snackbar when extension update fails in the repo tab",
"placeholders": {
"extensionName": {
"type": "String"
}
}
},
"releaseTypeEp": "EP",
"@releaseTypeEp": {
"description": "Badge label for EP releases"
},
"releaseTypeSingle": "Single",
"@releaseTypeSingle": {
"description": "Badge label for single releases"
},
"trackCoverOnline": "Online cover",
"@trackCoverOnline": {
"description": "Label shown when metadata autofill downloaded cover art from the internet"
},
"regionCountryUS": "United States",
"@regionCountryUS": {
"description": "Country name for SongLink region picker"
},
"regionCountryGB": "United Kingdom",
"@regionCountryGB": {
"description": "Country name for SongLink region picker"
},
"regionCountryFR": "France",
"@regionCountryFR": {
"description": "Country name for SongLink region picker"
},
"regionCountryDE": "Germany",
"@regionCountryDE": {
"description": "Country name for SongLink region picker"
},
"regionCountryJP": "Japan",
"@regionCountryJP": {
"description": "Country name for SongLink region picker"
},
"regionCountryKR": "South Korea",
"@regionCountryKR": {
"description": "Country name for SongLink region picker"
},
"regionCountryIN": "India",
"@regionCountryIN": {
"description": "Country name for SongLink region picker"
},
"regionCountryID": "Indonesia",
"@regionCountryID": {
"description": "Country name for SongLink region picker"
},
"regionCountryBR": "Brazil",
"@regionCountryBR": {
"description": "Country name for SongLink region picker"
},
"regionCountryMX": "Mexico",
"@regionCountryMX": {
"description": "Country name for SongLink region picker"
},
"regionCountryAU": "Australia",
"@regionCountryAU": {
"description": "Country name for SongLink region picker"
},
"regionCountryCA": "Canada",
"@regionCountryCA": {
"description": "Country name for SongLink region picker"
},
"regionCountryXK": "Kosovo",
"@regionCountryXK": {
"description": "Country name for SongLink region picker"
},
"extensionVerificationBrowserTitle": "Verification browser",
"@extensionVerificationBrowserTitle": {
"description": "Settings option title for extension verification browser preference"
},
"extensionVerificationBrowserSubtitleExternal": "Open challenges in the default browser first",
"@extensionVerificationBrowserSubtitleExternal": {
"description": "Subtitle when external browser is preferred for extension verification"
},
"extensionVerificationBrowserSubtitleInApp": "Open challenges in the in-app browser first",
"@extensionVerificationBrowserSubtitleInApp": {
"description": "Subtitle when in-app browser is preferred for extension verification"
},
"extensionVerificationBrowserExternal": "External",
"@extensionVerificationBrowserExternal": {
"description": "Chip label for external browser verification mode"
},
"extensionVerificationBrowserInApp": "In-app",
"@extensionVerificationBrowserInApp": {
"description": "Chip label for in-app browser verification mode"
},
"extensionVerificationHelpTitleManual": "Open verification manually",
"@extensionVerificationHelpTitleManual": {
"description": "Dialog title when automatic browser launch for verification fails"
},
"extensionVerificationHelpTitleWaiting": "Verification still waiting",
"@extensionVerificationHelpTitleWaiting": {
"description": "Dialog title when verification is taking longer than expected"
},
"extensionVerificationHelpMessageManual": "SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.",
"@extensionVerificationHelpMessageManual": {
"description": "Dialog message when automatic browser launch for verification fails"
},
"extensionVerificationHelpMessageWaiting": "If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.",
"@extensionVerificationHelpMessageWaiting": {
"description": "Dialog message when verification may need manual browser help"
},
"extensionVerificationClose": "Close",
"@extensionVerificationClose": {
"description": "Button to dismiss the extension verification help dialog"
},
"extensionVerificationCopyLink": "Copy link",
"@extensionVerificationCopyLink": {
"description": "Button to copy the extension verification URL"
},
"extensionVerificationLinkCopied": "Verification link copied",
"@extensionVerificationLinkCopied": {
"description": "Snackbar after copying the extension verification URL"
},
"extensionVerificationOpenBrowser": "Open browser",
"@extensionVerificationOpenBrowser": {
"description": "Button to open the extension verification URL in a browser"
}
}
+4
View File
@@ -1592,6 +1592,10 @@
"@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization"
},
"albumFolderStructureDescription": "Choose how album folders are structured",
"@albumFolderStructureDescription": {
"description": "Album folder structure picker description"
},
"downloadSelectQuality": "Select Quality",
"@downloadSelectQuality": {
"description": "Dialog title - choose audio quality"
+4
View File
@@ -1980,6 +1980,10 @@
"@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization"
},
"albumFolderStructureDescription": "Elige cómo se estructuran las carpetas de los álbumes",
"@albumFolderStructureDescription": {
"description": "Album folder structure picker description"
},
"downloadUseAlbumArtistForFolders": "Usar álbum de artista cómo carpeta",
"@downloadUseAlbumArtistForFolders": {
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
+4
View File
@@ -1980,6 +1980,10 @@
"@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization"
},
"albumFolderStructureDescription": "Choisir la structure des dossiers d'album",
"@albumFolderStructureDescription": {
"description": "Album folder structure picker description"
},
"downloadUseAlbumArtistForFolders": "Utilisez l'artiste de l'album pour les dossiers",
"@downloadUseAlbumArtistForFolders": {
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
+4
View File
@@ -1980,6 +1980,10 @@
"@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization"
},
"albumFolderStructureDescription": "Choose how album folders are structured",
"@albumFolderStructureDescription": {
"description": "Album folder structure picker description"
},
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
"@downloadUseAlbumArtistForFolders": {
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
+515
View File
@@ -1800,6 +1800,10 @@
"@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization"
},
"albumFolderStructureDescription": "Pilih struktur folder album",
"@albumFolderStructureDescription": {
"description": "Album folder structure picker description"
},
"downloadUseAlbumArtistForFolders": "Gunakan Artis Album untuk folder",
"@downloadUseAlbumArtistForFolders": {
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
@@ -3689,6 +3693,87 @@
"@settingsDonateSubtitle": {
"description": "Subtitle for donate menu item"
},
"settingsBackup": "Cadangkan & Pulihkan",
"settingsBackupSubtitle": "Pindahkan pustaka, riwayat, dan pengaturan ke perangkat baru",
"backupTitle": "Cadangkan & Pulihkan",
"backupExportSectionTitle": "Buat cadangan",
"backupExportSectionDescription": "Simpan pengaturan, riwayat unduhan, lagu disukai, wishlist, artis favorit, dan playlist ke dalam satu file yang bisa kamu simpan atau pindahkan ke ponsel lain.",
"backupExportButton": "Buat file cadangan",
"backupImportSectionTitle": "Pulihkan cadangan",
"backupImportSectionDescription": "Pilih file cadangan untuk memulihkan data. Ini akan menggantikan pengaturan, riwayat, dan pustaka di perangkat ini.",
"backupImportButton": "Pilih file cadangan",
"backupCreating": "Membuat cadangan...",
"backupCreated": "Cadangan berhasil dibuat",
"backupCreateFailed": "Gagal membuat cadangan",
"backupEmpty": "Belum ada data untuk dicadangkan",
"backupRestoreConfirmTitle": "Pulihkan cadangan ini?",
"backupRestoreConfirmMessage": "Ini akan menggantikan pengaturan, riwayat unduhan, lagu disukai, wishlist, dan playlist saat ini dengan isi cadangan. Tindakan ini tidak bisa dibatalkan.",
"backupRestoreConfirmButton": "Pulihkan",
"backupRestoring": "Memulihkan cadangan...",
"backupRestored": "Cadangan berhasil dipulihkan",
"backupRestoreFailed": "Gagal memulihkan cadangan",
"backupInvalidFile": "File ini bukan cadangan SpotiFLAC yang valid",
"backupRestoreRestartHint": "Mulai ulang aplikasi untuk memastikan semua perubahan diterapkan.",
"backupContentsTitle": "Isi cadangan",
"backupContentsSettings": "Pengaturan aplikasi",
"backupContentsHistory": "{count} item riwayat",
"@backupContentsHistory": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"backupContentsLiked": "{count} lagu disukai",
"@backupContentsLiked": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"backupContentsWishlist": "{count} lagu di wishlist",
"@backupContentsWishlist": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"backupContentsPlaylists": "{count, plural, =1{1 playlist} other{{count} playlist}}",
"@backupContentsPlaylists": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"backupContentsArtists": "{count, plural, =1{1 artis favorit} other{{count} artis favorit}}",
"@backupContentsArtists": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"backupContentsExtensions": "{count, plural, =1{1 extension} other{{count} extension}}",
"@backupContentsExtensions": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"backupIncludeSecrets": "Sertakan kredensial extension",
"backupIncludeSecretsDescription": "Token dan API key dari extension akan ikut disimpan ke file cadangan. Jaga kerahasiaan file-nya. Jika dimatikan, kamu perlu memasukkannya lagi setelah pemulihan.",
"backupExtensionsRestoreFailed": "{count} extension gagal dipasang ulang. Pasang manual dari store.",
"@backupExtensionsRestoreFailed": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"tooltipLoveAll": "Love All",
"@tooltipLoveAll": {
"description": "Tooltip for the Love All button on album/playlist screens"
@@ -3829,6 +3914,18 @@
"@downloadNetworkCompatibilityModeDisabled": {
"description": "Subtitle when network compatibility mode is disabled"
},
"downloadAllowLocalNetwork": "Izinkan Akses Jaringan Lokal",
"@downloadAllowLocalNetwork": {
"description": "Setting title for allowing requests to private/local network targets"
},
"downloadAllowLocalNetworkEnabled": "Permintaan ke alamat lokal/privat diizinkan (untuk proxy lokal atau DNS kustom)",
"@downloadAllowLocalNetworkEnabled": {
"description": "Subtitle when allow local network access is on"
},
"downloadAllowLocalNetworkDisabled": "Alamat lokal/privat diblokir demi keamanan",
"@downloadAllowLocalNetworkDisabled": {
"description": "Subtitle when allow local network access is off"
},
"downloadSelectServiceToEnable": "Select a provider with quality options to enable this option",
"@downloadSelectServiceToEnable": {
"description": "Hint shown instead of Ask-quality subtitle when selected provider has no quality options"
@@ -5533,5 +5630,423 @@
"artistReleases": "Releases",
"@artistReleases": {
"description": "Section header for all artist releases"
},
"libraryPlayback": "Pemutaran",
"@libraryPlayback": {
"description": "Section header for playback settings in library settings"
},
"libraryExternalPlayer": "Pemutar eksternal",
"@libraryExternalPlayer": {
"description": "Setting option to use an external music player"
},
"libraryExternalPlayerSubtitle": "Disarankan untuk mendengarkan, kualitas terbaik, pemutaran tanpa jeda, EQ, dan dukungan format lebih luas",
"@libraryExternalPlayerSubtitle": {
"description": "Subtitle for external player option"
},
"libraryBuiltInPreviewPlayer": "Pemutar pratinjau bawaan",
"@libraryBuiltInPreviewPlayer": {
"description": "Setting option to use the built-in preview player"
},
"libraryBuiltInPreviewPlayerSubtitle": "Hanya untuk pratinjau lokal cepat di dalam SpotiFLAC Mobile, tidak disarankan untuk mendengarkan secara rutin",
"@libraryBuiltInPreviewPlayerSubtitle": {
"description": "Subtitle for built-in preview player option"
},
"libraryBuiltInPlayerInfo": "Pemutar bawaan adalah alat pratinjau untuk memeriksa trek lokal dengan cepat. Gunakan pemutar musik eksternal untuk mendengarkan sebenarnya.",
"@libraryBuiltInPlayerInfo": {
"description": "Info note explaining the built-in player is for previews only"
},
"nowPlayingTitle": "Sedang Diputar",
"@nowPlayingTitle": {
"description": "Title for the now playing screen"
},
"nowPlayingNothingPlaying": "Tidak ada yang diputar",
"@nowPlayingNothingPlaying": {
"description": "Empty state when no track is currently playing"
},
"nowPlayingMinimize": "Minimalkan",
"@nowPlayingMinimize": {
"description": "Tooltip for minimizing the now playing screen"
},
"nowPlayingUpNext": "Berikutnya",
"@nowPlayingUpNext": {
"description": "Title for the playback queue sheet"
},
"nowPlayingDetails": "Detail",
"@nowPlayingDetails": {
"description": "Menu item and section title for track metadata details"
},
"nowPlayingOpenInExternalPlayer": "Buka di pemutar eksternal",
"@nowPlayingOpenInExternalPlayer": {
"description": "Menu item to open the current track in an external player"
},
"nowPlayingTabPlayer": "Pemutar",
"@nowPlayingTabPlayer": {
"description": "Tab label for the player view"
},
"nowPlayingTabLyrics": "Lirik",
"@nowPlayingTabLyrics": {
"description": "Tab label for the lyrics view"
},
"nowPlayingNoLyrics": "Tidak ada lirik di file ini",
"@nowPlayingNoLyrics": {
"description": "Empty state when the playing file has no embedded lyrics"
},
"nowPlayingLibraryEmpty": "Perpustakaan Anda kosong",
"@nowPlayingLibraryEmpty": {
"description": "Snackbar when shuffle library is requested but library has no tracks"
},
"nowPlayingShuffleLibraryFailed": "Tidak dapat mengacak perpustakaan: {error}",
"@nowPlayingShuffleLibraryFailed": {
"description": "Snackbar when shuffling the library fails",
"placeholders": {
"error": {
"type": "String"
}
}
},
"nowPlayingShuffleOn": "Acak aktif",
"@nowPlayingShuffleOn": {
"description": "Tooltip when shuffle mode is enabled"
},
"nowPlayingPlayInOrder": "Putar berurutan",
"@nowPlayingPlayInOrder": {
"description": "Tooltip when shuffle mode is disabled"
},
"nowPlayingShuffleLibrary": "Acak perpustakaan",
"@nowPlayingShuffleLibrary": {
"description": "Button label to shuffle and play the entire local library"
},
"nowPlayingQueueEmpty": "Antrean kosong",
"@nowPlayingQueueEmpty": {
"description": "Empty state when the playback queue has no items"
},
"nowPlayingNoMetadata": "Metadata tidak tersedia",
"@nowPlayingNoMetadata": {
"description": "Empty state when track metadata cannot be loaded"
},
"announcementUnableToOpenLink": "Tidak dapat membuka tautan. Silakan coba lagi.",
"@announcementUnableToOpenLink": {
"description": "Snackbar shown when an announcement CTA link cannot be opened"
},
"trackConvertLosslessOutputWithCap": "Output lossless dengan batas {quality}",
"@trackConvertLosslessOutputWithCap": {
"description": "Hint shown when lossless conversion will cap bit depth or sample rate",
"placeholders": {
"quality": {
"type": "String"
}
}
},
"trackConvertConfirmMessageLosslessCapped": "Konversi dari {sourceFormat} ke {targetFormat} ({quality})?\n\nOutput tetap codec lossless, tetapi kedalaman bit/sample rate akan dibatasi. File asli akan dihapus setelah konversi.",
"@trackConvertConfirmMessageLosslessCapped": {
"description": "Confirmation dialog message for capped lossless conversion of a single file",
"placeholders": {
"sourceFormat": {
"type": "String"
},
"targetFormat": {
"type": "String"
},
"quality": {
"type": "String"
}
}
},
"selectionBatchConvertConfirmMessageLosslessCapped": "Konversi {count} {count, plural, =1{trek} other{trek}} ke {format} ({quality})?\n\nOutput tetap codec lossless, tetapi kedalaman bit/sample rate akan dibatasi. File asli akan dihapus setelah konversi.",
"@selectionBatchConvertConfirmMessageLosslessCapped": {
"description": "Confirmation dialog message for capped lossless batch conversion",
"placeholders": {
"count": {
"type": "int"
},
"format": {
"type": "String"
},
"quality": {
"type": "String"
}
}
},
"trackConvertActionLabelLossless": "{sourceFormat} → {targetFormat} ({quality})",
"@trackConvertActionLabelLossless": {
"description": "Convert button label for lossless conversion with quality cap",
"placeholders": {
"sourceFormat": {
"type": "String"
},
"targetFormat": {
"type": "String"
},
"quality": {
"type": "String"
}
}
},
"trackConvertActionLabelLossy": "{sourceFormat} → {targetFormat} @ {bitrate}",
"@trackConvertActionLabelLossy": {
"description": "Convert button label for lossy conversion",
"placeholders": {
"sourceFormat": {
"type": "String"
},
"targetFormat": {
"type": "String"
},
"bitrate": {
"type": "String"
}
}
},
"aboutPaxsenixSubtitle": "Proxy lirik untuk Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, dan Genius",
"@aboutPaxsenixSubtitle": {
"description": "Subtitle for Paxsenix special thanks entry on the about page"
},
"snackbarPlayingNext": "Memutar berikutnya",
"@snackbarPlayingNext": {
"description": "Snackbar when a track is inserted as the next queue item"
},
"snackbarAddedToQueueGeneric": "Ditambahkan ke antrean",
"@snackbarAddedToQueueGeneric": {
"description": "Snackbar when a track is added to the playback queue without naming it"
},
"selectionDeletePlaylistsCount": "Hapus {count} {count, plural, =1{playlist} other{playlist}}",
"@selectionDeletePlaylistsCount": {
"description": "Button label for deleting multiple selected playlists",
"placeholders": {
"count": {
"type": "int"
}
}
},
"actionShuffle": "Acak",
"@actionShuffle": {
"description": "Tooltip for shuffle playback action"
},
"downloadPrimaryArtistOnlyOn": "Hanya utama: Aktif",
"@downloadPrimaryArtistOnlyOn": {
"description": "Status label when primary-artist-only folder naming is enabled"
},
"downloadPrimaryArtistOnlyOff": "Hanya utama: Nonaktif",
"@downloadPrimaryArtistOnlyOff": {
"description": "Status label when primary-artist-only folder naming is disabled"
},
"downloadAlbumArtistMetadataPrimaryOnly": "Metadata Album Artist: Hanya utama",
"@downloadAlbumArtistMetadataPrimaryOnly": {
"description": "Status label when album-artist folder filtering uses primary artist only"
},
"downloadAlbumArtistMetadataFull": "Metadata Album Artist: Lengkap",
"@downloadAlbumArtistMetadataFull": {
"description": "Status label when album-artist folder filtering uses full metadata"
},
"trackConvertOriginal": "Asli",
"@trackConvertOriginal": {
"description": "Label for keeping original bit depth or sample rate during conversion"
},
"trackConvertOriginalQuality": "Kualitas asli",
"@trackConvertOriginalQuality": {
"description": "Label when no bit depth or sample rate cap is applied during lossless conversion"
},
"trackConvertLosslessSuffix": "Lossless",
"@trackConvertLosslessSuffix": {
"description": "Suffix used in converted lossless quality labels"
},
"trackConvertDithering": "Dithering",
"@trackConvertDithering": {
"description": "Section label for lossless conversion dithering options"
},
"trackConvertResampler": "Resampler",
"@trackConvertResampler": {
"description": "Section label for lossless conversion resampler options"
},
"trackConvertDitherNone": "Tidak ada",
"@trackConvertDitherNone": {
"description": "Lossless conversion dither option with no dithering applied"
},
"trackConvertDitherTriangular": "TPDF",
"@trackConvertDitherTriangular": {
"description": "Lossless conversion triangular probability density function dither option"
},
"trackConvertDitherTriangularHp": "Triangular HP",
"@trackConvertDitherTriangularHp": {
"description": "Lossless conversion high-pass triangular dither option"
},
"trackConvertResamplerSwr": "SWR",
"@trackConvertResamplerSwr": {
"description": "Lossless conversion default FFmpeg swresample resampler option"
},
"trackConvertResamplerSoxr": "SoXr",
"@trackConvertResamplerSoxr": {
"description": "Lossless conversion SoX resampler option"
},
"updateSeeReleaseNotes": "Lihat catatan rilis untuk detail.",
"@updateSeeReleaseNotes": {
"description": "Fallback changelog text when release notes cannot be parsed"
},
"unknownTitle": "Judul tidak diketahui",
"@unknownTitle": {
"description": "Fallback track title when metadata is missing"
},
"trackPlayNext": "Putar berikutnya",
"@trackPlayNext": {
"description": "Menu action to play a track as the next queue item"
},
"trackAddToQueue": "Tambah ke antrean",
"@trackAddToQueue": {
"description": "Menu action to add a track to the playback queue"
},
"snackbarExtensionInstalledEnable": "{extensionName} terpasang. Aktifkan di Pengaturan > Ekstensi",
"@snackbarExtensionInstalledEnable": {
"description": "Snackbar after installing an extension from the repo tab",
"placeholders": {
"extensionName": {
"type": "String"
}
}
},
"snackbarExtensionUpdatedVersion": "{extensionName} diperbarui ke v{version}",
"@snackbarExtensionUpdatedVersion": {
"description": "Snackbar after updating an extension from the repo tab",
"placeholders": {
"extensionName": {
"type": "String"
},
"version": {
"type": "String"
}
}
},
"snackbarFailedToInstallNamed": "Gagal memasang {extensionName}",
"@snackbarFailedToInstallNamed": {
"description": "Snackbar when extension install fails in the repo tab",
"placeholders": {
"extensionName": {
"type": "String"
}
}
},
"snackbarFailedToUpdateNamed": "Gagal memperbarui {extensionName}",
"@snackbarFailedToUpdateNamed": {
"description": "Snackbar when extension update fails in the repo tab",
"placeholders": {
"extensionName": {
"type": "String"
}
}
},
"releaseTypeEp": "EP",
"@releaseTypeEp": {
"description": "Badge label for EP releases"
},
"releaseTypeSingle": "Single",
"@releaseTypeSingle": {
"description": "Badge label for single releases"
},
"trackCoverOnline": "Sampul daring",
"@trackCoverOnline": {
"description": "Label shown when metadata autofill downloaded cover art from the internet"
},
"regionCountryUS": "Amerika Serikat",
"@regionCountryUS": {
"description": "Country name for SongLink region picker"
},
"regionCountryGB": "Britania Raya",
"@regionCountryGB": {
"description": "Country name for SongLink region picker"
},
"regionCountryFR": "Prancis",
"@regionCountryFR": {
"description": "Country name for SongLink region picker"
},
"regionCountryDE": "Jerman",
"@regionCountryDE": {
"description": "Country name for SongLink region picker"
},
"regionCountryJP": "Jepang",
"@regionCountryJP": {
"description": "Country name for SongLink region picker"
},
"regionCountryKR": "Korea Selatan",
"@regionCountryKR": {
"description": "Country name for SongLink region picker"
},
"regionCountryIN": "India",
"@regionCountryIN": {
"description": "Country name for SongLink region picker"
},
"regionCountryID": "Indonesia",
"@regionCountryID": {
"description": "Country name for SongLink region picker"
},
"regionCountryBR": "Brasil",
"@regionCountryBR": {
"description": "Country name for SongLink region picker"
},
"regionCountryMX": "Meksiko",
"@regionCountryMX": {
"description": "Country name for SongLink region picker"
},
"regionCountryAU": "Australia",
"@regionCountryAU": {
"description": "Country name for SongLink region picker"
},
"regionCountryCA": "Kanada",
"@regionCountryCA": {
"description": "Country name for SongLink region picker"
},
"regionCountryXK": "Kosovo",
"@regionCountryXK": {
"description": "Country name for SongLink region picker"
},
"extensionVerificationBrowserTitle": "Browser verifikasi",
"@extensionVerificationBrowserTitle": {
"description": "Settings option title for extension verification browser preference"
},
"extensionVerificationBrowserSubtitleExternal": "Buka tantangan di browser default terlebih dahulu",
"@extensionVerificationBrowserSubtitleExternal": {
"description": "Subtitle when external browser is preferred for extension verification"
},
"extensionVerificationBrowserSubtitleInApp": "Buka tantangan di browser dalam aplikasi terlebih dahulu",
"@extensionVerificationBrowserSubtitleInApp": {
"description": "Subtitle when in-app browser is preferred for extension verification"
},
"extensionVerificationBrowserExternal": "Eksternal",
"@extensionVerificationBrowserExternal": {
"description": "Chip label for external browser verification mode"
},
"extensionVerificationBrowserInApp": "Dalam aplikasi",
"@extensionVerificationBrowserInApp": {
"description": "Chip label for in-app browser verification mode"
},
"extensionVerificationHelpTitleManual": "Buka verifikasi secara manual",
"@extensionVerificationHelpTitleManual": {
"description": "Dialog title when automatic browser launch for verification fails"
},
"extensionVerificationHelpTitleWaiting": "Verifikasi masih menunggu",
"@extensionVerificationHelpTitleWaiting": {
"description": "Dialog title when verification is taking longer than expected"
},
"extensionVerificationHelpMessageManual": "SpotiFLAC Mobile tidak dapat membuka browser secara otomatis. Buka tautan ini di browser Anda, atau salin secara manual.",
"@extensionVerificationHelpMessageManual": {
"description": "Dialog message when automatic browser launch for verification fails"
},
"extensionVerificationHelpMessageWaiting": "Jika browser tidak terbuka, atau verifikasi selesai tetapi tidak kembali ke SpotiFLAC Mobile, buka tautan ini lagi atau salin secara manual.",
"@extensionVerificationHelpMessageWaiting": {
"description": "Dialog message when verification may need manual browser help"
},
"extensionVerificationClose": "Tutup",
"@extensionVerificationClose": {
"description": "Button to dismiss the extension verification help dialog"
},
"extensionVerificationCopyLink": "Salin tautan",
"@extensionVerificationCopyLink": {
"description": "Button to copy the extension verification URL"
},
"extensionVerificationLinkCopied": "Tautan verifikasi disalin",
"@extensionVerificationLinkCopied": {
"description": "Snackbar after copying the extension verification URL"
},
"extensionVerificationOpenBrowser": "Buka browser",
"@extensionVerificationOpenBrowser": {
"description": "Button to open the extension verification URL in a browser"
}
}
+4
View File
@@ -1756,6 +1756,10 @@
"@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization"
},
"albumFolderStructureDescription": "アルバムフォルダの構成を選択",
"@albumFolderStructureDescription": {
"description": "Album folder structure picker description"
},
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
"@downloadUseAlbumArtistForFolders": {
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
+4
View File
@@ -1980,6 +1980,10 @@
"@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization"
},
"albumFolderStructureDescription": "Choose how album folders are structured",
"@albumFolderStructureDescription": {
"description": "Album folder structure picker description"
},
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
"@downloadUseAlbumArtistForFolders": {
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
+4
View File
@@ -1980,6 +1980,10 @@
"@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization"
},
"albumFolderStructureDescription": "Choose how album folders are structured",
"@albumFolderStructureDescription": {
"description": "Album folder structure picker description"
},
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
"@downloadUseAlbumArtistForFolders": {
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
+4
View File
@@ -1592,6 +1592,10 @@
"@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization"
},
"albumFolderStructureDescription": "Choose how album folders are structured",
"@albumFolderStructureDescription": {
"description": "Album folder structure picker description"
},
"downloadSelectQuality": "Select Quality",
"@downloadSelectQuality": {
"description": "Dialog title - choose audio quality"
+4
View File
@@ -1980,6 +1980,10 @@
"@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization"
},
"albumFolderStructureDescription": "Escolher a estrutura das pastas dos álbuns",
"@albumFolderStructureDescription": {
"description": "Album folder structure picker description"
},
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
"@downloadUseAlbumArtistForFolders": {
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
+4
View File
@@ -1980,6 +1980,10 @@
"@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization"
},
"albumFolderStructureDescription": "Выберите структуру папок альбомов",
"@albumFolderStructureDescription": {
"description": "Album folder structure picker description"
},
"downloadUseAlbumArtistForFolders": "Использовать исполнителя альбома для папок",
"@downloadUseAlbumArtistForFolders": {
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
+4
View File
@@ -1892,6 +1892,10 @@
"@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization"
},
"albumFolderStructureDescription": "Albüm klasör yapısını seçin",
"@albumFolderStructureDescription": {
"description": "Album folder structure picker description"
},
"downloadUseAlbumArtistForFolders": "Klasörler için Albüm Sanatçısı'nı kullan",
"@downloadUseAlbumArtistForFolders": {
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
+4
View File
@@ -1980,6 +1980,10 @@
"@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization"
},
"albumFolderStructureDescription": "Виберіть структуру папок альбомів",
"@albumFolderStructureDescription": {
"description": "Album folder structure picker description"
},
"downloadUseAlbumArtistForFolders": "Використовувати виконавця альбому для папок",
"@downloadUseAlbumArtistForFolders": {
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
+4
View File
@@ -1592,6 +1592,10 @@
"@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization"
},
"albumFolderStructureDescription": "Choose how album folders are structured",
"@albumFolderStructureDescription": {
"description": "Album folder structure picker description"
},
"downloadSelectQuality": "Select Quality",
"@downloadSelectQuality": {
"description": "Dialog title - choose audio quality"
+4
View File
@@ -1980,6 +1980,10 @@
"@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization"
},
"albumFolderStructureDescription": "Choose how album folders are structured",
"@albumFolderStructureDescription": {
"description": "Album folder structure picker description"
},
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
"@downloadUseAlbumArtistForFolders": {
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
+4
View File
@@ -1980,6 +1980,10 @@
"@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization"
},
"albumFolderStructureDescription": "Choose how album folders are structured",
"@albumFolderStructureDescription": {
"description": "Album folder structure picker description"
},
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
"@downloadUseAlbumArtistForFolders": {
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
+17 -4
View File
@@ -12,7 +12,14 @@ enum DownloadStatus {
skipped,
}
enum DownloadErrorType { unknown, notFound, rateLimit, network, permission }
enum DownloadErrorType {
unknown,
notFound,
rateLimit,
network,
permission,
verificationRequired,
}
@JsonSerializable()
class DownloadItem {
@@ -22,14 +29,15 @@ class DownloadItem {
final DownloadStatus status;
final double progress;
final double speedMBps;
final int bytesReceived; // Bytes downloaded so far
final int bytesReceived;
final int bytesTotal; // Total bytes when the server provides content length
final String? filePath;
final String? error;
final DownloadErrorType? errorType;
final DateTime createdAt;
final String? qualityOverride; // Override quality for this specific download
final String? playlistName; // Playlist context for folder organization
final String? qualityOverride;
final String? playlistName;
final int? playlistPosition; // 1-based position in the source playlist
const DownloadItem({
required this.id,
@@ -46,6 +54,7 @@ class DownloadItem {
required this.createdAt,
this.qualityOverride,
this.playlistName,
this.playlistPosition,
});
DownloadItem copyWith({
@@ -63,6 +72,7 @@ class DownloadItem {
DateTime? createdAt,
String? qualityOverride,
String? playlistName,
int? playlistPosition,
}) {
return DownloadItem(
id: id ?? this.id,
@@ -79,6 +89,7 @@ class DownloadItem {
createdAt: createdAt ?? this.createdAt,
qualityOverride: qualityOverride ?? this.qualityOverride,
playlistName: playlistName ?? this.playlistName,
playlistPosition: playlistPosition ?? this.playlistPosition,
);
}
@@ -94,6 +105,8 @@ class DownloadItem {
return 'Connection failed, check your internet';
case DownloadErrorType.permission:
return 'Cannot write to folder, check storage permission';
case DownloadErrorType.verificationRequired:
return 'Verification required. Open the extension and complete the security check.';
default:
return error ?? 'An error occurred';
}
+3
View File
@@ -23,6 +23,7 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
createdAt: DateTime.parse(json['createdAt'] as String),
qualityOverride: json['qualityOverride'] as String?,
playlistName: json['playlistName'] as String?,
playlistPosition: (json['playlistPosition'] as num?)?.toInt(),
);
Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
@@ -41,6 +42,7 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
'createdAt': instance.createdAt.toIso8601String(),
'qualityOverride': instance.qualityOverride,
'playlistName': instance.playlistName,
'playlistPosition': instance.playlistPosition,
};
const _$DownloadStatusEnumMap = {
@@ -58,4 +60,5 @@ const _$DownloadErrorTypeEnumMap = {
DownloadErrorType.rateLimit: 'rateLimit',
DownloadErrorType.network: 'network',
DownloadErrorType.permission: 'permission',
DownloadErrorType.verificationRequired: 'verificationRequired',
};
+27 -15
View File
@@ -15,11 +15,11 @@ class AppSettings {
final String storageMode; // 'app' or 'saf'
final String downloadTreeUri; // SAF persistable tree URI
final bool autoFallback;
final bool embedMetadata; // Master switch for metadata/cover/lyrics embedding
final bool embedMetadata;
final String
artistTagMode; // 'joined' or 'split_vorbis' for Vorbis-based formats
final bool embedLyrics;
final bool embedReplayGain; // Calculate and embed ReplayGain tags
final bool embedReplayGain;
final bool maxQualityCover;
final bool isFirstLaunch;
final bool checkForUpdates;
@@ -43,37 +43,37 @@ class AppSettings {
final String singleFilenameFormat;
final String albumFolderStructure;
final bool showExtensionStore;
final String
extensionVerificationBrowserMode; // 'external_first' or 'in_app_first'
final String locale;
final String lyricsMode;
final String
tidalHighFormat; // Legacy key for 320kbps lossy output format: 'mp3_320', 'aac_320', 'opus_256', or 'opus_128'
final bool
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
final bool
autoExportFailedDownloads; // Auto export failed downloads to TXT file
final bool autoExportFailedDownloads;
final String
downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only
final bool
networkCompatibilityMode; // Try HTTP + allow invalid TLS cert for API requests
final bool
allowLocalNetwork; // Allow requests to private/local network targets (local proxy / custom DNS)
final String
songLinkRegion; // SongLink userCountry region code used for platform lookup
final bool
nativeDownloadWorkerEnabled; // Experimental Android service-owned worker
final bool localLibraryEnabled; // Enable local library scanning
final String localLibraryPath; // Path to scan for audio files
final bool localLibraryEnabled;
final String localLibraryPath;
final String
localLibraryBookmark; // Base64-encoded iOS security-scoped bookmark
final bool
localLibraryShowDuplicates; // Show indicator when searching for existing tracks
final bool localLibraryShowDuplicates;
final String
localLibraryAutoScan; // Auto-scan mode: 'off', 'on_open', 'daily', 'weekly'
final bool
hasCompletedTutorial; // Track if user has completed the app tutorial
final bool hasCompletedTutorial;
final List<String>
lyricsProviders; // Ordered list of enabled lyrics provider IDs
final List<String> lyricsProviders;
final bool
lyricsIncludeTranslationNetease; // Append translated lyrics (Netease)
final bool
@@ -88,9 +88,10 @@ class AppSettings {
final String
lastSeenVersion; // Last app version the user has acknowledged (e.g. '3.7.0')
final bool
deduplicateDownloads; // Skip downloading tracks already present in history
final bool saveDownloadHistory; // Record completed downloads in local history
final bool deduplicateDownloads;
final bool saveDownloadHistory;
final String playerMode;
const AppSettings({
this.defaultService = '',
@@ -128,6 +129,7 @@ class AppSettings {
this.singleFilenameFormat = '{title} - {artist}',
this.albumFolderStructure = 'artist_album',
this.showExtensionStore = true,
this.extensionVerificationBrowserMode = 'in_app_first',
this.locale = 'system',
this.lyricsMode = 'embed',
this.tidalHighFormat = 'mp3_320',
@@ -135,6 +137,7 @@ class AppSettings {
this.autoExportFailedDownloads = false,
this.downloadNetworkMode = 'any',
this.networkCompatibilityMode = false,
this.allowLocalNetwork = false,
this.songLinkRegion = 'US',
this.nativeDownloadWorkerEnabled = false,
this.localLibraryEnabled = false,
@@ -152,6 +155,7 @@ class AppSettings {
this.lastSeenVersion = '',
this.deduplicateDownloads = true,
this.saveDownloadHistory = true,
this.playerMode = 'external',
});
AppSettings copyWith({
@@ -193,6 +197,7 @@ class AppSettings {
String? singleFilenameFormat,
String? albumFolderStructure,
bool? showExtensionStore,
String? extensionVerificationBrowserMode,
String? locale,
String? lyricsMode,
String? tidalHighFormat,
@@ -200,6 +205,7 @@ class AppSettings {
bool? autoExportFailedDownloads,
String? downloadNetworkMode,
bool? networkCompatibilityMode,
bool? allowLocalNetwork,
String? songLinkRegion,
bool? nativeDownloadWorkerEnabled,
bool? localLibraryEnabled,
@@ -217,6 +223,7 @@ class AppSettings {
String? lastSeenVersion,
bool? deduplicateDownloads,
bool? saveDownloadHistory,
String? playerMode,
}) {
return AppSettings(
defaultService: defaultService ?? this.defaultService,
@@ -266,6 +273,9 @@ class AppSettings {
singleFilenameFormat: singleFilenameFormat ?? this.singleFilenameFormat,
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
extensionVerificationBrowserMode:
extensionVerificationBrowserMode ??
this.extensionVerificationBrowserMode,
locale: locale ?? this.locale,
lyricsMode: lyricsMode ?? this.lyricsMode,
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
@@ -275,6 +285,7 @@ class AppSettings {
downloadNetworkMode: downloadNetworkMode ?? this.downloadNetworkMode,
networkCompatibilityMode:
networkCompatibilityMode ?? this.networkCompatibilityMode,
allowLocalNetwork: allowLocalNetwork ?? this.allowLocalNetwork,
songLinkRegion: songLinkRegion ?? this.songLinkRegion,
nativeDownloadWorkerEnabled:
nativeDownloadWorkerEnabled ?? this.nativeDownloadWorkerEnabled,
@@ -300,6 +311,7 @@ class AppSettings {
lastSeenVersion: lastSeenVersion ?? this.lastSeenVersion,
deduplicateDownloads: deduplicateDownloads ?? this.deduplicateDownloads,
saveDownloadHistory: saveDownloadHistory ?? this.saveDownloadHistory,
playerMode: playerMode ?? this.playerMode,
);
}
+7
View File
@@ -48,6 +48,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
albumFolderStructure:
json['albumFolderStructure'] as String? ?? 'artist_album',
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
extensionVerificationBrowserMode:
json['extensionVerificationBrowserMode'] as String? ?? 'in_app_first',
locale: json['locale'] as String? ?? 'system',
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
@@ -56,6 +58,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
json['autoExportFailedDownloads'] as bool? ?? false,
downloadNetworkMode: json['downloadNetworkMode'] as String? ?? 'any',
networkCompatibilityMode: json['networkCompatibilityMode'] as bool? ?? false,
allowLocalNetwork: json['allowLocalNetwork'] as bool? ?? false,
songLinkRegion: json['songLinkRegion'] as String? ?? 'US',
nativeDownloadWorkerEnabled:
json['nativeDownloadWorkerEnabled'] as bool? ?? false,
@@ -82,6 +85,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
lastSeenVersion: json['lastSeenVersion'] as String? ?? '',
deduplicateDownloads: json['deduplicateDownloads'] as bool? ?? true,
saveDownloadHistory: json['saveDownloadHistory'] as bool? ?? true,
playerMode: json['playerMode'] as String? ?? 'external',
);
Map<String, dynamic> _$AppSettingsToJson(
@@ -123,6 +127,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'singleFilenameFormat': instance.singleFilenameFormat,
'albumFolderStructure': instance.albumFolderStructure,
'showExtensionStore': instance.showExtensionStore,
'extensionVerificationBrowserMode': instance.extensionVerificationBrowserMode,
'locale': instance.locale,
'lyricsMode': instance.lyricsMode,
'tidalHighFormat': instance.tidalHighFormat,
@@ -130,6 +135,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
'downloadNetworkMode': instance.downloadNetworkMode,
'networkCompatibilityMode': instance.networkCompatibilityMode,
'allowLocalNetwork': instance.allowLocalNetwork,
'songLinkRegion': instance.songLinkRegion,
'nativeDownloadWorkerEnabled': instance.nativeDownloadWorkerEnabled,
'localLibraryEnabled': instance.localLibraryEnabled,
@@ -147,4 +153,5 @@ Map<String, dynamic> _$AppSettingsToJson(
'lastSeenVersion': instance.lastSeenVersion,
'deduplicateDownloads': instance.deduplicateDownloads,
'saveDownloadHistory': instance.saveDownloadHistory,
'playerMode': instance.playerMode,
};
+4
View File
@@ -13,6 +13,7 @@ class Track {
final String? albumId;
final String? coverUrl;
final String? isrc;
final String? previewUrl;
final int duration;
final int? trackNumber;
final int? discNumber;
@@ -38,6 +39,7 @@ class Track {
this.albumId,
this.coverUrl,
this.isrc,
this.previewUrl,
required this.duration,
this.trackNumber,
this.discNumber,
@@ -81,6 +83,8 @@ class Track {
audioModes != null && audioModes!.contains('DOLBY_ATMOS');
bool get hasAudioQuality => audioQuality != null && audioQuality!.isNotEmpty;
bool get hasPreview => previewUrl != null && previewUrl!.isNotEmpty;
}
@JsonSerializable()
+2
View File
@@ -16,6 +16,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
albumId: json['albumId'] as String?,
coverUrl: json['coverUrl'] as String?,
isrc: json['isrc'] as String?,
previewUrl: json['previewUrl'] as String?,
duration: (json['duration'] as num).toInt(),
trackNumber: (json['trackNumber'] as num?)?.toInt(),
discNumber: (json['discNumber'] as num?)?.toInt(),
@@ -46,6 +47,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
'albumId': instance.albumId,
'coverUrl': instance.coverUrl,
'isrc': instance.isrc,
'previewUrl': instance.previewUrl,
'duration': instance.duration,
'trackNumber': instance.trackNumber,
'discNumber': instance.discNumber,
+481 -50
View File
@@ -22,6 +22,7 @@ import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/utils/artist_utils.dart';
import 'package:spotiflac_android/utils/int_utils.dart';
import 'package:spotiflac_android/utils/extension_auth_launcher.dart';
export 'package:spotiflac_android/services/history_database.dart'
show HistoryLookupRequest, HistoryBatchLookupRequest;
@@ -480,6 +481,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
static const _startupSafRepairCursorKey =
'history_startup_saf_repair_cursor_v1';
static const _startupOrphanCursorKey = 'history_startup_orphan_cursor_v1';
static const _startupOrphanSuspectPrefix =
'history_startup_orphan_suspect_v1_';
static const _startupAudioCursorKey = 'history_startup_audio_cursor_v1';
final HistoryDatabase _db = HistoryDatabase.instance;
bool _isLoaded = false;
@@ -1540,24 +1543,39 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
}
final result = await _inspectOrphanedEntries(entries);
final confirmedOrphanIds = <String>[];
for (final id in result.orphanedIds) {
final key = '$_startupOrphanSuspectPrefix$id';
if (prefs.getBool(key) == true) {
confirmedOrphanIds.add(id);
await prefs.remove(key);
} else {
await prefs.setBool(key, true);
_historyLog.d(
'Deferring orphan removal until next pass: $id (${result.pathById[id] ?? ''})',
);
}
}
for (final replacement in result.replacementPaths.entries) {
await _db.updateFilePath(replacement.key, replacement.value);
await prefs.remove('$_startupOrphanSuspectPrefix${replacement.key}');
}
final deletedCount = result.orphanedIds.isEmpty
final deletedCount = confirmedOrphanIds.isEmpty
? 0
: await _db.deleteByIds(result.orphanedIds);
: await _db.deleteByIds(confirmedOrphanIds);
_applyHistoryPathAndDeletionChanges(
deletedIds: result.orphanedIds,
deletedIds: confirmedOrphanIds,
replacementPaths: result.replacementPaths,
);
if (entries.length < maxItems) {
await prefs.remove(_startupOrphanCursorKey);
} else {
final nextCursor =
safeCursor + entries.length - result.orphanedIds.length;
final nextCursor = result.orphanedIds.isNotEmpty
? safeCursor
: safeCursor + entries.length;
await prefs.setInt(_startupOrphanCursorKey, nextCursor);
}
@@ -1633,6 +1651,17 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
Future<int> getDatabaseCount() async {
return await _db.getCount();
}
/// Replaces all download history with [items] (each in the
/// [DownloadHistoryItem.toJson] shape) from a restored backup, then reloads
/// the in-memory state from storage.
Future<void> restoreFromBackup(List<Map<String, dynamic>> items) async {
await _db.clearAll();
if (items.isNotEmpty) {
await _db.upsertBatch(items);
}
await reloadFromStorage();
}
}
final downloadHistoryProvider =
@@ -1905,6 +1934,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
int _lastNotifQueueCount = -1;
final Set<String> _locallyCancelledItemIds = {};
final Set<String> _pausePendingItemIds = {};
final Set<String> _verificationRetriedItemIds = {};
final Set<String> _rateLimitRetriedItemIds = {};
String? _activeNativeWorkerRunId;
// Album ReplayGain accumulator: keyed by album identifier.
@@ -1912,6 +1943,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// then computes and writes album gain/peak to every track in the album.
final Map<String, _AlbumRgAccumulator> _albumRgData = {};
String _verificationRetryKey(String itemId, String service) =>
'$itemId::${service.trim().toLowerCase()}';
double _normalizeProgressForUi(double value) {
final clamped = value.clamp(0.0, 1.0).toDouble();
if (clamped <= 0) return 0;
@@ -2047,6 +2081,166 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
Future<bool> _openVerificationAndWait(String extensionId) async {
final normalizedExtensionId = extensionId.trim();
if (normalizedExtensionId.isEmpty) return false;
final grantEventFuture = PlatformBridge.extensionSessionGrantEvents()
.where((event) => event.extensionId == normalizedExtensionId)
.first
.timeout(
const Duration(minutes: 5),
onTimeout: () => ExtensionSessionGrantEvent(
extensionId: normalizedExtensionId,
success: false,
),
);
final browserMode = ref
.read(settingsProvider)
.extensionVerificationBrowserMode;
Uri? authUri;
Timer? helpDialogTimer;
try {
final opened = await openPendingExtensionVerification(
normalizedExtensionId,
browserMode: browserMode,
onAuthUri: (uri) => authUri = uri,
);
if (!opened) return false;
helpDialogTimer = scheduleExtensionVerificationHelpDialog(
normalizedExtensionId,
authUri,
browserMode: browserMode,
);
final event = await grantEventFuture;
return event.success;
} finally {
helpDialogTimer?.cancel();
}
}
Future<bool> _handleVerificationRequiredDownload(
DownloadItem item,
String errorMsg,
String? verificationService,
) async {
final targetService = (verificationService ?? '').trim().isNotEmpty
? verificationService!.trim()
: item.service;
final verificationRetryKey = _verificationRetryKey(item.id, targetService);
if (_verificationRetriedItemIds.contains(verificationRetryKey)) {
_log.e(
'Verification was already completed once for ${item.track.name} on $targetService; not opening another challenge',
);
updateItemStatus(
item.id,
DownloadStatus.failed,
error: errorMsg,
errorType: DownloadErrorType.verificationRequired,
);
_failedInSession++;
return true;
}
_verificationRetriedItemIds.add(verificationRetryKey);
_log.i(
'Download for ${item.track.name} requires verification; waiting for $targetService grant',
);
updateItemStatus(
item.id,
DownloadStatus.downloading,
error: 'Waiting for verification',
errorType: DownloadErrorType.verificationRequired,
);
final verified = await _openVerificationAndWait(targetService);
final current = _findItemById(item.id);
if (current == null || _isLocallyCancelled(item.id, item: current)) {
_log.i('Verification completed after item was removed or cancelled');
return true;
}
if (verified) {
_log.i(
'Verification complete for $targetService; retrying ${item.track.name}',
);
updateItemStatus(
item.id,
DownloadStatus.queued,
progress: 0,
speedMBps: 0,
error: 'Retrying after verification',
errorType: DownloadErrorType.verificationRequired,
);
_saveQueueToStorage();
return true;
}
_log.e('Verification did not complete for $targetService');
updateItemStatus(
item.id,
DownloadStatus.failed,
error: errorMsg,
errorType: DownloadErrorType.verificationRequired,
);
_failedInSession++;
return true;
}
Duration _rateLimitBackoffDelay(String errorMsg) {
final lower = errorMsg.toLowerCase();
final retryAfterMatch = RegExp(
r'retry[- ]?after(?: seconds)?[:= ]+(\d+)',
caseSensitive: false,
).firstMatch(lower);
final parsedSeconds = retryAfterMatch == null
? null
: int.tryParse(retryAfterMatch.group(1) ?? '');
final seconds = (parsedSeconds ?? 30).clamp(5, 300).toInt();
return Duration(seconds: seconds);
}
Future<bool> _handleRateLimitedDownload(
DownloadItem item,
String errorMsg,
) async {
if (_rateLimitRetriedItemIds.contains(item.id)) {
return false;
}
_rateLimitRetriedItemIds.add(item.id);
final delay = _rateLimitBackoffDelay(errorMsg);
_log.i(
'Rate limited while downloading ${item.track.name}; retrying after ${delay.inSeconds}s',
);
updateItemStatus(
item.id,
DownloadStatus.downloading,
error: 'Rate limited, retrying after ${delay.inSeconds}s',
errorType: DownloadErrorType.rateLimit,
);
await Future<void>.delayed(delay);
final current = _findItemById(item.id);
if (current == null || _isLocallyCancelled(item.id, item: current)) {
return true;
}
updateItemStatus(
item.id,
DownloadStatus.queued,
progress: 0,
speedMBps: 0,
error: 'Retrying after rate limit',
errorType: DownloadErrorType.rateLimit,
);
_saveQueueToStorage();
return true;
}
void _saveQueueToStorage() {
_queuePersistDebounce?.cancel();
_queuePersistDebounce = Timer(_queuePersistDebounceDuration, () {
@@ -3191,6 +3385,29 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (lower.endsWith(ext)) return ext;
}
}
// Generic safety net: when neither an explicit extension field nor a
// recognizable path suffix is available (e.g. SAF content URIs that drop
// the suffix), fall back to the actual audio codec reported by the backend
// probe. This keeps any extension that returns a non-FLAC container (Opus,
// MP3, AAC) from being mislabeled as FLAC.
final codec = _normalizeAudioFormatValue(
result['audio_codec']?.toString() ??
result['actual_audio_codec']?.toString() ??
result['format']?.toString(),
);
switch (codec) {
case 'opus':
return '.opus';
case 'mp3':
return '.mp3';
case 'aac':
case 'alac':
case 'm4a':
return '.m4a';
case 'flac':
return '.flac';
}
return null;
}
@@ -3606,6 +3823,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String service, {
String? qualityOverride,
String? playlistName,
int? playlistPosition,
}) {
final settings = ref.read(settingsProvider);
updateSettings(settings);
@@ -3619,6 +3837,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
createdAt: DateTime.now(),
qualityOverride: qualityOverride,
playlistName: playlistName,
playlistPosition: playlistPosition,
);
state = state.copyWith(items: [...state.items, item]);
@@ -3636,12 +3855,23 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String service, {
String? qualityOverride,
String? playlistName,
List<int?>? playlistPositions,
}) {
final settings = ref.read(settingsProvider);
updateSettings(settings);
final takenIds = state.items.map((item) => item.id).toSet();
final newItems = tracks.map((track) {
final shouldAssignPlaylistPositions =
playlistName != null && playlistName.trim().isNotEmpty;
final newItems = tracks.asMap().entries.map((entry) {
final track = entry.value;
final index = entry.key;
final explicitPosition =
playlistPositions != null &&
index < playlistPositions.length &&
(playlistPositions[index] ?? 0) > 0
? playlistPositions[index]
: null;
final id = _newQueueItemId(track, takenIds: takenIds);
takenIds.add(id);
return DownloadItem(
@@ -3651,6 +3881,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
createdAt: DateTime.now(),
qualityOverride: qualityOverride,
playlistName: playlistName,
playlistPosition:
explicitPosition ??
(shouldAssignPlaylistPositions ? index + 1 : null),
);
}).toList();
@@ -3662,6 +3895,45 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
int _validPlaylistPosition(DownloadItem item) {
final position = item.playlistPosition;
if (position == null || position <= 0) return 0;
return position;
}
String _filenameFormatForItem(DownloadItem item, String baseFormat) {
if (_validPlaylistPosition(item) == 0 ||
item.playlistName == null ||
item.playlistName!.trim().isEmpty) {
return baseFormat;
}
final lower = baseFormat.toLowerCase();
if (lower.contains('{playlist_position') ||
lower.contains('{playlist position') ||
lower.contains('{playlistposition')) {
return baseFormat;
}
return '{playlist_position:02} - $baseFormat';
}
Map<String, dynamic> _filenameMetadataForTrack(
Track track, {
int playlistPosition = 0,
}) {
return {
'title': track.name,
'artist': track.artistName,
'album': track.albumName,
'track': track.trackNumber ?? 0,
'disc': track.discNumber ?? 0,
'year': _extractYear(track.releaseDate) ?? '',
'date': track.releaseDate ?? '',
'playlist_position': playlistPosition,
'playlistPosition': playlistPosition,
};
}
void updateItemStatus(
String id,
DownloadStatus status, {
@@ -3977,6 +4249,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.i('Retrying item: ${item.track.name} (id: $id)');
_locallyCancelledItemIds.remove(id);
_verificationRetriedItemIds.removeWhere(
(retryKey) => retryKey == id || retryKey.startsWith('$id::'),
);
_rateLimitRetriedItemIds.remove(id);
// Purge stale ReplayGain entry for this track so a re-scan doesn't
// produce duplicate entries that bias album gain.
@@ -4310,14 +4586,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
.where((item) => _albumRgKey(item.track) == key)
.toList();
// If any item is still in-flight, the album isn't complete yet.
final pending = albumItemsInQueue.where(
(item) =>
item.status == DownloadStatus.queued ||
item.status == DownloadStatus.downloading ||
item.status == DownloadStatus.finalizing,
);
if (pending.isNotEmpty) return; // still in progress
if (pending.isNotEmpty) return;
// If any item is failed/skipped, the user might retry it later.
// Don't finalize album RG with partial data — wait until all album
@@ -4327,7 +4602,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
item.status == DownloadStatus.failed ||
item.status == DownloadStatus.skipped,
);
if (retryable.isNotEmpty) return; // still retryable
if (retryable.isNotEmpty) return;
// The accumulator entries represent successfully scanned tracks. Entries
// are only added after a successful ReplayGain scan, removed on retry or
@@ -4467,7 +4742,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
continue;
}
// If any representative item is available, use its track.
final representative = albumItems.first;
_checkAndWriteAlbumReplayGain(representative.track);
}
@@ -4860,6 +5134,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
scannedReplayGain = rgResult;
metadata['REPLAYGAIN_TRACK_GAIN'] = rgResult.trackGain;
metadata['REPLAYGAIN_TRACK_PEAK'] = rgResult.trackPeak;
if (format == 'opus') {
final r128 = FFmpegService.replayGainDbToR128(rgResult.trackGain);
if (r128 != null) metadata['R128_TRACK_GAIN'] = r128;
}
_log.d(
'ReplayGain for $format: gain=${rgResult.trackGain}, peak=${rgResult.trackPeak}',
);
@@ -4874,6 +5152,48 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
? coverPath
: null;
// AC-4 is passthrough-only: the FFmpeg mov muxer would re-wrap it as
// QuickTime and break the ISO MP4 from decryption. writeAC4Metadata is a
// no-op for non-AC-4 files, so other m4a downloads fall through to FFmpeg.
if (isM4a) {
try {
final ac4Meta = <String, String>{
'title': track.name,
'artist': track.artistName,
'album': track.albumName,
'albumArtist': ?albumArtist,
if (track.releaseDate != null) 'date': track.releaseDate!,
if (genre != null && genre.isNotEmpty) 'genre': genre,
if (track.composer != null && track.composer!.isNotEmpty)
'composer': track.composer!,
if (track.trackNumber != null && track.trackNumber! > 0)
'trackNumber': track.trackNumber!.toString(),
if (track.totalTracks != null && track.totalTracks! > 0)
'totalTracks': track.totalTracks!.toString(),
if (track.discNumber != null && track.discNumber! > 0)
'discNumber': track.discNumber!.toString(),
if (track.totalDiscs != null && track.totalDiscs! > 0)
'totalDiscs': track.totalDiscs!.toString(),
if (track.isrc != null) 'isrc': track.isrc!,
if (label != null && label.isNotEmpty) 'label': label,
if (copyright != null && copyright.isNotEmpty)
'copyright': copyright,
if (shouldEmbedLyrics) 'lyrics': ?lrcContent,
};
final ac4Result = await PlatformBridge.writeAC4Metadata(
filePath,
ac4Meta,
validCover ?? '',
);
if (ac4Result['handled'] == true) {
_log.d('AC-4 metadata embedded natively for $format');
return;
}
} catch (e) {
_log.w('AC-4 metadata path failed, falling back to FFmpeg: $e');
}
}
String? ffmpegResult;
if (isFlac) {
ffmpegResult = await FFmpegService.embedMetadata(
@@ -5515,19 +5835,21 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String? safFileName;
final safOutputExt = isSafMode ? outputExt : '';
final baseFilenameFormat = _shouldTreatAsSingleRelease(item.track)
? state.singleFilenameFormat
: state.filenameFormat;
final effectiveFilenameFormat = _filenameFormatForItem(
item,
baseFilenameFormat,
);
if (isSafMode) {
final effectiveFormat = _shouldTreatAsSingleRelease(item.track)
? state.singleFilenameFormat
: state.filenameFormat;
final baseName = await PlatformBridge.buildFilename(effectiveFormat, {
'title': item.track.name,
'artist': item.track.artistName,
'album': item.track.albumName,
'track': item.track.trackNumber ?? 0,
'disc': item.track.discNumber ?? 0,
'year': _extractYear(item.track.releaseDate) ?? '',
'date': item.track.releaseDate ?? '',
});
final baseName = await PlatformBridge.buildFilename(
effectiveFilenameFormat,
_filenameMetadataForTrack(
item.track,
playlistPosition: _validPlaylistPosition(item),
),
);
safFileName = await _buildSafFileName(baseName, safOutputExt);
}
@@ -5615,9 +5937,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
albumArtist: resolvedAlbumArtist ?? '',
coverUrl: settings.embedMetadata ? (trackForPayload.coverUrl ?? '') : '',
outputDir: outputDir,
filenameFormat: _shouldTreatAsSingleRelease(trackForPayload)
? state.singleFilenameFormat
: state.filenameFormat,
filenameFormat: effectiveFilenameFormat,
quality: quality,
embedMetadata: settings.embedMetadata,
artistTagMode: settings.artistTagMode,
@@ -5634,6 +5954,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
postProcessingEnabled: postProcessingEnabled,
tidalHighFormat: settings.tidalHighFormat,
trackNumber: normalizedTrackNumber,
playlistPosition: _validPlaylistPosition(item),
discNumber: normalizedDiscNumber,
totalTracks: trackForPayload.totalTracks ?? 0,
totalDiscs: trackForPayload.totalDiscs ?? 0,
@@ -5766,15 +6087,40 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (status == 'skipped') {
updateItemStatus(itemId, DownloadStatus.skipped);
} else {
final errorType = result is Map
? _downloadErrorTypeFromBackend(
Map<String, dynamic>.from(result)['error_type']?.toString(),
)
: DownloadErrorType.unknown;
final resultMap = result is Map
? Map<String, dynamic>.from(result)
: null;
final errorMsg = (error == null || error.isEmpty)
? (resultMap?['error']?.toString() ?? 'Download failed')
: error;
final backendErrorType = resultMap == null
? DownloadErrorType.unknown
: _downloadErrorTypeFromBackend(
resultMap['error_type']?.toString(),
);
final errorType = backendErrorType == DownloadErrorType.unknown
? _downloadErrorTypeFromMessage(errorMsg)
: backendErrorType;
if (errorType == DownloadErrorType.verificationRequired) {
_log.i(
'Android native worker requires verification for ${current.track.name}; switching back to interactive queue',
);
try {
await PlatformBridge.cancelNativeDownloadWorker();
} catch (e) {
_log.w('Failed to cancel native worker before verification: $e');
}
await _handleVerificationRequiredDownload(
current,
errorMsg,
_nativeWorkerVerificationService(resultMap, context),
);
continue;
}
updateItemStatus(
itemId,
DownloadStatus.failed,
error: error == null || error.isEmpty ? 'Download failed' : error,
error: errorMsg,
errorType: errorType,
);
_failedInSession++;
@@ -6591,13 +6937,36 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return DownloadErrorType.network;
case 'permission':
return DownloadErrorType.permission;
case 'verification_required':
return DownloadErrorType.verificationRequired;
default:
return DownloadErrorType.unknown;
}
}
String _nativeWorkerVerificationService(
Map<String, dynamic>? result,
_NativeWorkerRequestContext context,
) {
if (result != null) {
for (final key in const [
'service',
'verification_service',
'provider',
'source',
]) {
final value = result[key]?.toString().trim() ?? '';
if (value.isNotEmpty) return value;
}
}
return context.item.service;
}
DownloadErrorType _downloadErrorTypeFromMessage(String errorMsg) {
final lowerMsg = errorMsg.toLowerCase();
if (isExtensionVerificationRequired(errorMsg)) {
return DownloadErrorType.verificationRequired;
}
if (errorMsg.contains('429') ||
lowerMsg.contains('rate limit') ||
lowerMsg.contains('too many requests')) {
@@ -6922,6 +7291,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final remainingIds = state.items.map((item) => item.id).toSet();
_locallyCancelledItemIds.removeWhere((id) => !remainingIds.contains(id));
_pausePendingItemIds.removeWhere((id) => !remainingIds.contains(id));
_verificationRetriedItemIds.removeWhere((retryKey) {
final itemId = retryKey.split('::').first;
return !remainingIds.contains(itemId);
});
_rateLimitRetriedItemIds.removeWhere((id) => !remainingIds.contains(id));
}
Future<void> _downloadSingleItem(DownloadItem item) async {
@@ -7133,19 +7507,21 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String? safFileName;
String? safBaseName;
String safOutputExt = _determineOutputExt(quality, item.service);
final baseFilenameFormat = _shouldTreatAsSingleRelease(trackToDownload)
? state.singleFilenameFormat
: state.filenameFormat;
final effectiveFilenameFormat = _filenameFormatForItem(
item,
baseFilenameFormat,
);
if (isSafMode) {
final effectiveFormat = _shouldTreatAsSingleRelease(trackToDownload)
? state.singleFilenameFormat
: state.filenameFormat;
final baseName = await PlatformBridge.buildFilename(effectiveFormat, {
'title': trackToDownload.name,
'artist': trackToDownload.artistName,
'album': trackToDownload.albumName,
'track': trackToDownload.trackNumber ?? 0,
'disc': trackToDownload.discNumber ?? 0,
'year': _extractYear(trackToDownload.releaseDate) ?? '',
'date': trackToDownload.releaseDate ?? '',
});
final baseName = await PlatformBridge.buildFilename(
effectiveFilenameFormat,
_filenameMetadataForTrack(
trackToDownload,
playlistPosition: _validPlaylistPosition(item),
),
);
safFileName = await _buildSafFileName(baseName, safOutputExt);
safBaseName = safFileName.replaceFirst(RegExp(r'\.[^.]+$'), '');
}
@@ -7334,9 +7710,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
? (trackToDownload.coverUrl ?? '')
: '',
outputDir: outputDir,
filenameFormat: _shouldTreatAsSingleRelease(trackToDownload)
? state.singleFilenameFormat
: state.filenameFormat,
filenameFormat: effectiveFilenameFormat,
quality: quality,
embedMetadata: metadataEmbeddingEnabled,
artistTagMode: settings.artistTagMode,
@@ -7354,6 +7728,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
postProcessingEnabled: postProcessingEnabled,
tidalHighFormat: settings.tidalHighFormat,
trackNumber: normalizedTrackNumber,
playlistPosition: _validPlaylistPosition(item),
discNumber: normalizedDiscNumber,
totalTracks: trackToDownload.totalTracks ?? 0,
totalDiscs: trackToDownload.totalDiscs ?? 0,
@@ -7561,6 +7936,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return;
}
// Repair AC-4 (dac4 + ISO MP4) using the still-present encrypted
// source. No-op for other codecs.
try {
await PlatformBridge.ensureAC4Config(
decryptedTempPath,
tempPath,
);
} catch (e) {
_log.w('AC-4 container repair skipped: $e');
}
final dotIndex = decryptedTempPath.lastIndexOf('.');
final decryptedExt = dotIndex >= 0
? decryptedTempPath.substring(dotIndex).toLowerCase()
@@ -7613,10 +7999,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
} else {
final encryptedSource = filePath;
final decryptedPath = await FFmpegService.decryptWithDescriptor(
inputPath: filePath,
inputPath: encryptedSource,
descriptor: decryptionDescriptor,
deleteOriginal: true,
deleteOriginal: false,
);
if (decryptedPath == null) {
_log.e('FFmpeg decrypt failed for local file');
@@ -7627,10 +8014,23 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
errorType: DownloadErrorType.unknown,
);
try {
await deleteFile(filePath);
await deleteFile(encryptedSource);
} catch (_) {}
return;
}
// Repair AC-4 (dac4 + ISO MP4) using the still-present encrypted
// source before discarding it. No-op for other codecs.
try {
await PlatformBridge.ensureAC4Config(
decryptedPath,
encryptedSource,
);
} catch (e) {
_log.w('AC-4 container repair skipped: $e');
}
try {
await deleteFile(encryptedSource);
} catch (_) {}
filePath = decryptedPath;
_log.i('Local decryption completed');
}
@@ -8768,8 +9168,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return;
}
final errorMsg = result['error'] as String? ?? 'Download failed';
var errorMsg = result['error'] as String? ?? 'Download failed';
final errorTypeStr = result['error_type'] as String? ?? 'unknown';
final retryAfterSeconds = readPositiveInt(
result['retry_after_seconds'],
);
if (retryAfterSeconds != null && retryAfterSeconds > 0) {
errorMsg = '$errorMsg retry-after: $retryAfterSeconds';
}
if (errorTypeStr == 'cancelled') {
if (_isPausePending(item.id)) {
pausedDuringThisRun = true;
@@ -8798,10 +9204,26 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
case 'permission':
errorType = DownloadErrorType.permission;
break;
case 'verification_required':
errorType = DownloadErrorType.verificationRequired;
break;
default:
errorType = _downloadErrorTypeFromMessage(errorMsg);
}
if (errorType == DownloadErrorType.verificationRequired) {
await _handleVerificationRequiredDownload(
item,
errorMsg,
result['service'] as String?,
);
return;
}
if (errorType == DownloadErrorType.rateLimit &&
await _handleRateLimitedDownload(item, errorMsg)) {
return;
}
_log.e('Download failed: $errorMsg (type: $errorTypeStr)');
updateItemStatus(
item.id,
@@ -8857,6 +9279,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
errorType = _downloadErrorTypeFromMessage(errorMsg);
}
if (errorType == DownloadErrorType.verificationRequired) {
await _handleVerificationRequiredDownload(item, errorMsg, item.service);
return;
}
if (errorType == DownloadErrorType.rateLimit &&
await _handleRateLimitedDownload(item, errorMsg)) {
return;
}
updateItemStatus(
item.id,
DownloadStatus.failed,
+279 -22
View File
@@ -14,6 +14,22 @@ final _log = AppLogger('ExtensionProvider');
const _metadataProviderPriorityKey = 'metadata_provider_priority';
const _providerPriorityKey = 'provider_priority';
const _spotifyWebExtensionId = 'spotify-web';
const _storeRegistryUrlPrefKey = 'store_registry_url';
/// Result of restoring extensions from a backup.
class ExtensionRestoreResult {
final int installed;
final int alreadyPresent;
final int failed;
final List<String> failedIds;
const ExtensionRestoreResult({
this.installed = 0,
this.alreadyPresent = 0,
this.failed = 0,
this.failedIds = const [],
});
}
bool _stringListEquals(List<String> a, List<String> b) {
if (identical(a, b)) return true;
@@ -792,12 +808,15 @@ class ExtensionInstallBatchResult {
}
class ExtensionNotifier extends Notifier<ExtensionState> {
static const _extensionHealthCacheTtl = Duration(seconds: 60);
static const _extensionHealthDefaultCacheTtl = Duration(minutes: 10);
static const _extensionHealthMinimumCacheTtl = Duration(minutes: 1);
static const _extensionHealthUnknownCacheTtl = Duration(minutes: 2);
AppLifecycleListener? _appLifecycleListener;
bool _cleanupInFlight = false;
Completer<void>? _initializationCompleter;
final Map<String, DateTime> _healthExpiresAt = {};
final Map<String, Future<ExtensionHealthStatus?>> _healthInFlight = {};
final Map<String, int> _healthRequestSerial = {};
@override
ExtensionState build() {
@@ -809,6 +828,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
_appLifecycleListener = null;
_healthExpiresAt.clear();
_healthInFlight.clear();
_healthRequestSerial.clear();
});
return const ExtensionState();
}
@@ -938,15 +958,46 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
void _scheduleExtensionHealthRefresh(List<Extension> extensions) {
void _scheduleExtensionHealthRefresh(
List<Extension> extensions, {
bool force = false,
}) {
for (final ext in extensions) {
if (!ext.enabled || !ext.hasServiceHealth) continue;
unawaited(checkExtensionHealth(ext.id));
unawaited(checkExtensionHealth(ext.id, force: force));
}
}
void refreshEnabledExtensionHealth() {
_scheduleExtensionHealthRefresh(state.extensions);
void refreshEnabledExtensionHealth({bool force = false}) {
_scheduleExtensionHealthRefresh(state.extensions, force: force);
}
Duration _extensionHealthCacheTtlFor(Extension extension) {
var ttl = _extensionHealthDefaultCacheTtl;
for (final check in extension.serviceHealth) {
final seconds = check.cacheTtlSeconds;
if (seconds == null || seconds <= 0) continue;
var checkTtl = Duration(seconds: seconds);
if (checkTtl < _extensionHealthMinimumCacheTtl) {
checkTtl = _extensionHealthMinimumCacheTtl;
}
if (checkTtl < ttl) {
ttl = checkTtl;
}
}
return ttl;
}
Duration _extensionHealthCacheTtlForStatus(
Extension extension,
String status,
) {
final ttl = _extensionHealthCacheTtlFor(extension);
if (status == 'unknown' && ttl > _extensionHealthUnknownCacheTtl) {
return _extensionHealthUnknownCacheTtl;
}
return ttl;
}
Future<ExtensionHealthStatus?> checkExtensionHealth(
@@ -974,17 +1025,22 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
return inFlight;
}
final requestSerial = (_healthRequestSerial[extensionId] ?? 0) + 1;
_healthRequestSerial[extensionId] = requestSerial;
final future = () async {
try {
final result = await PlatformBridge.checkExtensionHealth(extensionId);
final status = ExtensionHealthStatus.fromJson(result);
final updated = Map<String, ExtensionHealthStatus>.of(
state.healthStatuses,
)..[extensionId] = status;
_healthExpiresAt[extensionId] = DateTime.now().add(
_extensionHealthCacheTtl,
);
state = state.copyWith(healthStatuses: updated);
if (_healthRequestSerial[extensionId] == requestSerial) {
final updated = Map<String, ExtensionHealthStatus>.of(
state.healthStatuses,
)..[extensionId] = status;
_healthExpiresAt[extensionId] = DateTime.now().add(
_extensionHealthCacheTtlForStatus(ext, status.status),
);
state = state.copyWith(healthStatuses: updated);
}
return status;
} catch (e) {
_log.w('Failed to check extension health for $extensionId: $e');
@@ -994,16 +1050,20 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
checkedAt: DateTime.now(),
checks: const [],
);
final updated = Map<String, ExtensionHealthStatus>.of(
state.healthStatuses,
)..[extensionId] = status;
_healthExpiresAt[extensionId] = DateTime.now().add(
const Duration(seconds: 20),
);
state = state.copyWith(healthStatuses: updated);
if (_healthRequestSerial[extensionId] == requestSerial) {
final updated = Map<String, ExtensionHealthStatus>.of(
state.healthStatuses,
)..[extensionId] = status;
_healthExpiresAt[extensionId] = DateTime.now().add(
_extensionHealthUnknownCacheTtl,
);
state = state.copyWith(healthStatuses: updated);
}
return status;
} finally {
_healthInFlight.remove(extensionId);
if (_healthRequestSerial[extensionId] == requestSerial) {
_healthInFlight.remove(extensionId);
}
}
}();
@@ -1640,7 +1700,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
List<Extension> get enabledExtensions {
List<Extension> enabledExtensions() {
return state.extensions.where((ext) => ext.enabled).toList();
}
@@ -1717,11 +1777,208 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
return result;
}
List<Extension> get searchProviders {
List<Extension> searchProviders() {
return state.extensions
.where((ext) => ext.enabled && ext.hasCustomSearch)
.toList();
}
/// Collects the keys flagged as `secret` in an extension's manifest schema
/// (top-level settings and quality-specific settings).
Set<String> _secretKeysFromManifest(Map<String, dynamic> raw) {
final keys = <String>{};
void scan(Object? settingsList) {
if (settingsList is! List) return;
for (final entry in settingsList) {
if (entry is Map && entry['secret'] == true && entry['key'] is String) {
keys.add(entry['key'] as String);
}
}
}
scan(raw['settings']);
final quality = raw['quality_options'];
if (quality is List) {
for (final option in quality) {
if (option is Map) {
scan(option['settings']);
}
}
}
return keys;
}
/// Builds the extensions section of a backup: the store registry URL plus the
/// installed extensions with their id, version, enabled flag and settings.
/// Secret-flagged settings (tokens, API keys) are only included when
/// [includeSecrets] is true.
Future<Map<String, dynamic>> exportBackup({
required bool includeSecrets,
}) async {
if (!PlatformBridge.supportsExtensionSystem) {
return {'registry_url': '', 'items': const <Map<String, dynamic>>[]};
}
String registryUrl = '';
try {
registryUrl = await PlatformBridge.getStoreRegistryUrl();
} catch (_) {}
List<Map<String, dynamic>> installed;
try {
installed = await PlatformBridge.getInstalledExtensions();
} catch (e) {
_log.w('Backup: failed to list extensions: $e');
installed = const [];
}
final items = <Map<String, dynamic>>[];
for (final raw in installed) {
final id = raw['id'] as String?;
if (id == null || id.isEmpty) continue;
final secretKeys = _secretKeysFromManifest(raw);
Map<String, dynamic> settings = {};
try {
settings = await PlatformBridge.getExtensionSettings(id);
} catch (_) {}
final filtered = <String, dynamic>{};
var omittedSecret = false;
settings.forEach((key, value) {
if (secretKeys.contains(key)) {
if (!includeSecrets) {
omittedSecret = true;
return;
}
}
filtered[key] = value;
});
items.add({
'id': id,
'version': raw['version']?.toString() ?? '',
'enabled': raw['enabled'] == true,
'settings': filtered,
if (omittedSecret) 'secrets_omitted': true,
});
}
return {'registry_url': registryUrl, 'items': items};
}
/// Restores extensions from a backup section produced by [exportBackup]:
/// re-applies the store registry URL, reinstalls each extension from the
/// store when missing, then merges settings and restores the enabled flag.
/// Missing settings (e.g. omitted secrets) are merged with the current values
/// so they are not wiped.
Future<ExtensionRestoreResult> restoreFromBackup(
Map<String, dynamic> data,
) async {
if (!PlatformBridge.supportsExtensionSystem) {
return const ExtensionRestoreResult();
}
final registryUrl = (data['registry_url'] as String?)?.trim() ?? '';
final itemsRaw = data['items'];
final items = itemsRaw is List
? itemsRaw
.whereType<Map<Object?, Object?>>()
.map((e) => Map<String, dynamic>.from(e))
.toList()
: <Map<String, dynamic>>[];
Directory? destDir;
try {
final tmp = await getTemporaryDirectory();
destDir = await Directory(
'${tmp.path}/spotiflac_restore_ext',
).create(recursive: true);
await PlatformBridge.initExtensionStore(destDir.path);
if (registryUrl.isNotEmpty) {
await PlatformBridge.setStoreRegistryUrl(registryUrl);
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_storeRegistryUrlPrefKey, registryUrl);
}
} catch (e) {
_log.w('Restore: failed to prepare extension store: $e');
}
await refreshExtensions();
final installedIds = state.extensions
.map((e) => e.id.toLowerCase())
.toSet();
var installedCount = 0;
var alreadyPresent = 0;
var failed = 0;
final failedIds = <String>[];
for (final item in items) {
final id = item['id'] as String?;
if (id == null || id.isEmpty) continue;
final enabled = item['enabled'] != false;
var present = installedIds.contains(id.toLowerCase());
if (!present) {
if (destDir == null) {
failed++;
failedIds.add(id);
continue;
}
try {
final path = await PlatformBridge.downloadStoreExtension(
id,
destDir.path,
);
final ok = await installExtension(path);
if (ok) {
installedCount++;
present = true;
} else {
failed++;
failedIds.add(id);
}
} catch (e) {
_log.w('Restore: failed to install extension $id: $e');
failed++;
failedIds.add(id);
}
} else {
alreadyPresent++;
}
if (!present) continue;
final settings = item['settings'];
if (settings is Map && settings.isNotEmpty) {
try {
final current = await PlatformBridge.getExtensionSettings(id);
final merged = <String, dynamic>{
...current,
...Map<String, dynamic>.from(settings),
};
await PlatformBridge.setExtensionSettings(id, merged);
} catch (e) {
_log.w('Restore: failed to apply settings for $id: $e');
}
}
try {
await setExtensionEnabled(id, enabled);
} catch (_) {}
}
await refreshExtensions();
return ExtensionRestoreResult(
installed: installedCount,
alreadyPresent: alreadyPresent,
failed: failed,
failedIds: failedIds,
);
}
}
final extensionProvider = NotifierProvider<ExtensionNotifier, ExtensionState>(
@@ -953,6 +953,90 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
});
_invalidatePlaylistPickerSummaries();
}
/// Returns the full collections snapshot (wishlist, loved, playlists,
/// favorite artists) for a backup, ensuring data is loaded first.
Future<Map<String, dynamic>> exportCollections() async {
await _ensureLoaded();
return state.toJson();
}
/// Exports custom playlist cover images as base64, keyed by playlist id.
/// Each value contains the original file extension and the encoded bytes so a
/// restore on another device can recreate the cover files.
Future<Map<String, Map<String, String>>> exportPlaylistCovers() async {
await _ensureLoaded();
final covers = <String, Map<String, String>>{};
for (final playlist in state.playlists) {
final path = playlist.coverImagePath;
if (path == null || path.isEmpty) continue;
try {
final file = File(path);
if (!await file.exists()) continue;
final bytes = await file.readAsBytes();
if (bytes.isEmpty) continue;
covers[playlist.id] = {
'ext': p.extension(path).toLowerCase(),
'data': base64Encode(bytes),
};
} catch (_) {
// Skip unreadable cover; the rest of the backup still succeeds.
}
}
return covers;
}
/// Replaces all collections (wishlist, loved, playlists, favorite artists)
/// with the contents of a backup. [collectionsJson] uses the
/// [LibraryCollectionsState.toJson] shape; [coverImages] is the map produced
/// by [exportPlaylistCovers]. Cover images are rewritten into this device's
/// covers directory and their paths fixed up before persisting.
Future<void> restoreFromBackup(
Map<String, dynamic> collectionsJson, {
Map<String, dynamic>? coverImages,
}) async {
final normalized = Map<String, dynamic>.from(collectionsJson);
final coversDir = await _playlistCoversDir();
final playlistsRaw = normalized['playlists'];
if (playlistsRaw is List) {
final rewritten = <Map<String, dynamic>>[];
for (final entry in playlistsRaw.whereType<Map<Object?, Object?>>()) {
final playlist = Map<String, dynamic>.from(entry);
final id = playlist['id'] as String?;
String? newCoverPath;
final coverEntry = (id != null && coverImages != null)
? coverImages[id]
: null;
if (id != null && coverEntry is Map) {
final data = coverEntry['data'] as String?;
final ext = (coverEntry['ext'] as String?) ?? '.jpg';
if (data != null && data.isNotEmpty) {
try {
final destPath = p.join(coversDir.path, '$id$ext');
await File(destPath).writeAsBytes(base64Decode(data));
newCoverPath = destPath;
} catch (_) {
newCoverPath = null;
}
}
}
// Always replace the backup's device-specific path: either with the
// freshly written local cover, or drop it so a stale path is not kept.
if (newCoverPath != null) {
playlist['coverImagePath'] = newCoverPath;
} else {
playlist.remove('coverImagePath');
}
rewritten.add(playlist);
}
normalized['playlists'] = rewritten;
}
await _db.replaceAllFromBackup(normalized);
await _load();
_invalidatePlaylistPickerSummaries();
}
}
final libraryCollectionsProvider =
+153
View File
@@ -0,0 +1,153 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/music_player_service.dart';
final currentMediaItemProvider = StreamProvider<MediaItem?>((ref) {
return musicPlayerMediaItemEvents();
});
final playbackStateProvider = StreamProvider<PlaybackState>((ref) {
return musicPlayerPlaybackStateEvents();
});
final playQueueProvider = StreamProvider<List<MediaItem>>((ref) {
return musicPlayerQueueEvents();
});
class MusicPlayerController {
const MusicPlayerController();
MusicPlayerHandler? get _handler => musicPlayerHandler;
bool get isAvailable => _handler != null;
Future<MusicPlayerHandler?> ensureInitialized() async {
try {
return await initMusicPlayer();
} catch (_) {
return null;
}
}
Future<void> playAll(
List<PlayableMedia> items, {
int initialIndex = 0,
}) async {
final handler = await ensureInitialized();
await handler?.setQueueAndPlay(items, initialIndex: initialIndex);
}
Future<void> playSingle(PlayableMedia item) => playAll([item]);
Future<void> playHistory(
List<DownloadHistoryItem> items, {
int initialIndex = 0,
}) async {
final media = items
.where((i) => i.filePath.trim().isNotEmpty)
.map(playableFromHistory)
.toList();
if (media.isEmpty) return;
await playAll(media, initialIndex: initialIndex.clamp(0, media.length - 1));
}
Future<void> playLocal(
List<LocalLibraryItem> items, {
int initialIndex = 0,
}) async {
final media = items
.where((i) => i.filePath.trim().isNotEmpty)
.map(playableFromLocal)
.toList();
if (media.isEmpty) return;
await playAll(media, initialIndex: initialIndex.clamp(0, media.length - 1));
}
Future<void> play() async => _handler?.play();
Future<void> pause() async => _handler?.pause();
Future<void> stop() async => _handler?.stop();
Future<void> seek(Duration position) async => _handler?.seek(position);
Future<void> next() async => _handler?.skipToNext();
Future<void> previous() async => _handler?.skipToPrevious();
Future<void> togglePlayPause(bool isPlaying) async {
if (isPlaying) {
await pause();
} else {
await play();
}
}
Future<void> setShuffle(bool enabled) async {
await _handler?.setShuffleMode(
enabled ? AudioServiceShuffleMode.all : AudioServiceShuffleMode.none,
);
}
Future<void> playNext(PlayableMedia item) async =>
(await ensureInitialized())?.enqueue(item, playNext: true);
Future<void> addToQueue(PlayableMedia item) async =>
(await ensureInitialized())?.enqueue(item);
Future<void> playNextHistory(DownloadHistoryItem item) async =>
playNext(playableFromHistory(item));
Future<void> addToQueueHistory(DownloadHistoryItem item) async =>
addToQueue(playableFromHistory(item));
Future<void> playNextLocal(LocalLibraryItem item) async =>
playNext(playableFromLocal(item));
Future<void> addToQueueLocal(LocalLibraryItem item) async =>
addToQueue(playableFromLocal(item));
Future<void> jumpTo(int index) async => _handler?.skipToQueueItem(index);
void moveQueueItem(int oldIndex, int newIndex) {
_handler?.moveQueueItem(oldIndex, newIndex);
}
}
final musicPlayerControllerProvider = Provider<MusicPlayerController>(
(ref) => const MusicPlayerController(),
);
PlayableMedia playableFromHistory(DownloadHistoryItem item) {
return PlayableMedia(
id: item.id,
source: item.filePath,
title: item.trackName,
artist: item.artistName,
album: item.albumName,
artUri: (item.coverUrl != null && item.coverUrl!.trim().isNotEmpty)
? item.coverUrl
: null,
duration: (item.duration != null && item.duration! > 0)
? Duration(seconds: item.duration!)
: null,
);
}
PlayableMedia playableFromLocal(LocalLibraryItem item) {
String? art;
final cover = item.coverPath;
if (cover != null && cover.trim().isNotEmpty) {
art = cover.startsWith('http') || cover.startsWith('content://')
? cover
: Uri.file(cover).toString();
}
return PlayableMedia(
id: item.id,
source: item.filePath,
title: item.trackName,
artist: item.artistName,
album: item.albumName,
artUri: art,
duration: (item.duration != null && item.duration! > 0)
? Duration(seconds: item.duration!)
: null,
);
}
+167
View File
@@ -2,7 +2,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/music_player_service.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/logger.dart';
@@ -16,6 +19,24 @@ class PlaybackController extends Notifier<PlaybackState> {
@override
PlaybackState build() => const PlaybackState();
Future<bool> _useInternalPlayer() async {
final mode = ref.read(settingsProvider).playerMode;
if (mode != 'internal') return false;
return await ref.read(musicPlayerControllerProvider).ensureInitialized() !=
null;
}
String? _normalizeArtUri(String cover) {
final value = cover.trim();
if (value.isEmpty) return null;
if (value.startsWith('http') ||
value.startsWith('content://') ||
value.startsWith('file://')) {
return value;
}
return Uri.file(value).toString();
}
Future<void> playLocalPath({
required String path,
required String title,
@@ -27,14 +48,143 @@ class PlaybackController extends Notifier<PlaybackState> {
if (isCueVirtualPath(path)) {
throw Exception(cueVirtualTrackRequiresSplitMessage);
}
if (await _useInternalPlayer()) {
_log.d('Playing "$title" in the internal player: $path');
await ref
.read(musicPlayerControllerProvider)
.playSingle(
PlayableMedia(
id: path,
source: path,
title: title,
artist: artist,
album: album,
artUri: _normalizeArtUri(coverUrl),
duration: (track != null && track.duration > 0)
? Duration(seconds: track.duration)
: null,
),
);
return;
}
_log.d('Opening external player for "$title" by $artist: $path');
await openFile(path);
}
/// Plays a local-library album/list starting at [startItem], queuing the rest
/// so playback continues to the next track automatically. Honors player mode.
Future<void> playLocalLibraryQueue(
List<LocalLibraryItem> items, {
required LocalLibraryItem startItem,
}) async {
final playable = items
.where(
(i) => i.filePath.trim().isNotEmpty && !isCueVirtualPath(i.filePath),
)
.toList();
if (playable.isEmpty) return;
var startIndex = playable.indexWhere((i) => i.id == startItem.id);
if (startIndex < 0) startIndex = 0;
if (await _useInternalPlayer()) {
await ref
.read(musicPlayerControllerProvider)
.playLocal(playable, initialIndex: startIndex);
} else {
await openFile(playable[startIndex].filePath);
}
}
/// Plays a downloaded-history album/list starting at [startItem], queuing the
/// rest. Honors player mode.
Future<void> playHistoryQueue(
List<DownloadHistoryItem> items, {
required DownloadHistoryItem startItem,
}) async {
final playable = items
.where(
(i) => i.filePath.trim().isNotEmpty && !isCueVirtualPath(i.filePath),
)
.toList();
if (playable.isEmpty) return;
var startIndex = playable.indexWhere((i) => i.id == startItem.id);
if (startIndex < 0) startIndex = 0;
if (await _useInternalPlayer()) {
await ref
.read(musicPlayerControllerProvider)
.playHistory(playable, initialIndex: startIndex);
} else {
await openFile(playable[startIndex].filePath);
}
}
/// Plays a prebuilt media queue starting at [startIndex]. Honors player mode
/// ([externalPath] is opened externally when the built-in player is off).
Future<void> playMediaQueue(
Iterable<PlayableMedia> queue, {
required int startIndex,
required String externalPath,
}) async {
if (await _useInternalPlayer()) {
final items = queue.toList(growable: false);
if (items.isEmpty) return;
final i = startIndex.clamp(0, items.length - 1);
await ref
.read(musicPlayerControllerProvider)
.playAll(items, initialIndex: i);
} else {
await openFile(externalPath);
}
}
Future<void> playTrackList(List<Track> tracks, {int startIndex = 0}) async {
if (tracks.isEmpty) return;
final orderedTracks = _orderedTracksFromStartIndex(tracks, startIndex);
if (await _useInternalPlayer()) {
final queue = <PlayableMedia>[];
var skippedCueVirtualTrack = false;
final resolvedPaths = await _resolveTrackPaths(orderedTracks);
for (var index = 0; index < orderedTracks.length; index++) {
final track = orderedTracks[index];
final resolvedPath = resolvedPaths[index];
if (resolvedPath == null) continue;
if (isCueVirtualPath(resolvedPath)) {
skippedCueVirtualTrack = true;
continue;
}
queue.add(
PlayableMedia(
id: resolvedPath,
source: resolvedPath,
title: track.name,
artist: track.artistName,
album: track.albumName,
artUri: _normalizeArtUri(track.coverUrl ?? ''),
duration: track.duration > 0
? Duration(seconds: track.duration)
: null,
),
);
}
if (queue.isNotEmpty) {
_log.d('Playing ${queue.length} tracks in the internal player');
await ref.read(musicPlayerControllerProvider).playAll(queue);
return;
}
if (skippedCueVirtualTrack) {
throw Exception(cueVirtualTrackRequiresSplitMessage);
}
throw Exception(
'No local audio file is available to play. Download the track first.',
);
}
var skippedCueVirtualTrack = false;
for (final track in orderedTracks) {
final resolvedPath = await _resolveTrackPath(track);
@@ -98,6 +248,23 @@ class PlaybackController extends Notifier<PlaybackState> {
return null;
}
Future<List<String?>> _resolveTrackPaths(List<Track> tracks) async {
if (tracks.isEmpty) return const [];
final results = List<String?>.filled(tracks.length, null);
var next = 0;
final workerCount = tracks.length < 4 ? tracks.length : 4;
Future<void> worker() async {
while (true) {
final index = next++;
if (index >= tracks.length) return;
results[index] = await _resolveTrackPath(tracks[index]);
}
}
await Future.wait(List.generate(workerCount, (_) => worker()));
return results;
}
Future<LocalLibraryItem?> _findLocalLibraryItemForTrack(Track track) async {
final isLocalSource = (track.source ?? '').toLowerCase() == 'local';
if (isLocalSource) {
+248
View File
@@ -0,0 +1,248 @@
import 'dart:async';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/services/music_player_service.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('PreviewPlayer');
enum PreviewStatus { idle, loading, playing, paused }
class PreviewPlayerState {
final String? activeUrl;
final PreviewStatus status;
final Duration position;
final Duration duration;
const PreviewPlayerState({
this.activeUrl,
this.status = PreviewStatus.idle,
this.position = Duration.zero,
this.duration = Duration.zero,
});
bool get isActive => activeUrl != null && activeUrl!.isNotEmpty;
bool isActiveUrl(String? url) =>
url != null && url.isNotEmpty && url == activeUrl;
double get progress {
final total = duration.inMilliseconds;
if (total <= 0) return 0;
return (position.inMilliseconds / total).clamp(0.0, 1.0);
}
PreviewPlayerState copyWith({
String? activeUrl,
bool clearActiveUrl = false,
PreviewStatus? status,
Duration? position,
Duration? duration,
}) {
return PreviewPlayerState(
activeUrl: clearActiveUrl ? null : (activeUrl ?? this.activeUrl),
status: status ?? this.status,
position: position ?? this.position,
duration: duration ?? this.duration,
);
}
}
class PreviewPlayerController extends Notifier<PreviewPlayerState> {
AudioPlayer? _player;
final List<StreamSubscription<dynamic>> _subscriptions = [];
AppLifecycleListener? _lifecycleListener;
@override
PreviewPlayerState build() {
_lifecycleListener = AppLifecycleListener(
onStateChange: _handleAppLifecycleState,
);
musicPlayerExclusiveAudioHook = () async {
if (state.isActive) await stop();
};
ref.onDispose(() {
musicPlayerExclusiveAudioHook = null;
_disposePlayer();
});
return const PreviewPlayerState();
}
void _handleAppLifecycleState(AppLifecycleState lifecycleState) {
if (lifecycleState == AppLifecycleState.paused ||
lifecycleState == AppLifecycleState.hidden ||
lifecycleState == AppLifecycleState.detached) {
if (state.isActive) {
unawaited(stop());
}
}
}
AudioPlayer _ensurePlayer() {
final existing = _player;
if (existing != null) return existing;
final player = AudioPlayer(playerId: 'preview-player');
player.setReleaseMode(ReleaseMode.stop);
_attachListeners(player);
_player = player;
return player;
}
void _attachListeners(AudioPlayer player) {
_subscriptions.add(
player.onPlayerStateChanged.listen(_handlePlayerStateChanged),
);
_subscriptions.add(
player.onPositionChanged.listen((position) {
if (state.status == PreviewStatus.playing ||
state.status == PreviewStatus.paused) {
state = state.copyWith(position: position);
}
}),
);
_subscriptions.add(
player.onDurationChanged.listen((duration) {
state = state.copyWith(duration: duration);
}),
);
_subscriptions.add(
player.onPlayerComplete.listen((_) {
_log.d('Preview playback completed');
state = const PreviewPlayerState();
}),
);
}
void _discardActivePlayer() {
for (final sub in _subscriptions) {
sub.cancel();
}
_subscriptions.clear();
final player = _player;
_player = null;
if (player != null) {
try {
player.dispose();
} catch (_) {}
}
}
void _handlePlayerStateChanged(PlayerState playerState) {
switch (playerState) {
case PlayerState.playing:
state = state.copyWith(status: PreviewStatus.playing);
break;
case PlayerState.paused:
if (state.isActive) {
state = state.copyWith(status: PreviewStatus.paused);
}
break;
case PlayerState.stopped:
case PlayerState.completed:
break;
case PlayerState.disposed:
break;
}
}
Future<void> toggle(String? url) async {
final trimmed = url?.trim() ?? '';
if (trimmed.isEmpty) return;
if (state.isActiveUrl(trimmed)) {
if (state.status == PreviewStatus.playing) {
await pause();
} else if (state.status == PreviewStatus.paused) {
await resume();
}
return;
}
await play(trimmed);
}
Future<void> play(String url) async {
final trimmed = url.trim();
if (trimmed.isEmpty) return;
try {
await musicPlayerHandler?.pause();
} catch (_) {}
state = PreviewPlayerState(
activeUrl: trimmed,
status: PreviewStatus.loading,
);
try {
_log.i('Starting preview playback');
await _playOnPlayer(_ensurePlayer(), trimmed);
} catch (e) {
_log.w('Preview playback failed, recreating player and retrying: $e');
_discardActivePlayer();
try {
await _playOnPlayer(_ensurePlayer(), trimmed);
} catch (retryError) {
_log.e('Preview playback failed after retry', retryError);
_discardActivePlayer();
state = const PreviewPlayerState();
rethrow;
}
}
}
Future<void> _playOnPlayer(AudioPlayer player, String url) async {
await player.stop();
await player.play(UrlSource(url));
}
Future<void> pause() async {
final player = _player;
if (player == null) return;
try {
await player.pause();
state = state.copyWith(status: PreviewStatus.paused);
} catch (e) {
_log.w('Failed to pause preview: $e');
}
}
Future<void> resume() async {
final player = _player;
if (player == null || !state.isActive) return;
try {
await player.resume();
state = state.copyWith(status: PreviewStatus.playing);
} catch (e) {
_log.w('Failed to resume preview: $e');
}
}
Future<void> stop() async {
final player = _player;
if (player == null) {
state = const PreviewPlayerState();
return;
}
try {
await player.stop();
} catch (e) {
_log.w('Failed to stop preview: $e');
}
state = const PreviewPlayerState();
}
void _disposePlayer() {
_lifecycleListener?.dispose();
_lifecycleListener = null;
_discardActivePlayer();
}
}
final previewPlayerProvider =
NotifierProvider<PreviewPlayerController, PreviewPlayerState>(
PreviewPlayerController.new,
);
+95 -12
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -27,6 +28,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
'album',
'playlist',
};
static const Set<String> _extensionVerificationBrowserModeValues = {
'external_first',
'in_app_first',
};
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
@@ -78,6 +83,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
defaultSearchTab: sanitizedDefaultSearchTab,
defaultService: loaded.defaultService,
searchProvider: loaded.searchProvider,
extensionVerificationBrowserMode:
_normalizeExtensionVerificationBrowserMode(
loaded.extensionVerificationBrowserMode,
),
);
await _runMigrations(prefs);
@@ -96,23 +105,29 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
void _syncLyricsSettingsToBackend() {
unawaited(syncLyricsSettingsToBackend());
}
Future<void> syncLyricsSettingsToBackend() async {
if (!PlatformBridge.supportsCoreBackend) return;
PlatformBridge.setLyricsProviders(state.lyricsProviders).catchError((
Object e,
) {
try {
await PlatformBridge.setLyricsProviders(state.lyricsProviders);
} catch (e) {
_log.w('Failed to sync lyrics providers to backend: $e');
});
}
PlatformBridge.setLyricsFetchOptions({
'include_translation_netease': state.lyricsIncludeTranslationNetease,
'include_romanization_netease': state.lyricsIncludeRomanizationNetease,
'multi_person_word_by_word': state.lyricsMultiPersonWordByWord,
'apple_elrc_word_sync': state.lyricsAppleElrcWordSync,
'musixmatch_language': state.musixmatchLanguage,
}).catchError((Object e) {
try {
await PlatformBridge.setLyricsFetchOptions({
'include_translation_netease': state.lyricsIncludeTranslationNetease,
'include_romanization_netease': state.lyricsIncludeRomanizationNetease,
'multi_person_word_by_word': state.lyricsMultiPersonWordByWord,
'apple_elrc_word_sync': state.lyricsAppleElrcWordSync,
'musixmatch_language': state.musixmatchLanguage,
});
} catch (e) {
_log.w('Failed to sync lyrics fetch options to backend: $e');
});
}
}
void _syncNetworkCompatibilitySettingsToBackend() {
@@ -125,6 +140,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
).catchError((Object e) {
_log.w('Failed to sync network compatibility options to backend: $e');
});
PlatformBridge.setAllowPrivateNetwork(state.allowLocalNetwork).catchError((
Object e,
) {
_log.w('Failed to sync allow local network option to backend: $e');
});
}
void _syncExtensionFallbackSettingsToBackend() {
@@ -194,6 +215,40 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
}
/// Restores settings from a backup payload (the map produced by
/// [AppSettings.toJson]). Device-specific storage location fields
/// (download directory and SAF tree URI) are intentionally preserved from the
/// current device, because a SAF tree URI from another phone is not valid
/// here and would break downloads.
Future<void> restoreFromBackup(Map<String, dynamic> json) async {
final current = state;
AppSettings restored;
try {
restored = AppSettings.fromJson(Map<String, dynamic>.from(json));
} catch (e, stack) {
_log.e('Failed to parse settings from backup: $e', e, stack);
rethrow;
}
state = restored.copyWith(
// Always keep extension providers enabled (matches _loadSettings).
useExtensionProviders: true,
// Preserve this device's storage location; the backup's values point at
// the original device and would not resolve here.
downloadDirectory: current.downloadDirectory,
downloadDirectoryBookmark: current.downloadDirectoryBookmark,
storageMode: current.storageMode,
downloadTreeUri: current.downloadTreeUri,
);
await _saveSettings();
LogBuffer.loggingEnabled = state.enableLogging;
_syncLyricsSettingsToBackend();
_syncNetworkCompatibilitySettingsToBackend();
_syncExtensionFallbackSettingsToBackend();
}
Future<void> _normalizeIosDownloadDirectoryIfNeeded() async {
if (!Platform.isIOS) return;
@@ -223,6 +278,14 @@ class SettingsNotifier extends Notifier<AppSettings> {
return 'all';
}
String _normalizeExtensionVerificationBrowserMode(String value) {
final normalized = value.trim().toLowerCase();
if (_extensionVerificationBrowserModeValues.contains(normalized)) {
return normalized;
}
return 'in_app_first';
}
String? _sanitizeRetiredBuiltInProviderId(String? providerId) {
final normalized = providerId?.trim().toLowerCase();
if (normalized == null || normalized.isEmpty) return providerId;
@@ -510,6 +573,14 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setExtensionVerificationBrowserMode(String mode) {
state = state.copyWith(
extensionVerificationBrowserMode:
_normalizeExtensionVerificationBrowserMode(mode),
);
_saveSettings();
}
void setLocale(String locale) {
state = state.copyWith(locale: locale);
_saveSettings();
@@ -541,6 +612,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
_syncNetworkCompatibilitySettingsToBackend();
}
void setAllowLocalNetwork(bool enabled) {
state = state.copyWith(allowLocalNetwork: enabled);
_saveSettings();
_syncNetworkCompatibilitySettingsToBackend();
}
void setSongLinkRegion(String region) {
final normalized = _normalizeSongLinkRegion(region);
state = state.copyWith(songLinkRegion: normalized);
@@ -599,6 +676,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = state.copyWith(saveDownloadHistory: enabled);
_saveSettings();
}
void setPlayerMode(String mode) {
final normalized = mode == 'internal' ? 'internal' : 'external';
state = state.copyWith(playerMode: normalized);
_saveSettings();
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
+100 -6
View File
@@ -1,8 +1,11 @@
import 'dart:async';
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/utils/extension_auth_launcher.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
@@ -20,6 +23,7 @@ class TrackState {
final String? artistName;
final String? coverUrl;
final String? headerImageUrl;
final String? headerVideoUrl;
final int? monthlyListeners;
final List<ArtistAlbum>? artistAlbums;
final List<Track>? artistTopTracks;
@@ -43,6 +47,7 @@ class TrackState {
this.artistName,
this.coverUrl,
this.headerImageUrl,
this.headerVideoUrl,
this.monthlyListeners,
this.artistAlbums,
this.artistTopTracks,
@@ -74,6 +79,7 @@ class TrackState {
String? artistName,
String? coverUrl,
String? headerImageUrl,
String? headerVideoUrl,
int? monthlyListeners,
List<ArtistAlbum>? artistAlbums,
List<Track>? artistTopTracks,
@@ -99,6 +105,7 @@ class TrackState {
artistName: artistName ?? this.artistName,
coverUrl: coverUrl ?? this.coverUrl,
headerImageUrl: headerImageUrl ?? this.headerImageUrl,
headerVideoUrl: headerVideoUrl ?? this.headerVideoUrl,
monthlyListeners: monthlyListeners ?? this.monthlyListeners,
artistAlbums: artistAlbums ?? this.artistAlbums,
artistTopTracks: artistTopTracks ?? this.artistTopTracks,
@@ -304,6 +311,9 @@ class TrackNotifier extends Notifier<TrackState> {
(result['album'] as Map<String, dynamic>?)?['name'] as String?,
playlistName: type == 'playlist' ? result['name'] as String? : null,
coverUrl: normalizeCoverReference(result['cover_url']?.toString()),
headerVideoUrl: normalizeRemoteHttpUrl(
result['header_video']?.toString(),
),
searchExtensionId: extensionId,
);
return;
@@ -314,7 +324,8 @@ class TrackNotifier extends Notifier<TrackState> {
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
final topTracksList =
artistData['top_tracks'] as List<dynamic>? ?? [];
final topTracks = topTracksList
.map(
(t) => _parseSearchTrack(
@@ -335,6 +346,9 @@ class TrackNotifier extends Notifier<TrackState> {
headerImageUrl: normalizeRemoteHttpUrl(
artistData['header_image']?.toString(),
),
headerVideoUrl: normalizeRemoteHttpUrl(
artistData['header_video']?.toString(),
),
monthlyListeners: artistData['listeners'] as int?,
artistAlbums: albums,
artistTopTracks: topTracks.isNotEmpty ? topTracks : null,
@@ -359,10 +373,7 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
Future<void> search(
String query, {
String? filterOverride,
}) async {
Future<void> search(String query, {String? filterOverride}) async {
final requestId = ++_currentRequestId;
final currentFilter = filterOverride ?? state.selectedSearchFilter;
final requestFilter = currentFilter == 'all' ? null : currentFilter;
@@ -560,6 +571,7 @@ class TrackNotifier extends Notifier<TrackState> {
String query, {
Map<String, dynamic>? options,
String? selectedFilter,
bool allowVerificationRetry = true,
}) async {
final requestId = ++_currentRequestId;
final currentFilter = selectedFilter ?? state.selectedSearchFilter;
@@ -602,6 +614,12 @@ class TrackNotifier extends Notifier<TrackState> {
'Custom search complete: ${tracks.length} tracks parsed (source=$extensionId)',
);
final previewCount = tracks.where((t) => t.hasPreview).length;
_log.d(
'Custom search preview availability: $previewCount/${tracks.length} tracks have preview_url'
'${results.isNotEmpty ? '; first raw keys=${(results.first).keys.toList()}' : ''}',
);
state = TrackState(
tracks: tracks,
searchArtists: [],
@@ -614,6 +632,33 @@ class TrackNotifier extends Notifier<TrackState> {
} catch (e, stackTrace) {
if (!_isRequestValid(requestId)) return;
_log.e('Custom search failed: $e', e, stackTrace);
if (allowVerificationRetry && isExtensionVerificationRequired(e)) {
_log.i(
'Custom search requires verification; waiting for $extensionId grant',
);
state = TrackState(
isLoading: true,
hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess,
searchExtensionId: extensionId,
selectedSearchFilter: currentFilter,
);
final verified = await _openVerificationAndWait(extensionId);
if (!_isRequestValid(requestId)) return;
if (verified) {
_log.i(
'Verification complete for $extensionId; retrying custom search',
);
await customSearch(
extensionId,
query,
options: options,
selectedFilter: currentFilter,
allowVerificationRetry: false,
);
return;
}
}
state = TrackState(
isLoading: false,
error: e.toString(),
@@ -624,6 +669,55 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
Future<bool> _openVerificationAndWait(String extensionId) async {
final normalizedExtensionId = extensionId.trim();
if (normalizedExtensionId.isEmpty) return false;
final grantCompleter = Completer<ExtensionSessionGrantEvent>();
late final StreamSubscription<ExtensionSessionGrantEvent> grantSub;
grantSub = PlatformBridge.extensionSessionGrantEvents()
.where((event) => event.extensionId.trim() == normalizedExtensionId)
.listen((event) {
if (!grantCompleter.isCompleted) {
grantCompleter.complete(event);
}
});
final browserMode = ref
.read(settingsProvider)
.extensionVerificationBrowserMode;
Uri? authUri;
Timer? helpDialogTimer;
try {
final opened = await openPendingExtensionVerification(
normalizedExtensionId,
browserMode: browserMode,
onAuthUri: (uri) => authUri = uri,
);
if (!opened) return false;
helpDialogTimer = scheduleExtensionVerificationHelpDialog(
normalizedExtensionId,
authUri,
browserMode: browserMode,
);
final event = await grantCompleter.future.timeout(
const Duration(minutes: 5),
);
return event.success;
} on TimeoutException {
_log.w(
'Timed out waiting for verification grant: $normalizedExtensionId',
);
return false;
} finally {
helpDialogTimer?.cancel();
await grantSub.cancel();
}
}
Future<void> checkAvailability(int index) async {
if (index < 0 || index >= state.tracks.length) return;
@@ -751,6 +845,7 @@ class TrackNotifier extends Notifier<TrackState> {
itemType: itemType,
audioQuality: data['audio_quality']?.toString(),
audioModes: data['audio_modes']?.toString(),
previewUrl: data['preview_url']?.toString(),
);
}
@@ -826,7 +921,6 @@ class TrackNotifier extends Notifier<TrackState> {
totalTracks: data['total_tracks'] as int? ?? 0,
);
}
}
final trackProvider = NotifierProvider<TrackNotifier, TrackState>(
+341 -122
View File
@@ -1,3 +1,4 @@
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
@@ -23,6 +24,8 @@ import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
import 'package:spotiflac_android/widgets/cross_extension_share_sheet.dart';
import 'package:spotiflac_android/widgets/preview_button.dart';
import 'package:spotiflac_android/widgets/motion_header_banner.dart';
class _AlbumCache {
static final Map<String, _CacheEntry> _cache = {};
@@ -53,6 +56,9 @@ class AlbumScreen extends ConsumerStatefulWidget {
final String albumId;
final String albumName;
final String? coverUrl;
final String? headerVideoUrl;
final String? headerImageUrl;
final List<String>? audioTraits;
final List<Track>? tracks;
final String? extensionId;
final String? artistId;
@@ -63,6 +69,9 @@ class AlbumScreen extends ConsumerStatefulWidget {
required this.albumId,
required this.albumName,
this.coverUrl,
this.headerVideoUrl,
this.headerImageUrl,
this.audioTraits,
this.tracks,
this.extensionId,
this.artistId,
@@ -81,6 +90,10 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
String? _artistId;
String? _albumType;
int? _albumTotalTracks;
String? _headerVideoUrl;
String? _headerImageUrl;
List<String> _audioTraits = const [];
bool _tallHeader = false;
final ScrollController _scrollController = ScrollController();
String _legacyProviderIdFromResourceId(String value) {
@@ -139,6 +152,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
_artistId = widget.artistId;
_albumType = _tracks?.firstOrNull?.albumType;
_albumTotalTracks = _tracks?.firstOrNull?.totalTracks;
_headerVideoUrl = widget.headerVideoUrl;
_headerImageUrl = widget.headerImageUrl;
_audioTraits = widget.audioTraits ?? const [];
if (_tracks == null || _tracks!.isEmpty) {
_fetchTracks();
@@ -153,7 +169,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
void _onScroll() {
final expandedHeight = _calculateExpandedHeight(context);
final expandedHeight = _calculateExpandedHeight(context, tall: _tallHeader);
final shouldShow =
_scrollController.offset > (expandedHeight - kToolbarHeight - 20);
if (shouldShow != _showTitleInAppBar) {
@@ -161,9 +177,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
}
double _calculateExpandedHeight(BuildContext context) {
double _calculateExpandedHeight(BuildContext context, {bool tall = false}) {
final mediaSize = MediaQuery.of(context).size;
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
if (tall) {
return (mediaSize.height * 0.68).clamp(440.0, 660.0);
}
return (mediaSize.height * 0.6).clamp(400.0, 580.0);
}
String? _highResCoverUrl(String? url) {
@@ -214,6 +233,11 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
albumInfo?['album_type']?.toString(),
);
final totalTracks = albumInfo?['total_tracks'] as int?;
final headerVideo = albumInfo?['header_video']?.toString();
final headerImage = albumInfo?['header_image']?.toString();
final audioTraits = (albumInfo?['audio_traits'] as List?)
?.map((e) => e.toString())
.toList();
final tracks = trackList
.map(
(t) => _parseTrack(
@@ -232,6 +256,15 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
_artistId = artistId;
_albumType = albumType;
_albumTotalTracks = totalTracks;
_headerVideoUrl = (headerVideo != null && headerVideo.isNotEmpty)
? headerVideo
: _headerVideoUrl;
_headerImageUrl = (headerImage != null && headerImage.isNotEmpty)
? headerImage
: _headerImageUrl;
_audioTraits = (audioTraits != null && audioTraits.isNotEmpty)
? audioTraits
: _audioTraits;
_isLoading = false;
});
}
@@ -251,6 +284,14 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
albumInfo?['album_type']?.toString(),
);
final totalTracks = albumInfo?['total_tracks'] as int?;
final headerVideo =
(albumInfo?['header_video'] ?? result['header_video'])?.toString();
final headerImage =
(albumInfo?['header_image'] ?? result['header_image'])?.toString();
final audioTraits =
((albumInfo?['audio_traits'] ?? result['audio_traits']) as List?)
?.map((e) => e.toString())
.toList();
final tracks = trackList
.map(
(t) => _parseTrack(
@@ -269,6 +310,15 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
_artistId = artistId;
_albumType = albumType;
_albumTotalTracks = totalTracks;
_headerVideoUrl = (headerVideo != null && headerVideo.isNotEmpty)
? headerVideo
: _headerVideoUrl;
_headerImageUrl = (headerImage != null && headerImage.isNotEmpty)
? headerImage
: _headerImageUrl;
_audioTraits = (audioTraits != null && audioTraits.isNotEmpty)
? audioTraits
: _audioTraits;
_isLoading = false;
});
}
@@ -293,6 +343,101 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
return _stripPrefixedResourceId(widget.albumId);
}
double _albumTitleFontSize() {
final length = widget.albumName.trim().length;
if (length > 45) return 18;
if (length > 30) return 21;
return 24;
}
Widget _metaInlineItem(IconData? icon, String label) {
const textStyle = TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
);
if (icon == null) {
return Text(label, style: textStyle);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 15, color: Colors.white),
const SizedBox(width: 4),
Text(label, style: textStyle),
],
);
}
List<Widget> _audioTraitInline() {
final traits = _audioTraits
.map((t) => t.toLowerCase().trim())
.where((t) => t.isNotEmpty)
.toSet();
if (traits.isEmpty) return const [];
bool has(List<String> keys) => keys.any(traits.contains);
final items = <Widget>[];
if (has(['atmos', 'dolby_atmos', 'dolby-atmos'])) {
items.add(_metaInlineItem(Icons.surround_sound, 'Dolby Atmos'));
} else if (has(['spatial'])) {
items.add(_metaInlineItem(Icons.surround_sound, 'Spatial Audio'));
}
if (has(['hi-res-lossless', 'hi_res_lossless', 'hires-lossless'])) {
items.add(_metaInlineItem(Icons.graphic_eq, 'Hi-Res Lossless'));
} else if (has(['lossless'])) {
items.add(_metaInlineItem(Icons.graphic_eq, 'Lossless'));
}
return items;
}
Widget _buildHeaderMeta(BuildContext context, String? releaseDate) {
final items = <Widget>[];
void add(Widget widget) {
if (items.isNotEmpty) {
items.add(
const Padding(
padding: EdgeInsets.symmetric(horizontal: 6),
child: Text(
'',
style: TextStyle(color: Colors.white70, fontSize: 12),
),
),
);
}
items.add(widget);
}
final year = _releaseYear(releaseDate);
if (year != null) {
add(_metaInlineItem(null, year));
}
for (final trait in _audioTraitInline()) {
add(trait);
}
return ConstrainedBox(
constraints: const BoxConstraints(minHeight: 20),
child: Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 0,
runSpacing: 4,
children: items,
),
);
}
String? _releaseYear(String? date) {
if (date == null || date.isEmpty) return null;
final match = RegExp(r'(\d{4})').firstMatch(date);
return match?.group(1);
}
Track _parseTrack(
Map<String, dynamic> data, {
String? albumTypeFallback,
@@ -325,6 +470,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
composer: data['composer']?.toString(),
audioQuality: data['audio_quality']?.toString(),
audioModes: data['audio_modes']?.toString(),
previewUrl: data['preview_url']?.toString(),
);
}
@@ -362,6 +508,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
),
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
_buildTrackList(context, colorScheme, tracks),
_buildAlbumFooter(context, colorScheme, tracks),
],
SliverToBoxAdapter(child: SizedBox(height: 32 + bottomInset)),
],
@@ -374,7 +521,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
ColorScheme colorScheme,
Color pageBackgroundColor,
) {
final expandedHeight = _calculateExpandedHeight(context);
final tracks = _tracks ?? [];
final artistName =
widget.artistName ??
@@ -383,6 +529,16 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
: null);
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
final motionUrl = _headerVideoUrl ?? widget.headerVideoUrl;
final hasMotion =
motionUrl != null &&
motionUrl.trim().isNotEmpty &&
Uri.tryParse(motionUrl)?.hasAuthority == true;
final coverThumbUrl = widget.coverUrl ?? _headerImageUrl;
final showSquareCover = !hasMotion;
_tallHeader = false;
final expandedHeight = _calculateExpandedHeight(context);
return SliverAppBar(
expandedHeight: expandedHeight,
pinned: true,
@@ -410,33 +566,46 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3;
final cacheWidth = coverCacheWidthForViewport(context);
final headerBgUrl =
_headerImageUrl ?? widget.headerImageUrl ?? widget.coverUrl;
final Widget headerBgImage = headerBgUrl != null
? CachedNetworkImage(
imageUrl: _highResCoverUrl(headerBgUrl) ?? headerBgUrl,
fit: BoxFit.cover,
memCacheWidth: cacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => Container(color: colorScheme.surface),
errorWidget: (_, _, _) =>
Container(color: colorScheme.surface),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
size: 80,
color: colorScheme.onSurfaceVariant,
),
);
return FlexibleSpaceBar(
collapseMode: CollapseMode.pin,
background: Stack(
fit: StackFit.expand,
children: [
if (widget.coverUrl != null)
CachedNetworkImage(
imageUrl:
_highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: cacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) =>
Container(color: colorScheme.surface),
errorWidget: (_, _, _) =>
Container(color: colorScheme.surface),
if (hasMotion)
MotionHeaderBanner(
videoUrl: motionUrl,
fallback: headerBgImage,
)
else if (showSquareCover)
ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32),
child: headerBgImage,
)
else
Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
size: 80,
color: colorScheme.onSurfaceVariant,
),
),
headerBgImage,
if (showSquareCover)
Container(color: Colors.black.withValues(alpha: 0.35)),
Positioned(
left: 0,
right: 0,
@@ -466,11 +635,75 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (showSquareCover) ...[
Builder(
builder: (context) {
final coverSize = (constraints.maxWidth * 0.5)
.clamp(150.0, 210.0)
.toDouble();
return Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(
alpha: 0.45,
),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: coverThumbUrl != null
? CachedNetworkImage(
imageUrl:
_highResCoverUrl(coverThumbUrl) ??
coverThumbUrl,
fit: BoxFit.cover,
width: coverSize,
height: coverSize,
memCacheWidth: cacheWidth,
cacheManager:
CoverCacheManager.instance,
placeholder: (_, _) => Container(
color: colorScheme
.surfaceContainerHighest,
),
errorWidget: (_, _, _) => Container(
color: colorScheme
.surfaceContainerHighest,
child: Icon(
Icons.album,
size: 48,
color:
colorScheme.onSurfaceVariant,
),
),
)
: Container(
color: colorScheme
.surfaceContainerHighest,
child: Icon(
Icons.album,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
),
);
},
),
const SizedBox(height: 20),
],
Text(
widget.albumName,
style: const TextStyle(
style: TextStyle(
color: Colors.white,
fontSize: 24,
fontSize: _albumTitleFontSize(),
fontWeight: FontWeight.bold,
height: 1.2,
),
@@ -495,106 +728,42 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
overflow: TextOverflow.ellipsis,
),
],
if (tracks.isNotEmpty) ...[
const SizedBox(height: 12),
Wrap(
alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
const SizedBox(height: 12),
_buildHeaderMeta(context, releaseDate),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildLoveAllButton(),
const SizedBox(width: 12),
Flexible(
child: FilledButton.icon(
onPressed: tracks.isEmpty
? null
: () => _downloadAll(context),
icon: const Icon(Icons.download, size: 18),
label: Text(
context.l10n.downloadAllCount(tracks.length),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.music_note,
size: 14,
color: Colors.white,
),
const SizedBox(width: 4),
Text(
context.l10n.tracksCount(tracks.length),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
if (releaseDate != null && releaseDate.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.calendar_today,
size: 14,
color: Colors.white,
),
const SizedBox(width: 4),
Text(
_formatReleaseDate(releaseDate),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildLoveAllButton(),
const SizedBox(width: 12),
Flexible(
child: FilledButton.icon(
onPressed: () => _downloadAll(context),
icon: Icon(Icons.download, size: 18),
label: Text(
context.l10n.downloadAllCount(
tracks.length,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
disabledBackgroundColor: Colors.white
.withValues(alpha: 0.45),
disabledForegroundColor: Colors.black54,
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
),
const SizedBox(width: 12),
_buildAddToPlaylistButton(context),
],
),
],
),
const SizedBox(width: 12),
_buildAddToPlaylistButton(context),
],
),
],
),
),
@@ -641,6 +810,49 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
Widget _buildAlbumFooter(
BuildContext context,
ColorScheme colorScheme,
List<Track> tracks,
) {
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
final totalSeconds = tracks.fold<int>(
0,
(sum, t) => sum + (t.duration > 0 ? t.duration : 0),
);
final totalMinutes = (totalSeconds / 60).round();
final lines = <String>[];
if (releaseDate != null && releaseDate.isNotEmpty) {
lines.add(_formatReleaseDate(releaseDate));
}
final countText = context.l10n.tracksCount(tracks.length);
lines.add(totalMinutes > 0 ? '$countText$totalMinutes min' : countText);
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final line in lines)
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
line,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
);
}
Widget _buildTrackList(
BuildContext context,
ColorScheme colorScheme,
@@ -1072,6 +1284,7 @@ class _AlbumTrackItem extends ConsumerWidget {
artistName: track.artistName,
artistId: track.artistId,
coverUrl: track.coverUrl,
extensionId: track.source,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
@@ -1116,7 +1329,13 @@ class _AlbumTrackItem extends ConsumerWidget {
],
],
),
trailing: TrackCollectionQuickActions(track: track),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
PreviewButton(track: track),
TrackCollectionQuickActions(track: track),
],
),
onTap: () => _handleTap(context, ref, isQueued: isQueued),
onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet(
context,
+64 -2
View File
@@ -24,6 +24,7 @@ import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
import 'package:spotiflac_android/widgets/motion_header_banner.dart';
import 'package:spotiflac_android/widgets/cross_extension_share_sheet.dart';
class _ArtistCache {
@@ -46,6 +47,7 @@ class _ArtistCache {
List<ArtistAlbum>? releases,
List<Track>? topTracks,
String? headerImageUrl,
String? headerVideoUrl,
int? monthlyListeners,
}) {
_cache[artistId] = _CacheEntry(
@@ -53,6 +55,7 @@ class _ArtistCache {
releases: releases,
topTracks: topTracks,
headerImageUrl: headerImageUrl,
headerVideoUrl: headerVideoUrl,
monthlyListeners: monthlyListeners,
expiresAt: DateTime.now().add(_ttl),
);
@@ -64,6 +67,7 @@ class _CacheEntry {
final List<ArtistAlbum>? releases;
final List<Track>? topTracks;
final String? headerImageUrl;
final String? headerVideoUrl;
final int? monthlyListeners;
final DateTime expiresAt;
@@ -72,6 +76,7 @@ class _CacheEntry {
this.releases,
this.topTracks,
this.headerImageUrl,
this.headerVideoUrl,
this.monthlyListeners,
required this.expiresAt,
});
@@ -82,6 +87,7 @@ class ArtistScreen extends ConsumerStatefulWidget {
final String artistName;
final String? coverUrl;
final String? headerImageUrl;
final String? headerVideoUrl;
final int? monthlyListeners;
final List<ArtistAlbum>? albums;
final List<Track>? topTracks;
@@ -93,6 +99,7 @@ class ArtistScreen extends ConsumerStatefulWidget {
required this.artistName,
this.coverUrl,
this.headerImageUrl,
this.headerVideoUrl,
this.monthlyListeners,
this.albums,
this.topTracks,
@@ -109,6 +116,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
List<ArtistAlbum>? _releases;
List<Track>? _topTracks;
String? _headerImageUrl;
String? _headerVideoUrl;
int? _monthlyListeners;
String? _error;
@@ -217,6 +225,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
_albums = widget.albums;
_topTracks = widget.topTracks;
_headerImageUrl = widget.headerImageUrl;
_headerVideoUrl = widget.headerVideoUrl;
_monthlyListeners = widget.monthlyListeners;
if ((_albums == null || _albums!.isEmpty) ||
@@ -232,6 +241,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
_albums = widget.albums;
_topTracks = widget.topTracks;
_headerImageUrl = widget.headerImageUrl;
_headerVideoUrl = widget.headerVideoUrl;
_monthlyListeners = widget.monthlyListeners;
if (_topTracks == null || _topTracks!.isEmpty) {
@@ -242,6 +252,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
_releases = cached.releases;
_topTracks = cached.topTracks;
_headerImageUrl = cached.headerImageUrl;
_headerVideoUrl = cached.headerVideoUrl;
_monthlyListeners = cached.monthlyListeners;
if (_topTracks == null || _topTracks!.isEmpty) {
@@ -274,6 +285,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
List<ArtistAlbum>? releases;
List<Track>? topTracks;
String? headerImage;
String? headerVideo;
int? listeners;
if (_directMetadataProviderId() != null) {
@@ -310,6 +322,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
artistData['header_image'] as String? ??
artistData['cover_url'] as String? ??
artistData['image_url'] as String?;
headerVideo =
artistInfo?['header_video'] as String? ??
artistData['header_video'] as String?;
listeners =
artistInfo?['listeners'] as int? ?? artistData['listeners'] as int?;
} else {
@@ -332,6 +347,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
}
headerImage = artistData['header_image'] as String?;
headerVideo = artistData['header_video'] as String?;
listeners = artistData['listeners'] as int?;
} else {
throw StateError('Failed to load artist metadata from extension');
@@ -340,6 +356,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
final finalHeaderImage =
headerImage ?? _headerImageUrl ?? widget.headerImageUrl;
final finalHeaderVideo =
headerVideo ?? _headerVideoUrl ?? widget.headerVideoUrl;
final finalListeners =
listeners ?? _monthlyListeners ?? widget.monthlyListeners;
@@ -349,6 +367,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
releases: releases,
topTracks: topTracks,
headerImageUrl: finalHeaderImage,
headerVideoUrl: finalHeaderVideo,
monthlyListeners: finalListeners,
);
@@ -358,6 +377,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
_releases = releases;
_topTracks = topTracks;
_headerImageUrl = finalHeaderImage;
_headerVideoUrl = finalHeaderVideo;
_monthlyListeners = finalListeners;
_isLoadingDiscography = false;
});
@@ -410,6 +430,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
totalTracks: data['total_tracks'] as int? ?? album?.totalTracks,
composer: data['composer']?.toString(),
source: data['provider_id']?.toString() ?? widget.extensionId,
previewUrl: data['preview_url']?.toString(),
);
}
@@ -1127,6 +1148,15 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
imageUrl.isNotEmpty &&
Uri.tryParse(imageUrl)?.hasAuthority == true;
String? headerVideoUrl = _headerVideoUrl;
if (headerVideoUrl == null || headerVideoUrl.isEmpty) {
headerVideoUrl = widget.headerVideoUrl;
}
final hasMotionBanner =
headerVideoUrl != null &&
headerVideoUrl.isNotEmpty &&
Uri.tryParse(headerVideoUrl)?.hasAuthority == true;
final isDark = Theme.of(context).brightness == Brightness.dark;
String? listenersText;
@@ -1174,7 +1204,37 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
background: Stack(
fit: StackFit.expand,
children: [
if (hasValidImage)
if (hasMotionBanner)
MotionHeaderBanner(
videoUrl: headerVideoUrl,
fallback: hasValidImage
? CachedCoverImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
alignment: Alignment.topCenter,
memCacheWidth: 800,
placeholder: (context, url) => Container(
color: colorScheme.surfaceContainerHighest,
),
errorWidget: (context, url, error) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.person,
size: 80,
color: colorScheme.onSurfaceVariant,
),
),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.person,
size: 80,
color: colorScheme.onSurfaceVariant,
),
),
)
else if (hasValidImage)
CachedCoverImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
@@ -1907,7 +1967,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
borderRadius: BorderRadius.circular(4),
),
child: Text(
album.albumType == 'ep' ? 'EP' : 'Single',
album.albumType == 'ep'
? context.l10n.releaseTypeEp
: context.l10n.releaseTypeSingle,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
+306 -93
View File
@@ -1,4 +1,6 @@
import 'dart:io';
import 'dart:math';
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -20,6 +22,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
import 'package:spotiflac_android/widgets/batch_convert_sheet.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/providers/music_player_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';
@@ -97,7 +100,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
double _calculateExpandedHeight(BuildContext context) {
final mediaSize = MediaQuery.of(context).size;
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
return (mediaSize.height * 0.6).clamp(400.0, 580.0);
}
String? _highResCoverUrl(String? url) {
@@ -269,16 +272,16 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}
}
Future<void> _openFile(DownloadHistoryItem track) async {
Future<void> _openFile(
DownloadHistoryItem track, {
List<DownloadHistoryItem> queueItems = const [],
}) async {
try {
await ref
.read(playbackProvider.notifier)
.playLocalPath(
path: track.filePath,
title: track.trackName,
artist: track.artistName,
album: track.albumName,
coverUrl: track.coverUrl ?? '',
.playHistoryQueue(
queueItems.isNotEmpty ? queueItems : [track],
startItem: track,
);
} catch (e) {
if (mounted) {
@@ -502,26 +505,32 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
fit: StackFit.expand,
children: [
if (embeddedCoverPath != null)
Image.file(
File(embeddedCoverPath),
fit: BoxFit.cover,
cacheWidth: cacheWidth,
gaplessPlayback: true,
filterQuality: FilterQuality.low,
errorBuilder: (_, _, _) =>
Container(color: colorScheme.surface),
ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32),
child: Image.file(
File(embeddedCoverPath),
fit: BoxFit.cover,
cacheWidth: cacheWidth,
gaplessPlayback: true,
filterQuality: FilterQuality.low,
errorBuilder: (_, _, _) =>
Container(color: colorScheme.surface),
),
)
else if (widget.coverUrl != null)
CachedNetworkImage(
imageUrl:
_highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: cacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) =>
Container(color: colorScheme.surface),
errorWidget: (_, _, _) =>
Container(color: colorScheme.surface),
ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32),
child: CachedNetworkImage(
imageUrl:
_highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: cacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) =>
Container(color: colorScheme.surface),
errorWidget: (_, _, _) =>
Container(color: colorScheme.surface),
),
)
else
Container(
@@ -532,6 +541,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
color: colorScheme.onSurfaceVariant,
),
),
if (embeddedCoverPath != null || widget.coverUrl != null)
Container(color: Colors.black.withValues(alpha: 0.35)),
Positioned(
left: 0,
right: 0,
@@ -561,11 +572,43 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Builder(
builder: (context) {
final coverSize = (constraints.maxWidth * 0.5)
.clamp(150.0, 210.0)
.toDouble();
return Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.45),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: _buildSquareCover(
context,
colorScheme,
embeddedCoverPath,
coverSize,
cacheWidth,
),
),
);
},
),
const SizedBox(height: 20),
Text(
widget.albumName,
style: const TextStyle(
style: TextStyle(
color: Colors.white,
fontSize: 24,
fontSize: _albumTitleFontSize(),
fontWeight: FontWeight.bold,
height: 1.2,
),
@@ -587,62 +630,49 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
),
if (tracks.isNotEmpty) ...[
const SizedBox(height: 12),
Wrap(
alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
_buildDownloadedHeaderMeta(
context,
tracks,
commonQuality,
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.download_done,
size: 14,
color: Colors.white,
Flexible(
child: FilledButton.icon(
onPressed: () => _playAll(tracks),
icon: const Icon(Icons.play_arrow, size: 20),
label: Text(
context.l10n.tooltipPlay,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
const SizedBox(width: 4),
Text(
context.l10n
.downloadedAlbumDownloadedCount(
tracks.length,
),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
),
if (commonQuality != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(20),
),
child: Text(
commonQuality,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
),
const SizedBox(width: 12),
Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: IconButton(
tooltip: context.l10n.actionShuffle,
onPressed: () => _shuffleAll(tracks),
icon: const Icon(
Icons.shuffle,
color: Colors.white,
),
),
),
],
),
],
@@ -671,6 +701,132 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
);
}
Widget _buildSquareCover(
BuildContext context,
ColorScheme colorScheme,
String? embeddedCoverPath,
double coverSize,
int cacheWidth,
) {
Widget placeholder() => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 48, color: colorScheme.onSurfaceVariant),
);
if (embeddedCoverPath != null) {
return Image.file(
File(embeddedCoverPath),
fit: BoxFit.cover,
width: coverSize,
height: coverSize,
cacheWidth: cacheWidth,
gaplessPlayback: true,
errorBuilder: (_, _, _) => placeholder(),
);
}
final coverUrl = widget.coverUrl;
if (coverUrl != null && coverUrl.isNotEmpty) {
return CachedNetworkImage(
imageUrl: _highResCoverUrl(coverUrl) ?? coverUrl,
fit: BoxFit.cover,
width: coverSize,
height: coverSize,
memCacheWidth: cacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => placeholder(),
errorWidget: (_, _, _) => placeholder(),
);
}
return placeholder();
}
double _albumTitleFontSize() {
final length = widget.albumName.trim().length;
if (length > 45) return 18;
if (length > 30) return 21;
return 24;
}
Widget _metaWhiteItem(IconData? icon, String label) {
const textStyle = TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
);
if (icon == null) return Text(label, style: textStyle);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 15, color: Colors.white),
const SizedBox(width: 4),
Text(label, style: textStyle),
],
);
}
Widget _buildDownloadedHeaderMeta(
BuildContext context,
List<DownloadHistoryItem> tracks,
String? commonQuality,
) {
final totalSeconds = tracks.fold<int>(
0,
(sum, t) => sum + ((t.duration ?? 0) > 0 ? t.duration! : 0),
);
final totalMinutes = (totalSeconds / 60).round();
final parts = <Widget>[];
void add(Widget w) {
if (parts.isNotEmpty) {
parts.add(
const Padding(
padding: EdgeInsets.symmetric(horizontal: 6),
child: Text(
'',
style: TextStyle(color: Colors.white70, fontSize: 12),
),
),
);
}
parts.add(w);
}
add(
_metaWhiteItem(
null,
context.l10n.downloadedAlbumDownloadedCount(tracks.length),
),
);
if (totalMinutes > 0) add(_metaWhiteItem(null, '$totalMinutes min'));
if (commonQuality != null && commonQuality.isNotEmpty) {
add(_metaWhiteItem(Icons.graphic_eq, commonQuality));
}
return Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
runSpacing: 4,
children: parts,
);
}
Future<void> _playAll(List<DownloadHistoryItem> tracks) async {
if (tracks.isEmpty) return;
await ref.read(musicPlayerControllerProvider).setShuffle(false);
await _openFile(tracks.first, queueItems: tracks);
}
Future<void> _shuffleAll(List<DownloadHistoryItem> tracks) async {
if (tracks.isEmpty) return;
await ref.read(musicPlayerControllerProvider).setShuffle(true);
await _openFile(
tracks[Random().nextInt(tracks.length)],
queueItems: tracks,
);
}
Widget _buildInfoCard(
BuildContext context,
ColorScheme colorScheme,
@@ -888,7 +1044,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
? null
: IconButton(
tooltip: context.l10n.tooltipPlay,
onPressed: () => _openFile(track),
onPressed: () =>
_openFile(track, queueItems: navigationItems),
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer.withValues(
@@ -947,6 +1104,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
) {
final tracksById = {for (final t in allTracks) t.id: t};
final sourceFormats = <String>{};
final sourceBitDepths = <int?>[];
final sourceSampleRates = <int?>[];
for (final id in _selectedIds) {
final item = tracksById[id];
if (item == null) continue;
@@ -956,6 +1115,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
fileName: item.safFileName,
);
if (sourceFormat != null) sourceFormats.add(sourceFormat);
sourceBitDepths.add(item.bitDepth);
sourceSampleRates.add(item.sampleRate);
}
final formats = audioConversionTargetFormats
@@ -979,6 +1140,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
@@ -986,12 +1148,16 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
formats: formats,
title: sheetTitle,
confirmLabel: sheetConfirmLabel,
onConvert: (format, bitrate) {
sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths),
sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates),
onConvert: (format, bitrate, losslessQuality, losslessProcessing) {
Navigator.pop(sheetContext);
_performBatchConversion(
allTracks: allTracks,
targetFormat: format,
bitrate: bitrate,
losslessQuality: losslessQuality,
losslessProcessing: losslessProcessing,
);
},
),
@@ -1002,6 +1168,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
required List<DownloadHistoryItem> allTracks,
required String targetFormat,
required String bitrate,
LosslessConversionQuality losslessQuality =
const LosslessConversionQuality(),
LosslessConversionProcessing losslessProcessing =
const LosslessConversionProcessing(),
}) async {
final tracksById = {for (final t in allTracks) t.id: t};
final selected = <DownloadHistoryItem>[];
@@ -1033,12 +1203,23 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}
final isLossless = isLosslessConversionTarget(targetFormat);
final losslessLabels = context.l10n.losslessConversionLabels;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
content: Text(
isLossless
isLossless && losslessQuality.hasCaps
? context.l10n.selectionBatchConvertConfirmMessageLosslessCapped(
selected.length,
targetFormat,
losslessQualityLabel(
losslessQuality,
originalLabel: losslessLabels.original,
originalQualityLabel: losslessLabels.originalQuality,
),
)
: isLossless
? context.l10n.selectionBatchConvertConfirmMessageLossless(
selected.length,
targetFormat,
@@ -1067,10 +1248,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
int successCount = 0;
final total = selected.length;
final historyDb = HistoryDatabase.instance;
final newQuality =
isLosslessConversionTarget(targetFormat)
? '${targetFormat.toUpperCase()} Lossless'
: '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
final settings = ref.read(settingsProvider);
final shouldEmbedLyrics =
settings.embedLyrics && settings.lyricsMode != 'external';
@@ -1147,6 +1324,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
coverPath: coverPath,
artistTagMode: settings.artistTagMode,
deleteOriginal: !isSaf,
sourceBitDepth: item.bitDepth,
losslessQuality: losslessQuality,
losslessProcessing: losslessProcessing,
);
if (coverPath != null) {
@@ -1164,6 +1344,39 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
continue;
}
final isLosslessOutput = isLosslessConversionTarget(targetFormat);
int? convertedBitDepth;
int? convertedSampleRate;
if (isLosslessOutput) {
try {
final convertedMetadata = await PlatformBridge.readFileMetadata(
newPath,
);
if (convertedMetadata['error'] == null) {
convertedBitDepth = readPositiveAudioInt(
convertedMetadata['bit_depth'],
);
convertedSampleRate = readPositiveAudioInt(
convertedMetadata['sample_rate'],
);
}
} catch (_) {}
convertedBitDepth ??= losslessQuality.effectiveBitDepth(
item.bitDepth,
);
convertedSampleRate ??= losslessQuality.effectiveSampleRate(
item.sampleRate,
);
}
final newQuality = convertedAudioQualityLabel(
targetFormat: targetFormat,
bitrate: bitrate,
labels: losslessLabels,
losslessQuality: losslessQuality,
actualBitDepth: convertedBitDepth,
actualSampleRate: convertedSampleRate,
);
if (isSaf) {
final treeUri = item.downloadTreeUri;
final relativeDir = item.safRelativeDir ?? '';
@@ -1213,7 +1426,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
targetFormat: targetFormat,
bitrate: bitrate,
),
clearAudioSpecs: true,
newBitDepth: convertedBitDepth,
newSampleRate: convertedSampleRate,
clearAudioSpecs: !isLosslessOutput,
);
}
try {
@@ -1234,7 +1449,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
targetFormat: targetFormat,
bitrate: bitrate,
),
clearAudioSpecs: true,
newBitDepth: convertedBitDepth,
newSampleRate: convertedSampleRate,
clearAudioSpecs: !isLosslessOutput,
);
}
@@ -1279,9 +1496,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
context: context,
builder: (ctx) => AlertDialog(
title: Text(ctx.l10n.replayGainBatchConfirmTitle),
content: Text(
ctx.l10n.replayGainBatchConfirmMessage(selected.length),
),
content: Text(ctx.l10n.replayGainBatchConfirmMessage(selected.length)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
@@ -1331,9 +1546,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.replayGainBatchSuccess(successCount, total),
),
content: Text(context.l10n.replayGainBatchSuccess(successCount, total)),
),
);
}
+3 -1
View File
@@ -32,6 +32,7 @@ import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
import 'package:spotiflac_android/widgets/preview_button.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
part 'home_tab_helpers.dart';
@@ -177,7 +178,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
},
);
// Watch for new homeFeed extension being installed/enabled after init
_homeFeedExtSub = ref.listenManual<bool>(
extensionProvider.select(
(s) => s.extensions.any((e) => e.enabled && e.hasHomeFeed),
@@ -821,6 +821,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
artistId: trackState.artistId!,
artistName: trackState.artistName!,
coverUrl: trackState.coverUrl,
headerImageUrl: trackState.headerImageUrl,
headerVideoUrl: trackState.headerVideoUrl,
albums: trackState.artistAlbums!,
extensionId: extensionId,
),

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