Compare commits

...

26 Commits

Author SHA1 Message Date
zarzet 98abaf6635 v3.7.0: roll back from v4, remove internal player — v3 is already complete
Version rolled back from v4.x to v3.7.0. After extensive work on v4's
internal streaming engine, smart queue, DASH pipeline, and media controls,
we realized v3 was already feature-complete. Adding more big features
only made maintenance increasingly difficult and the developer's life
miserable. Stripped back to what works: external player only, cleaner
codebase, sustainable long-term.

- Remove just_audio, audio_service, audio_session and entire internal
  playback engine (smart queue, notification, shuffle/repeat, prefetch)
- Remove PlaybackItem model, MiniPlayerBar widget, notification drawables
- Remove playerMode setting (external-only now)
- Migrate MainActivity from AudioServiceFragmentActivity to
  FlutterFragmentActivity
- Migrate Qobuz to MusicDL API
- Update changelog with v3.7.0 rollback explanation
2026-03-04 02:02:25 +07:00
zarzet 4747119a7f fix(playback): prevent internal mini-player flash in external mode 2026-02-27 15:05:16 +07:00
zarzet bfd769b349 fix(library): exclude downloaded tracks from local scan reliably 2026-02-27 15:05:14 +07:00
zarzet 40c3c73bfd fix: hide internal player UI when external mode is active 2026-02-27 14:42:15 +07:00
zarzet 96d11b1d7d feat: add external player mode for local library playback 2026-02-27 14:38:45 +07:00
zarzet b3771f3488 fix: disable automatic spotify-web install during setup 2026-02-27 14:31:49 +07:00
zarzet a07c125454 feat: update collection actions for offline-first playback 2026-02-27 14:30:10 +07:00
zarzet 54a7b6b568 fix: load lyrics from sidecar lrc before online lookup 2026-02-27 14:27:30 +07:00
zarzet 77d0ac4fce fix: prioritize local embedded lyrics before online fetch 2026-02-27 14:26:11 +07:00
zarzet bddd733466 fix: trigger smart queue for local play actions 2026-02-27 14:21:02 +07:00
zarzet e6ffb08954 feat: make smart queue offline-only 2026-02-27 14:14:44 +07:00
zarzet 2fe8f659bc refactor: simplify setup flow and update collection actions 2026-02-27 13:48:47 +07:00
zarzet ab26d84632 chore: rebuild dev history without streaming-era commits 2026-02-27 13:48:44 +07:00
zarzet c89600591c feat: add love and add-to-playlist circular buttons to album screen
Flanks the Download All button with two circular icon buttons:
- Left: heart (favorite_border/favorite) toggles love for all tracks; turns red when all are loved
- Right: plus icon opens the playlist picker sheet for all album tracks
2026-02-20 03:30:34 +07:00
zarzet f1d57d89c7 refactor: extract duplicated code to shared utilities across Dart and Go
- Extract normalizeOptionalString() to lib/utils/string_utils.dart from download_queue_provider and track_metadata_screen
- Extract PrioritySettingsScaffold widget from lyrics and metadata priority pages, reducing ~280 lines of duplication
- Extract _ensureDefaultDocumentsOutputDir/_ensureDefaultAndroidMusicOutputDir in download queue provider
- Extract collectLibraryAudioFiles() and applyDefaultLibraryMetadata() in Go library_scan.go
- Extract plainTextLyricsLines() in Go lyrics.go, used by Apple Music, Musixmatch, and QQ Music clients
2026-02-19 19:49:58 +07:00
zarzet 83124875d3 fix: wrap collection folder list items in Card to match history item style in library tab 2026-02-19 19:31:18 +07:00
zarzet 9460e9faae fix: remove dividers and align content padding in playlist track list to match album screen 2026-02-19 19:26:08 +07:00
zarzet 882afd938b feat: add SongLink region setting and fix track metadata lookup with name+artist fallback
- Add configurable SongLink region (userCountry) setting with picker UI
- Pass songLinkRegion through download request payload to Go backend
- Go backend: thread-safe global SongLink region with per-request override
- Fix downloaded track not recognized in collection tap: add findByTrackAndArtist
  fallback in download history lookup chain (Spotify ID → ISRC → name+artist)
- Apply same name+artist fallback to isDownloaded check in track options sheet
- Add missing library_database.dart import for LocalLibraryItem
2026-02-19 19:16:55 +07:00
zarzet ab72a10578 feat: add multi-select to library folders, batch playlist picker, and Go backend FD safety
- Add multi-select support to library_tracks_folder_screen (wishlist, loved,
  playlist) with long-press to enter selection mode, animated bottom bar with
  batch remove/download/add-to-playlist actions, and PopScope exit handling
- Create batch showAddTracksToPlaylistSheet in playlist_picker_sheet with
  playlist thumbnail widget and cover image support
- Add playlist grid selection tint overlay in queue_tab
- Optimize collection lookups with pre-built _allPlaylistTrackKeys index and
  isTrackInAnyPlaylist/hasPlaylistTracks accessors
- Eagerly initialize localLibraryProvider and libraryCollectionsProvider
- Enable SQLite WAL mode and PRAGMA synchronous=NORMAL across all databases
- Go backend: duplicate SAF output FDs before provider attempts to prevent
  fdsan abort on fallback retries; close detached FDs after download completes
- Go backend: rewrite compatibilityTransport to try HTTPS first and only
  fallback to HTTP on transport-level failures, preventing redirect loops
- Go backend: enforce HTTPS-only for extension sandbox HTTP clients
2026-02-19 18:27:14 +07:00
zarzet e39756fa3f refactor: migrate persistence to SQLite, add strict provider mode, and optimize collection lookups
- Replace SharedPreferences with SQLite (AppStateDatabase, LibraryCollectionsDatabase) for download queue, library collections, and recent access history
- Add Set-based O(1) track containment checks for wishlist, loved, and playlist tracks
- Add batch addTracksToPlaylist with PlaylistAddBatchResult
- Go backend: strict mode locks download to selected provider when auto fallback is off
- Go backend: fix extension progress normalization (percent/100) and lifecycle tracking
- Go backend: case-insensitive provider ID matching throughout fallback chain
- Lyrics embedding now respects lyricsMode setting (embed/both/off)
- Debounced queue persistence to reduce write frequency
- Fix shouldUseFallback logic to not be gated by useExtensions
2026-02-19 16:40:03 +07:00
zarzet 8e794e1ef1 feat: Library tab redesign with playlists, drag-and-drop categorization, and pinned app bars 2026-02-19 15:55:24 +07:00
zarzet caf68c8137 redesign: full-screen cover art with parallax scroll across all detail screens
Replace blurred background + centered cover thumbnail with full-screen
cover art, dark gradient overlay, and parallax collapse mode for a
consistent Apple Music-inspired design across album, playlist, downloaded
album, local album, and track metadata screens. Remove select button UI
(users enter selection via long-press), upgrade cover resolution for
Spotify/Deezer CDN, and move track/album info into the overlay.
2026-02-19 00:28:12 +07:00
zarzet 5161ac8f77 chore: bump version to 3.7.0+83 2026-02-18 19:43:50 +07:00
zarzet 4df96db809 feat: batch re-enrich for local tracks, SAF FD refactor, Ogg quality fix
- Replace batch Share action with batch Re-enrich in local album selection bar
  - Full native/FFmpeg re-enrich flow with SAF write-back support
  - Triggers incremental local library scan after completion to refresh metadata
- Queue tab: switch first selection action to Re-enrich when all selected items are local-only
- Refactor SAF FD handoff in MainActivity: drop detachFd/dup pattern, pass procfs
  path to Go and let Go re-open it to avoid fdsan double-close race conditions
- Handle /proc/self/fd/ path in output_fd.go: re-open via O_WRONLY|O_TRUNC instead
  of taking raw FD ownership
- Fix Ogg duration/bitrate calculation in audio_metadata.go:
  - Use float64 arithmetic and math.Round for accurate duration
  - Compute bitrate from file size / float duration at the source
  - Validate Ogg page header fields (version, headerType, segment table) to avoid
    false positives from payload bytes during backward scan
  - Guard against corrupted granule values (>24h duration, <8kbps bitrate)
- Rename trackReEnrich label from 'Re-enrich Metadata' to 'Re-enrich' across all
  13 locales and ARB files
- Update CHANGELOG.md with 3.7.0 entry
2026-02-18 19:29:59 +07:00
zarzet 5605930aef feat: add multi-select share and batch convert in downloaded/local album screens
- Add shareMultipleContentUris native handler in MainActivity for ACTION_SEND_MULTIPLE
- Add shareMultipleContentUris binding in PlatformBridge
- Add _shareSelected and _performBatchConversion methods to DownloadedAlbumScreen and LocalAlbumScreen
- Add batch convert bottom sheet UI with format/bitrate selection (MP3/Opus, 128k-320k)
- Add share & convert action buttons to selection bottom bar in both screens
- Add batch convert with full SAF support: temp copy, write-back, history update
- Add share/convert selection strings to l10n (all supported locales + app_en.arb)
- Add queue tab selection share/convert feature (queue_tab.dart)
- Update donate page
- Update go.sum with bumped dependency hashes
2026-02-18 18:05:48 +07:00
zarzet cdc5836785 fix: rollback Go toolchain to 1.25.7 to fix ARM32 SIGSYS crash
Go 1.26.0 runtime uses futex_time64 (syscall 422) which is blocked
by seccomp on Android 10 and older ARM32 devices, causing immediate
SIGSYS (signal 31) crash on app launch.

Downgraded:
- toolchain: go1.26.0 -> go1.25.7
- golang.org/x/sys: v0.41.0 -> v0.40.0
- golang.org/x/crypto: v0.48.0 -> v0.47.0
- golang.org/x/mod: v0.33.0 -> v0.32.0
- golang.org/x/text: v0.34.0 -> v0.33.0
- golang.org/x/tools: v0.42.0 -> v0.41.0
2026-02-18 00:04:32 +07:00
132 changed files with 23022 additions and 5754 deletions
+20
View File
@@ -0,0 +1,20 @@
* text=auto eol=lf
# Windows scripts
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf
# Binary files
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.ico binary
*.pdf binary
*.zip binary
*.jar binary
*.aar binary
*.keystore binary
*.jks binary
+2 -2
View File
@@ -71,7 +71,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: "1.26"
go-version: "1.25.7"
cache-dependency-path: go_backend/go.sum
# Cache Gradle for faster builds
@@ -174,7 +174,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: "1.26"
go-version: "1.25.7"
cache-dependency-path: go_backend/go.sum
# Cache CocoaPods
Binary file not shown.
+110
View File
@@ -1,5 +1,115 @@
# Changelog
## [3.7.0] - 2026-03-04
Hey everyone, thank you so much for sticking with SpotiFLAC Mobile.
Starting from this release, we're rolling the version back from **v4.x to v3.x**.
### Removed
- **Internal Audio Player** — Removed `just_audio`, `audio_service`, and `audio_session` dependencies entirely. The internal playback engine (smart queue, media notification, shuffle/repeat, lyrics sync, prefetch, playback state persistence) has been completely removed. Playback now delegates to the system's external player.
- **PlaybackItem Model** — No longer needed without internal playback.
- **MiniPlayerBar Widget** — Removed the in-app mini player UI.
- **Media Notification Controls** — Removed notification drawables (`ic_stat_favorite`, `ic_stat_favorite_border`) and the `keep.xml` resource file.
- **Player Mode Setting** — The `playerMode` setting has been removed since external player is now the only mode.
- **Online Playback Feature** — Online streaming mode, DASH pipeline, and related components introduced in v4.0.0 are gone from the main branch.
### Changed
- **MainActivity** now extends `FlutterFragmentActivity` directly (previously `AudioServiceFragmentActivity`).
- **PlaybackController** simplified from ~1200 lines to ~87 lines — now only resolves local file paths and opens them via external player.
- **ProGuard rules** cleaned up — removed audio_service/just_audio/audio_session rules.
- **Qobuz** migrated to MusicDL API (Thanks @Ruubiiiii for Hosting the API).
### Note
There are three main reasons behind this decision:
1. **Respecting the API providers** — After giving it some thought, we realized that the streaming feature was indirectly hurting the API providers who have been generous enough to make their services available. They already offer streaming directly on their own websites, and it only feels right to direct streaming usage back to their platforms.
2. **Long-term sustainability** — We want SpotiFLAC to be around for as long as possible. Keeping certain features in the app could attract unwanted attention and put the project's continued existence at risk. Removing them is a proactive step to keep things running smoothly for everyone.
**Still want online playback? Check out these services:**
- [DabMusic](https://dabmusic.xyz)
- [SquidWTF](https://tidal.squid.wtf)
Thank you for your understanding and continued support. This decision was made to ensure the long-term sustainability of the app and to respect the ecosystem that has been supporting SpotiFLAC all along. You guys are the best, and we truly appreciate each and every one of you!
---
## [3.6.0] - 2026-02-19
### Added
- **Library Tab Redesign**: Wishlist, Loved, and individual Playlist collections now appear as unified list/grid items in the "All" tab alongside tracks, replacing the old "My Folders" horizontal card section
- **Drag-and-Drop Track Categorization**: Long-press-drag tracks onto playlist items to add them to that playlist; when multiple tracks are selected and one is dragged, all selected tracks are added to the target playlist
- Drag feedback widget displays multi-select count badge
- **Playlist Multi-Select Deletion**: Long-press playlists to enter selection mode, select multiple playlists, and batch-delete all selected at once via a dedicated selection bottom bar
- **Track Categorization System**: Tracks added to any playlist are automatically hidden from the main tracks list; removing a track from a playlist or deleting the playlist makes the track reappear — no actual file deletion ever occurs
- **Create Playlist Button**: New "+" `TextButton.icon` in Library tab header with dynamic theme colors, replacing the old "Select" button
- **Track Options Bottom Sheet**: Rewrote `TrackCollectionQuickActions` from inline action buttons to a single styled bottom sheet with track header (cover, title, artist), divider, and option tiles matching `DownloadServicePicker` visual style
- **Library Tracks Folder SliverAppBar**: Wishlist, Loved, and Playlist detail screens now feature a collapsible SliverAppBar with cover art (45% viewport height, parallax, gradient overlay), mode-specific icons (bookmark/heart/queue_music), title, and track count badge
- **Custom Playlist Cover Images**: Users can set custom cover images for playlists via long-press menu or camera icon in SliverAppBar
- Covers stored locally in app support directory with priority: custom cover > first track URL > icon fallback
- Cover options bottom sheet with change/remove actions
- Playlist list screen shows cover thumbnails
- **Long-Press Context Menus**: Track tiles in library folders and playlist list items now use long-press for styled bottom sheet context menus instead of trailing icon buttons, matching platform conventions
- **Wishlist Quick Download**: Tapping a track in Wishlist opens quality picker (respects "Ask quality before download" setting) and starts download
- **Playlist Track Playback**: Tapping a downloaded track in a Playlist opens it in the device's external music player via `openFile()` with file existence check
- **Collapsible AppBar on Playlist List Screen**: Playlist list screen now uses a collapsible SliverAppBar matching Settings sub-page style (animated title size 20→28px, animated left padding 56→24px) for visual consistency
- **`UnifiedLibraryItem.collectionKey` Getter**: Efficient playlist membership checking without constructing a full `Track` object
- **Multi-select Share**: Share multiple downloaded/local tracks at once from the selection bottom bar
- Supports SAF content URIs via native `ACTION_SEND_MULTIPLE` intent
- Supports regular file paths via SharePlus
- Available in Downloaded Album, Local Album, and Queue tab screens
- **Multi-select Batch Convert**: Convert multiple selected tracks to MP3 or Opus in one operation
- Bottom sheet UI with format (MP3 / Opus) and bitrate (128k / 192k / 256k / 320k) selection
- Full SAF support: copies to temp, converts, writes back, deletes original, updates history
- Progress and result snackbar feedback during conversion
- Available in Downloaded Album, Local Album, and Queue tab screens
- **Native `shareMultipleContentUris`**: New Android `ACTION_SEND_MULTIPLE` handler in `MainActivity` for sharing multiple SAF content URIs
- **Localization**: Added selection share/convert strings to all 13 supported locales (`selectionShareCount`, `selectionShareNoFiles`, `selectionConvertCount`, `selectionConvertNoConvertible`, `selectionBatchConvertConfirmTitle`, `selectionBatchConvertConfirmMessage`, `selectionBatchConvertProgress`, `selectionBatchConvertSuccess`)
- **Localization**: Added library collection l10n keys (`trackOptionAddToLoved`, `trackOptionRemoveFromLoved`, `trackOptionAddToWishlist`, `trackOptionRemoveFromWishlist`, `libraryTracksUnit`, `collectionPlaylistChangeCover`, `collectionPlaylistRemoveCover`)
- **Global Network Compatibility Mode**: New Download settings toggle to help restricted/ISP-filtered networks
- Applies to backend API requests (not SongLink-only)
- Enables HTTP scheme fallback and optional insecure TLS behavior in one switch
- Synced end-to-end across Flutter settings, platform channel (Android/iOS), and Go backend
### Changed
- **Removed "My Folders" Section**: Horizontal card section removed from Library tab header; collections are now inline items in the unified main list/grid
- **Playlist Subtitle Simplified**: Playlist items now show "N tracks" instead of "Playlist • N tracks"
- **Pinned App Bar on All Detail Screens**: `SliverAppBar` changed from `pinned: false` to `pinned: true` in 6 detail screens (album, downloaded album, local album, playlist, track metadata, library tracks folder) so the app bar stays visible when scrolling
- **Local Album Multi-select Action Updated**: Replaced batch `Share` action with batch `Re-enrich`
- Local album selection bar now uses `Re-enrich` + `Convert` actions
- Added batch re-enrich processing for local tracks (FLAC native path and MP3/Opus FFmpeg path, including SAF write-back flow)
- After batch re-enrich completes, local library is refreshed via incremental scan so updated metadata appears in UI immediately
- **Queue Multi-select Local Action Updated**: Queue selection bar now switches the first action to `Re-enrich` when selected items are local-only
- If selection contains downloaded or mixed items, action remains `Share`
- Local-only selection now supports batch re-enrich with the same native/FFmpeg + SAF flow and auto-refreshes local library metadata after completion
- **SongLink Network Option Scope Expanded**: The previous SongLink compatibility path now routes through global network compatibility controls so all supported backend API clients can benefit under problematic networks
- **Removed Per-Track Action Buttons**: Album, playlist, home, artist, and search screens no longer show individual download/add buttons on each track tile; all actions accessed via `TrackCollectionQuickActions` bottom sheet
- **Loved SliverAppBar Always Shows Heart Icon**: Loved tracks folder always displays the heart icon as cover, never uses first track's cover art (like Spotify's Liked Songs)
- **Wishlist Long-Press Menu Conditional Actions**: "Add to Playlist" option only appears when the track is already downloaded
- **Loved Track Tap Disabled**: Tapping a track in the Loved folder performs no action (long-press for options only)
- **Removed Duplicate Create Playlist Button**: Removed `+` IconButton from playlist list screen AppBar since the FAB already serves the same purpose
- **`coverImagePath` Field on `UserPlaylistCollection`**: Model now supports nullable custom cover path with `copyWith` using `String? Function()?` pattern for explicit null assignment
### Fixed
- **Local Cover Path Handling**: All cover image renderers (Library tab, playlist detail screen hero cover, per-track tiles, options bottom sheet) now detect whether `coverUrl` is a URL or local file path and use `Image.file` for local paths instead of `CachedNetworkImage`
- **Empty Playlists Now Clickable**: Empty playlist items in Library tab can now be tapped to navigate to their detail screen
- **RenderFlex Overflow**: Fixed overflow in unified library item Row layout when track metadata text was too long
- **SAF FD Permission Denied on Tidal Downloads**: Fixed `failed to create file: open /proc/self/fd/*: permission denied` on some devices/providers
- Android SAF bridge now hands off detached raw FD (`output_fd`) to Go instead of forcing procfs path reopen
- Go output writer includes safer procfs fallback behavior for providers that reject truncate semantics
- **Batch Convert Lyrics Embedding Gap**: Batch convert in Downloaded Album, Local Album, and Queue now preserves/adds lyrics consistently like single convert
- Reuses embedded lyrics when available
- Falls back to sidecar `.lrc` when present
- Falls back to online lyrics fetch and injects into conversion metadata when embedding is enabled
---
## [3.6.9] - 2026-02-17
### Added
+1 -6
View File
@@ -94,12 +94,7 @@ The software is provided "as is", without warranty of any kind. The author assum
## API Credits
- **Tidal**: [hifi-api](https://github.com/binimum/hifi-api), [music.binimum.org](https://music.binimum.org), [qqdl.site](https://qqdl.site), [squid.wtf](https://squid.wtf), [spotisaver.net](https://spotisaver.net)
- **Qobuz**: [dabmusic.xyz](https://dabmusic.xyz), [squid.wtf](https://squid.wtf), [jumo-dl](https://jumo-dl.pages.dev)
- **Amazon**: [AfkarXYZ](https://github.com/afkarxyz)
- **Lyrics**: [LRCLib](https://lrclib.net), [Paxsenix](https://lyrics.paxsenix.org) (Apple Music/QQ Music lyrics proxy)
- **YouTube Audio**: [Cobalt](https://cobalt.tools) via [qwkuns.me](https://qwkuns.me), [SpotubeDL](https://spotubedl.com)
- **Track Linking**: [SongLink / Odesli](https://odesli.co), [IDHS](https://github.com/sjdonado/idonthavespotify)
[hifi-api](https://github.com/binimum/hifi-api) · [music.binimum.org](https://music.binimum.org) · [qqdl.site](https://qqdl.site) · [squid.wtf](https://squid.wtf) · [spotisaver.net](https://spotisaver.net) · [dabmusic.xyz](https://dabmusic.xyz) · [AfkarXYZ](https://github.com/afkarxyz) · [LRCLib](https://lrclib.net) · [Paxsenix](https://lyrics.paxsenix.org) · [Cobalt](https://cobalt.tools) · [qwkuns.me](https://qwkuns.me) · [SpotubeDL](https://spotubedl.com) · [Song.link](https://song.link) · [IDHS](https://github.com/sjdonado/idonthavespotify)
> [!TIP]
+20
View File
@@ -12,6 +12,7 @@
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
@@ -21,6 +22,7 @@
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="false"
android:networkSecurityConfig="@xml/network_security_config"
android:enableOnBackInvokedCallback="true"
android:localeConfig="@xml/locale_config">
@@ -92,6 +94,24 @@
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- Audio playback service for media notification / background audio -->
<service
android:name="com.ryanheise.audioservice.AudioService"
android:exported="true"
android:foregroundServiceType="mediaPlayback">
<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">
@@ -4,21 +4,25 @@ import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.ParcelFileDescriptor
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.documentfile.provider.DocumentFile
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.android.FlutterActivityLaunchConfigs.BackgroundMode
import io.flutter.embedding.android.FlutterFragment
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.android.RenderMode
import io.flutter.embedding.android.TransparencyMode
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 gobackend.Gobackend
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
@@ -30,11 +34,23 @@ import java.util.Locale
class MainActivity: FlutterFragmentActivity() {
private val CHANNEL = "com.zarz.spotiflac/backend"
private val DOWNLOAD_PROGRESS_STREAM_CHANNEL =
"com.zarz.spotiflac/download_progress_stream"
private val LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL =
"com.zarz.spotiflac/library_scan_progress_stream"
private val STREAM_POLLING_INTERVAL_MS = 800L
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var pendingSafTreeResult: MethodChannel.Result? = null
private val safScanLock = Any()
private val safDirLock = Any()
private var safScanProgress = SafScanProgress()
private var downloadProgressStreamJob: Job? = null
private var downloadProgressEventSink: EventChannel.EventSink? = null
private var lastDownloadProgressPayload: String? = null
private var libraryScanProgressStreamJob: Job? = null
private var libraryScanProgressEventSink: EventChannel.EventSink? = null
private var lastLibraryScanProgressPayload: String? = null
private var flutterBackCallback: OnBackPressedCallback? = null
@Volatile private var safScanCancel = false
@Volatile private var safScanActive = false
private val safTreeLauncher = registerForActivityResult(
@@ -381,6 +397,78 @@ class MainActivity: FlutterFragmentActivity() {
return obj.toString()
}
private fun readLibraryScanProgressJsonForStream(): String {
return if (safScanActive) {
safProgressToJson()
} else {
Gobackend.getLibraryScanProgressJSON()
}
}
private fun startDownloadProgressStream(sink: EventChannel.EventSink) {
stopDownloadProgressStream()
downloadProgressEventSink = sink
lastDownloadProgressPayload = null
downloadProgressStreamJob = scope.launch {
while (isActive && downloadProgressEventSink === sink) {
try {
val payload = withContext(Dispatchers.IO) {
Gobackend.getAllDownloadProgress()
}
if (payload != lastDownloadProgressPayload) {
lastDownloadProgressPayload = payload
sink.success(payload)
}
} catch (e: Exception) {
android.util.Log.w(
"SpotiFLAC",
"Download progress stream poll failed: ${e.message}",
)
}
delay(STREAM_POLLING_INTERVAL_MS)
}
}
}
private fun stopDownloadProgressStream() {
downloadProgressStreamJob?.cancel()
downloadProgressStreamJob = null
downloadProgressEventSink = null
lastDownloadProgressPayload = null
}
private fun startLibraryScanProgressStream(sink: EventChannel.EventSink) {
stopLibraryScanProgressStream()
libraryScanProgressEventSink = sink
lastLibraryScanProgressPayload = null
libraryScanProgressStreamJob = scope.launch {
while (isActive && libraryScanProgressEventSink === sink) {
try {
val payload = withContext(Dispatchers.IO) {
readLibraryScanProgressJsonForStream()
}
if (payload != lastLibraryScanProgressPayload) {
lastLibraryScanProgressPayload = payload
sink.success(payload)
}
} catch (e: Exception) {
android.util.Log.w(
"SpotiFLAC",
"Library scan progress stream poll failed: ${e.message}",
)
}
delay(STREAM_POLLING_INTERVAL_MS)
}
}
}
private fun stopLibraryScanProgressStream() {
libraryScanProgressStreamJob?.cancel()
libraryScanProgressStreamJob = null
libraryScanProgressEventSink = null
lastLibraryScanProgressPayload = null
}
private fun resolveSafFile(treeUriStr: String, relativeDir: String, fileName: String): String {
val obj = JSONObject()
if (treeUriStr.isBlank() || fileName.isBlank()) {
@@ -666,21 +754,13 @@ class MainActivity: FlutterFragmentActivity() {
val pfd = contentResolver.openFileDescriptor(document.uri, "rw")
?: return errorJson("Failed to open SAF file")
var fdHandedOffToGo = false
var detachedFd: Int? = null
try {
// Keep the original PFD open so the document provider receives close signaling.
// Pass a duplicated FD to Go and detach only the duplicate.
val writerPfd = ParcelFileDescriptor.dup(pfd.fileDescriptor)
val detachedFd = writerPfd.detachFd()
try {
writerPfd.close()
} catch (_: Exception) {}
// After detach, ownership is intended for Go. Kotlin must never close this FD,
// otherwise Android fdsan may abort on double-close during cancellation races.
fdHandedOffToGo = true
req.put("output_path", "/proc/self/fd/$detachedFd")
// Prefer handing off a detached FD directly to Go.
// Some devices/providers reject re-opening /proc/self/fd/* with permission denied.
detachedFd = pfd.detachFd()
req.put("output_path", "")
req.put("output_fd", detachedFd)
req.put("output_ext", outputExt)
val response = downloader(req.toString())
@@ -696,12 +776,13 @@ class MainActivity: FlutterFragmentActivity() {
document.delete()
return errorJson("SAF download failed: ${e.message}")
} finally {
if (!fdHandedOffToGo) {
android.util.Log.w("SpotiFLAC", "SAF writer FD was not handed off to Go")
// If detachFd() failed before handoff, close original ParcelFileDescriptor.
// Otherwise Go owns the detached raw FD and is responsible for closing it.
if (detachedFd == null) {
try {
pfd.close()
} catch (_: Exception) {}
}
try {
pfd.close()
} catch (_: Exception) {}
}
}
@@ -1260,19 +1341,92 @@ class MainActivity: FlutterFragmentActivity() {
return respObj.toString()
}
// Disable Flutter's built-in deep linking so that incoming ACTION_VIEW URLs
// (Spotify, Deezer, Tidal, YouTube Music) are NOT forwarded to GoRouter.
// We handle these URLs ourselves via receive_sharing_intent + ShareIntentService.
override fun shouldHandleDeeplinking(): Boolean = false
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Update the intent so receive_sharing_intent can access the new data
setIntent(intent)
}
override fun onDestroy() {
try {
Gobackend.cleanupExtensions()
} catch (e: Exception) {
android.util.Log.w("SpotiFLAC", "Failed to cleanup extensions on destroy: ${e.message}")
}
stopDownloadProgressStream()
stopLibraryScanProgressStream()
super.onDestroy()
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
// Always-enabled back callback to ensure back presses reach Flutter.
// Nested tab navigators can incorrectly set frameworkHandlesBack(false),
// which disables Flutter's own OnBackPressedCallback and causes the
// system default (finish activity) to run. This callback guarantees
// popRoute is always forwarded to Flutter, where PopScope handles it.
flutterBackCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
flutterEngine.navigationChannel.popRoute()
}
}
onBackPressedDispatcher.addCallback(this, flutterBackCallback!!)
val messenger = flutterEngine.dartExecutor.binaryMessenger
EventChannel(
messenger,
DOWNLOAD_PROGRESS_STREAM_CHANNEL,
).setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
if (events != null) {
startDownloadProgressStream(events)
}
}
override fun onCancel(arguments: Any?) {
stopDownloadProgressStream()
}
},
)
EventChannel(
messenger,
LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL,
).setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
if (events != null) {
startLibraryScanProgressStream(events)
}
}
override fun onCancel(arguments: Any?) {
stopLibraryScanProgressStream()
}
},
)
MethodChannel(messenger, CHANNEL).setMethodCallHandler { call, result ->
scope.launch {
try {
when (call.method) {
"exitApp" -> {
flutterBackCallback?.isEnabled = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
finishAndRemoveTask()
} else {
finish()
}
result.success(null)
}
"parseSpotifyUrl" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
@@ -1304,6 +1458,14 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"getSpotifyRelatedArtists" -> {
val artistId = call.argument<String>("artist_id") ?: ""
val limit = call.argument<Int>("limit") ?: 12
val response = withContext(Dispatchers.IO) {
Gobackend.getSpotifyRelatedArtists(artistId, limit.toLong())
}
result.success(response)
}
"checkAvailability" -> {
val spotifyId = call.argument<String>("spotify_id") ?: ""
val isrc = call.argument<String>("isrc") ?: ""
@@ -1368,6 +1530,14 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
"setNetworkCompatibilityOptions", "setSongLinkNetworkOptions" -> {
val allowHttp = call.argument<Boolean>("allow_http") ?: false
val insecureTls = call.argument<Boolean>("insecure_tls") ?: false
withContext(Dispatchers.IO) {
Gobackend.setNetworkCompatibilityOptions(allowHttp, insecureTls)
}
result.success(null)
}
"checkDuplicate" -> {
val outputDir = call.argument<String>("output_dir") ?: ""
val isrc = call.argument<String>("isrc") ?: ""
@@ -1546,6 +1716,28 @@ class MainActivity: FlutterFragmentActivity() {
result.error("share_failed", e.message, null)
}
}
"shareMultipleContentUris" -> {
val uriStrings = call.argument<List<String>>("uris") ?: emptyList()
val title = call.argument<String>("title") ?: ""
try {
val uris = ArrayList<Uri>(uriStrings.size)
for (s in uriStrings) {
uris.add(Uri.parse(s))
}
val shareIntent = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris)
setType("audio/*")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
if (title.isNotBlank()) {
putExtra(Intent.EXTRA_SUBJECT, title)
}
}
startActivity(Intent.createChooser(shareIntent, title.ifBlank { "Share" }))
result.success(true)
} catch (e: Exception) {
result.error("share_failed", e.message, null)
}
}
"fetchLyrics" -> {
val spotifyId = call.argument<String>("spotify_id") ?: ""
val trackName = call.argument<String>("track_name") ?: ""
@@ -1951,6 +2143,14 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"getDeezerRelatedArtists" -> {
val artistId = call.argument<String>("artist_id") ?: ""
val limit = call.argument<Int>("limit") ?: 12
val response = withContext(Dispatchers.IO) {
Gobackend.getDeezerRelatedArtists(artistId, limit.toLong())
}
result.success(response)
}
"getDeezerMetadata" -> {
val resourceType = call.argument<String>("resource_type") ?: ""
val resourceId = call.argument<String>("resource_id") ?: ""
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<!-- Allow local loopback cleartext for FFmpeg live decrypt tunnel only. -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
</domain-config>
</network-security-config>
+77 -47
View File
@@ -45,7 +45,7 @@ type AfkarXYZResponse struct {
} `json:"data"`
}
// AmazonStreamResponse is the new response format from amazon.afkarxyz.fun/api/track/{asin}
// AmazonStreamResponse is the new response format from amzn.afkarxyz.fun/api/track/{asin}
type AmazonStreamResponse struct {
StreamURL string `json:"streamUrl"`
DecryptionKey string `json:"decryptionKey"`
@@ -179,7 +179,7 @@ func (a *AmazonDownloader) doAfkarXYZRequestNew(asin string) (string, string, st
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
defer cancel()
apiURL := fmt.Sprintf("https://amazon.afkarxyz.fun/api/track/%s", asin)
apiURL := fmt.Sprintf("https://amzn.afkarxyz.fun/api/track/%s", asin)
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != nil {
return "", "", "", fmt.Errorf("failed to create request: %w", err)
@@ -193,13 +193,13 @@ func (a *AmazonDownloader) doAfkarXYZRequestNew(asin string) (string, string, st
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", "", "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode)
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return "", "", "", fmt.Errorf("failed to read response: %w", readErr)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", "", fmt.Errorf("failed to read response: %w", err)
if resp.StatusCode != 200 {
return "", "", "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode)
}
var apiResp AmazonStreamResponse
@@ -219,7 +219,7 @@ func (a *AmazonDownloader) doAfkarXYZRequestLegacy(amazonURL string) (string, st
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
defer cancel()
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
apiURL := "https://amzn.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != nil {
return "", "", "", fmt.Errorf("failed to create legacy request: %w", err)
@@ -375,6 +375,57 @@ type AmazonDownloadResult struct {
DecryptionKey string
}
func resolveAmazonURLForRequest(req DownloadRequest, logPrefix string) (string, error) {
if strings.TrimSpace(logPrefix) == "" {
logPrefix = "Amazon"
}
amazonURL := ""
if req.ISRC != "" {
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.AmazonURL != "" {
amazonURL = cached.AmazonURL
GoLog("[%s] Cache hit! Using cached Amazon URL for ISRC %s\n", logPrefix, req.ISRC)
}
}
if amazonURL != "" {
return amazonURL, nil
}
songlink := NewSongLinkClient()
var availability *TrackAvailability
var err error
deezerID := strings.TrimSpace(req.DeezerID)
if prefixedDeezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found && strings.TrimSpace(prefixedDeezerID) != "" {
deezerID = strings.TrimSpace(prefixedDeezerID)
}
if deezerID != "" {
GoLog("[%s] Using Deezer ID for SongLink lookup: %s\n", logPrefix, deezerID)
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
} else if req.SpotifyID != "" {
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
} else {
return "", fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
}
if err != nil {
return "", fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
}
if availability == nil || !availability.Amazon || availability.AmazonURL == "" {
return "", fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
}
amazonURL = availability.AmazonURL
if req.ISRC != "" {
GetTrackIDCache().SetAmazonURL(req.ISRC, amazonURL)
}
return amazonURL, nil
}
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
downloader := NewAmazonDownloader()
@@ -385,40 +436,9 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
}
}
amazonURL := ""
if req.ISRC != "" {
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.AmazonURL != "" {
amazonURL = cached.AmazonURL
GoLog("[Amazon] Cache hit! Using cached Amazon URL for ISRC %s\n", req.ISRC)
}
}
songlink := NewSongLinkClient()
var availability *TrackAvailability
var err error
if amazonURL == "" {
if deezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found {
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
} else if req.SpotifyID != "" {
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
} else {
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
}
if err != nil {
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
}
if !availability.Amazon || availability.AmazonURL == "" {
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
}
amazonURL = availability.AmazonURL
if req.ISRC != "" {
GetTrackIDCache().SetAmazonURL(req.ISRC, amazonURL)
}
amazonURL, err := resolveAmazonURLForRequest(req, "Amazon")
if err != nil {
return AmazonDownloadResult{}, err
}
if !isSafOutput && req.OutputDir != "." {
@@ -467,13 +487,19 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
parallelDone := make(chan struct{})
go func() {
defer close(parallelDone)
coverURL := req.CoverURL
embedLyrics := req.EmbedLyrics
if !req.EmbedMetadata {
coverURL = ""
embedLyrics = false
}
parallelResult = FetchCoverAndLyricsParallel(
req.CoverURL,
coverURL,
req.EmbedMaxQualityCover,
req.SpotifyID,
req.TrackName,
req.ArtistName,
req.EmbedLyrics,
embedLyrics,
int64(req.DurationMS),
)
}()
@@ -560,8 +586,12 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
}
}
if isSafOutput || needsDecryption {
GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
if isSafOutput || needsDecryption || !req.EmbedMetadata {
if !req.EmbedMetadata {
GoLog("[Amazon] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n")
} else {
GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
}
} else {
isFlacOutput := strings.HasSuffix(strings.ToLower(actualOutputPath), ".flac")
if isFlacOutput {
@@ -641,7 +671,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
}
lyricsLRC := ""
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsLRC = parallelResult.LyricsLRC
}
+47 -16
View File
@@ -5,6 +5,7 @@ import (
"encoding/binary"
"fmt"
"io"
"math"
"os"
"path/filepath"
"strconv"
@@ -1127,17 +1128,33 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
// Opus always uses 48kHz granule position internally
totalSamples := granule - int64(preSkip)
if totalSamples > 0 {
quality.Duration = int(totalSamples / 48000)
durationSec := float64(totalSamples) / 48000.0
if durationSec > 0 {
quality.Duration = int(math.Round(durationSec))
quality.Bitrate = int(float64(fileSize*8) / durationSec)
}
}
} else if quality.SampleRate > 0 {
quality.Duration = int(granule / int64(quality.SampleRate))
durationSec := float64(granule) / float64(quality.SampleRate)
if durationSec > 0 {
quality.Duration = int(math.Round(durationSec))
quality.Bitrate = int(float64(fileSize*8) / durationSec)
}
}
}
// Calculate average bitrate from file size and actual duration
if quality.Duration > 0 {
// Fallback bitrate estimate if duration exists but bitrate couldn't be derived.
if quality.Bitrate <= 0 && quality.Duration > 0 {
quality.Bitrate = int(fileSize * 8 / int64(quality.Duration))
}
// Guard against obviously invalid values from corrupted/unreliable granule reads.
if quality.Duration > 24*60*60 {
quality.Duration = 0
quality.Bitrate = 0
}
if quality.Bitrate > 0 && quality.Bitrate < 8000 {
quality.Bitrate = 0
}
return quality, nil
}
@@ -1162,21 +1179,35 @@ func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
}
buf = buf[:n]
// Scan backwards for "OggS" magic
lastPageOffset := -1
for i := n - 4; i >= 0; i-- {
if buf[i] == 'O' && buf[i+1] == 'g' && buf[i+2] == 'g' && buf[i+3] == 'S' {
lastPageOffset = i
break
if buf[i] != 'O' || buf[i+1] != 'g' || buf[i+2] != 'g' || buf[i+3] != 'S' {
continue
}
if i+27 > n {
continue
}
// Validate minimal header fields to avoid false positives inside payload bytes.
version := buf[i+4]
headerType := buf[i+5]
if version != 0 || headerType > 0x07 {
continue
}
segmentCount := int(buf[i+26])
headerLen := 27 + segmentCount
if i+headerLen > n {
continue
}
payloadLen := 0
for s := 0; s < segmentCount; s++ {
payloadLen += int(buf[i+27+s])
}
if i+headerLen+payloadLen > n {
continue
}
// Granule position is at bytes 6-13 of the Ogg page header (little-endian int64).
return int64(binary.LittleEndian.Uint64(buf[i+6 : i+14]))
}
if lastPageOffset < 0 || lastPageOffset+14 > n {
return 0
}
// Granule position is at bytes 6-13 of the Ogg page header (little-endian int64)
return int64(binary.LittleEndian.Uint64(buf[lastPageOffset+6 : lastPageOffset+14]))
return 0
}
// =============================================================================
+69 -6
View File
@@ -13,12 +13,13 @@ import (
)
const (
deezerBaseURL = "https://api.deezer.com/2.0"
deezerSearchURL = deezerBaseURL + "/search"
deezerTrackURL = deezerBaseURL + "/track/%s"
deezerAlbumURL = deezerBaseURL + "/album/%s"
deezerArtistURL = deezerBaseURL + "/artist/%s"
deezerPlaylistURL = deezerBaseURL + "/playlist/%s"
deezerBaseURL = "https://api.deezer.com/2.0"
deezerSearchURL = deezerBaseURL + "/search"
deezerTrackURL = deezerBaseURL + "/track/%s"
deezerAlbumURL = deezerBaseURL + "/album/%s"
deezerArtistURL = deezerBaseURL + "/artist/%s"
deezerArtistRelatedURL = deezerBaseURL + "/artist/%s/related"
deezerPlaylistURL = deezerBaseURL + "/playlist/%s"
deezerCacheTTL = 10 * time.Minute
@@ -234,6 +235,8 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
DiscNumber: track.DiskNumber,
ExternalURL: track.Link,
ISRC: track.ISRC,
AlbumID: fmt.Sprintf("deezer:%d", track.Album.ID),
ArtistID: fmt.Sprintf("deezer:%d", track.Artist.ID),
}
}
@@ -756,6 +759,66 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
return result, nil
}
func (c *DeezerClient) GetRelatedArtists(ctx context.Context, artistID string, limit int) ([]SearchArtistResult, error) {
normalizedArtistID := strings.TrimSpace(strings.TrimPrefix(artistID, "deezer:"))
if normalizedArtistID == "" {
return nil, fmt.Errorf("invalid Deezer artist ID")
}
effectiveLimit := limit
if effectiveLimit <= 0 {
effectiveLimit = 12
}
relatedURL := fmt.Sprintf("%s?limit=%d", fmt.Sprintf(deezerArtistRelatedURL, normalizedArtistID), effectiveLimit)
var relatedResp struct {
Data []struct {
ID int64 `json:"id"`
Name string `json:"name"`
Picture string `json:"picture"`
PictureMedium string `json:"picture_medium"`
PictureBig string `json:"picture_big"`
PictureXL string `json:"picture_xl"`
NbFan int `json:"nb_fan"`
} `json:"data"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
Code int `json:"code"`
} `json:"error,omitempty"`
}
if err := c.getJSON(ctx, relatedURL, &relatedResp); err != nil {
return nil, err
}
if relatedResp.Error != nil {
return nil, fmt.Errorf("deezer related artists error: %s", relatedResp.Error.Message)
}
result := make([]SearchArtistResult, 0, len(relatedResp.Data))
for _, artist := range relatedResp.Data {
imageURL := artist.PictureXL
if imageURL == "" {
imageURL = artist.PictureBig
}
if imageURL == "" {
imageURL = artist.PictureMedium
}
if imageURL == "" {
imageURL = artist.Picture
}
result = append(result, SearchArtistResult{
ID: fmt.Sprintf("deezer:%d", artist.ID),
Name: artist.Name,
Images: imageURL,
Followers: artist.NbFan,
Popularity: 0,
})
}
return result, nil
}
func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) {
playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID)
+352
View File
@@ -0,0 +1,352 @@
package gobackend
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
)
const deezerYoinkifyURL = "https://yoinkify.lol/api/download"
type YoinkifyRequest struct {
URL string `json:"url"`
Format string `json:"format"`
GenreSource string `json:"genreSource"`
}
type DeezerDownloadResult struct {
FilePath string
BitDepth int
SampleRate int
Title string
Artist string
Album string
ReleaseDate string
TrackNumber int
DiscNumber int
ISRC string
LyricsLRC string
}
func resolveSpotifyURLForYoinkify(req DownloadRequest) (string, error) {
rawSpotify := strings.TrimSpace(req.SpotifyID)
if rawSpotify != "" {
if isLikelySpotifyTrackID(rawSpotify) {
return fmt.Sprintf("https://open.spotify.com/track/%s", rawSpotify), nil
}
if parsed, err := parseSpotifyURI(rawSpotify); err == nil && parsed.Type == "track" && parsed.ID != "" {
return fmt.Sprintf("https://open.spotify.com/track/%s", parsed.ID), nil
}
}
deezerID := strings.TrimSpace(req.DeezerID)
if deezerID == "" {
if prefixed, found := strings.CutPrefix(rawSpotify, "deezer:"); found {
deezerID = strings.TrimSpace(prefixed)
}
}
if deezerID != "" {
songlink := NewSongLinkClient()
spotifyID, err := songlink.GetSpotifyIDFromDeezer(deezerID)
if err != nil {
return "", fmt.Errorf("failed to map deezer:%s to Spotify ID: %w", deezerID, err)
}
spotifyID = strings.TrimSpace(spotifyID)
if spotifyID == "" {
return "", fmt.Errorf("SongLink returned empty Spotify ID for deezer:%s", deezerID)
}
return fmt.Sprintf("https://open.spotify.com/track/%s", spotifyID), nil
}
return "", fmt.Errorf("missing Spotify track ID for Deezer Yoinkify")
}
func isLikelySpotifyTrackID(value string) bool {
if len(value) != 22 {
return false
}
for _, r := range value {
switch {
case r >= 'A' && r <= 'Z':
case r >= 'a' && r <= 'z':
case r >= '0' && r <= '9':
default:
return false
}
}
return true
}
func (c *DeezerClient) DownloadFromYoinkify(spotifyURL, outputPath string, outputFD int, itemID string) error {
payload := YoinkifyRequest{
URL: spotifyURL,
Format: "flac",
GenreSource: "spotify",
}
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to encode Yoinkify request: %w", err)
}
ctx := context.Background()
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
ctx = initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
}
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, deezerYoinkifyURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create Yoinkify request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "*/*")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("failed to call Yoinkify: %w", err)
}
defer resp.Body.Close()
contentType := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Type")))
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
bodyText := strings.TrimSpace(string(bodyBytes))
if bodyText != "" {
return fmt.Errorf("Yoinkify returned status %d: %s", resp.StatusCode, bodyText)
}
return fmt.Errorf("Yoinkify returned status %d", resp.StatusCode)
}
if strings.Contains(contentType, "application/json") {
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
bodyText := strings.TrimSpace(string(bodyBytes))
if bodyText == "" {
bodyText = "empty JSON payload"
}
return fmt.Errorf("Yoinkify returned JSON instead of audio: %s", bodyText)
}
expectedSize := resp.ContentLength
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
out, err := openOutputForWrite(outputPath, outputFD)
if err != nil {
return err
}
bufWriter := bufio.NewWriterSize(out, 256*1024)
var written int64
if itemID != "" {
pw := NewItemProgressWriter(bufWriter, itemID)
written, err = io.Copy(pw, resp.Body)
} else {
written, err = io.Copy(bufWriter, resp.Body)
}
flushErr := bufWriter.Flush()
closeErr := out.Close()
if err != nil {
cleanupOutputOnError(outputPath, outputFD)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to flush output: %w", flushErr)
}
if closeErr != nil {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to close output: %w", closeErr)
}
if expectedSize > 0 && written != expectedSize {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
}
GoLog("[Deezer] Downloaded via Yoinkify: %.2f MB\n", float64(written)/(1024*1024))
return nil
}
func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
deezerClient := GetDeezerClient()
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
if !isSafOutput {
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return DeezerDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
}
spotifyURL, err := resolveSpotifyURLForYoinkify(req)
if err != nil {
return DeezerDownloadResult{}, err
}
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName,
"artist": req.ArtistName,
"album": req.AlbumName,
"track": req.TrackNumber,
"year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate,
"disc": req.DiscNumber,
})
var outputPath string
if isSafOutput {
outputPath = strings.TrimSpace(req.OutputPath)
if outputPath == "" && isFDOutput(req.OutputFD) {
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
}
} else {
filename = sanitizeFilename(filename) + ".flac"
outputPath = filepath.Join(req.OutputDir, filename)
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return DeezerDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
}
var parallelResult *ParallelDownloadResult
parallelDone := make(chan struct{})
go func() {
defer close(parallelDone)
coverURL := req.CoverURL
embedLyrics := req.EmbedLyrics
if !req.EmbedMetadata {
coverURL = ""
embedLyrics = false
}
parallelResult = FetchCoverAndLyricsParallel(
coverURL,
req.EmbedMaxQualityCover,
req.SpotifyID,
req.TrackName,
req.ArtistName,
embedLyrics,
int64(req.DurationMS),
)
}()
if err := deezerClient.DownloadFromYoinkify(spotifyURL, outputPath, req.OutputFD, req.ItemID); err != nil {
if errors.Is(err, ErrDownloadCancelled) {
return DeezerDownloadResult{}, ErrDownloadCancelled
}
return DeezerDownloadResult{}, fmt.Errorf("deezer yoinkify failed: %w", err)
}
<-parallelDone
if req.ItemID != "" {
SetItemProgress(req.ItemID, 1.0, 0, 0)
SetItemFinalizing(req.ItemID)
}
metadata := Metadata{
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
AlbumArtist: req.AlbumArtist,
Date: req.ReleaseDate,
TrackNumber: req.TrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber,
ISRC: req.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
}
var coverData []byte
if parallelResult != nil && parallelResult.CoverData != nil {
coverData = parallelResult.CoverData
}
if isSafOutput || !req.EmbedMetadata {
if !req.EmbedMetadata {
GoLog("[Deezer] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n")
} else {
GoLog("[Deezer] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
}
} else {
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
GoLog("[Deezer] Warning: failed to embed metadata: %v\n", err)
}
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsMode := req.LyricsMode
if lyricsMode == "" {
lyricsMode = "embed"
}
if lyricsMode == "external" || lyricsMode == "both" {
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Deezer] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Deezer] LRC file saved: %s\n", lrcPath)
}
}
if lyricsMode == "embed" || lyricsMode == "both" {
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Deezer] Warning: failed to embed lyrics: %v\n", embedErr)
}
}
}
}
if !isSafOutput {
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
}
bitDepth, sampleRate := 0, 0
if quality, qErr := GetAudioQuality(outputPath); qErr == nil {
bitDepth = quality.BitDepth
sampleRate = quality.SampleRate
}
lyricsLRC := ""
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsLRC = parallelResult.LyricsLRC
}
return DeezerDownloadResult{
FilePath: outputPath,
BitDepth: bitDepth,
SampleRate: sampleRate,
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
ReleaseDate: req.ReleaseDate,
TrackNumber: req.TrackNumber,
DiscNumber: req.DiscNumber,
ISRC: req.ISRC,
LyricsLRC: lyricsLRC,
}, nil
}
+123 -28
View File
@@ -1,5 +1,3 @@
// Package gobackend provides exported functions for gomobile binding
// These functions are the bridge between Flutter and Go backend
package gobackend
import (
@@ -125,6 +123,35 @@ func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error)
return string(jsonBytes), nil
}
func GetSpotifyRelatedArtists(artistID string, limit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
client, err := NewSpotifyMetadataClient()
if err != nil {
return "", err
}
normalizedArtistID := strings.TrimSpace(strings.TrimPrefix(artistID, "spotify:"))
if normalizedArtistID == "" {
return "", fmt.Errorf("invalid Spotify artist ID")
}
artists, err := client.GetRelatedArtists(ctx, normalizedArtistID, limit)
if err != nil {
return "", err
}
resp := map[string]interface{}{
"artists": artists,
}
jsonBytes, err := json.Marshal(resp)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func CheckAvailability(spotifyID, isrc string) (string, error) {
client := NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
@@ -140,6 +167,12 @@ func CheckAvailability(spotifyID, isrc string) (string, error) {
return string(jsonBytes), nil
}
// SetSongLinkNetworkOptions is kept for backward compatibility.
// It now applies global network compatibility options for all backend API requests.
func SetSongLinkNetworkOptions(allowHTTP, insecureTLS bool) {
SetNetworkCompatibilityOptions(allowHTTP, insecureTLS)
}
type DownloadRequest struct {
ISRC string `json:"isrc"`
Service string `json:"service"`
@@ -155,6 +188,7 @@ type DownloadRequest struct {
OutputExt string `json:"output_ext,omitempty"`
FilenameFormat string `json:"filename_format"`
Quality string `json:"quality"`
EmbedMetadata bool `json:"embed_metadata"`
EmbedLyrics bool `json:"embed_lyrics"`
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
TrackNumber int `json:"track_number"`
@@ -173,6 +207,7 @@ type DownloadRequest struct {
LyricsMode string `json:"lyrics_mode,omitempty"`
UseExtensions bool `json:"use_extensions,omitempty"`
UseFallback bool `json:"use_fallback,omitempty"`
SongLinkRegion string `json:"songlink_region,omitempty"`
}
type DownloadResponse struct {
@@ -374,11 +409,20 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) {
}
}
func applySongLinkRegionFromRequest(req *DownloadRequest) {
if req == nil {
return
}
SetSongLinkRegion(req.SongLinkRegion)
}
func DownloadTrack(requestJSON string) (string, error) {
var req DownloadRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return errorResponse("Invalid request: " + err.Error())
}
applySongLinkRegionFromRequest(&req)
defer closeOwnedOutputFD(req.OutputFD)
req.TrackName = strings.TrimSpace(req.TrackName)
req.ArtistName = strings.TrimSpace(req.ArtistName)
@@ -453,13 +497,31 @@ func DownloadTrack(requestJSON string) (string, error) {
}
}
err = amazonErr
case "deezer":
deezerResult, deezerErr := downloadFromDeezer(req)
if deezerErr == nil {
result = DownloadResult{
FilePath: deezerResult.FilePath,
BitDepth: deezerResult.BitDepth,
SampleRate: deezerResult.SampleRate,
Title: deezerResult.Title,
Artist: deezerResult.Artist,
Album: deezerResult.Album,
ReleaseDate: deezerResult.ReleaseDate,
TrackNumber: deezerResult.TrackNumber,
DiscNumber: deezerResult.DiscNumber,
ISRC: deezerResult.ISRC,
LyricsLRC: deezerResult.LyricsLRC,
}
}
err = deezerErr
case "youtube":
youtubeResult, youtubeErr := downloadFromYouTube(req)
if youtubeErr == nil {
result = DownloadResult{
FilePath: youtubeResult.FilePath,
BitDepth: 0, // Lossy format, no bit depth
SampleRate: 0, // Lossy format
BitDepth: 0,
SampleRate: 0,
Title: youtubeResult.Title,
Artist: youtubeResult.Artist,
Album: youtubeResult.Album,
@@ -537,6 +599,11 @@ func DownloadByStrategy(requestJSON string) (string, error) {
}
if req.UseExtensions {
// Respect strict mode when auto fallback is disabled:
// for built-in providers, route directly to selected service only.
if !req.UseFallback && isBuiltInProvider(serviceNormalized) {
return DownloadTrack(normalizedJSON)
}
resp, err := DownloadWithExtensionsJSON(normalizedJSON)
if err != nil {
return errorResponse(err.Error())
@@ -556,6 +623,8 @@ func DownloadWithFallback(requestJSON string) (string, error) {
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return errorResponse("Invalid request: " + err.Error())
}
applySongLinkRegionFromRequest(&req)
defer closeOwnedOutputFD(req.OutputFD)
req.TrackName = strings.TrimSpace(req.TrackName)
req.ArtistName = strings.TrimSpace(req.ArtistName)
@@ -571,7 +640,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
enrichRequestExtendedMetadata(&req)
allServices := []string{"tidal", "qobuz", "amazon"}
allServices := []string{"tidal", "qobuz", "amazon", "deezer"}
preferredService := req.Service
if preferredService == "" {
preferredService = "tidal"
@@ -659,6 +728,26 @@ func DownloadWithFallback(requestJSON string) (string, error) {
GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
}
err = amazonErr
case "deezer":
deezerResult, deezerErr := downloadFromDeezer(req)
if deezerErr == nil {
result = DownloadResult{
FilePath: deezerResult.FilePath,
BitDepth: deezerResult.BitDepth,
SampleRate: deezerResult.SampleRate,
Title: deezerResult.Title,
Artist: deezerResult.Artist,
Album: deezerResult.Album,
ReleaseDate: deezerResult.ReleaseDate,
TrackNumber: deezerResult.TrackNumber,
DiscNumber: deezerResult.DiscNumber,
ISRC: deezerResult.ISRC,
LyricsLRC: deezerResult.LyricsLRC,
}
} else if !errors.Is(deezerErr, ErrDownloadCancelled) {
GoLog("[DownloadWithFallback] Deezer error: %v\n", deezerErr)
}
err = deezerErr
}
if err != nil && errors.Is(err, ErrDownloadCancelled) {
@@ -910,7 +999,6 @@ func SetDownloadDirectory(path string) error {
return setDownloadDir(path)
}
// AllowDownloadDir adds a directory to the extension file sandbox allowlist.
func AllowDownloadDir(path string) {
if strings.TrimSpace(path) == "" {
return
@@ -1142,6 +1230,26 @@ func SearchDeezerAll(query string, trackLimit, artistLimit int, filter string) (
return string(jsonBytes), nil
}
func GetDeezerRelatedArtists(artistID string, limit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
client := GetDeezerClient()
artists, err := client.GetRelatedArtists(ctx, artistID, limit)
if err != nil {
return "", err
}
resp := map[string]interface{}{
"artists": artists,
}
jsonBytes, err := json.Marshal(resp)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -1518,16 +1626,13 @@ func errorResponse(msg string) (string, error) {
return string(jsonBytes), nil
}
// ==================== YOUTUBE PROVIDER (LOSSY ONLY) ====================
// DownloadFromYouTube downloads a track from YouTube via Cobalt API
// This is a lossy-only provider (Opus/MP3 with configurable bitrate)
// It does NOT participate in the lossless fallback chain
func DownloadFromYouTube(requestJSON string) (string, error) {
var req DownloadRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return errorResponse("Invalid request: " + err.Error())
}
applySongLinkRegionFromRequest(&req)
defer closeOwnedOutputFD(req.OutputFD)
req.TrackName = strings.TrimSpace(req.TrackName)
req.ArtistName = strings.TrimSpace(req.ArtistName)
@@ -1569,20 +1674,14 @@ func DownloadFromYouTube(requestJSON string) (string, error) {
return string(jsonBytes), nil
}
// IsYouTubeURLExport checks if a URL is a YouTube URL (exported for Flutter)
func IsYouTubeURLExport(urlStr string) bool {
return IsYouTubeURL(urlStr)
}
// ExtractYouTubeVideoIDExport extracts video ID from YouTube URL (exported for Flutter)
func ExtractYouTubeVideoIDExport(urlStr string) (string, error) {
return ExtractYouTubeVideoID(urlStr)
}
// ==================== COVER & LYRICS SAVE ====================
// DownloadCoverToFile downloads cover art from URL and saves to outputPath.
// If maxQuality is true, upgrades to highest available resolution.
func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error {
if coverURL == "" {
return fmt.Errorf("no cover URL provided")
@@ -1601,7 +1700,6 @@ func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) er
return nil
}
// ExtractCoverToFile extracts embedded cover art from audio file and saves to outputPath.
func ExtractCoverToFile(audioPath string, outputPath string) error {
lower := strings.ToLower(audioPath)
@@ -1630,7 +1728,6 @@ func ExtractCoverToFile(audioPath string, outputPath string) error {
return nil
}
// FetchAndSaveLyrics fetches lyrics from lrclib and saves as .lrc file.
func FetchAndSaveLyrics(trackName, artistName, spotifyID string, durationMs int64, outputPath string) error {
client := NewLyricsClient()
durationSec := float64(durationMs) / 1000.0
@@ -1657,9 +1754,6 @@ func FetchAndSaveLyrics(trackName, artistName, spotifyID string, durationMs int6
return nil
}
// ==================== LYRICS PROVIDER SETTINGS ====================
// SetLyricsProvidersJSON sets the lyrics provider order from a JSON array of provider IDs.
func SetLyricsProvidersJSON(providersJSON string) error {
var providers []string
if err := json.Unmarshal([]byte(providersJSON), &providers); err != nil {
@@ -1670,7 +1764,6 @@ func SetLyricsProvidersJSON(providersJSON string) error {
return nil
}
// GetLyricsProvidersJSON returns the current lyrics provider order as JSON.
func GetLyricsProvidersJSON() (string, error) {
providers := GetLyricsProviderOrder()
jsonBytes, err := json.Marshal(providers)
@@ -1680,7 +1773,6 @@ func GetLyricsProvidersJSON() (string, error) {
return string(jsonBytes), nil
}
// GetAvailableLyricsProvidersJSON returns metadata about all available lyrics providers.
func GetAvailableLyricsProvidersJSON() (string, error) {
providers := GetAvailableLyricsProviders()
jsonBytes, err := json.Marshal(providers)
@@ -1690,7 +1782,6 @@ func GetAvailableLyricsProvidersJSON() (string, error) {
return string(jsonBytes), nil
}
// SetLyricsFetchOptionsJSON sets lyrics provider fetch options.
func SetLyricsFetchOptionsJSON(optionsJSON string) error {
opts := GetLyricsFetchOptions()
if strings.TrimSpace(optionsJSON) != "" {
@@ -1703,7 +1794,6 @@ func SetLyricsFetchOptionsJSON(optionsJSON string) error {
return nil
}
// GetLyricsFetchOptionsJSON returns current lyrics provider fetch options.
func GetLyricsFetchOptionsJSON() (string, error) {
opts := GetLyricsFetchOptions()
jsonBytes, err := json.Marshal(opts)
@@ -2260,6 +2350,8 @@ func DownloadWithExtensionsJSON(requestJSON string) (string, error) {
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return "", fmt.Errorf("invalid request: %w", err)
}
applySongLinkRegionFromRequest(&req)
defer closeOwnedOutputFD(req.OutputFD)
req.TrackName = strings.TrimSpace(req.TrackName)
req.ArtistName = strings.TrimSpace(req.ArtistName)
@@ -3141,7 +3233,10 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du
return "", fmt.Errorf("extension '%s' is disabled", extensionID)
}
provider := NewExtensionProviderWrapper(ext)
// Goja runtime is not thread-safe; guard direct extension.*() calls with VMMu
// to avoid races with other provider calls (e.g. getAlbum/getPlaylist).
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
script := fmt.Sprintf(`
(function() {
@@ -3152,7 +3247,7 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du
})()
`, functionName, functionName)
result, err := RunWithTimeoutAndRecover(provider.vm, script, timeout)
result, err := RunWithTimeoutAndRecover(ext.VM, script, timeout)
if err != nil {
return "", fmt.Errorf("%s failed: %w", functionName, err)
}
+14 -7
View File
@@ -48,11 +48,12 @@ type LoadedExtension struct {
Manifest *ExtensionManifest `json:"manifest"`
VM *goja.Runtime `json:"-"`
VMMu sync.Mutex `json:"-"`
Enabled bool `json:"enabled"`
Error string `json:"error,omitempty"`
DataDir string `json:"data_dir"`
SourceDir string `json:"source_dir"`
IconPath string `json:"icon_path"`
runtime *ExtensionRuntime
Enabled bool `json:"enabled"`
Error string `json:"error,omitempty"`
DataDir string `json:"data_dir"`
SourceDir string `json:"source_dir"`
IconPath string `json:"icon_path"`
}
type ExtensionManager struct {
@@ -243,6 +244,7 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
}
runtime := NewExtensionRuntime(ext)
ext.runtime = runtime
runtime.RegisterAPIs(vm)
runtime.RegisterGoBackendAPIs(vm)
@@ -295,6 +297,13 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
GoLog("[Extension] Cleanup called for %s\n", extensionID)
}
}
if ext.runtime != nil {
if err := ext.runtime.flushStorageNow(); err != nil {
GoLog("[Extension] Failed to flush storage for %s: %v\n", extensionID, err)
}
ext.runtime.closeStorageFlusher()
ext.runtime = nil
}
delete(m.extensions, extensionID)
GoLog("[Extension] Unloaded extension: %s\n", extensionID)
@@ -536,7 +545,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
extDir := existing.SourceDir
wasEnabled := existing.Enabled
m.CleanupExtension(existing.ID)
m.UnloadExtension(existing.ID)
if extDir != "" {
@@ -909,7 +917,6 @@ func (m *ExtensionManager) UnloadAllExtensions() {
m.mu.Unlock()
for _, id := range extensionIDs {
m.CleanupExtension(id)
m.UnloadExtension(id)
}
+85 -18
View File
@@ -1,4 +1,3 @@
// Package gobackend provides extension provider interfaces
package gobackend
import (
@@ -15,9 +14,6 @@ import (
"github.com/dop251/goja"
)
// ==================== Metadata Types ====================
// ExtTrackMetadata represents track metadata from an extension
type ExtTrackMetadata struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -635,7 +631,7 @@ func GetProviderPriority() []string {
defer providerPriorityMu.RUnlock()
if len(providerPriority) == 0 {
return []string{"tidal", "qobuz", "amazon"}
return []string{"tidal", "qobuz", "amazon", "deezer"}
}
result := make([]string, len(providerPriority))
@@ -675,8 +671,20 @@ func isBuiltInProvider(providerID string) bool {
func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) {
priority := GetProviderPriority()
extManager := GetExtensionManager()
strictMode := !req.UseFallback
selectedProvider := strings.TrimSpace(req.Service)
if req.Service != "" && isBuiltInProvider(req.Service) {
if strictMode {
if selectedProvider == "" {
selectedProvider = strings.TrimSpace(req.Source)
}
if selectedProvider != "" {
priority = []string{selectedProvider}
GoLog("[DownloadWithExtensionFallback] Strict mode enabled, provider locked to: %s\n", selectedProvider)
}
}
if !strictMode && req.Service != "" && isBuiltInProvider(strings.ToLower(req.Service)) {
GoLog("[DownloadWithExtensionFallback] User selected service: %s, prioritizing it first\n", req.Service)
newPriority := []string{req.Service}
for _, p := range priority {
@@ -691,7 +699,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
var lastErr error
var skipBuiltIn bool
if req.Source != "" && !isBuiltInProvider(req.Source) {
if req.Source != "" && !isBuiltInProvider(strings.ToLower(req.Source)) {
ext, err := extManager.GetExtension(req.Source)
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsMetadataProvider() {
GoLog("[DownloadWithExtensionFallback] Enriching track from extension '%s'...\n", req.Source)
@@ -754,7 +762,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
}
if req.Source != "" && !isBuiltInProvider(req.Source) {
if req.Source != "" &&
!isBuiltInProvider(strings.ToLower(req.Source)) &&
(!strictMode || selectedProvider == "" || strings.EqualFold(selectedProvider, req.Source)) {
GoLog("[DownloadWithExtensionFallback] Track source is extension '%s', trying it first\n", req.Source)
ext, err := extManager.GetExtension(req.Source)
@@ -768,12 +778,29 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn)
outputPath := buildOutputPath(req)
if req.ItemID != "" {
StartItemProgress(req.ItemID)
}
result, err := provider.Download(trackID, req.Quality, outputPath, func(percent int) {
if req.ItemID != "" {
SetItemProgress(req.ItemID, float64(percent), 0, 0)
normalized := float64(percent) / 100.0
if normalized < 0 {
normalized = 0
}
if normalized > 1 {
normalized = 1
}
SetItemProgress(req.ItemID, normalized, 0, 0)
}
})
if req.ItemID != "" {
if err == nil && result != nil && result.Success {
CompleteItemProgress(req.ItemID)
} else {
RemoveItemProgress(req.ItemID)
}
}
if err == nil && result.Success {
resp := &DownloadResponse{
@@ -788,7 +815,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
Copyright: req.Copyright,
}
if req.Genre != "" || req.Label != "" {
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
} else {
@@ -860,18 +887,23 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
for _, providerID := range priority {
providerID = strings.TrimSpace(providerID)
if providerID == "" {
continue
}
providerIDNormalized := strings.ToLower(providerID)
if providerID == req.Source {
continue
}
if skipBuiltIn && isBuiltInProvider(providerID) {
if skipBuiltIn && isBuiltInProvider(providerIDNormalized) {
GoLog("[DownloadWithExtensionFallback] Skipping built-in provider %s (skipBuiltInFallback)\n", providerID)
continue
}
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
if isBuiltInProvider(providerID) {
if isBuiltInProvider(providerIDNormalized) {
if (req.Genre == "" || req.Label == "") && req.ISRC != "" {
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -892,9 +924,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
}
result, err := tryBuiltInProvider(providerID, req)
result, err := tryBuiltInProvider(providerIDNormalized, req)
if err == nil && result.Success {
result.Service = providerID
result.Service = providerIDNormalized
if req.Label != "" {
result.Label = req.Label
}
@@ -915,11 +947,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
Success: false,
Error: "Download cancelled",
ErrorType: "cancelled",
Service: providerID,
Service: providerIDNormalized,
}, nil
}
lastErr = err
GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, err)
GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerIDNormalized, err)
}
} else {
ext, err := extManager.GetExtension(providerID)
@@ -944,12 +976,29 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
outputPath := buildOutputPath(req)
if req.ItemID != "" {
StartItemProgress(req.ItemID)
}
result, err := provider.Download(availability.TrackID, req.Quality, outputPath, func(percent int) {
if req.ItemID != "" {
SetItemProgress(req.ItemID, float64(percent), 0, 0)
normalized := float64(percent) / 100.0
if normalized < 0 {
normalized = 0
}
if normalized > 1 {
normalized = 1
}
SetItemProgress(req.ItemID, normalized, 0, 0)
}
})
if req.ItemID != "" {
if err == nil && result != nil && result.Success {
CompleteItemProgress(req.ItemID)
} else {
RemoveItemProgress(req.ItemID)
}
}
if err == nil && result.Success {
resp := &DownloadResponse{
@@ -964,7 +1013,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
Copyright: req.Copyright,
}
if req.Genre != "" || req.Label != "" {
if req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
} else {
@@ -1098,6 +1147,24 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
}
}
err = amazonErr
case "deezer":
deezerResult, deezerErr := downloadFromDeezer(req)
if deezerErr == nil {
result = DownloadResult{
FilePath: deezerResult.FilePath,
BitDepth: deezerResult.BitDepth,
SampleRate: deezerResult.SampleRate,
Title: deezerResult.Title,
Artist: deezerResult.Artist,
Album: deezerResult.Album,
ReleaseDate: deezerResult.ReleaseDate,
TrackNumber: deezerResult.TrackNumber,
DiscNumber: deezerResult.DiscNumber,
ISRC: deezerResult.ISRC,
LyricsLRC: deezerResult.LyricsLRC,
}
}
err = deezerErr
default:
return nil, fmt.Errorf("unknown built-in provider: %s", providerID)
}
+118 -34
View File
@@ -88,47 +88,82 @@ type ExtensionRuntime struct {
cookieJar http.CookieJar
dataDir string
vm *goja.Runtime
storageMu sync.RWMutex
storageCache map[string]interface{}
storageLoaded bool
storageDirty bool
storageClosed bool
storageTimer *time.Timer
storageWriteMu sync.Mutex
credentialsMu sync.RWMutex
credentialsCache map[string]interface{}
credentialsLoaded bool
storageFlushDelay time.Duration
}
type privateIPCacheEntry struct {
isPrivate bool
expiresAt time.Time
}
const (
privateIPCacheTTL = 5 * time.Minute
privateIPErrorCacheTTL = 30 * time.Second
maxPrivateIPCacheSize = 1024
)
var (
privateIPCache = make(map[string]privateIPCacheEntry)
privateIPCacheMu sync.RWMutex
)
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
jar, _ := newSimpleCookieJar()
runtime := &ExtensionRuntime{
extensionID: ext.ID,
manifest: ext.Manifest,
settings: make(map[string]interface{}),
cookieJar: jar,
dataDir: ext.DataDir,
vm: ext.VM,
extensionID: ext.ID,
manifest: ext.Manifest,
settings: make(map[string]interface{}),
cookieJar: jar,
dataDir: ext.DataDir,
vm: ext.VM,
storageFlushDelay: defaultStorageFlushDelay,
}
// Extension sandbox enforces HTTPS-only domains. Do not apply global
// allow_http scheme downgrade here, because some extension APIs (e.g.
// spotify-web) will redirect http -> https and can end up in 301 loops.
// We still reuse sharedTransport so insecure TLS compatibility mode remains effective.
client := &http.Client{
Timeout: 30 * time.Second,
Jar: jar,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if req.URL.Scheme != "https" {
GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme)
return fmt.Errorf("redirect blocked: only https is allowed")
}
Transport: sharedTransport,
Timeout: 30 * time.Second,
Jar: jar,
}
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if req.URL.Scheme != "https" {
GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme)
return fmt.Errorf("redirect blocked: only https is allowed")
}
domain := req.URL.Hostname()
if domain == "" {
GoLog("[Extension:%s] Redirect blocked: missing hostname\n", ext.ID)
return fmt.Errorf("redirect blocked: hostname is required")
}
if !ext.Manifest.IsDomainAllowed(domain) {
GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain)
return &RedirectBlockedError{Domain: domain}
}
if isPrivateIP(domain) {
GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain)
return &RedirectBlockedError{Domain: domain, IsPrivate: true}
}
if len(via) >= 10 {
return http.ErrUseLastResponse
}
return nil
},
domain := req.URL.Hostname()
if domain == "" {
GoLog("[Extension:%s] Redirect blocked: missing hostname\n", ext.ID)
return fmt.Errorf("redirect blocked: hostname is required")
}
if !ext.Manifest.IsDomainAllowed(domain) {
GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain)
return &RedirectBlockedError{Domain: domain}
}
if isPrivateIP(domain) {
GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain)
return &RedirectBlockedError{Domain: domain, IsPrivate: true}
}
if len(via) >= 10 {
return http.ErrUseLastResponse
}
return nil
}
runtime.httpClient = client
@@ -147,7 +182,6 @@ func (e *RedirectBlockedError) Error() string {
return "redirect blocked: domain '" + e.Domain + "' not in allowed list"
}
// isPrivateIP checks if a hostname resolves to a private/local IP address
func isPrivateIP(host string) bool {
hostLower := strings.ToLower(strings.TrimSpace(host))
if hostLower == "" {
@@ -162,18 +196,68 @@ func isPrivateIP(host string) bool {
return isPrivateIPAddr(ip)
}
if cached, ok := getPrivateIPCache(hostLower); ok {
return cached
}
ips, err := net.LookupIP(hostLower)
if err != nil {
setPrivateIPCache(hostLower, false, privateIPErrorCacheTTL)
return false
}
isPrivate := false
for _, ip := range ips {
if isPrivateIPAddr(ip) {
return true
isPrivate = true
break
}
}
return false
setPrivateIPCache(hostLower, isPrivate, privateIPCacheTTL)
return isPrivate
}
func getPrivateIPCache(host string) (bool, bool) {
now := time.Now()
privateIPCacheMu.RLock()
entry, exists := privateIPCache[host]
privateIPCacheMu.RUnlock()
if !exists {
return false, false
}
if now.Before(entry.expiresAt) {
return entry.isPrivate, true
}
privateIPCacheMu.Lock()
delete(privateIPCache, host)
privateIPCacheMu.Unlock()
return false, false
}
func setPrivateIPCache(host string, isPrivate bool, ttl time.Duration) {
expiresAt := time.Now().Add(ttl)
privateIPCacheMu.Lock()
if len(privateIPCache) >= maxPrivateIPCacheSize {
now := time.Now()
for key, entry := range privateIPCache {
if now.After(entry.expiresAt) {
delete(privateIPCache, key)
}
}
if len(privateIPCache) >= maxPrivateIPCacheSize {
privateIPCache = make(map[string]privateIPCacheEntry)
}
}
privateIPCache[host] = privateIPCacheEntry{
isPrivate: isPrivate,
expiresAt: expiresAt,
}
privateIPCacheMu.Unlock()
}
func isPrivateIPAddr(ip net.IP) bool {
+20 -3
View File
@@ -396,13 +396,14 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
})
}
data, err := os.ReadFile(fullSrc)
srcFile, err := os.Open(fullSrc)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to read source: %v", err),
})
}
defer srcFile.Close()
dir := filepath.Dir(fullDst)
if err := os.MkdirAll(dir, 0755); err != nil {
@@ -412,10 +413,26 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
})
}
if err := os.WriteFile(fullDst, data, 0644); err != nil {
dstFile, err := os.OpenFile(fullDst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to write destination: %v", err),
"error": fmt.Sprintf("failed to open destination: %v", err),
})
}
if _, err := io.Copy(dstFile, srcFile); err != nil {
_ = dstFile.Close()
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to copy file: %v", err),
})
}
if err := dstFile.Close(); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to finalize destination: %v", err),
})
}
+225 -44
View File
@@ -11,42 +11,164 @@ import (
"io"
"os"
"path/filepath"
"reflect"
"time"
"github.com/dop251/goja"
)
// ==================== Storage API ====================
const (
defaultStorageFlushDelay = 400 * time.Millisecond
storageFlushRetryDelay = 2 * time.Second
)
func (r *ExtensionRuntime) getStoragePath() string {
return filepath.Join(r.dataDir, "storage.json")
}
func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
func cloneInterfaceMap(src map[string]interface{}) map[string]interface{} {
if len(src) == 0 {
return make(map[string]interface{})
}
dst := make(map[string]interface{}, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
func (r *ExtensionRuntime) ensureStorageLoaded() error {
r.storageMu.RLock()
if r.storageLoaded {
r.storageMu.RUnlock()
return nil
}
r.storageMu.RUnlock()
r.storageMu.Lock()
defer r.storageMu.Unlock()
if r.storageLoaded {
return nil
}
storagePath := r.getStoragePath()
data, err := os.ReadFile(storagePath)
if err != nil {
if os.IsNotExist(err) {
return make(map[string]interface{}), nil
r.storageCache = make(map[string]interface{})
r.storageLoaded = true
return nil
}
return nil, err
return err
}
var storage map[string]interface{}
if err := json.Unmarshal(data, &storage); err != nil {
return err
}
if storage == nil {
storage = make(map[string]interface{})
}
r.storageCache = storage
r.storageLoaded = true
return nil
}
func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
if err := r.ensureStorageLoaded(); err != nil {
return nil, err
}
return storage, nil
r.storageMu.RLock()
defer r.storageMu.RUnlock()
return cloneInterfaceMap(r.storageCache), nil
}
func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
storagePath := r.getStoragePath()
data, err := json.MarshalIndent(storage, "", " ")
func (r *ExtensionRuntime) queueStorageFlushLocked(delay time.Duration) {
if r.storageClosed {
return
}
if r.storageTimer != nil {
return
}
r.storageTimer = time.AfterFunc(delay, r.flushStorageDirtyAsync)
}
func (r *ExtensionRuntime) persistStorageSnapshot(storage map[string]interface{}) error {
data, err := json.Marshal(storage)
if err != nil {
return err
}
return os.WriteFile(storagePath, data, 0600)
r.storageWriteMu.Lock()
defer r.storageWriteMu.Unlock()
return os.WriteFile(r.getStoragePath(), data, 0600)
}
func (r *ExtensionRuntime) flushStorageDirtyAsync() {
if err := r.flushStorageDirty(); err != nil {
GoLog("[Extension:%s] Storage flush error: %v\n", r.extensionID, err)
}
}
func (r *ExtensionRuntime) flushStorageDirty() error {
r.storageMu.Lock()
if r.storageClosed {
r.storageTimer = nil
r.storageMu.Unlock()
return nil
}
if !r.storageDirty {
r.storageTimer = nil
r.storageMu.Unlock()
return nil
}
snapshot := cloneInterfaceMap(r.storageCache)
r.storageDirty = false
r.storageTimer = nil
r.storageMu.Unlock()
if err := r.persistStorageSnapshot(snapshot); err != nil {
r.storageMu.Lock()
r.storageDirty = true
r.queueStorageFlushLocked(storageFlushRetryDelay)
r.storageMu.Unlock()
return err
}
return nil
}
func (r *ExtensionRuntime) flushStorageNow() error {
r.storageMu.Lock()
if r.storageTimer != nil {
r.storageTimer.Stop()
r.storageTimer = nil
}
if !r.storageLoaded || r.storageClosed {
r.storageMu.Unlock()
return nil
}
snapshot := cloneInterfaceMap(r.storageCache)
r.storageDirty = false
r.storageMu.Unlock()
return r.persistStorageSnapshot(snapshot)
}
func (r *ExtensionRuntime) closeStorageFlusher() {
r.storageMu.Lock()
r.storageClosed = true
r.storageDirty = false
if r.storageTimer != nil {
r.storageTimer.Stop()
r.storageTimer = nil
}
r.storageMu.Unlock()
}
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
@@ -56,13 +178,14 @@ func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
key := call.Arguments[0].String()
storage, err := r.loadStorage()
if err != nil {
if err := r.ensureStorageLoaded(); err != nil {
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
return goja.Undefined()
}
value, exists := storage[key]
r.storageMu.RLock()
value, exists := r.storageCache[key]
r.storageMu.RUnlock()
if !exists {
if len(call.Arguments) > 1 {
return call.Arguments[1]
@@ -81,18 +204,26 @@ func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
key := call.Arguments[0].String()
value := call.Arguments[1].Export()
storage, err := r.loadStorage()
if err != nil {
if err := r.ensureStorageLoaded(); err != nil {
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
return r.vm.ToValue(false)
}
storage[key] = value
if err := r.saveStorage(storage); err != nil {
GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err)
r.storageMu.Lock()
if r.storageClosed {
r.storageMu.Unlock()
return r.vm.ToValue(false)
}
if existing, exists := r.storageCache[key]; exists {
if reflect.DeepEqual(existing, value) {
r.storageMu.Unlock()
return r.vm.ToValue(true)
}
}
r.storageCache[key] = value
r.storageDirty = true
r.queueStorageFlushLocked(r.storageFlushDelay)
r.storageMu.Unlock()
return r.vm.ToValue(true)
}
@@ -104,18 +235,24 @@ func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
key := call.Arguments[0].String()
storage, err := r.loadStorage()
if err != nil {
if err := r.ensureStorageLoaded(); err != nil {
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
return r.vm.ToValue(false)
}
delete(storage, key)
if err := r.saveStorage(storage); err != nil {
GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err)
r.storageMu.Lock()
if r.storageClosed {
r.storageMu.Unlock()
return r.vm.ToValue(false)
}
if _, exists := r.storageCache[key]; !exists {
r.storageMu.Unlock()
return r.vm.ToValue(true)
}
delete(r.storageCache, key)
r.storageDirty = true
r.queueStorageFlushLocked(r.storageFlushDelay)
r.storageMu.Unlock()
return r.vm.ToValue(true)
}
@@ -159,31 +296,61 @@ func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
return hash[:], nil
}
func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
func (r *ExtensionRuntime) ensureCredentialsLoaded() error {
r.credentialsMu.RLock()
if r.credentialsLoaded {
r.credentialsMu.RUnlock()
return nil
}
r.credentialsMu.RUnlock()
r.credentialsMu.Lock()
defer r.credentialsMu.Unlock()
if r.credentialsLoaded {
return nil
}
credPath := r.getCredentialsPath()
data, err := os.ReadFile(credPath)
if err != nil {
if os.IsNotExist(err) {
return make(map[string]interface{}), nil
r.credentialsCache = make(map[string]interface{})
r.credentialsLoaded = true
return nil
}
return nil, err
return err
}
key, err := r.getEncryptionKey()
if err != nil {
return nil, fmt.Errorf("failed to get encryption key: %w", err)
return fmt.Errorf("failed to get encryption key: %w", err)
}
decrypted, err := decryptAES(data, key)
if err != nil {
return nil, fmt.Errorf("failed to decrypt credentials: %w", err)
return fmt.Errorf("failed to decrypt credentials: %w", err)
}
var creds map[string]interface{}
if err := json.Unmarshal(decrypted, &creds); err != nil {
return err
}
if creds == nil {
creds = make(map[string]interface{})
}
r.credentialsCache = creds
r.credentialsLoaded = true
return nil
}
func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
if err := r.ensureCredentialsLoaded(); err != nil {
return nil, err
}
return creds, nil
r.credentialsMu.RLock()
defer r.credentialsMu.RUnlock()
return cloneInterfaceMap(r.credentialsCache), nil
}
func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
@@ -202,7 +369,15 @@ func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
}
credPath := r.getCredentialsPath()
return os.WriteFile(credPath, encrypted, 0600)
if err := os.WriteFile(credPath, encrypted, 0600); err != nil {
return err
}
r.credentialsMu.Lock()
r.credentialsCache = cloneInterfaceMap(creds)
r.credentialsLoaded = true
r.credentialsMu.Unlock()
return nil
}
func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
@@ -216,8 +391,7 @@ func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
key := call.Arguments[0].String()
value := call.Arguments[1].Export()
creds, err := r.loadCredentials()
if err != nil {
if err := r.ensureCredentialsLoaded(); err != nil {
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -225,9 +399,12 @@ func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
})
}
creds[key] = value
r.credentialsMu.RLock()
nextCreds := cloneInterfaceMap(r.credentialsCache)
r.credentialsMu.RUnlock()
nextCreds[key] = value
if err := r.saveCredentials(creds); err != nil {
if err := r.saveCredentials(nextCreds); err != nil {
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
return r.vm.ToValue(map[string]interface{}{
"success": false,
@@ -247,13 +424,14 @@ func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
key := call.Arguments[0].String()
creds, err := r.loadCredentials()
if err != nil {
if err := r.ensureCredentialsLoaded(); err != nil {
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
return goja.Undefined()
}
value, exists := creds[key]
r.credentialsMu.RLock()
value, exists := r.credentialsCache[key]
r.credentialsMu.RUnlock()
if !exists {
if len(call.Arguments) > 1 {
return call.Arguments[1]
@@ -271,15 +449,17 @@ func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value
key := call.Arguments[0].String()
creds, err := r.loadCredentials()
if err != nil {
if err := r.ensureCredentialsLoaded(); err != nil {
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
return r.vm.ToValue(false)
}
delete(creds, key)
r.credentialsMu.RLock()
nextCreds := cloneInterfaceMap(r.credentialsCache)
r.credentialsMu.RUnlock()
delete(nextCreds, key)
if err := r.saveCredentials(creds); err != nil {
if err := r.saveCredentials(nextCreds); err != nil {
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
return r.vm.ToValue(false)
}
@@ -294,12 +474,13 @@ func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
key := call.Arguments[0].String()
creds, err := r.loadCredentials()
if err != nil {
if err := r.ensureCredentialsLoaded(); err != nil {
return r.vm.ToValue(false)
}
_, exists := creds[key]
r.credentialsMu.RLock()
_, exists := r.credentialsCache[key]
r.credentialsMu.RUnlock()
return r.vm.ToValue(exists)
}
@@ -0,0 +1,120 @@
package gobackend
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"github.com/dop251/goja"
)
func setStorageValue(t *testing.T, runtime *ExtensionRuntime, key string, value interface{}) {
t.Helper()
result := runtime.storageSet(goja.FunctionCall{
Arguments: []goja.Value{
runtime.vm.ToValue(key),
runtime.vm.ToValue(value),
},
})
if !result.ToBoolean() {
t.Fatalf("storage.set(%q) returned false", key)
}
}
func readStorageMap(t *testing.T, storagePath string) map[string]interface{} {
t.Helper()
data, err := os.ReadFile(storagePath)
if err != nil {
t.Fatalf("failed to read storage file: %v", err)
}
var parsed map[string]interface{}
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("failed to unmarshal storage file: %v", err)
}
return parsed
}
func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
ext := &LoadedExtension{
ID: "storage-test",
Manifest: &ExtensionManifest{
Name: "storage-test",
},
DataDir: t.TempDir(),
}
runtime := NewExtensionRuntime(ext)
runtime.storageFlushDelay = 25 * time.Millisecond
runtime.RegisterAPIs(goja.New())
setStorageValue(t, runtime, "k1", "v1")
setStorageValue(t, runtime, "k2", 2)
storagePath := filepath.Join(ext.DataDir, "storage.json")
deadline := time.Now().Add(1500 * time.Millisecond)
var raw []byte
for time.Now().Before(deadline) {
data, err := os.ReadFile(storagePath)
if err == nil {
raw = data
break
}
time.Sleep(20 * time.Millisecond)
}
if len(raw) == 0 {
t.Fatalf("storage.json was not written within timeout")
}
var parsed map[string]interface{}
if err := json.Unmarshal(raw, &parsed); err != nil {
t.Fatalf("failed to unmarshal storage file: %v", err)
}
if parsed["k1"] != "v1" {
t.Fatalf("expected k1=v1, got %v", parsed["k1"])
}
if parsed["k2"] != float64(2) {
t.Fatalf("expected k2=2, got %v", parsed["k2"])
}
if bytes.Contains(raw, []byte("\n")) {
t.Fatalf("expected compact JSON without indentation, got: %q", string(raw))
}
}
func TestUnloadExtension_FlushesPendingStorage(t *testing.T) {
ext := &LoadedExtension{
ID: "unload-storage-test",
Manifest: &ExtensionManifest{
Name: "unload-storage-test",
},
DataDir: t.TempDir(),
VM: goja.New(),
}
runtime := NewExtensionRuntime(ext)
runtime.storageFlushDelay = time.Hour
runtime.RegisterAPIs(ext.VM)
ext.runtime = runtime
manager := &ExtensionManager{
extensions: map[string]*LoadedExtension{
ext.ID: ext,
},
}
setStorageValue(t, runtime, "persist_on_unload", true)
if err := manager.UnloadExtension(ext.ID); err != nil {
t.Fatalf("UnloadExtension failed: %v", err)
}
storagePath := filepath.Join(ext.DataDir, "storage.json")
parsed := readStorageMap(t, storagePath)
if parsed["persist_on_unload"] != true {
t.Fatalf("expected pending storage value to be flushed on unload, got %v", parsed["persist_on_unload"])
}
}
+2 -4
View File
@@ -77,7 +77,6 @@ type StoreRegistry struct {
Extensions []StoreExtension `json:"extensions"`
}
// StoreExtensionResponse is the normalized response sent to Flutter
type StoreExtensionResponse struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -218,7 +217,7 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL)
client := &http.Client{Timeout: 30 * time.Second}
client := NewHTTPClientWithTimeout(30 * time.Second)
resp, err := client.Get(s.registryURL)
if err != nil {
if s.cache != nil {
@@ -310,7 +309,7 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
client := &http.Client{Timeout: 5 * time.Minute}
client := NewHTTPClientWithTimeout(5 * time.Minute)
resp, err := client.Get(ext.getDownloadURL())
if err != nil {
return fmt.Errorf("failed to download: %w", err)
@@ -421,7 +420,6 @@ func (s *ExtensionStore) ClearCache() {
LogInfo("ExtensionStore", "Cache cleared")
}
// Helper: case-insensitive contains
func containsIgnoreCase(s, substr string) bool {
return containsStr(toLower(s), substr)
}
+1 -1
View File
@@ -2,7 +2,7 @@ module github.com/zarz/spotiflac_android/go_backend
go 1.25.0
toolchain go1.26.0
toolchain go1.25.7
require (
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5
+120 -7
View File
@@ -11,6 +11,7 @@ import (
"net/url"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
@@ -37,6 +38,16 @@ const (
Second = time.Second
)
type NetworkCompatibilityOptions struct {
AllowHTTP bool
InsecureTLS bool
}
var (
networkCompatibilityMu sync.RWMutex
networkCompatibilityOptions NetworkCompatibilityOptions
)
var sharedTransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
@@ -77,18 +88,18 @@ var metadataTransport = &http.Transport{
}
var sharedClient = &http.Client{
Transport: sharedTransport,
Transport: newCompatibilityTransport(sharedTransport),
Timeout: DefaultTimeout,
}
var downloadClient = &http.Client{
Transport: sharedTransport,
Transport: newCompatibilityTransport(sharedTransport),
Timeout: DownloadTimeout,
}
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
return &http.Client{
Transport: sharedTransport,
Transport: newCompatibilityTransport(sharedTransport),
Timeout: timeout,
}
}
@@ -97,7 +108,7 @@ func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
// Use this for API calls that should not be affected by download traffic.
func NewMetadataHTTPClient(timeout time.Duration) *http.Client {
return &http.Client{
Transport: metadataTransport,
Transport: newCompatibilityTransport(metadataTransport),
Timeout: timeout,
}
}
@@ -115,6 +126,109 @@ func CloseIdleConnections() {
metadataTransport.CloseIdleConnections()
}
func SetNetworkCompatibilityOptions(allowHTTP, insecureTLS bool) {
networkCompatibilityMu.Lock()
networkCompatibilityOptions = NetworkCompatibilityOptions{
AllowHTTP: allowHTTP,
InsecureTLS: insecureTLS,
}
networkCompatibilityMu.Unlock()
applyTLSCompatibility(sharedTransport, insecureTLS)
applyTLSCompatibility(metadataTransport, insecureTLS)
CloseIdleConnections()
GoLog("[HTTP] Network compatibility options updated: allow_http=%v insecure_tls=%v\n", allowHTTP, insecureTLS)
}
func GetNetworkCompatibilityOptions() NetworkCompatibilityOptions {
networkCompatibilityMu.RLock()
defer networkCompatibilityMu.RUnlock()
return networkCompatibilityOptions
}
func applyTLSCompatibility(transport *http.Transport, insecureTLS bool) {
if insecureTLS {
cfg := &tls.Config{InsecureSkipVerify: true}
if transport.TLSClientConfig != nil {
cfg = transport.TLSClientConfig.Clone()
cfg.InsecureSkipVerify = true
}
transport.TLSClientConfig = cfg
return
}
transport.TLSClientConfig = nil
}
type compatibilityTransport struct {
base http.RoundTripper
}
func newCompatibilityTransport(base http.RoundTripper) http.RoundTripper {
return &compatibilityTransport{base: base}
}
func (t *compatibilityTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if req == nil || req.URL == nil {
return t.base.RoundTrip(req)
}
opts := GetNetworkCompatibilityOptions()
if !opts.AllowHTTP || req.URL.Scheme != "https" {
return t.base.RoundTrip(req)
}
// Compatibility mode should prefer HTTPS and only fallback to HTTP on
// transport-level failures. Forcing HTTP unconditionally can trigger
// redirect loops (http -> https) on providers that enforce HTTPS.
resp, err := t.base.RoundTrip(req)
if err == nil {
return resp, nil
}
if !canFallbackToHTTP(req) {
return nil, err
}
fallbackReq, cloneErr := cloneRequestWithHTTPScheme(req, "http")
if cloneErr != nil {
return nil, err
}
GoLog("[HTTP] HTTPS request failed for %s, retrying over HTTP: %v\n", req.URL.Host, err)
return t.base.RoundTrip(fallbackReq)
}
func canFallbackToHTTP(req *http.Request) bool {
if req == nil {
return false
}
switch strings.ToUpper(req.Method) {
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodDelete:
return true
default:
return req.GetBody != nil
}
}
func cloneRequestWithHTTPScheme(req *http.Request, scheme string) (*http.Request, error) {
reqCopy := req.Clone(req.Context())
if req.Body != nil && req.GetBody != nil {
bodyCopy, err := req.GetBody()
if err != nil {
return nil, err
}
reqCopy.Body = bodyCopy
}
urlCopy := *req.URL
urlCopy.Scheme = scheme
reqCopy.URL = &urlCopy
return reqCopy, nil
}
// Also checks for ISP blocking on errors
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent())
@@ -145,7 +259,6 @@ func DefaultRetryConfig() RetryConfig {
func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) {
var lastErr error
delay := config.InitialDelay
requestURL := req.URL.String()
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
reqCopy := req.Clone(req.Context())
@@ -155,8 +268,8 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
if err != nil {
lastErr = err
if CheckAndLogISPBlocking(err, requestURL, "HTTP") {
return nil, WrapErrorWithISPCheck(err, requestURL, "HTTP")
if CheckAndLogISPBlocking(err, reqCopy.URL.String(), "HTTP") {
return nil, WrapErrorWithISPCheck(err, reqCopy.URL.String(), "HTTP")
}
if attempt < config.MaxRetries {
+69 -80
View File
@@ -67,6 +67,48 @@ var supportedAudioFormats = map[string]bool{
".ogg": true,
}
type libraryAudioFileInfo struct {
path string
modTime int64
}
func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]libraryAudioFileInfo, error) {
var files []libraryAudioFileInfo
err := filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
select {
case <-cancelCh:
return fmt.Errorf("scan cancelled")
default:
}
if info.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(path))
if !supportedAudioFormats[ext] {
return nil
}
files = append(files, libraryAudioFileInfo{
path: path,
modTime: info.ModTime().UnixMilli(),
})
return nil
})
if err != nil {
return nil, err
}
return files, nil
}
func SetLibraryCoverCacheDir(cacheDir string) {
libraryCoverCacheMu.Lock()
libraryCoverCacheDir = cacheDir
@@ -98,31 +140,16 @@ func ScanLibraryFolder(folderPath string) (string, error) {
cancelCh := libraryScanCancel
libraryScanCancelMu.Unlock()
var audioFiles []string
err = filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
select {
case <-cancelCh:
return fmt.Errorf("scan cancelled")
default:
}
if !info.IsDir() {
ext := strings.ToLower(filepath.Ext(path))
if supportedAudioFormats[ext] {
audioFiles = append(audioFiles, path)
}
}
return nil
})
audioFileInfos, err := collectLibraryAudioFiles(folderPath, cancelCh)
if err != nil {
return "[]", err
}
audioFiles := make([]string, 0, len(audioFileInfos))
for _, fileInfo := range audioFileInfos {
audioFiles = append(audioFiles, fileInfo.path)
}
totalFiles := len(audioFiles)
libraryScanProgressMu.Lock()
libraryScanProgress.TotalFiles = totalFiles
@@ -218,6 +245,18 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
}
}
func applyDefaultLibraryMetadata(filePath string, result *LibraryScanResult) {
if result.TrackName == "" {
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
}
if result.ArtistName == "" {
result.ArtistName = "Unknown Artist"
}
if result.AlbumName == "" {
result.AlbumName = "Unknown Album"
}
}
func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
metadata, err := ReadMetadata(filePath)
if err != nil {
@@ -243,15 +282,7 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul
}
}
if result.TrackName == "" {
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
}
if result.ArtistName == "" {
result.ArtistName = "Unknown Artist"
}
if result.AlbumName == "" {
result.AlbumName = "Unknown Album"
}
applyDefaultLibraryMetadata(filePath, result)
return result, nil
}
@@ -297,15 +328,7 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
}
}
if result.TrackName == "" {
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
}
if result.ArtistName == "" {
result.ArtistName = "Unknown Artist"
}
if result.AlbumName == "" {
result.AlbumName = "Unknown Album"
}
applyDefaultLibraryMetadata(filePath, result)
return result, nil
}
@@ -337,15 +360,7 @@ func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
}
}
if result.TrackName == "" {
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
}
if result.ArtistName == "" {
result.ArtistName = "Unknown Artist"
}
if result.AlbumName == "" {
result.AlbumName = "Unknown Album"
}
applyDefaultLibraryMetadata(filePath, result)
return result, nil
}
@@ -476,40 +491,14 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
libraryScanCancelMu.Unlock()
// Collect all audio files with their mod times
type fileInfo struct {
path string
modTime int64
}
var currentFiles []fileInfo
currentPathSet := make(map[string]bool)
err = filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
select {
case <-cancelCh:
return fmt.Errorf("scan cancelled")
default:
}
if !info.IsDir() {
ext := strings.ToLower(filepath.Ext(path))
if supportedAudioFormats[ext] {
currentFiles = append(currentFiles, fileInfo{
path: path,
modTime: info.ModTime().UnixMilli(),
})
currentPathSet[path] = true
}
}
return nil
})
currentFiles, err := collectLibraryAudioFiles(folderPath, cancelCh)
if err != nil {
return "{}", err
}
currentPathSet := make(map[string]bool, len(currentFiles))
for _, fileInfo := range currentFiles {
currentPathSet[fileInfo.path] = true
}
totalFiles := len(currentFiles)
libraryScanProgressMu.Lock()
@@ -517,7 +506,7 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
libraryScanProgressMu.Unlock()
// Find files to scan (new or modified)
var filesToScan []fileInfo
var filesToScan []libraryAudioFileInfo
skippedCount := 0
for _, f := range currentFiles {
+206
View File
@@ -22,6 +22,7 @@ const (
// Lyrics provider names (used in settings and cascade ordering)
const (
LyricsProviderSpotifyAPI = "spotify_api"
LyricsProviderLRCLIB = "lrclib"
LyricsProviderNetease = "netease"
LyricsProviderMusixmatch = "musixmatch"
@@ -33,6 +34,7 @@ const (
// LRCLIB first (no proxy dependency), then the others.
var DefaultLyricsProviders = []string{
LyricsProviderLRCLIB,
LyricsProviderSpotifyAPI,
LyricsProviderMusixmatch,
LyricsProviderNetease,
LyricsProviderAppleMusic,
@@ -45,6 +47,11 @@ var (
lyricsProviders []string // ordered list of enabled providers
)
var (
spotifyLyricsRateLimitMu sync.RWMutex
spotifyLyricsRateLimitedTil time.Time
)
// LyricsFetchOptions controls optional provider-specific enhancements.
type LyricsFetchOptions struct {
IncludeTranslationNetease bool `json:"include_translation_netease"`
@@ -78,6 +85,7 @@ func SetLyricsProviderOrder(providers []string) {
// Validate provider names
validNames := map[string]bool{
LyricsProviderSpotifyAPI: true,
LyricsProviderLRCLIB: true,
LyricsProviderNetease: true,
LyricsProviderMusixmatch: true,
@@ -114,6 +122,7 @@ func GetLyricsProviderOrder() []string {
// GetAvailableLyricsProviders returns metadata about all available providers.
func GetAvailableLyricsProviders() []map[string]interface{} {
return []map[string]interface{}{
{"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced synced lyrics via community API"},
{"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"},
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": false, "description": "NetEase Cloud Music (good for Asian songs)"},
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Largest lyrics database (multi-language)"},
@@ -245,6 +254,18 @@ type LRCLibResponse struct {
SyncedLyrics string `json:"syncedLyrics"`
}
type SpotifyLyricsLine struct {
TimeTag string `json:"timeTag"`
Words string `json:"words"`
}
type SpotifyLyricsAPIResponse struct {
Error bool `json:"error"`
Message string `json:"message"`
SyncType string `json:"syncType"`
Lines []SpotifyLyricsLine `json:"lines"`
}
type LyricsLine struct {
StartTimeMs int64 `json:"startTimeMs"`
Words string `json:"words"`
@@ -352,6 +373,172 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo
return c.parseLRCLibResponse(&results[0]), nil
}
func parseSpotifyLyricsTimeTagToMs(tag string) int64 {
raw := strings.TrimSpace(tag)
raw = strings.TrimPrefix(raw, "[")
raw = strings.TrimSuffix(raw, "]")
if raw == "" {
return 0
}
if ms, err := strconv.ParseInt(raw, 10, 64); err == nil {
return ms
}
re := regexp.MustCompile(`^(\d{1,2}):(\d{2})\.(\d{1,3})$`)
matches := re.FindStringSubmatch(raw)
if len(matches) != 4 {
return 0
}
minutes, _ := strconv.ParseInt(matches[1], 10, 64)
seconds, _ := strconv.ParseInt(matches[2], 10, 64)
fraction := matches[3]
fractionInt, _ := strconv.ParseInt(fraction, 10, 64)
if len(fraction) == 2 {
fractionInt *= 10
} else if len(fraction) == 1 {
fractionInt *= 100
}
return minutes*60*1000 + seconds*1000 + fractionInt
}
func getSpotifyLyricsRateLimitUntil() time.Time {
spotifyLyricsRateLimitMu.RLock()
defer spotifyLyricsRateLimitMu.RUnlock()
return spotifyLyricsRateLimitedTil
}
func setSpotifyLyricsRateLimitUntil(until time.Time) {
spotifyLyricsRateLimitMu.Lock()
spotifyLyricsRateLimitedTil = until
spotifyLyricsRateLimitMu.Unlock()
}
func parseSpotifyRetryAfter(retryAfter string, now time.Time) time.Time {
raw := strings.TrimSpace(retryAfter)
if raw == "" {
return now.Add(10 * time.Minute)
}
if sec, err := strconv.Atoi(raw); err == nil && sec > 0 {
return now.Add(time.Duration(sec) * time.Second)
}
if when, err := http.ParseTime(raw); err == nil && when.After(now) {
return when
}
return now.Add(10 * time.Minute)
}
func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsResponse, error) {
now := time.Now()
if limitedUntil := getSpotifyLyricsRateLimitUntil(); limitedUntil.After(now) {
waitFor := int(math.Ceil(limitedUntil.Sub(now).Seconds()))
return nil, fmt.Errorf(
"Spotify Lyrics API cooldown active (%ds remaining after previous 429)",
waitFor,
)
}
spotifyID = strings.TrimSpace(spotifyID)
if spotifyID == "" {
return nil, fmt.Errorf("spotify ID is empty")
}
if parsed, err := parseSpotifyURI(spotifyID); err == nil && parsed.Type == "track" && parsed.ID != "" {
spotifyID = parsed.ID
}
apiURL := fmt.Sprintf("https://spotify-lyrics-api-pi.vercel.app/?trackid=%s&format=lrc", url.QueryEscape(spotifyID))
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch from Spotify Lyrics API: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
if resp.StatusCode == http.StatusTooManyRequests {
retryUntil := parseSpotifyRetryAfter(resp.Header.Get("Retry-After"), now)
setSpotifyLyricsRateLimitUntil(retryUntil)
}
var payload map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&payload); err == nil {
if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" {
return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg))
}
if msg, ok := payload["error"].(string); ok && strings.TrimSpace(msg) != "" {
return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg))
}
}
return nil, fmt.Errorf("Spotify Lyrics API returned status %d", resp.StatusCode)
}
var apiResp SpotifyLyricsAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err)
}
if apiResp.Error {
msg := strings.TrimSpace(apiResp.Message)
if msg == "" {
msg = "Spotify Lyrics API returned error"
}
return nil, fmt.Errorf("%s", msg)
}
result := &LyricsResponse{
Lines: make([]LyricsLine, 0, len(apiResp.Lines)),
SyncType: apiResp.SyncType,
Instrumental: false,
PlainLyrics: "",
Provider: "Spotify Lyrics API",
Source: "Spotify Lyrics API",
}
for _, line := range apiResp.Lines {
words := strings.TrimSpace(line.Words)
if words == "" {
continue
}
startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag)
result.Lines = append(result.Lines, LyricsLine{
StartTimeMs: startMs,
Words: words,
EndTimeMs: 0,
})
}
if len(result.Lines) > 1 {
for i := 0; i < len(result.Lines)-1; i++ {
nextStart := result.Lines[i+1].StartTimeMs
if nextStart > result.Lines[i].StartTimeMs {
result.Lines[i].EndTimeMs = nextStart
}
}
last := len(result.Lines) - 1
if result.Lines[last].EndTimeMs == 0 {
result.Lines[last].EndTimeMs = result.Lines[last].StartTimeMs + 5000
}
}
if len(result.Lines) == 0 {
return nil, fmt.Errorf("Spotify Lyrics API returned empty lines")
}
if result.SyncType == "" {
result.SyncType = "LINE_SYNCED"
}
return result, nil
}
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
var bestSynced *LRCLibResponse
var bestPlain *LRCLibResponse
@@ -448,6 +635,9 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
var err error
switch providerName {
case LyricsProviderSpotifyAPI:
lyrics, err = c.FetchLyricsFromSpotifyAPI(spotifyID)
case LyricsProviderLRCLIB:
lyrics, err = c.tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack, durationSec)
@@ -651,6 +841,22 @@ func parseSyncedLyrics(syncedLyrics string) []LyricsLine {
return lines
}
func plainTextLyricsLines(rawLyrics string) []LyricsLine {
var lines []LyricsLine
for _, line := range strings.Split(rawLyrics, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
lines = append(lines, LyricsLine{
StartTimeMs: 0,
Words: trimmed,
EndTimeMs: 0,
})
}
return lines
}
func lyricsHasUsableText(lyrics *LyricsResponse) bool {
if lyrics == nil {
return false
+1 -12
View File
@@ -355,18 +355,7 @@ func (c *AppleMusicClient) FetchLyrics(
}
// Fall back to plain text if no timestamps found
plainLines := strings.Split(lrcText, "\n")
var resultLines []LyricsLine
for _, line := range plainLines {
trimmed := strings.TrimSpace(line)
if trimmed != "" {
resultLines = append(resultLines, LyricsLine{
StartTimeMs: 0,
Words: trimmed,
EndTimeMs: 0,
})
}
}
resultLines := plainTextLyricsLines(lrcText)
if len(resultLines) > 0 {
return &LyricsResponse{
+2 -22
View File
@@ -131,17 +131,7 @@ func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string)
// Fall back to unsynced lyrics for selected language
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
var lines []LyricsLine
for _, line := range strings.Split(result.UnsyncedLyrics.Lyrics, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed != "" {
lines = append(lines, LyricsLine{
StartTimeMs: 0,
Words: trimmed,
EndTimeMs: 0,
})
}
}
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
if len(lines) > 0 {
return &LyricsResponse{
@@ -187,17 +177,7 @@ func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec
// Fall back to unsynced lyrics
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
var lines []LyricsLine
for _, line := range strings.Split(result.UnsyncedLyrics.Lyrics, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed != "" {
lines = append(lines, LyricsLine{
StartTimeMs: 0,
Words: trimmed,
EndTimeMs: 0,
})
}
}
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
if len(lines) > 0 {
return &LyricsResponse{
+1 -12
View File
@@ -185,18 +185,7 @@ func (c *QQMusicClient) FetchLyrics(
}
// Fall back to plain text
plainLines := strings.Split(lrcText, "\n")
var resultLines []LyricsLine
for _, line := range plainLines {
trimmed := strings.TrimSpace(line)
if trimmed != "" {
resultLines = append(resultLines, LyricsLine{
StartTimeMs: 0,
Words: trimmed,
EndTimeMs: 0,
})
}
}
resultLines := plainTextLyricsLines(lrcText)
if len(resultLines) > 0 {
return &LyricsResponse{
+41 -19
View File
@@ -545,38 +545,60 @@ func ExtractLyrics(filePath string) (string, error) {
lower := strings.ToLower(filePath)
if strings.HasSuffix(lower, ".flac") {
return extractLyricsFromFlac(filePath)
lyrics, err := extractLyricsFromFlac(filePath)
if err == nil && strings.TrimSpace(lyrics) != "" {
return lyrics, nil
}
return extractLyricsFromSidecarLRC(filePath)
}
if strings.HasSuffix(lower, ".mp3") {
meta, err := ReadID3Tags(filePath)
if err != nil || meta == nil {
return "", fmt.Errorf("no lyrics found in file")
if err == nil && meta != nil {
if strings.TrimSpace(meta.Lyrics) != "" {
return meta.Lyrics, nil
}
if looksLikeEmbeddedLyrics(meta.Comment) {
return meta.Comment, nil
}
}
if strings.TrimSpace(meta.Lyrics) != "" {
return meta.Lyrics, nil
}
if looksLikeEmbeddedLyrics(meta.Comment) {
return meta.Comment, nil
}
return "", fmt.Errorf("no lyrics found in file")
return extractLyricsFromSidecarLRC(filePath)
}
if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
meta, err := ReadOggVorbisComments(filePath)
if err != nil || meta == nil {
return "", fmt.Errorf("no lyrics found in file")
}
if strings.TrimSpace(meta.Lyrics) != "" {
return meta.Lyrics, nil
}
if looksLikeEmbeddedLyrics(meta.Comment) {
return meta.Comment, nil
if err == nil && meta != nil {
if strings.TrimSpace(meta.Lyrics) != "" {
return meta.Lyrics, nil
}
if looksLikeEmbeddedLyrics(meta.Comment) {
return meta.Comment, nil
}
}
return extractLyricsFromSidecarLRC(filePath)
}
return extractLyricsFromSidecarLRC(filePath)
}
func extractLyricsFromSidecarLRC(filePath string) (string, error) {
ext := filepath.Ext(filePath)
base := strings.TrimSuffix(filePath, ext)
if strings.TrimSpace(base) == "" {
return "", fmt.Errorf("no lyrics found in file")
}
return "", fmt.Errorf("unsupported file format for lyrics extraction")
lrcPath := base + ".lrc"
data, err := os.ReadFile(lrcPath)
if err != nil {
return "", fmt.Errorf("no lyrics found in file")
}
lyrics := strings.TrimSpace(string(data))
if lyrics == "" {
return "", fmt.Errorf("no lyrics found in file")
}
return lyrics, nil
}
func extractLyricsFromFlac(filePath string) (string, error) {
+58 -1
View File
@@ -12,11 +12,68 @@ func isFDOutput(outputFD int) bool {
func openOutputForWrite(outputPath string, outputFD int) (*os.File, error) {
if isFDOutput(outputFD) {
return os.NewFile(uintptr(outputFD), fmt.Sprintf("saf_fd_%d", outputFD)), nil
// Never hand the original detached FD directly to a provider attempt.
// Fallback chains may retry with another provider after a failure.
// If the first attempt closes the original FD, its numeric ID can be
// reused by unrelated resources and a later close may trigger fdsan abort.
dupFD, err := dupOutputFD(outputFD)
if err != nil {
return nil, fmt.Errorf("failed to duplicate output fd %d: %w", outputFD, err)
}
if err := prepareDupFDForWrite(dupFD, outputFD); err != nil {
_ = closeFD(dupFD)
return nil, err
}
return os.NewFile(uintptr(dupFD), fmt.Sprintf("saf_fd_%d_dup_%d", outputFD, dupFD)), nil
}
path := strings.TrimSpace(outputPath)
if strings.HasPrefix(path, "/proc/self/fd/") {
// Re-open procfs fd path instead of taking ownership of raw detached fd.
// Some SAF providers reject O_TRUNC on these descriptors with EACCES/EPERM.
file, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0)
if err == nil {
return file, nil
}
if strings.Contains(strings.ToLower(err.Error()), "permission denied") {
return os.OpenFile(path, os.O_WRONLY, 0)
}
return nil, err
}
return os.Create(outputPath)
}
func prepareDupFDForWrite(dupFD, originalFD int) error {
// Best-effort reset so retries start writing from byte 0.
if err := truncateFD(dupFD); err != nil {
if isBestEffortTruncateError(err) {
GoLog("[OutputFD] truncate not supported on fd %d (dup of %d): %v\n", dupFD, originalFD, err)
} else {
return fmt.Errorf("failed to truncate output fd %d (dup of %d): %w", dupFD, originalFD, err)
}
}
if err := seekFDStart(dupFD); err != nil {
GoLog("[OutputFD] seek reset failed on fd %d (dup of %d): %v\n", dupFD, originalFD, err)
}
return nil
}
func closeOwnedOutputFD(outputFD int) {
if !isFDOutput(outputFD) {
return
}
if err := closeFD(outputFD); err != nil {
if !isBadFD(err) {
GoLog("[OutputFD] failed to close detached fd %d: %v\n", outputFD, err)
}
return
}
GoLog("[OutputFD] closed detached fd %d\n", outputFD)
}
func cleanupOutputOnError(outputPath string, outputFD int) {
if isFDOutput(outputFD) {
return
+35
View File
@@ -0,0 +1,35 @@
//go:build !windows
package gobackend
import "syscall"
func dupOutputFD(fd int) (int, error) {
return syscall.Dup(fd)
}
func truncateFD(fd int) error {
return syscall.Ftruncate(fd, 0)
}
func seekFDStart(fd int) error {
_, err := syscall.Seek(fd, 0, 0)
return err
}
func closeFD(fd int) error {
return syscall.Close(fd)
}
func isBestEffortTruncateError(err error) bool {
switch err {
case syscall.EPERM, syscall.EACCES, syscall.EINVAL, syscall.ESPIPE, syscall.ENOSYS:
return true
default:
return false
}
}
func isBadFD(err error) bool {
return err == syscall.EBADF
}
+29
View File
@@ -0,0 +1,29 @@
//go:build windows
package gobackend
func dupOutputFD(fd int) (int, error) {
// Windows build is primarily for local tooling/tests.
// Android runtime uses the !windows implementation.
return fd, nil
}
func truncateFD(fd int) error {
return nil
}
func seekFDStart(fd int) error {
return nil
}
func closeFD(fd int) error {
return nil
}
func isBestEffortTruncateError(err error) bool {
return true
}
func isBadFD(err error) bool {
return false
}
+338 -216
View File
@@ -2,8 +2,8 @@ package gobackend
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -28,6 +28,24 @@ var (
qobuzDownloaderOnce sync.Once
)
const (
qobuzTrackGetBaseURL = "https://www.qobuz.com/api.json/0.2/track/get?track_id="
qobuzTrackSearchBaseURL = "https://www.qobuz.com/api.json/0.2/track/search?query="
qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/"
qobuzDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download"
qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId="
qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId="
qobuzDebugKeyXORMask = byte(0x5A)
)
var qobuzDebugKeyObfuscated = []byte{
0x69, 0x3b, 0x38, 0x3e, 0x36, 0x37, 0x35, 0x2f, 0x36, 0x3b,
0x33, 0x29, 0x2e, 0x32, 0x3f, 0x3d, 0x35, 0x3b, 0x2e, 0x3b,
0x34, 0x3e, 0x34, 0x35, 0x35, 0x34, 0x3f, 0x39, 0x35, 0x37,
0x3f, 0x29, 0x3f, 0x2c, 0x3f, 0x34, 0x39, 0x36, 0x35, 0x29,
0x3f,
}
type QobuzTrack struct {
ID int64 `json:"id"`
Title string `json:"title"`
@@ -185,13 +203,19 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
}
}
// Some tracks are symbol/emoji-heavy and providers can return textual
// aliases. If artist/duration already matched upstream, avoid false rejects.
// Emoji/symbol-only titles must be matched strictly to avoid false positives
// like mapping "🪐" to unrelated textual tracks.
if (!hasAlphaNumericRunes(expectedTitle) || !hasAlphaNumericRunes(foundTitle)) &&
strings.TrimSpace(expectedTitle) != "" &&
strings.TrimSpace(foundTitle) != "" {
GoLog("[Qobuz] Symbol-heavy title detected, relaxing match: '%s' vs '%s'\n", expectedTitle, foundTitle)
return true
expectedSymbols := normalizeSymbolOnlyTitle(expectedTitle)
foundSymbols := normalizeSymbolOnlyTitle(foundTitle)
if expectedSymbols != "" && foundSymbols != "" && expectedSymbols == foundSymbols {
GoLog("[Qobuz] Symbol-heavy title matched strictly: '%s' vs '%s'\n", expectedTitle, foundTitle)
return true
}
GoLog("[Qobuz] Symbol-heavy title mismatch: '%s' vs '%s'\n", expectedTitle, foundTitle)
return false
}
expectedLatin := qobuzIsLatinScript(expectedTitle)
@@ -331,8 +355,7 @@ func NewQobuzDownloader() *QobuzDownloader {
}
func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9nZXQ/dHJhY2tfaWQ9")
trackURL := fmt.Sprintf("%s%d&app_id=%s", string(apiBase), trackID, q.appID)
trackURL := fmt.Sprintf("%s%d&app_id=%s", qobuzTrackGetBaseURL, trackID, q.appID)
req, err := http.NewRequest("GET", trackURL, nil)
if err != nil {
@@ -358,145 +381,186 @@ func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
}
func (q *QobuzDownloader) GetAvailableAPIs() []string {
encodedAPIs := []string{
"ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==",
"ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=",
"cW9idXouc3F1aWQud3RmL2FwaS9kb3dubG9hZC1tdXNpYz90cmFja19pZD0=",
}
var apis []string
for _, encoded := range encodedAPIs {
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
continue
}
apis = append(apis, "https://"+string(decoded))
}
return apis
}
func mapJumoQuality(quality string) int {
switch quality {
case "6":
return 6
case "7":
return 7
case "27":
return 27
default:
return 6
return []string{
qobuzDownloadAPIURL,
}
}
func decodeXOR(data []byte) string {
text := string(data)
runes := []rune(text)
result := make([]rune, len(runes))
for i, char := range runes {
key := rune((i * 17) % 128)
result[i] = char ^ 253 ^ key
}
return string(result)
type qobuzAPIProvider struct {
Name string
URL string
Kind string
}
func extractQobuzDownloadURLFromBody(body []byte) (string, error) {
const (
qobuzAPIKindMusicDL = "musicdl"
qobuzAPIKindStandard = "standard"
)
func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider {
return []qobuzAPIProvider{
{Name: "musicdl", URL: qobuzDownloadAPIURL, Kind: qobuzAPIKindMusicDL},
{Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard},
// "deeb" is mapped from the legacy reference fallback endpoint.
{Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard},
}
}
type qobuzDownloadInfo struct {
DownloadURL string
BitDepth int
SampleRate int
}
func extractQobuzDownloadInfoFromBody(body []byte) (qobuzDownloadInfo, error) {
var raw map[string]any
if err := json.Unmarshal(body, &raw); err != nil {
return "", fmt.Errorf("invalid JSON: %v", err)
return qobuzDownloadInfo{}, fmt.Errorf("invalid JSON: %v", err)
}
if errMsg, ok := raw["error"].(string); ok && strings.TrimSpace(errMsg) != "" {
return "", fmt.Errorf("%s", errMsg)
return qobuzDownloadInfo{}, fmt.Errorf("%s", errMsg)
}
if detail, ok := raw["detail"].(string); ok && strings.TrimSpace(detail) != "" {
return qobuzDownloadInfo{}, fmt.Errorf("%s", detail)
}
if success, ok := raw["success"].(bool); ok && !success {
if msg, ok := raw["message"].(string); ok && strings.TrimSpace(msg) != "" {
return "", fmt.Errorf("%s", msg)
return qobuzDownloadInfo{}, fmt.Errorf("%s", msg)
}
return "", fmt.Errorf("api returned success=false")
return qobuzDownloadInfo{}, fmt.Errorf("api returned success=false")
}
info := qobuzDownloadInfo{
BitDepth: qobuzParseBitDepth(raw["bit_depth"]),
SampleRate: qobuzParseSampleRate(raw["sampling_rate"]),
}
if urlVal, ok := raw["download_url"].(string); ok && strings.TrimSpace(urlVal) != "" {
info.DownloadURL = strings.TrimSpace(urlVal)
return info, nil
}
if urlVal, ok := raw["url"].(string); ok && strings.TrimSpace(urlVal) != "" {
return strings.TrimSpace(urlVal), nil
info.DownloadURL = strings.TrimSpace(urlVal)
return info, nil
}
if linkVal, ok := raw["link"].(string); ok && strings.TrimSpace(linkVal) != "" {
return strings.TrimSpace(linkVal), nil
info.DownloadURL = strings.TrimSpace(linkVal)
return info, nil
}
if data, ok := raw["data"].(map[string]any); ok {
if info.BitDepth == 0 {
info.BitDepth = qobuzParseBitDepth(data["bit_depth"])
}
if info.SampleRate == 0 {
info.SampleRate = qobuzParseSampleRate(data["sampling_rate"])
}
if urlVal, ok := data["download_url"].(string); ok && strings.TrimSpace(urlVal) != "" {
info.DownloadURL = strings.TrimSpace(urlVal)
return info, nil
}
if urlVal, ok := data["url"].(string); ok && strings.TrimSpace(urlVal) != "" {
return strings.TrimSpace(urlVal), nil
info.DownloadURL = strings.TrimSpace(urlVal)
return info, nil
}
if linkVal, ok := data["link"].(string); ok && strings.TrimSpace(linkVal) != "" {
return strings.TrimSpace(linkVal), nil
info.DownloadURL = strings.TrimSpace(linkVal)
return info, nil
}
}
return "", fmt.Errorf("no download URL in response")
return qobuzDownloadInfo{}, fmt.Errorf("no download URL in response")
}
func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) {
formatID := mapJumoQuality(quality)
region := "US"
jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/get?track_id=%d&format_id=%d&region=%s", trackID, formatID, region)
GoLog("[Qobuz] Trying Jumo API fallback...\n")
client := NewHTTPClientWithTimeout(30 * time.Second)
req, err := http.NewRequest("GET", jumoURL, nil)
func extractQobuzDownloadURLFromBody(body []byte) (string, error) {
info, err := extractQobuzDownloadInfoFromBody(body)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", getRandomUserAgent())
req.Header.Set("Referer", "https://jumo-dl.pages.dev/")
return info.DownloadURL, nil
}
resp, err := client.Do(req)
if err != nil {
return "", err
func qobuzParseBitDepth(value any) int {
switch v := value.(type) {
case float64:
return int(v)
case int:
return v
case int64:
return int(v)
case json.Number:
n, _ := v.Int64()
return int(n)
default:
return 0
}
defer resp.Body.Close()
}
if resp.StatusCode != 200 {
return "", fmt.Errorf("Jumo API returned HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
var result map[string]any
if err := json.Unmarshal(body, &result); err != nil {
decoded := decodeXOR(body)
if err := json.Unmarshal([]byte(decoded), &result); err != nil {
return "", fmt.Errorf("failed to parse Jumo response (plain or XOR): %w", err)
func qobuzParseSampleRate(value any) int {
switch v := value.(type) {
case float64:
if v > 0 && v < 1000 {
return int(v * 1000)
}
}
if urlVal, ok := result["url"].(string); ok && urlVal != "" {
GoLog("[Qobuz] Jumo API returned URL successfully\n")
return urlVal, nil
}
if data, ok := result["data"].(map[string]any); ok {
if urlVal, ok := data["url"].(string); ok && urlVal != "" {
GoLog("[Qobuz] Jumo API returned URL successfully (from data)\n")
return urlVal, nil
return int(v)
case int:
if v > 0 && v < 1000 {
return v * 1000
}
return v
case int64:
if v > 0 && v < 1000 {
return int(v * 1000)
}
return int(v)
case json.Number:
if n, err := v.Float64(); err == nil {
if n > 0 && n < 1000 {
return int(n * 1000)
}
return int(n)
}
return 0
default:
return 0
}
}
if linkVal, ok := result["link"].(string); ok && linkVal != "" {
GoLog("[Qobuz] Jumo API returned URL successfully (from link)\n")
return linkVal, nil
func normalizeQobuzQualityCode(quality string) string {
switch strings.ToLower(strings.TrimSpace(quality)) {
case "", "5", "6", "cd", "lossless":
return "6"
case "7", "hi-res":
return "7"
case "27", "hi-res-max":
return "27"
default:
return "6"
}
}
return "", fmt.Errorf("URL not found in Jumo response")
func mapQobuzQualityCodeToAPI(qualityCode string) string {
switch normalizeQobuzQualityCode(qualityCode) {
case "27":
return "hi-res-max"
case "7":
return "hi-res"
default:
return "cd"
}
}
func getQobuzDebugKey() string {
decoded := make([]byte, len(qobuzDebugKeyObfuscated))
for i, b := range qobuzDebugKeyObfuscated {
decoded[i] = b ^ qobuzDebugKeyXORMask
}
return string(decoded)
}
func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", qobuzTrackSearchBaseURL, url.QueryEscape(isrc), q.appID)
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil {
@@ -538,8 +602,7 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
GoLog("[Qobuz] Searching by ISRC: %s\n", isrc)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", qobuzTrackSearchBaseURL, url.QueryEscape(isrc), q.appID)
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil {
@@ -621,8 +684,6 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
}
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
queries := []string{}
if artistName != "" && trackName != "" {
@@ -674,7 +735,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
GoLog("[Qobuz] Searching for: %s\n", cleanQuery)
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(cleanQuery), q.appID)
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", qobuzTrackSearchBaseURL, url.QueryEscape(cleanQuery), q.appID)
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil {
@@ -777,10 +838,10 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
}
type qobuzAPIResult struct {
apiURL string
downloadURL string
err error
duration time.Duration
provider qobuzAPIProvider
info qobuzDownloadInfo
err error
duration time.Duration
}
// Qobuz API timeout configuration
@@ -799,54 +860,73 @@ func getQobuzAPITimeout() time.Duration {
return qobuzAPITimeoutMobile
}
// qobuzSquidCountries defines the region fallback order for squid.wtf API
var qobuzSquidCountries = []string{"US", "FR"}
// fetchQobuzURLWithRetry fetches download URL from a single Qobuz API with retry logic
// For squid.wtf APIs, it tries US region first, then falls back to FR
func fetchQobuzURLWithRetry(api string, trackID int64, quality string, timeout time.Duration) (string, error) {
isSquid := strings.Contains(api, "squid.wtf")
if isSquid {
for _, country := range qobuzSquidCountries {
GoLog("[Qobuz] Trying squid.wtf with country=%s\n", country)
result, err := fetchQobuzURLSingleAttempt(api, trackID, quality, timeout, country)
if err == nil {
return result, nil
}
GoLog("[Qobuz] squid.wtf country=%s failed: %v\n", country, err)
}
return "", fmt.Errorf("squid.wtf failed for all regions (US, FR)")
}
return fetchQobuzURLSingleAttempt(api, trackID, quality, timeout, "")
func fetchQobuzURLWithRetry(provider qobuzAPIProvider, trackID int64, quality string, timeout time.Duration) (qobuzDownloadInfo, error) {
return fetchQobuzURLSingleAttempt(provider, trackID, quality, timeout, "")
}
// fetchQobuzURLSingleAttempt fetches download URL with retry logic for a single API+country combination
func fetchQobuzURLSingleAttempt(api string, trackID int64, quality string, timeout time.Duration, country string) (string, error) {
func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, quality string, timeout time.Duration, country string) (qobuzDownloadInfo, error) {
var lastErr error
retryDelay := qobuzRetryDelay
var payloadBytes []byte
if provider.Kind == qobuzAPIKindMusicDL {
requestQuality := mapQobuzQualityCodeToAPI(quality)
payload := map[string]any{
"quality": requestQuality,
"upload_to_r2": false,
"url": fmt.Sprintf("%s%d", qobuzTrackPlayBaseURL, trackID),
}
var err error
payloadBytes, err = json.Marshal(payload)
if err != nil {
return qobuzDownloadInfo{}, fmt.Errorf("failed to encode qobuz request: %w", err)
}
}
for attempt := 0; attempt <= qobuzMaxRetries; attempt++ {
if attempt > 0 {
GoLog("[Qobuz] Retry %d/%d for %s after %v\n", attempt, qobuzMaxRetries, api, retryDelay)
GoLog("[Qobuz] Retry %d/%d for %s after %v\n", attempt, qobuzMaxRetries, provider.Name, retryDelay)
time.Sleep(retryDelay)
retryDelay *= 2 // Exponential backoff
}
client := NewHTTPClientWithTimeout(timeout)
reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality)
reqURL := provider.URL
if country != "" {
reqURL += "&country=" + country
reqURL += "?country=" + url.QueryEscape(country)
}
req, err := http.NewRequest("GET", reqURL, nil)
var (
req *http.Request
err error
)
if provider.Kind == qobuzAPIKindStandard {
separator := "&"
if !strings.Contains(reqURL, "?") {
separator = "?"
}
reqURL = fmt.Sprintf(
"%s%d%squality=%s",
reqURL,
trackID,
separator,
url.QueryEscape(normalizeQobuzQualityCode(quality)),
)
req, err = http.NewRequest("GET", reqURL, nil)
} else {
req, err = http.NewRequest("POST", reqURL, bytes.NewReader(payloadBytes))
}
if err != nil {
lastErr = err
continue
}
if provider.Kind == qobuzAPIKindMusicDL {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Debug-Key", getQobuzDebugKey())
}
resp, err := client.Do(req)
resp, err := DoRequestWithUserAgent(client, req)
if err != nil {
lastErr = err
// Check for retryable errors (timeout, connection reset)
@@ -879,7 +959,7 @@ func fetchQobuzURLSingleAttempt(api string, trackID int64, quality string, timeo
if resp.StatusCode != 200 {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
return qobuzDownloadInfo{}, fmt.Errorf("HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
@@ -890,108 +970,115 @@ func fetchQobuzURLSingleAttempt(api string, trackID int64, quality string, timeo
}
if len(body) > 0 && body[0] == '<' {
return "", fmt.Errorf("received HTML instead of JSON")
return qobuzDownloadInfo{}, fmt.Errorf("received HTML instead of JSON")
}
urlVal, parseErr := extractQobuzDownloadURLFromBody(body)
info, parseErr := extractQobuzDownloadInfoFromBody(body)
if parseErr == nil {
return urlVal, nil
return info, nil
}
lastErr = parseErr
continue
}
if lastErr != nil {
return "", lastErr
return qobuzDownloadInfo{}, lastErr
}
return "", fmt.Errorf("all retries failed")
return qobuzDownloadInfo{}, fmt.Errorf("all retries failed")
}
func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) {
if len(apis) == 0 {
return "", "", fmt.Errorf("no APIs available")
func getQobuzDownloadURLParallel(providers []qobuzAPIProvider, trackID int64, quality string) (qobuzAPIProvider, qobuzDownloadInfo, error) {
if len(providers) == 0 {
return qobuzAPIProvider{}, qobuzDownloadInfo{}, fmt.Errorf("no APIs available")
}
GoLog("[Qobuz] Requesting download URL from %d APIs in parallel (with retry)...\n", len(apis))
GoLog("[Qobuz] Requesting download URL from %d APIs in parallel (with retry)...\n", len(providers))
resultChan := make(chan qobuzAPIResult, len(apis))
resultChan := make(chan qobuzAPIResult, len(providers))
startTime := time.Now()
timeout := getQobuzAPITimeout()
for _, apiURL := range apis {
go func(api string) {
for _, provider := range providers {
go func(provider qobuzAPIProvider) {
reqStart := time.Now()
downloadURL, err := fetchQobuzURLWithRetry(api, trackID, quality, timeout)
info, err := fetchQobuzURLWithRetry(provider, trackID, quality, timeout)
resultChan <- qobuzAPIResult{
apiURL: api,
downloadURL: downloadURL,
err: err,
duration: time.Since(reqStart),
provider: provider,
info: info,
err: err,
duration: time.Since(reqStart),
}
}(apiURL)
}(provider)
}
var errors []string
for i := 0; i < len(apis); i++ {
for i := 0; i < len(providers); i++ {
result := <-resultChan
if result.err == nil {
GoLog("[Qobuz] [Parallel] Got response from %s in %v\n", result.apiURL, result.duration)
GoLog("[Qobuz] [Parallel] Got response from %s in %v\n", result.provider.Name, result.duration)
go func(remaining int) {
for j := 0; j < remaining; j++ {
<-resultChan
}
}(len(apis) - i - 1)
}(len(providers) - i - 1)
GoLog("[Qobuz] [Parallel] Total time: %v (first success)\n", time.Since(startTime))
return result.apiURL, result.downloadURL, nil
return result.provider, result.info, nil
}
errMsg := result.err.Error()
if len(errMsg) > 50 {
errMsg = errMsg[:50] + "..."
}
errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg))
errors = append(errors, fmt.Sprintf("%s: %s", result.provider.Name, errMsg))
}
GoLog("[Qobuz] [Parallel] All %d APIs failed in %v\n", len(apis), time.Since(startTime))
return "", "", fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(apis), errors)
GoLog("[Qobuz] [Parallel] All %d APIs failed in %v\n", len(providers), time.Since(startTime))
return qobuzAPIProvider{}, qobuzDownloadInfo{}, fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(providers), errors)
}
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
apis := q.GetAvailableAPIs()
if len(apis) == 0 {
return "", fmt.Errorf("no Qobuz API available")
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (qobuzDownloadInfo, error) {
providers := q.GetAvailableProviders()
if len(providers) == 0 {
return qobuzDownloadInfo{}, fmt.Errorf("no Qobuz API available")
}
_, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, quality)
qualityCode := normalizeQobuzQualityCode(quality)
downloadFunc := func(qual string) (qobuzDownloadInfo, error) {
provider, info, err := getQobuzDownloadURLParallel(providers, trackID, qual)
if err != nil {
return qobuzDownloadInfo{}, err
}
GoLog("[Qobuz] Download URL resolved via %s\n", provider.Name)
return info, nil
}
downloadInfo, err := downloadFunc(qualityCode)
if err == nil {
return downloadURL, nil
return downloadInfo, nil
}
GoLog("[Qobuz] Standard APIs failed, trying Jumo fallback...\n")
jumoURL, jumoErr := q.downloadFromJumo(trackID, quality)
if jumoErr == nil {
return jumoURL, nil
}
if quality == "27" {
currentQuality := qualityCode
if currentQuality == "27" {
GoLog("[Qobuz] Hi-res (27) failed, trying 24-bit (7)...\n")
jumoURL, jumoErr = q.downloadFromJumo(trackID, "7")
if jumoErr == nil {
return jumoURL, nil
downloadInfo, err = downloadFunc("7")
if err == nil {
return downloadInfo, nil
}
currentQuality = "7"
}
if quality == "27" || quality == "7" {
if currentQuality == "7" {
GoLog("[Qobuz] 24-bit failed, trying 16-bit (6)...\n")
jumoURL, jumoErr = q.downloadFromJumo(trackID, "6")
if jumoErr == nil {
return jumoURL, nil
downloadInfo, err = downloadFunc("6")
if err == nil {
return downloadInfo, nil
}
}
return "", fmt.Errorf("all Qobuz APIs and Jumo fallback failed: %w", err)
return qobuzDownloadInfo{}, fmt.Errorf("all Qobuz APIs failed: %w", err)
}
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
@@ -1087,14 +1174,12 @@ type QobuzDownloadResult struct {
LyricsLRC string
}
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
downloader := NewQobuzDownloader()
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
if !isSafOutput {
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloader, logPrefix string) (*QobuzTrack, error) {
if downloader == nil {
downloader = NewQobuzDownloader()
}
if strings.TrimSpace(logPrefix) == "" {
logPrefix = "Qobuz"
}
expectedDurationSec := req.DurationMS / 1000
@@ -1104,15 +1189,15 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
// Strategy 1: Use Qobuz ID from Odesli enrichment (fastest, most accurate)
if req.QobuzID != "" {
GoLog("[Qobuz] Using Qobuz ID from Odesli enrichment: %s\n", req.QobuzID)
GoLog("[%s] Using Qobuz ID from Odesli enrichment: %s\n", logPrefix, req.QobuzID)
var trackID int64
if _, parseErr := fmt.Sscanf(req.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
track, err = downloader.GetTrackByID(trackID)
if err != nil {
GoLog("[Qobuz] Failed to get track by Odesli ID %d: %v\n", trackID, err)
GoLog("[%s] Failed to get track by Odesli ID %d: %v\n", logPrefix, trackID, err)
track = nil
} else if track != nil {
GoLog("[Qobuz] Successfully found track via Odesli ID: '%s' by '%s'\n", track.Title, track.Performer.Name)
GoLog("[%s] Successfully found track via Odesli ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
}
}
}
@@ -1120,10 +1205,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
// Strategy 2: Use cached Qobuz Track ID (fast, no search needed)
if track == nil && req.ISRC != "" {
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
GoLog("[%s] Cache hit! Using cached track ID: %d\n", logPrefix, cached.QobuzTrackID)
track, err = downloader.GetTrackByID(cached.QobuzTrackID)
if err != nil {
GoLog("[Qobuz] Cache hit but GetTrackByID failed: %v\n", err)
GoLog("[%s] Cache hit but GetTrackByID failed: %v\n", logPrefix, err)
track = nil
}
}
@@ -1131,19 +1216,19 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
// Strategy 3: Try to get QobuzID from SongLink if we have SpotifyID
if track == nil && req.SpotifyID != "" && req.QobuzID == "" {
GoLog("[Qobuz] Trying to get Qobuz ID from SongLink for Spotify ID: %s\n", req.SpotifyID)
GoLog("[%s] Trying to get Qobuz ID from SongLink for Spotify ID: %s\n", logPrefix, req.SpotifyID)
songLinkClient := NewSongLinkClient()
availability, slErr := songLinkClient.CheckTrackAvailability(req.SpotifyID, req.ISRC)
if slErr == nil && availability != nil && availability.QobuzID != "" {
var trackID int64
if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
GoLog("[Qobuz] Got Qobuz ID %d from SongLink\n", trackID)
GoLog("[%s] Got Qobuz ID %d from SongLink\n", logPrefix, trackID)
track, err = downloader.GetTrackByID(trackID)
if err != nil {
GoLog("[Qobuz] Failed to get track by SongLink ID %d: %v\n", trackID, err)
GoLog("[%s] Failed to get track by SongLink ID %d: %v\n", logPrefix, trackID, err)
track = nil
} else if track != nil {
GoLog("[Qobuz] Successfully found track via SongLink ID: '%s' by '%s'\n", track.Title, track.Performer.Name)
GoLog("[%s] Successfully found track via SongLink ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
// Cache for future use
if req.ISRC != "" {
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
@@ -1155,16 +1240,16 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
// Strategy 4: ISRC search with duration verification
if track == nil && req.ISRC != "" {
GoLog("[Qobuz] Trying ISRC search: %s\n", req.ISRC)
GoLog("[%s] Trying ISRC search: %s\n", logPrefix, req.ISRC)
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
if track != nil {
if !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
GoLog("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, track.Performer.Name)
GoLog("[%s] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
logPrefix, req.ArtistName, track.Performer.Name)
track = nil
} else if !qobuzTitlesMatch(req.TrackName, track.Title) {
GoLog("[Qobuz] Title mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
req.TrackName, track.Title)
GoLog("[%s] Title mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
logPrefix, req.TrackName, track.Title)
track = nil
}
}
@@ -1172,11 +1257,11 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
// Strategy 5: Metadata search with strict matching (duration tolerance: 10 seconds)
if track == nil {
GoLog("[Qobuz] Trying metadata search: '%s' by '%s'\n", req.TrackName, req.ArtistName)
GoLog("[%s] Trying metadata search: '%s' by '%s'\n", logPrefix, req.TrackName, req.ArtistName)
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, track.Performer.Name)
GoLog("[%s] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
logPrefix, req.ArtistName, track.Performer.Name)
track = nil
}
}
@@ -1186,14 +1271,32 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
if err != nil {
errMsg = err.Error()
}
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
return nil, fmt.Errorf("qobuz search failed: %s", errMsg)
}
GoLog("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
GoLog("[%s] Match found: '%s' by '%s' (duration: %ds)\n", logPrefix, track.Title, track.Performer.Name, track.Duration)
if req.ISRC != "" {
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
}
return track, nil
}
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
downloader := NewQobuzDownloader()
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
if !isSafOutput {
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
}
track, err := resolveQobuzTrackForRequest(req, downloader, "Qobuz")
if err != nil {
return QobuzDownloadResult{}, err
}
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName,
"artist": req.ArtistName,
@@ -1232,27 +1335,42 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
actualSampleRate := int(track.MaximumSamplingRate * 1000)
GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
downloadInfo, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
if err != nil {
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
}
if downloadInfo.BitDepth > 0 {
actualBitDepth = downloadInfo.BitDepth
}
if downloadInfo.SampleRate > 0 {
actualSampleRate = downloadInfo.SampleRate
}
if actualBitDepth > 0 || actualSampleRate > 0 {
GoLog("[Qobuz] API returned quality: %d-bit/%dHz\n", actualBitDepth, actualSampleRate)
}
var parallelResult *ParallelDownloadResult
parallelDone := make(chan struct{})
go func() {
defer close(parallelDone)
coverURL := req.CoverURL
embedLyrics := req.EmbedLyrics
if !req.EmbedMetadata {
coverURL = ""
embedLyrics = false
}
parallelResult = FetchCoverAndLyricsParallel(
req.CoverURL,
coverURL,
req.EmbedMaxQualityCover,
req.SpotifyID,
req.TrackName,
req.ArtistName,
req.EmbedLyrics,
embedLyrics,
int64(req.DurationMS),
)
}()
if err := downloader.DownloadFile(downloadURL, outputPath, req.OutputFD, req.ItemID); err != nil {
if err := downloader.DownloadFile(downloadInfo.DownloadURL, outputPath, req.OutputFD, req.ItemID); err != nil {
if errors.Is(err, ErrDownloadCancelled) {
return QobuzDownloadResult{}, ErrDownloadCancelled
}
@@ -1297,8 +1415,12 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
GoLog("[Qobuz] Using parallel-fetched cover (%d bytes)\n", len(coverData))
}
if isSafOutput {
GoLog("[Qobuz] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
if isSafOutput || !req.EmbedMetadata {
if !req.EmbedMetadata {
GoLog("[Qobuz] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n")
} else {
GoLog("[Qobuz] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
}
} else {
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
@@ -1337,7 +1459,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
}
lyricsLRC := ""
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsLRC = parallelResult.LyricsLRC
}
+88
View File
@@ -3,6 +3,24 @@ package gobackend
import "testing"
func TestExtractQobuzDownloadURLFromBody(t *testing.T) {
t.Run("reads top-level download_url and quality metadata", func(t *testing.T) {
body := []byte(`{"success":true,"download_url":"https://example.test/new.flac","bit_depth":24,"sampling_rate":96}`)
info, err := extractQobuzDownloadInfoFromBody(body)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if info.DownloadURL != "https://example.test/new.flac" {
t.Fatalf("unexpected URL: %q", info.DownloadURL)
}
if info.BitDepth != 24 {
t.Fatalf("unexpected bit depth: %d", info.BitDepth)
}
if info.SampleRate != 96000 {
t.Fatalf("unexpected sample rate: %d", info.SampleRate)
}
})
t.Run("reads nested data.url", func(t *testing.T) {
body := []byte(`{"success":true,"data":{"url":"https://example.test/audio.flac"}}`)
@@ -44,4 +62,74 @@ func TestExtractQobuzDownloadURLFromBody(t *testing.T) {
t.Fatalf("expected blocked error, got %v", err)
}
})
t.Run("returns detail error", func(t *testing.T) {
body := []byte(`{"detail":"Invalid quality 'lossless'. Choose from: ['mp3', 'cd', 'hi-res', 'hi-res-max']"}`)
_, err := extractQobuzDownloadURLFromBody(body)
if err == nil || err.Error() != "Invalid quality 'lossless'. Choose from: ['mp3', 'cd', 'hi-res', 'hi-res-max']" {
t.Fatalf("expected detail error, got %v", err)
}
})
}
func TestNormalizeQobuzQualityCode(t *testing.T) {
tests := map[string]string{
"": "6",
"5": "6",
"6": "6",
"cd": "6",
"lossless": "6",
"7": "7",
"hi-res": "7",
"27": "27",
"hi-res-max": "27",
"unexpected": "6",
}
for input, want := range tests {
if got := normalizeQobuzQualityCode(input); got != want {
t.Fatalf("normalizeQobuzQualityCode(%q) = %q, want %q", input, got, want)
}
}
}
func TestGetQobuzDebugKey(t *testing.T) {
got := getQobuzDebugKey()
if len(got) != len(qobuzDebugKeyObfuscated) {
t.Fatalf("unexpected debug key length: %d", len(got))
}
for i := range got {
if got[i]^qobuzDebugKeyXORMask != qobuzDebugKeyObfuscated[i] {
t.Fatalf("unexpected debug key reconstruction at index %d", i)
}
}
}
func TestQobuzAvailableProviders(t *testing.T) {
providers := NewQobuzDownloader().GetAvailableProviders()
if len(providers) != 3 {
t.Fatalf("expected 3 Qobuz providers, got %d", len(providers))
}
want := map[string]string{
"musicdl": qobuzAPIKindMusicDL,
"dabmusic": qobuzAPIKindStandard,
"deeb": qobuzAPIKindStandard,
}
for _, provider := range providers {
wantKind, ok := want[provider.Name]
if !ok {
t.Fatalf("unexpected provider %q", provider.Name)
}
if provider.Kind != wantKind {
t.Fatalf("provider %q has kind %q, want %q", provider.Name, provider.Kind, wantKind)
}
delete(want, provider.Name)
}
if len(want) != 0 {
t.Fatalf("missing providers: %v", want)
}
}
+91 -54
View File
@@ -1,7 +1,6 @@
package gobackend
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
@@ -35,6 +34,8 @@ type TrackAvailability struct {
var (
globalSongLinkClient *SongLinkClient
songLinkClientOnce sync.Once
songLinkRegion = "US"
songLinkRegionMu sync.RWMutex
)
func NewSongLinkClient() *SongLinkClient {
@@ -46,14 +47,72 @@ func NewSongLinkClient() *SongLinkClient {
return globalSongLinkClient
}
func normalizeSongLinkRegion(region string) string {
normalized := strings.ToUpper(strings.TrimSpace(region))
if len(normalized) != 2 {
return "US"
}
for _, ch := range normalized {
if ch < 'A' || ch > 'Z' {
return "US"
}
}
return normalized
}
func SetSongLinkRegion(region string) {
normalized := normalizeSongLinkRegion(region)
songLinkRegionMu.Lock()
songLinkRegion = normalized
songLinkRegionMu.Unlock()
}
func GetSongLinkRegion() string {
songLinkRegionMu.RLock()
region := songLinkRegion
songLinkRegionMu.RUnlock()
return region
}
func songLinkBaseURL() string {
opts := GetNetworkCompatibilityOptions()
if opts.AllowHTTP {
return "http://api.song.link/v1-alpha.1/links"
}
return "https://api.song.link/v1-alpha.1/links"
}
func buildSongLinkURLFromTarget(targetURL string, userCountry string) string {
if userCountry == "" {
userCountry = GetSongLinkRegion()
}
apiURL := fmt.Sprintf("%s?url=%s", songLinkBaseURL(), url.QueryEscape(targetURL))
if userCountry != "" {
apiURL = fmt.Sprintf("%s&userCountry=%s", apiURL, url.QueryEscape(userCountry))
}
return apiURL
}
func buildSongLinkURLByPlatform(platform, entityType, entityID, userCountry string) string {
if userCountry == "" {
userCountry = GetSongLinkRegion()
}
apiURL := fmt.Sprintf("%s?platform=%s&type=%s&id=%s",
songLinkBaseURL(),
url.QueryEscape(platform),
url.QueryEscape(entityType),
url.QueryEscape(entityID))
if userCountry != "" {
apiURL = fmt.Sprintf("%s&userCountry=%s", apiURL, url.QueryEscape(userCountry))
}
return apiURL
}
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
songLinkRateLimiter.WaitForSlot()
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
apiURL := buildSongLinkURLFromTarget(spotifyURL, "")
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
@@ -158,7 +217,6 @@ func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]str
return urls, nil
}
// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL
func extractDeezerIDFromURL(deezerURL string) string {
parts := strings.Split(deezerURL, "/")
if len(parts) > 0 {
@@ -236,10 +294,6 @@ func extractQobuzIDFromURL(qobuzURL string) string {
return ""
}
// extractTidalIDFromURL extracts Tidal track ID from URL
// URL formats:
// - https://tidal.com/browse/track/12345678
// - https://listen.tidal.com/track/12345678
func extractTidalIDFromURL(tidalURL string) string {
if tidalURL == "" {
return ""
@@ -265,11 +319,6 @@ func extractTidalIDFromURL(tidalURL string) string {
return ""
}
// extractYouTubeIDFromURL extracts YouTube video ID from URL
// URL formats:
// - https://www.youtube.com/watch?v=VIDEO_ID
// - https://youtu.be/VIDEO_ID
// - https://music.youtube.com/watch?v=VIDEO_ID
func extractYouTubeIDFromURL(youtubeURL string) string {
if youtubeURL == "" {
return ""
@@ -326,7 +375,6 @@ func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string,
return availability.DeezerID, nil
}
// GetYouTubeURLFromSpotify converts a Spotify track ID to YouTube URL using SongLink
func (s *SongLinkClient) GetYouTubeURLFromSpotify(spotifyTrackID string) (string, error) {
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
if err != nil {
@@ -340,7 +388,6 @@ func (s *SongLinkClient) GetYouTubeURLFromSpotify(spotifyTrackID string) (string
return availability.YouTubeURL, nil
}
// AlbumAvailability represents album availability on different platforms
type AlbumAvailability struct {
SpotifyID string `json:"spotify_id"`
Deezer bool `json:"deezer"`
@@ -351,11 +398,8 @@ type AlbumAvailability struct {
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
songLinkRateLimiter.WaitForSlot()
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyAlbumID)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
spotifyURL := fmt.Sprintf("https://open.spotify.com/album/%s", spotifyAlbumID)
apiURL := buildSongLinkURLFromTarget(spotifyURL, "")
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
@@ -401,7 +445,6 @@ func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAv
return availability, nil
}
// GetDeezerAlbumIDFromSpotify converts a Spotify album ID to Deezer album ID using SongLink
func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (string, error) {
availability, err := s.CheckAlbumAvailability(spotifyAlbumID)
if err != nil {
@@ -440,9 +483,7 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin
songLinkRateLimiter.WaitForSlot()
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
apiURL := fmt.Sprintf("%s%s&userCountry=US", string(apiBase), url.QueryEscape(deezerURL))
apiURL := buildSongLinkURLFromTarget(deezerURL, "")
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
@@ -520,16 +561,17 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin
availability.DeezerURL = deezerLink.URL
}
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
// Prefer youtubeMusic URLs — they are usually closer to music catalog matches.
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
}
if !availability.YouTube {
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
}
}
@@ -546,10 +588,7 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
songLinkRateLimiter.WaitForSlot()
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?platform=%s&type=%s&id=%s&userCountry=US",
url.QueryEscape(platform),
url.QueryEscape(entityType),
url.QueryEscape(entityID))
apiURL := buildSongLinkURLByPlatform(platform, entityType, entityID, "")
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
@@ -620,23 +659,23 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
}
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
// Prefer youtubeMusic URLs — they are usually closer to music catalog matches.
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
}
if !availability.YouTube {
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
}
}
return availability, nil
}
// extractSpotifyIDFromURL extracts Spotify track ID from URL
func extractSpotifyIDFromURL(spotifyURL string) string {
parts := strings.Split(spotifyURL, "/track/")
if len(parts) > 1 {
@@ -662,7 +701,6 @@ func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, e
return availability.SpotifyID, nil
}
// GetTidalURLFromDeezer converts a Deezer track ID to Tidal URL using SongLink
func (s *SongLinkClient) GetTidalURLFromDeezer(deezerTrackID string) (string, error) {
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
if err != nil {
@@ -689,7 +727,6 @@ func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, e
return availability.AmazonURL, nil
}
// GetYouTubeURLFromDeezer converts a Deezer track ID to YouTube URL using SongLink
func (s *SongLinkClient) GetYouTubeURLFromDeezer(deezerTrackID string) (string, error) {
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
if err != nil {
@@ -706,8 +743,7 @@ func (s *SongLinkClient) GetYouTubeURLFromDeezer(deezerTrackID string) (string,
func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) {
songLinkRateLimiter.WaitForSlot()
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(inputURL))
apiURL := buildSongLinkURLFromTarget(inputURL, "")
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
@@ -771,16 +807,17 @@ func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvaila
availability.DeezerURL = deezerLink.URL
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
}
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
// Prefer youtubeMusic URLs — they are usually closer to music catalog matches.
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
}
if !availability.YouTube {
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
}
}
+63 -7
View File
@@ -16,13 +16,14 @@ import (
)
const (
spotifyTokenURL = "https://accounts.spotify.com/api/token"
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
searchBaseURL = "https://api.spotify.com/v1/search"
spotifyTokenURL = "https://accounts.spotify.com/api/token"
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
artistRelatedURL = "https://api.spotify.com/v1/artists/%s/related-artists"
searchBaseURL = "https://api.spotify.com/v1/search"
artistCacheTTL = 10 * time.Minute
searchCacheTTL = 5 * time.Minute
@@ -140,6 +141,8 @@ type TrackMetadata struct {
DiscNumber int `json:"disc_number,omitempty"`
ExternalURL string `json:"external_urls"`
ISRC string `json:"isrc"`
AlbumID string `json:"album_id,omitempty"`
ArtistID string `json:"artist_id,omitempty"`
AlbumType string `json:"album_type,omitempty"`
}
@@ -361,6 +364,10 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
}
for _, track := range response.Tracks.Items {
var firstArtistID string
if len(track.Artists) > 0 {
firstArtistID = track.Artists[0].ID
}
result.Tracks = append(result.Tracks, TrackMetadata{
SpotifyID: track.ID,
Artists: joinArtists(track.Artists),
@@ -375,6 +382,8 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
DiscNumber: track.DiscNumber,
ExternalURL: track.ExternalURL.Spotify,
ISRC: track.ExternalID.ISRC,
AlbumID: track.Album.ID,
ArtistID: firstArtistID,
AlbumType: track.Album.AlbumType,
})
}
@@ -426,6 +435,10 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
}
for _, track := range response.Tracks.Items {
var firstArtistID string
if len(track.Artists) > 0 {
firstArtistID = track.Artists[0].ID
}
result.Tracks = append(result.Tracks, TrackMetadata{
SpotifyID: track.ID,
Artists: joinArtists(track.Artists),
@@ -440,6 +453,8 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
DiscNumber: track.DiscNumber,
ExternalURL: track.ExternalURL.Spotify,
ISRC: track.ExternalID.ISRC,
AlbumID: track.Album.ID,
ArtistID: firstArtistID,
AlbumType: track.Album.AlbumType,
})
}
@@ -838,6 +853,47 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
return result, nil
}
func (c *SpotifyMetadataClient) GetRelatedArtists(ctx context.Context, artistID string, limit int) ([]SearchArtistResult, error) {
token, err := c.getAccessToken(ctx)
if err != nil {
return nil, err
}
var data struct {
Artists []struct {
ID string `json:"id"`
Name string `json:"name"`
Images []image `json:"images"`
Followers struct {
Total int `json:"total"`
} `json:"followers"`
Popularity int `json:"popularity"`
} `json:"artists"`
}
if err := c.getJSON(ctx, fmt.Sprintf(artistRelatedURL, artistID), token, &data); err != nil {
return nil, err
}
maxItems := len(data.Artists)
if limit > 0 && limit < maxItems {
maxItems = limit
}
result := make([]SearchArtistResult, 0, maxItems)
for i := 0; i < maxItems; i++ {
artist := data.Artists[i]
result = append(result, SearchArtistResult{
ID: artist.ID,
Name: artist.Name,
Images: firstImageURL(artist.Images),
Followers: artist.Followers.Total,
Popularity: artist.Popularity,
})
}
return result, nil
}
func (c *SpotifyMetadataClient) fetchTrackISRC(ctx context.Context, trackID, token string) string {
var data struct {
ExternalID externalID `json:"external_ids"`
+179 -580
View File
@@ -20,13 +20,8 @@ import (
)
type TidalDownloader struct {
client *http.Client
clientID string
clientSecret string
apiURL string
cachedToken string
tokenExpiresAt time.Time
tokenMu sync.Mutex
client *http.Client
apiURL string
}
var (
@@ -34,6 +29,11 @@ var (
tidalDownloaderOnce sync.Once
)
const (
spotifyTrackBaseURL = "https://open.spotify.com/track/"
songLinkLookupBaseURL = "https://api.song.link/v1-alpha.1/links?url="
)
type TidalTrack struct {
ID int64 `json:"id"`
Title string `json:"title"`
@@ -102,13 +102,8 @@ type MPD struct {
func NewTidalDownloader() *TidalDownloader {
tidalDownloaderOnce.Do(func() {
clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==")
clientSecret, _ := base64.StdEncoding.DecodeString("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=")
globalTidalDownloader = &TidalDownloader{
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
clientID: string(clientID),
clientSecret: string(clientSecret),
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
}
apis := globalTidalDownloader.GetAvailableAPIs()
@@ -120,85 +115,27 @@ func NewTidalDownloader() *TidalDownloader {
}
func (t *TidalDownloader) GetAvailableAPIs() []string {
encodedAPIs := []string{
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // tidal-api.binimum.org (priority)
"dGlkYWwua2lub3BsdXMub25saW5l", // tidal.kinoplus.online
"dHJpdG9uLnNxdWlkLnd0Zg==", // triton.squid.wtf
"dm9nZWwucXFkbC5zaXRl", // vogel.qqdl.site
"bWF1cy5xcWRsLnNpdGU=", // maus.qqdl.site
"aHVuZC5xcWRsLnNpdGU=", // hund.qqdl.site
"a2F0emUucXFkbC5zaXRl", // katze.qqdl.site
"d29sZi5xcWRsLnNpdGU=", // wolf.qqdl.site
"aGlmaS1vbmUuc3BvdGlzYXZlci5uZXQ=", // hifi-one.spotisaver.net
"aGlmaS10d28uc3BvdGlzYXZlci5uZXQ=", // hifi-two.spotisaver.net
return []string{
"https://tidal-api.binimum.org", // priority
"https://tidal.kinoplus.online",
"https://triton.squid.wtf",
"https://vogel.qqdl.site",
"https://maus.qqdl.site",
"https://hund.qqdl.site",
"https://katze.qqdl.site",
"https://wolf.qqdl.site",
"https://hifi-one.spotisaver.net",
"https://hifi-two.spotisaver.net",
}
var apis []string
for _, encoded := range encodedAPIs {
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
continue
}
apis = append(apis, "https://"+string(decoded))
}
return apis
}
func (t *TidalDownloader) GetAccessToken() (string, error) {
t.tokenMu.Lock()
defer t.tokenMu.Unlock()
if t.cachedToken != "" && time.Now().Add(60*time.Second).Before(t.tokenExpiresAt) {
return t.cachedToken, nil
}
data := fmt.Sprintf("client_id=%s&grant_type=client_credentials", t.clientID)
authURL, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hdXRoLnRpZGFsLmNvbS92MS9vYXV0aDIvdG9rZW4=")
req, err := http.NewRequest("POST", string(authURL), strings.NewReader(data))
if err != nil {
return "", err
}
req.SetBasicAuth(t.clientID, t.clientSecret)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := DoRequestWithUserAgent(t.client, req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("failed to get access token: HTTP %d", resp.StatusCode)
}
var result struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
t.cachedToken = result.AccessToken
if result.ExpiresIn > 0 {
t.tokenExpiresAt = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
} else {
t.tokenExpiresAt = time.Now().Add(55 * time.Minute) // Default 55 min
}
return result.AccessToken, nil
return "", fmt.Errorf("tidal official metadata API disabled: no client credentials mode")
}
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
spotifyURL := fmt.Sprintf("%s%s", spotifyTrackBaseURL, spotifyTrackID)
apiURL := fmt.Sprintf("%s%s", songLinkLookupBaseURL, url.QueryEscape(spotifyURL))
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
@@ -251,321 +188,20 @@ func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) {
}
func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) {
token, err := t.GetAccessToken()
if err != nil {
return nil, fmt.Errorf("failed to get access token: %w", err)
}
trackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3RyYWNrcy8=")
trackURL := fmt.Sprintf("%s%d?countryCode=US", string(trackBase), trackID)
req, err := http.NewRequest("GET", trackURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := DoRequestWithUserAgent(t.client, req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("failed to get track info: HTTP %d", resp.StatusCode)
}
var trackInfo TidalTrack
if err := json.NewDecoder(resp.Body).Decode(&trackInfo); err != nil {
return nil, err
}
return &trackInfo, nil
return nil, fmt.Errorf("tidal track lookup API disabled: no client credentials mode")
}
func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
token, err := t.GetAccessToken()
if err != nil {
return nil, err
}
searchBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3NlYXJjaC90cmFja3M/cXVlcnk9")
searchURL := fmt.Sprintf("%s%s&limit=50&countryCode=US", string(searchBase), url.QueryEscape(isrc))
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := DoRequestWithUserAgent(t.client, req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode)
}
var result struct {
Items []TidalTrack `json:"items"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
for i := range result.Items {
if result.Items[i].ISRC == isrc {
return &result.Items[i], nil
}
}
if len(result.Items) == 0 {
return nil, fmt.Errorf("no tracks found for ISRC: %s", isrc)
}
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
return nil, fmt.Errorf("tidal ISRC search API disabled: no client credentials mode")
}
// Now includes romaji conversion for Japanese text (4 search strategies like PC)
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
token, err := t.GetAccessToken()
if err != nil {
return nil, err
}
// Build search queries - multiple strategies (same as PC version)
queries := []string{}
if artistName != "" && trackName != "" {
queries = append(queries, artistName+" "+trackName)
}
if trackName != "" {
queries = append(queries, trackName)
}
if ContainsJapanese(trackName) || ContainsJapanese(artistName) {
romajiTrack := JapaneseToRomaji(trackName)
romajiArtist := JapaneseToRomaji(artistName)
cleanRomajiTrack := CleanToASCII(romajiTrack)
cleanRomajiArtist := CleanToASCII(romajiArtist)
if cleanRomajiArtist != "" && cleanRomajiTrack != "" {
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
if !containsQuery(queries, romajiQuery) {
queries = append(queries, romajiQuery)
GoLog("[Tidal] Japanese detected, adding romaji query: %s\n", romajiQuery)
}
}
if cleanRomajiTrack != "" && cleanRomajiTrack != trackName {
if !containsQuery(queries, cleanRomajiTrack) {
queries = append(queries, cleanRomajiTrack)
}
}
if artistName != "" && cleanRomajiTrack != "" {
partialQuery := artistName + " " + cleanRomajiTrack
if !containsQuery(queries, partialQuery) {
queries = append(queries, partialQuery)
}
}
}
if artistName != "" {
artistOnly := CleanToASCII(JapaneseToRomaji(artistName))
if artistOnly != "" && !containsQuery(queries, artistOnly) {
queries = append(queries, artistOnly)
}
}
searchBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3NlYXJjaC90cmFja3M/cXVlcnk9")
var allTracks []TidalTrack
searchedQueries := make(map[string]bool)
for _, query := range queries {
cleanQuery := strings.TrimSpace(query)
if cleanQuery == "" || searchedQueries[cleanQuery] {
continue
}
searchedQueries[cleanQuery] = true
GoLog("[Tidal] Searching for: %s\n", cleanQuery)
searchURL := fmt.Sprintf("%s%s&limit=100&countryCode=US", string(searchBase), url.QueryEscape(cleanQuery))
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil {
continue
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := DoRequestWithUserAgent(t.client, req)
if err != nil {
GoLog("[Tidal] Search error for '%s': %v\n", cleanQuery, err)
continue
}
if resp.StatusCode != 200 {
resp.Body.Close()
continue
}
var result struct {
Items []TidalTrack `json:"items"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
resp.Body.Close()
continue
}
resp.Body.Close()
if len(result.Items) > 0 {
GoLog("[Tidal] Found %d results for '%s'\n", len(result.Items), cleanQuery)
if spotifyISRC != "" {
for i := range result.Items {
if result.Items[i].ISRC == spotifyISRC {
track := &result.Items[i]
if expectedDuration > 0 {
durationDiff := track.Duration - expectedDuration
if durationDiff < 0 {
durationDiff = -durationDiff
}
if durationDiff <= 3 {
GoLog("[Tidal] ISRC match: '%s' (duration verified)\n", track.Title)
return track, nil
}
GoLog("[Tidal] ISRC match but duration mismatch (expected %ds, got %ds), continuing...\n",
expectedDuration, track.Duration)
} else {
GoLog("[Tidal] ISRC match: '%s'\n", track.Title)
return track, nil
}
}
}
}
allTracks = append(allTracks, result.Items...)
}
}
if len(allTracks) == 0 {
return nil, fmt.Errorf("no tracks found for any search query")
}
if spotifyISRC != "" {
GoLog("[Tidal] Looking for ISRC match: %s\n", spotifyISRC)
var isrcMatches []*TidalTrack
for i := range allTracks {
track := &allTracks[i]
if track.ISRC == spotifyISRC {
isrcMatches = append(isrcMatches, track)
}
}
if len(isrcMatches) > 0 {
if expectedDuration > 0 {
var durationVerifiedMatches []*TidalTrack
for _, track := range isrcMatches {
durationDiff := track.Duration - expectedDuration
if durationDiff < 0 {
durationDiff = -durationDiff
}
if durationDiff <= 3 {
durationVerifiedMatches = append(durationVerifiedMatches, track)
}
}
if len(durationVerifiedMatches) > 0 {
GoLog("[Tidal] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration)
return durationVerifiedMatches[0], nil
}
GoLog("[Tidal] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
spotifyISRC, expectedDuration, isrcMatches[0].Duration)
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version/edit)",
expectedDuration, isrcMatches[0].Duration)
}
GoLog("[Tidal] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
return isrcMatches[0], nil
}
GoLog("[Tidal] No ISRC match found for: %s\n", spotifyISRC)
return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC)
}
if expectedDuration > 0 {
tolerance := 3 // 3 seconds tolerance
var durationMatches []*TidalTrack
for i := range allTracks {
track := &allTracks[i]
durationDiff := track.Duration - expectedDuration
if durationDiff < 0 {
durationDiff = -durationDiff
}
if durationDiff <= tolerance {
durationMatches = append(durationMatches, track)
}
}
if len(durationMatches) > 0 {
bestMatch := durationMatches[0]
for _, track := range durationMatches {
for _, tag := range track.MediaMetadata.Tags {
if tag == "HIRES_LOSSLESS" {
bestMatch = track
break
}
}
}
GoLog("[Tidal] Found via duration match: %s - %s (%s)\n",
bestMatch.Artist.Name, bestMatch.Title, bestMatch.AudioQuality)
return bestMatch, nil
}
}
bestMatch := &allTracks[0]
for i := range allTracks {
track := &allTracks[i]
for _, tag := range track.MediaMetadata.Tags {
if tag == "HIRES_LOSSLESS" {
bestMatch = track
break
}
}
if bestMatch != &allTracks[0] {
break
}
}
GoLog("[Tidal] Found via search (no ISRC provided): %s - %s (ISRC: %s, Quality: %s)\n",
bestMatch.Artist.Name, bestMatch.Title, bestMatch.ISRC, bestMatch.AudioQuality)
return bestMatch, nil
}
func containsQuery(queries []string, query string) bool {
for _, q := range queries {
if q == query {
return true
}
}
return false
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, albumName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode")
}
func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*TidalTrack, error) {
return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", 0)
return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode")
}
// TidalDownloadInfo contains download URL and quality info
@@ -1300,13 +936,19 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
}
}
// Some tracks are symbol/emoji-heavy and providers can return textual
// aliases. If artist/duration already matched upstream, avoid false rejects.
// Emoji/symbol-only titles must be matched strictly to avoid false positives
// like mapping "🪐" to "Higher Power".
if (!hasAlphaNumericRunes(expectedTitle) || !hasAlphaNumericRunes(foundTitle)) &&
strings.TrimSpace(expectedTitle) != "" &&
strings.TrimSpace(foundTitle) != "" {
GoLog("[Tidal] Symbol-heavy title detected, relaxing match: '%s' vs '%s'\n", expectedTitle, foundTitle)
return true
expectedSymbols := normalizeSymbolOnlyTitle(expectedTitle)
foundSymbols := normalizeSymbolOnlyTitle(foundTitle)
if expectedSymbols != "" && foundSymbols != "" && expectedSymbols == foundSymbols {
GoLog("[Tidal] Symbol-heavy title matched strictly: '%s' vs '%s'\n", expectedTitle, foundTitle)
return true
}
GoLog("[Tidal] Symbol-heavy title mismatch: '%s' vs '%s'\n", expectedTitle, foundTitle)
return false
}
expectedLatin := isLatinScript(expectedTitle)
@@ -1426,182 +1068,9 @@ func isLatinScript(s string) bool {
return true
}
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
downloader := NewTidalDownloader()
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
if !isSafOutput {
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
}
expectedDurationSec := req.DurationMS / 1000
var track *TidalTrack
var err error
if req.TidalID != "" {
GoLog("[Tidal] Using Tidal ID from Odesli enrichment: %s\n", req.TidalID)
var trackID int64
if _, parseErr := fmt.Sscanf(req.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
track, err = downloader.GetTrackInfoByID(trackID)
if err != nil {
GoLog("[Tidal] Failed to get track by Odesli ID %d: %v\n", trackID, err)
track = nil
} else if track != nil {
GoLog("[Tidal] Successfully found track via Odesli ID: '%s' by '%s'\n", track.Title, track.Artist.Name)
}
}
}
if track == nil && req.ISRC != "" {
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 {
GoLog("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID)
track, err = downloader.GetTrackInfoByID(cached.TidalTrackID)
if err != nil {
GoLog("[Tidal] Cache hit but failed to get track info: %v\n", err)
track = nil // Fall through to normal search
}
}
}
if track == nil && req.ISRC != "" {
GoLog("[Tidal] Trying ISRC search: %s\n", req.ISRC)
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec)
if track != nil {
// Verify artist only (ISRC match is already accurate)
tidalArtist := track.Artist.Name
if len(track.Artists) > 0 {
var artistNames []string
for _, a := range track.Artists {
artistNames = append(artistNames, a.Name)
}
tidalArtist = strings.Join(artistNames, ", ")
}
if !artistsMatch(req.ArtistName, tidalArtist) {
GoLog("[Tidal] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, tidalArtist)
track = nil
}
}
}
if track == nil && req.SpotifyID != "" {
GoLog("[Tidal] ISRC search failed, trying SongLink...\n")
var trackID int64
var gotTidalID bool
if strings.HasPrefix(req.SpotifyID, "deezer:") {
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
GoLog("[Tidal] Using Deezer ID for SongLink lookup: %s\n", deezerID)
songlink := NewSongLinkClient()
availability, slErr := songlink.CheckAvailabilityFromDeezer(deezerID)
if slErr == nil && availability != nil && availability.TidalID != "" {
if _, parseErr := fmt.Sscanf(availability.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
GoLog("[Tidal] Got Tidal ID %d directly from SongLink\n", trackID)
gotTidalID = true
}
}
// Fallback to URL parsing if TidalID not in struct
if !gotTidalID && availability != nil && availability.TidalURL != "" {
var idErr error
trackID, idErr = downloader.GetTrackIDFromURL(availability.TidalURL)
if idErr == nil && trackID > 0 {
GoLog("[Tidal] Got Tidal ID %d from URL parsing\n", trackID)
gotTidalID = true
}
}
} else {
songlink := NewSongLinkClient()
availability, slErr := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
if slErr == nil && availability != nil && availability.TidalID != "" {
if _, parseErr := fmt.Sscanf(availability.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
GoLog("[Tidal] Got Tidal ID %d directly from SongLink\n", trackID)
gotTidalID = true
}
}
// Fallback to URL parsing if TidalID not in struct
if !gotTidalID && availability != nil && availability.TidalURL != "" {
var idErr error
trackID, idErr = downloader.GetTrackIDFromURL(availability.TidalURL)
if idErr == nil && trackID > 0 {
GoLog("[Tidal] Got Tidal ID %d from URL parsing\n", trackID)
gotTidalID = true
}
}
}
if gotTidalID && trackID > 0 {
track, err = downloader.GetTrackInfoByID(trackID)
if track != nil {
tidalArtist := track.Artist.Name
if len(track.Artists) > 0 {
var artistNames []string
for _, a := range track.Artists {
artistNames = append(artistNames, a.Name)
}
tidalArtist = strings.Join(artistNames, ", ")
}
if !artistsMatch(req.ArtistName, tidalArtist) {
GoLog("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, tidalArtist)
track = nil
}
if track != nil && expectedDurationSec > 0 {
durationDiff := track.Duration - expectedDurationSec
if durationDiff < 0 {
durationDiff = -durationDiff
}
if durationDiff > 3 {
GoLog("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
expectedDurationSec, track.Duration)
track = nil // Reject this match
}
}
// Cache for future use
if track != nil && req.ISRC != "" {
GetTrackIDCache().SetTidal(req.ISRC, track.ID)
}
}
}
}
func tidalTrackArtistsDisplay(track *TidalTrack) string {
if track == nil {
GoLog("[Tidal] Trying metadata search as last resort...\n")
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec)
if track != nil {
tidalArtist := track.Artist.Name
if len(track.Artists) > 0 {
var artistNames []string
for _, a := range track.Artists {
artistNames = append(artistNames, a.Name)
}
tidalArtist = strings.Join(artistNames, ", ")
}
if !titlesMatch(req.TrackName, track.Title) {
GoLog("[Tidal] Title mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
req.TrackName, track.Title)
track = nil
} else if !artistsMatch(req.ArtistName, tidalArtist) {
GoLog("[Tidal] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, tidalArtist)
track = nil
}
}
}
if track == nil {
errMsg := "could not find matching track on Tidal (artist/duration mismatch)"
if err != nil {
errMsg = err.Error()
}
return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg)
return ""
}
tidalArtist := track.Artist.Name
@@ -1612,10 +1081,130 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
}
tidalArtist = strings.Join(artistNames, ", ")
}
GoLog("[Tidal] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, tidalArtist, track.Duration)
return tidalArtist
}
func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloader, logPrefix string) (*TidalTrack, error) {
if downloader == nil {
downloader = NewTidalDownloader()
}
if strings.TrimSpace(logPrefix) == "" {
logPrefix = "Tidal"
}
expectedDurationSec := req.DurationMS / 1000
var trackID int64
var gotTidalID bool
if req.TidalID != "" {
GoLog("[%s] Using Tidal ID from Odesli enrichment: %s\n", logPrefix, req.TidalID)
if _, parseErr := fmt.Sscanf(req.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
gotTidalID = true
}
}
if !gotTidalID && req.ISRC != "" {
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 {
GoLog("[%s] Cache hit! Using cached track ID: %d\n", logPrefix, cached.TidalTrackID)
trackID = cached.TidalTrackID
gotTidalID = true
}
}
if !gotTidalID && (req.SpotifyID != "" || req.DeezerID != "") {
GoLog("[%s] Trying SongLink for Tidal ID...\n", logPrefix)
resolveFromAvailability := func(availability *TrackAvailability) {
if availability == nil || gotTidalID {
return
}
if availability.TidalID != "" {
if _, parseErr := fmt.Sscanf(availability.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
GoLog("[%s] Got Tidal ID %d directly from SongLink\n", logPrefix, trackID)
gotTidalID = true
return
}
}
if availability.TidalURL != "" {
var idErr error
trackID, idErr = downloader.GetTrackIDFromURL(availability.TidalURL)
if idErr == nil && trackID > 0 {
GoLog("[%s] Got Tidal ID %d from URL parsing\n", logPrefix, trackID)
gotTidalID = true
}
}
}
// Prefer Deezer-based SongLink lookup when DeezerID is available.
if req.DeezerID != "" {
GoLog("[%s] Using Deezer ID for SongLink lookup: %s\n", logPrefix, req.DeezerID)
songlink := NewSongLinkClient()
availability, slErr := songlink.CheckAvailabilityFromDeezer(req.DeezerID)
if slErr == nil {
resolveFromAvailability(availability)
} else {
GoLog("[%s] SongLink Deezer lookup failed: %v\n", logPrefix, slErr)
}
}
if !gotTidalID && req.SpotifyID != "" {
if strings.HasPrefix(req.SpotifyID, "deezer:") {
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
GoLog("[%s] Using Deezer ID for SongLink lookup: %s\n", logPrefix, deezerID)
songlink := NewSongLinkClient()
availability, slErr := songlink.CheckAvailabilityFromDeezer(deezerID)
if slErr == nil {
resolveFromAvailability(availability)
} else {
GoLog("[%s] SongLink Deezer lookup failed: %v\n", logPrefix, slErr)
}
}
}
if !gotTidalID && req.SpotifyID != "" && !strings.HasPrefix(req.SpotifyID, "deezer:") {
songlink := NewSongLinkClient()
availability, slErr := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
if slErr == nil {
resolveFromAvailability(availability)
}
}
}
if !gotTidalID || trackID <= 0 {
return nil, fmt.Errorf("failed to find tidal track id from request/cache/songlink")
}
track := &TidalTrack{
ID: trackID,
Title: strings.TrimSpace(req.TrackName),
ISRC: strings.TrimSpace(req.ISRC),
Duration: expectedDurationSec,
TrackNumber: req.TrackNumber,
VolumeNumber: req.DiscNumber,
}
track.Artist.Name = strings.TrimSpace(req.ArtistName)
track.Album.Title = strings.TrimSpace(req.AlbumName)
track.Album.ReleaseDate = strings.TrimSpace(req.ReleaseDate)
if req.ISRC != "" {
GetTrackIDCache().SetTidal(req.ISRC, track.ID)
GetTrackIDCache().SetTidal(req.ISRC, trackID)
}
return track, nil
}
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
downloader := NewTidalDownloader()
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
if !isSafOutput {
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
}
track, err := resolveTidalTrackForRequest(req, downloader, "Tidal")
if err != nil {
return TidalDownloadResult{}, err
}
quality := req.Quality
@@ -1694,13 +1283,19 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
parallelDone := make(chan struct{})
go func() {
defer close(parallelDone)
coverURL := req.CoverURL
embedLyrics := req.EmbedLyrics
if !req.EmbedMetadata {
coverURL = ""
embedLyrics = false
}
parallelResult = FetchCoverAndLyricsParallel(
req.CoverURL,
coverURL,
req.EmbedMaxQualityCover,
req.SpotifyID,
req.TrackName,
req.ArtistName,
req.EmbedLyrics,
embedLyrics,
int64(req.DurationMS),
)
}()
@@ -1784,11 +1379,15 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
}
if (isSafOutput && actualExt == ".flac") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".flac")) {
if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
if req.EmbedMetadata {
if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
}
} else {
GoLog("[Tidal] Metadata embedding disabled by settings, skipping FLAC metadata/lyrics embedding\n")
}
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsMode := req.LyricsMode
if lyricsMode == "" {
lyricsMode = "embed"
@@ -1811,14 +1410,14 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
fmt.Println("[Tidal] Lyrics embedded successfully")
}
}
} else if req.EmbedLyrics {
} else if req.EmbedMetadata && req.EmbedLyrics {
fmt.Println("[Tidal] No lyrics available from parallel fetch")
}
} else if (isSafOutput && actualExt == ".m4a") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".m4a")) {
if quality == "HIGH" {
GoLog("[Tidal] HIGH quality M4A - skipping metadata embedding (file from server is already valid)\n")
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsMode := req.LyricsMode
if lyricsMode == "" {
lyricsMode = "embed"
@@ -1849,7 +1448,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
bitDepth = 0
sampleRate = 44100
}
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsLRC = parallelResult.LyricsLRC
}
+27
View File
@@ -41,3 +41,30 @@ func hasAlphaNumericRunes(value string) bool {
}
return false
}
// normalizeSymbolOnlyTitle keeps symbol/emoji runes while dropping letters,
// digits, spaces and punctuation. This is useful for emoji-only titles such as
// "🪐", "🌎" etc, so we can compare them strictly and avoid false matches.
func normalizeSymbolOnlyTitle(title string) string {
trimmed := strings.TrimSpace(strings.ToLower(title))
if trimmed == "" {
return ""
}
var b strings.Builder
b.Grow(len(trimmed))
for _, r := range trimmed {
switch {
case unicode.IsLetter(r), unicode.IsNumber(r), unicode.IsSpace(r), unicode.IsPunct(r):
continue
// Drop combining marks such as emoji variation selectors.
case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Mc, r), unicode.Is(unicode.Me, r):
continue
default:
b.WriteRune(r)
}
}
return b.String()
}
+18
View File
@@ -27,8 +27,26 @@ func TestTitlesMatch_SeparatorVariants(t *testing.T) {
}
}
func TestTitlesMatch_EmojiStrict(t *testing.T) {
if titlesMatch("🪐", "Higher Power") {
t.Fatal("expected emoji title not to match unrelated textual title")
}
if !titlesMatch("🪐", "🪐") {
t.Fatal("expected identical emoji titles to match")
}
}
func TestQobuzTitlesMatch_SeparatorVariants(t *testing.T) {
if !qobuzTitlesMatch("Doctor / Cops", "Doctor _ Cops") {
t.Fatal("expected qobuzTitlesMatch to accept / vs _ variant")
}
}
func TestQobuzTitlesMatch_EmojiStrict(t *testing.T) {
if qobuzTitlesMatch("🪐", "Higher Power") {
t.Fatal("expected emoji title not to match unrelated textual title")
}
if !qobuzTitlesMatch("🪐", "🪐") {
t.Fatal("expected identical emoji titles to match")
}
}
+2 -2
View File
@@ -276,11 +276,11 @@ func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitr
}
// requestSpotubeDL uses SpotubeDL as a Cobalt proxy (they handle auth to yt-dl.click instances).
// Note: engine v2 currently serves MP3-oriented outputs, so we only use v2 for MP3 requests.
// Engines v3/v2 are MP3-oriented outputs, so we only use them for MP3 requests.
func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate string) (*CobaltResponse, error) {
engines := []string{"v1"}
if strings.EqualFold(audioFormat, "mp3") {
engines = append(engines, "v2")
engines = append(engines, "v3", "v2")
}
var lastErr error
+153
View File
@@ -5,6 +5,15 @@ import Gobackend // Import Go framework
@main
@objc class AppDelegate: FlutterAppDelegate {
private let CHANNEL = "com.zarz.spotiflac/backend"
private let DOWNLOAD_PROGRESS_STREAM_CHANNEL = "com.zarz.spotiflac/download_progress_stream"
private let LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL = "com.zarz.spotiflac/library_scan_progress_stream"
private let streamQueue = DispatchQueue(label: "com.zarz.spotiflac.progress_stream", qos: .utility)
private var downloadProgressTimer: DispatchSourceTimer?
private var downloadProgressEventSink: FlutterEventSink?
private var lastDownloadProgressPayload: String?
private var libraryScanProgressTimer: DispatchSourceTimer?
private var libraryScanProgressEventSink: FlutterEventSink?
private var lastLibraryScanProgressPayload: String?
override func application(
_ application: UIApplication,
@@ -16,14 +25,111 @@ import Gobackend // Import Go framework
name: CHANNEL,
binaryMessenger: controller.binaryMessenger
)
let downloadProgressEvents = FlutterEventChannel(
name: DOWNLOAD_PROGRESS_STREAM_CHANNEL,
binaryMessenger: controller.binaryMessenger
)
let libraryScanProgressEvents = FlutterEventChannel(
name: LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL,
binaryMessenger: controller.binaryMessenger
)
channel.setMethodCallHandler { [weak self] call, result in
self?.handleMethodCall(call: call, result: result)
}
downloadProgressEvents.setStreamHandler(
ClosureStreamHandler(
onListen: { [weak self] _, events in
self?.startDownloadProgressStream(events)
return nil
},
onCancel: { [weak self] _ in
self?.stopDownloadProgressStream()
return nil
}
)
)
libraryScanProgressEvents.setStreamHandler(
ClosureStreamHandler(
onListen: { [weak self] _, events in
self?.startLibraryScanProgressStream(events)
return nil
},
onCancel: { [weak self] _ in
self?.stopLibraryScanProgressStream()
return nil
}
)
)
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
deinit {
stopDownloadProgressStream()
stopLibraryScanProgressStream()
}
private func startDownloadProgressStream(_ eventSink: @escaping FlutterEventSink) {
stopDownloadProgressStream()
downloadProgressEventSink = eventSink
lastDownloadProgressPayload = nil
let timer = DispatchSource.makeTimerSource(queue: streamQueue)
timer.schedule(deadline: .now(), repeating: .milliseconds(800))
timer.setEventHandler { [weak self] in
guard let self else { return }
let payload = GobackendGetAllDownloadProgress() as String? ?? "{}"
if payload == self.lastDownloadProgressPayload {
return
}
self.lastDownloadProgressPayload = payload
DispatchQueue.main.async { [weak self] in
self?.downloadProgressEventSink?(payload)
}
}
downloadProgressTimer = timer
timer.resume()
}
private func stopDownloadProgressStream() {
downloadProgressTimer?.setEventHandler {}
downloadProgressTimer?.cancel()
downloadProgressTimer = nil
downloadProgressEventSink = nil
lastDownloadProgressPayload = nil
}
private func startLibraryScanProgressStream(_ eventSink: @escaping FlutterEventSink) {
stopLibraryScanProgressStream()
libraryScanProgressEventSink = eventSink
lastLibraryScanProgressPayload = nil
let timer = DispatchSource.makeTimerSource(queue: streamQueue)
timer.schedule(deadline: .now(), repeating: .milliseconds(800))
timer.setEventHandler { [weak self] in
guard let self else { return }
let payload = GobackendGetLibraryScanProgressJSON() as String? ?? "{}"
if payload == self.lastLibraryScanProgressPayload {
return
}
self.lastLibraryScanProgressPayload = payload
DispatchQueue.main.async { [weak self] in
self?.libraryScanProgressEventSink?(payload)
}
}
libraryScanProgressTimer = timer
timer.resume()
}
private func stopLibraryScanProgressStream() {
libraryScanProgressTimer?.setEventHandler {}
libraryScanProgressTimer?.cancel()
libraryScanProgressTimer = nil
libraryScanProgressEventSink = nil
lastLibraryScanProgressPayload = nil
}
private func handleMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) {
DispatchQueue.global(qos: .userInitiated).async {
@@ -74,6 +180,14 @@ import Gobackend // Import Go framework
let response = GobackendSearchSpotifyAll(query, Int(trackLimit), Int(artistLimit), &error)
if let error = error { throw error }
return response
case "getSpotifyRelatedArtists":
let args = call.arguments as! [String: Any]
let artistId = args["artist_id"] as! String
let limit = args["limit"] as? Int ?? 12
let response = GobackendGetSpotifyRelatedArtists(artistId, Int(limit), &error)
if let error = error { throw error }
return response
case "checkAvailability":
let args = call.arguments as! [String: Any]
@@ -127,6 +241,13 @@ import Gobackend // Import Go framework
GobackendSetDownloadDirectory(path, &error)
if let error = error { throw error }
return nil
case "setNetworkCompatibilityOptions", "setSongLinkNetworkOptions":
let args = call.arguments as! [String: Any]
let allowHTTP = args["allow_http"] as? Bool ?? false
let insecureTLS = args["insecure_tls"] as? Bool ?? false
GobackendSetNetworkCompatibilityOptions(allowHTTP, insecureTLS)
return nil
case "checkDuplicate":
let args = call.arguments as! [String: Any]
@@ -275,6 +396,14 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "getDeezerRelatedArtists":
let args = call.arguments as! [String: Any]
let artistId = args["artist_id"] as! String
let limit = args["limit"] as? Int ?? 12
let response = GobackendGetDeezerRelatedArtists(artistId, Int(limit), &error)
if let error = error { throw error }
return response
case "getDeezerMetadata":
let args = call.arguments as! [String: Any]
let resourceType = args["resource_type"] as! String
@@ -833,3 +962,27 @@ import Gobackend // Import Go framework
}
}
}
private final class ClosureStreamHandler: NSObject, FlutterStreamHandler {
typealias ListenHandler = (_ arguments: Any?, _ events: @escaping FlutterEventSink) -> FlutterError?
typealias CancelHandler = (_ arguments: Any?) -> FlutterError?
private let onListenHandler: ListenHandler
private let onCancelHandler: CancelHandler
init(
onListen: @escaping ListenHandler,
onCancel: @escaping CancelHandler = { _ in nil }
) {
self.onListenHandler = onListen
self.onCancelHandler = onCancel
}
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
onListenHandler(arguments, events)
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
onCancelHandler(arguments)
}
}
+6
View File
@@ -105,5 +105,11 @@
<string>tidal</string>
<string>youtube-music</string>
</array>
<!-- Background audio playback -->
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
</dict>
</plist>
+28 -2
View File
@@ -37,6 +37,9 @@ final _routerProvider = Provider<GoRouter>((ref) {
builder: (context, state) => const TutorialScreen(),
),
],
// Safety net: if a deep link URL (e.g. Spotify/Deezer) somehow reaches
// GoRouter, redirect to home instead of showing "Page Not Found".
errorBuilder: (context, state) => const MainShell(),
);
});
@@ -54,10 +57,14 @@ class SpotiFLACApp extends ConsumerWidget {
: null;
Locale? locale;
if (localeString != 'system') {
if (localeString != 'system' && localeString.isNotEmpty) {
if (localeString.contains('_')) {
final parts = localeString.split('_');
locale = Locale(parts[0], parts[1]);
if (parts.length == 2) {
locale = Locale(parts[0], parts[1]);
} else {
locale = Locale(parts[0]);
}
} else {
locale = Locale(localeString);
}
@@ -76,6 +83,25 @@ class SpotiFLACApp extends ConsumerWidget {
themeAnimationCurve: Curves.easeInOut,
routerConfig: router,
locale: locale,
localeResolutionCallback: (deviceLocale, supportedLocales) {
if (locale != null) return locale;
if (deviceLocale == null) return supportedLocales.first;
for (var supportedLocale in supportedLocales) {
if (supportedLocale.languageCode == deviceLocale.languageCode &&
supportedLocale.countryCode == deviceLocale.countryCode) {
return supportedLocale;
}
}
for (var supportedLocale in supportedLocales) {
if (supportedLocale.languageCode == deviceLocale.languageCode) {
return supportedLocale;
}
}
return supportedLocales.first;
},
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '3.6.9';
static const String buildNumber = '82';
static const String version = '3.7.0';
static const String buildNumber = '103';
static const String fullVersion = '$version+$buildNumber';
+443 -1
View File
@@ -544,6 +544,54 @@ abstract class AppLocalizations {
/// **'Try other services if download fails'**
String get optionsAutoFallbackSubtitle;
/// Toggle to skip to the next queue track when current track stream resolution fails
///
/// In en, this message translates to:
/// **'Auto Skip Unavailable Tracks'**
String get optionsAutoSkipUnavailableTracks;
/// Subtitle when auto skip on resolve failure is enabled
///
/// In en, this message translates to:
/// **'Automatically skip to the next queue track when a stream cannot be resolved.'**
String get optionsAutoSkipUnavailableTracksSubtitleOn;
/// Subtitle when auto skip on resolve failure is disabled
///
/// In en, this message translates to:
/// **'Stop on failed track resolution and show an error.'**
String get optionsAutoSkipUnavailableTracksSubtitleOff;
/// Tap behavior mode for track lists
///
/// In en, this message translates to:
/// **'Interaction Mode'**
String get optionsInteractionMode;
/// Interaction mode where taps queue downloads
///
/// In en, this message translates to:
/// **'Downloader Mode'**
String get modeDownloader;
/// Subtitle for downloader interaction mode
///
/// In en, this message translates to:
/// **'Tap tracks to add them to download queue'**
String get modeDownloaderSubtitle;
/// Interaction mode where taps start playback
///
/// In en, this message translates to:
/// **'Streaming Mode'**
String get modeStreaming;
/// Subtitle for streaming interaction mode
///
/// In en, this message translates to:
/// **'Tap tracks to play instantly'**
String get modeStreamingSubtitle;
/// Enable extension download providers
///
/// In en, this message translates to:
@@ -1906,6 +1954,12 @@ abstract class AppLocalizations {
/// **'No tracks found'**
String get errorNoTracksFound;
/// Error - seek disabled for live decrypted stream
///
/// In en, this message translates to:
/// **'Seeking is not supported for this live stream'**
String get errorSeekNotSupported;
/// Error - extension source not available
///
/// In en, this message translates to:
@@ -2842,6 +2896,12 @@ abstract class AppLocalizations {
/// **'Download All ({count})'**
String downloadAllCount(int count);
/// Play all button with count
///
/// In en, this message translates to:
/// **'Play All ({count})'**
String playAllCount(int count);
/// Track count display
///
/// In en, this message translates to:
@@ -4048,12 +4108,24 @@ abstract class AppLocalizations {
/// **'Download Discography'**
String get discographyDownload;
/// Button - play artist discography
///
/// In en, this message translates to:
/// **'Play Discography'**
String get discographyPlay;
/// Option - download entire discography
///
/// In en, this message translates to:
/// **'Download All'**
String get discographyDownloadAll;
/// Option - play entire discography
///
/// In en, this message translates to:
/// **'Play All'**
String get discographyPlayAll;
/// Subtitle showing total tracks and albums
///
/// In en, this message translates to:
@@ -4120,6 +4192,12 @@ abstract class AppLocalizations {
/// **'Download Selected'**
String get discographyDownloadSelected;
/// Button - play selected albums
///
/// In en, this message translates to:
/// **'Play Selected'**
String get discographyPlaySelected;
/// Snackbar - tracks added from discography
///
/// In en, this message translates to:
@@ -4342,6 +4420,12 @@ abstract class AppLocalizations {
/// **'{count} tracks'**
String libraryTracksCount(int count);
/// Unit label for tracks count (without the number itself)
///
/// In en, this message translates to:
/// **'{count, plural, =1{track} other{tracks}}'**
String libraryTracksUnit(int count);
/// Last scan time display
///
/// In en, this message translates to:
@@ -5125,7 +5209,7 @@ abstract class AppLocalizations {
/// Menu action - re-embed metadata into audio file
///
/// In en, this message translates to:
/// **'Re-enrich Metadata'**
/// **'Re-enrich'**
String get trackReEnrich;
/// Subtitle for re-enrich metadata action
@@ -5257,6 +5341,364 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Conversion failed'**
String get trackConvertFailed;
/// Generic action button - create
///
/// In en, this message translates to:
/// **'Create'**
String get actionCreate;
/// Library section title for custom folders
///
/// In en, this message translates to:
/// **'My folders'**
String get collectionFoldersTitle;
/// Custom folder for saved tracks to download later
///
/// In en, this message translates to:
/// **'Wishlist'**
String get collectionWishlist;
/// Custom folder for favorite tracks
///
/// In en, this message translates to:
/// **'Loved'**
String get collectionLoved;
/// Custom user playlists folder
///
/// In en, this message translates to:
/// **'Playlists'**
String get collectionPlaylists;
/// Single playlist label
///
/// In en, this message translates to:
/// **'Playlist'**
String get collectionPlaylist;
/// Action to add a track to user playlist
///
/// In en, this message translates to:
/// **'Add to playlist'**
String get collectionAddToPlaylist;
/// Action to create a new playlist
///
/// In en, this message translates to:
/// **'Create playlist'**
String get collectionCreatePlaylist;
/// Empty state title when user has no playlists
///
/// In en, this message translates to:
/// **'No playlists yet'**
String get collectionNoPlaylistsYet;
/// Empty state subtitle when user has no playlists
///
/// In en, this message translates to:
/// **'Create a playlist to start categorizing tracks'**
String get collectionNoPlaylistsSubtitle;
/// Track count label for custom playlists
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 track} other{{count} tracks}}'**
String collectionPlaylistTracks(int count);
/// Snackbar after adding track to playlist
///
/// In en, this message translates to:
/// **'Added to \"{playlistName}\"'**
String collectionAddedToPlaylist(String playlistName);
/// Snackbar when track already exists in playlist
///
/// In en, this message translates to:
/// **'Already in \"{playlistName}\"'**
String collectionAlreadyInPlaylist(String playlistName);
/// Snackbar after creating playlist
///
/// In en, this message translates to:
/// **'Playlist created'**
String get collectionPlaylistCreated;
/// Hint text for playlist name input
///
/// In en, this message translates to:
/// **'Playlist name'**
String get collectionPlaylistNameHint;
/// Validation error for empty playlist name
///
/// In en, this message translates to:
/// **'Playlist name is required'**
String get collectionPlaylistNameRequired;
/// Action to rename playlist
///
/// In en, this message translates to:
/// **'Rename playlist'**
String get collectionRenamePlaylist;
/// Action to delete playlist
///
/// In en, this message translates to:
/// **'Delete playlist'**
String get collectionDeletePlaylist;
/// Confirmation message for deleting playlist
///
/// In en, this message translates to:
/// **'Delete \"{playlistName}\" and all tracks inside it?'**
String collectionDeletePlaylistMessage(String playlistName);
/// Snackbar after deleting playlist
///
/// In en, this message translates to:
/// **'Playlist deleted'**
String get collectionPlaylistDeleted;
/// Snackbar after renaming playlist
///
/// In en, this message translates to:
/// **'Playlist renamed'**
String get collectionPlaylistRenamed;
/// Wishlist empty state title
///
/// In en, this message translates to:
/// **'Wishlist is empty'**
String get collectionWishlistEmptyTitle;
/// Wishlist empty state subtitle
///
/// In en, this message translates to:
/// **'Tap + on tracks to save what you want to download later'**
String get collectionWishlistEmptySubtitle;
/// Loved empty state title
///
/// In en, this message translates to:
/// **'Loved folder is empty'**
String get collectionLovedEmptyTitle;
/// Loved empty state subtitle
///
/// In en, this message translates to:
/// **'Tap love on tracks to keep your favorites'**
String get collectionLovedEmptySubtitle;
/// Playlist empty state title
///
/// In en, this message translates to:
/// **'Playlist is empty'**
String get collectionPlaylistEmptyTitle;
/// Playlist empty state subtitle
///
/// In en, this message translates to:
/// **'Long-press + on any track to add it here'**
String get collectionPlaylistEmptySubtitle;
/// Tooltip for removing track from playlist
///
/// In en, this message translates to:
/// **'Remove from playlist'**
String get collectionRemoveFromPlaylist;
/// Tooltip for removing track from wishlist/loved folder
///
/// In en, this message translates to:
/// **'Remove from folder'**
String get collectionRemoveFromFolder;
/// Snackbar after removing a track from a collection
///
/// In en, this message translates to:
/// **'\"{trackName}\" removed'**
String collectionRemoved(String trackName);
/// Snackbar after adding track to loved folder
///
/// In en, this message translates to:
/// **'\"{trackName}\" added to Loved'**
String collectionAddedToLoved(String trackName);
/// Snackbar after removing track from loved folder
///
/// In en, this message translates to:
/// **'\"{trackName}\" removed from Loved'**
String collectionRemovedFromLoved(String trackName);
/// Snackbar after adding track to wishlist
///
/// In en, this message translates to:
/// **'\"{trackName}\" added to Wishlist'**
String collectionAddedToWishlist(String trackName);
/// Snackbar after removing track from wishlist
///
/// In en, this message translates to:
/// **'\"{trackName}\" removed from Wishlist'**
String collectionRemovedFromWishlist(String trackName);
/// Bottom sheet action label - add track to loved folder
///
/// In en, this message translates to:
/// **'Add to Loved'**
String get trackOptionAddToLoved;
/// Bottom sheet action label - remove track from loved folder
///
/// In en, this message translates to:
/// **'Remove from Loved'**
String get trackOptionRemoveFromLoved;
/// Bottom sheet action label - add track to wishlist
///
/// In en, this message translates to:
/// **'Add to Wishlist'**
String get trackOptionAddToWishlist;
/// Bottom sheet action label - remove track from wishlist
///
/// In en, this message translates to:
/// **'Remove from Wishlist'**
String get trackOptionRemoveFromWishlist;
/// Bottom sheet action to pick a custom cover image for a playlist
///
/// In en, this message translates to:
/// **'Change cover image'**
String get collectionPlaylistChangeCover;
/// Bottom sheet action to remove custom cover image from a playlist
///
/// In en, this message translates to:
/// **'Remove cover image'**
String get collectionPlaylistRemoveCover;
/// Share button text with count in selection mode
///
/// In en, this message translates to:
/// **'Share {count} {count, plural, =1{track} other{tracks}}'**
String selectionShareCount(int count);
/// Snackbar when no selected files exist on disk
///
/// In en, this message translates to:
/// **'No shareable files found'**
String get selectionShareNoFiles;
/// Convert button text with count in selection mode
///
/// In en, this message translates to:
/// **'Convert {count} {count, plural, =1{track} other{tracks}}'**
String selectionConvertCount(int count);
/// Snackbar when no selected tracks support conversion
///
/// In en, this message translates to:
/// **'No convertible tracks selected'**
String get selectionConvertNoConvertible;
/// Confirmation dialog title for batch conversion
///
/// In en, this message translates to:
/// **'Batch Convert'**
String get selectionBatchConvertConfirmTitle;
/// Confirmation dialog message for batch conversion
///
/// In en, this message translates to:
/// **'Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.'**
String selectionBatchConvertConfirmMessage(
int count,
String format,
String bitrate,
);
/// Snackbar during batch conversion progress
///
/// In en, this message translates to:
/// **'Converting {current} of {total}...'**
String selectionBatchConvertProgress(int current, int total);
/// Snackbar after batch conversion completes
///
/// In en, this message translates to:
/// **'Converted {success} of {total} tracks to {format}'**
String selectionBatchConvertSuccess(int success, int total, String format);
/// Title for mode selection step in setup wizard
///
/// In en, this message translates to:
/// **'Choose Your Mode'**
String get setupModeSelectionTitle;
/// Description for mode selection step
///
/// In en, this message translates to:
/// **'How would you like to use SpotiFLAC? You can always change this later in Settings.'**
String get setupModeSelectionDescription;
/// Title for downloader mode option
///
/// In en, this message translates to:
/// **'Downloader'**
String get setupModeDownloaderTitle;
/// Downloader mode feature 1
///
/// In en, this message translates to:
/// **'Download tracks in lossless FLAC quality'**
String get setupModeDownloaderFeature1;
/// Downloader mode feature 2
///
/// In en, this message translates to:
/// **'Save music to your device for offline listening'**
String get setupModeDownloaderFeature2;
/// Downloader mode feature 3
///
/// In en, this message translates to:
/// **'Manage your local music library'**
String get setupModeDownloaderFeature3;
/// Title for streaming mode option
///
/// In en, this message translates to:
/// **'Streaming'**
String get setupModeStreamingTitle;
/// Streaming mode feature 1
///
/// In en, this message translates to:
/// **'Stream tracks instantly without downloading'**
String get setupModeStreamingFeature1;
/// Streaming mode feature 2
///
/// In en, this message translates to:
/// **'Smart Queue auto-discovers new music for you'**
String get setupModeStreamingFeature2;
/// Streaming mode feature 3
///
/// In en, this message translates to:
/// **'Play any track on demand with playback controls'**
String get setupModeStreamingFeature3;
/// Hint that mode can be changed later
///
/// In en, this message translates to:
/// **'You can switch between modes anytime in Settings.'**
String get setupModeChangeableLater;
}
class _AppLocalizationsDelegate
+302 -1
View File
@@ -251,6 +251,33 @@ class AppLocalizationsDe extends AppLocalizations {
String get optionsAutoFallbackSubtitle =>
'Andere Dienste versuchen, wenn Download fehlschlägt';
@override
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
@override
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
'Automatically skip to the next queue track when a stream cannot be resolved.';
@override
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
'Stop on failed track resolution and show an error.';
@override
String get optionsInteractionMode => 'Interaction Mode';
@override
String get modeDownloader => 'Downloader Mode';
@override
String get modeDownloaderSubtitle =>
'Tap tracks to add them to download queue';
@override
String get modeStreaming => 'Streaming Mode';
@override
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
@override
String get optionsUseExtensionProviders => 'Erweiterungs-Anbieter verwenden';
@@ -1057,6 +1084,10 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get errorNoTracksFound => 'Keine Titel gefunden';
@override
String get errorSeekNotSupported =>
'Seeking is not supported for this live stream';
@override
String errorMissingExtensionSource(String item) {
return 'Kann $item nicht lade wegen fehlender Erweiterungsquelle';
@@ -1578,6 +1609,11 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Download All ($count)';
}
@override
String playAllCount(int count) {
return 'Play All ($count)';
}
@override
String tracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
@@ -2256,9 +2292,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyPlay => 'Play Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String get discographyPlayAll => 'Play All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
@@ -2303,6 +2345,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String get discographyPlaySelected => 'Play Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
@@ -2428,6 +2473,17 @@ class AppLocalizationsDe extends AppLocalizations {
return '$count tracks';
}
@override
String libraryTracksUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$_temp0';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
@@ -2907,7 +2963,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Re-enrich Metadata';
String get trackReEnrich => 'Re-enrich';
@override
String get trackReEnrichSubtitle =>
@@ -2987,4 +3043,249 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get actionCreate => 'Create';
@override
String get collectionFoldersTitle => 'My folders';
@override
String get collectionWishlist => 'Wishlist';
@override
String get collectionLoved => 'Loved';
@override
String get collectionPlaylists => 'Playlists';
@override
String get collectionPlaylist => 'Playlist';
@override
String get collectionAddToPlaylist => 'Add to playlist';
@override
String get collectionCreatePlaylist => 'Create playlist';
@override
String get collectionNoPlaylistsYet => 'No playlists yet';
@override
String get collectionNoPlaylistsSubtitle =>
'Create a playlist to start categorizing tracks';
@override
String collectionPlaylistTracks(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String collectionAddedToPlaylist(String playlistName) {
return 'Added to \"$playlistName\"';
}
@override
String collectionAlreadyInPlaylist(String playlistName) {
return 'Already in \"$playlistName\"';
}
@override
String get collectionPlaylistCreated => 'Playlist created';
@override
String get collectionPlaylistNameHint => 'Playlist name';
@override
String get collectionPlaylistNameRequired => 'Playlist name is required';
@override
String get collectionRenamePlaylist => 'Rename playlist';
@override
String get collectionDeletePlaylist => 'Delete playlist';
@override
String collectionDeletePlaylistMessage(String playlistName) {
return 'Delete \"$playlistName\" and all tracks inside it?';
}
@override
String get collectionPlaylistDeleted => 'Playlist deleted';
@override
String get collectionPlaylistRenamed => 'Playlist renamed';
@override
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
@override
String get collectionWishlistEmptySubtitle =>
'Tap + on tracks to save what you want to download later';
@override
String get collectionLovedEmptyTitle => 'Loved folder is empty';
@override
String get collectionLovedEmptySubtitle =>
'Tap love on tracks to keep your favorites';
@override
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
@override
String get collectionPlaylistEmptySubtitle =>
'Long-press + on any track to add it here';
@override
String get collectionRemoveFromPlaylist => 'Remove from playlist';
@override
String get collectionRemoveFromFolder => 'Remove from folder';
@override
String collectionRemoved(String trackName) {
return '\"$trackName\" removed';
}
@override
String collectionAddedToLoved(String trackName) {
return '\"$trackName\" added to Loved';
}
@override
String collectionRemovedFromLoved(String trackName) {
return '\"$trackName\" removed from Loved';
}
@override
String collectionAddedToWishlist(String trackName) {
return '\"$trackName\" added to Wishlist';
}
@override
String collectionRemovedFromWishlist(String trackName) {
return '\"$trackName\" removed from Wishlist';
}
@override
String get trackOptionAddToLoved => 'Add to Loved';
@override
String get trackOptionRemoveFromLoved => 'Remove from Loved';
@override
String get trackOptionAddToWishlist => 'Add to Wishlist';
@override
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
@override
String get collectionPlaylistChangeCover => 'Change cover image';
@override
String get collectionPlaylistRemoveCover => 'Remove cover image';
@override
String selectionShareCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Share $count $_temp0';
}
@override
String get selectionShareNoFiles => 'No shareable files found';
@override
String selectionConvertCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0';
}
@override
String get selectionConvertNoConvertible => 'No convertible tracks selected';
@override
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
@override
String selectionBatchConvertConfirmMessage(
int count,
String format,
String bitrate,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
}
@override
String selectionBatchConvertSuccess(int success, int total, String format) {
return 'Converted $success of $total tracks to $format';
}
@override
String get setupModeSelectionTitle => 'Wähle deinen Modus';
@override
String get setupModeSelectionDescription =>
'Wie möchtest du SpotiFLAC nutzen? Du kannst dies später jederzeit in den Einstellungen ändern.';
@override
String get setupModeDownloaderTitle => 'Downloader';
@override
String get setupModeDownloaderFeature1 =>
'Lade Titel in verlustfreier FLAC-Qualität herunter';
@override
String get setupModeDownloaderFeature2 =>
'Speichere Musik auf deinem Gerät zum Offline-Hören';
@override
String get setupModeDownloaderFeature3 =>
'Verwalte deine lokale Musikbibliothek';
@override
String get setupModeStreamingTitle => 'Streaming';
@override
String get setupModeStreamingFeature1 =>
'Streame Titel sofort ohne Herunterladen';
@override
String get setupModeStreamingFeature2 =>
'Smart Queue entdeckt automatisch neue Musik für dich';
@override
String get setupModeStreamingFeature3 =>
'Spiele jeden Titel auf Abruf mit Wiedergabesteuerung';
@override
String get setupModeChangeableLater =>
'Du kannst jederzeit in den Einstellungen zwischen den Modi wechseln.';
}
+301 -1
View File
@@ -248,6 +248,33 @@ class AppLocalizationsEn extends AppLocalizations {
String get optionsAutoFallbackSubtitle =>
'Try other services if download fails';
@override
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
@override
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
'Automatically skip to the next queue track when a stream cannot be resolved.';
@override
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
'Stop on failed track resolution and show an error.';
@override
String get optionsInteractionMode => 'Interaction Mode';
@override
String get modeDownloader => 'Downloader Mode';
@override
String get modeDownloaderSubtitle =>
'Tap tracks to add them to download queue';
@override
String get modeStreaming => 'Streaming Mode';
@override
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
@override
String get optionsUseExtensionProviders => 'Use Extension Providers';
@@ -1041,6 +1068,10 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get errorSeekNotSupported =>
'Seeking is not supported for this live stream';
@override
String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source';
@@ -1557,6 +1588,11 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Download All ($count)';
}
@override
String playAllCount(int count) {
return 'Play All ($count)';
}
@override
String tracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
@@ -2235,9 +2271,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyPlay => 'Play Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String get discographyPlayAll => 'Play All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
@@ -2282,6 +2324,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String get discographyPlaySelected => 'Play Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
@@ -2407,6 +2452,17 @@ class AppLocalizationsEn extends AppLocalizations {
return '$count tracks';
}
@override
String libraryTracksUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$_temp0';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
@@ -2886,7 +2942,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Re-enrich Metadata';
String get trackReEnrich => 'Re-enrich';
@override
String get trackReEnrichSubtitle =>
@@ -2966,4 +3022,248 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get actionCreate => 'Create';
@override
String get collectionFoldersTitle => 'My folders';
@override
String get collectionWishlist => 'Wishlist';
@override
String get collectionLoved => 'Loved';
@override
String get collectionPlaylists => 'Playlists';
@override
String get collectionPlaylist => 'Playlist';
@override
String get collectionAddToPlaylist => 'Add to playlist';
@override
String get collectionCreatePlaylist => 'Create playlist';
@override
String get collectionNoPlaylistsYet => 'No playlists yet';
@override
String get collectionNoPlaylistsSubtitle =>
'Create a playlist to start categorizing tracks';
@override
String collectionPlaylistTracks(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String collectionAddedToPlaylist(String playlistName) {
return 'Added to \"$playlistName\"';
}
@override
String collectionAlreadyInPlaylist(String playlistName) {
return 'Already in \"$playlistName\"';
}
@override
String get collectionPlaylistCreated => 'Playlist created';
@override
String get collectionPlaylistNameHint => 'Playlist name';
@override
String get collectionPlaylistNameRequired => 'Playlist name is required';
@override
String get collectionRenamePlaylist => 'Rename playlist';
@override
String get collectionDeletePlaylist => 'Delete playlist';
@override
String collectionDeletePlaylistMessage(String playlistName) {
return 'Delete \"$playlistName\" and all tracks inside it?';
}
@override
String get collectionPlaylistDeleted => 'Playlist deleted';
@override
String get collectionPlaylistRenamed => 'Playlist renamed';
@override
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
@override
String get collectionWishlistEmptySubtitle =>
'Tap + on tracks to save what you want to download later';
@override
String get collectionLovedEmptyTitle => 'Loved folder is empty';
@override
String get collectionLovedEmptySubtitle =>
'Tap love on tracks to keep your favorites';
@override
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
@override
String get collectionPlaylistEmptySubtitle =>
'Long-press + on any track to add it here';
@override
String get collectionRemoveFromPlaylist => 'Remove from playlist';
@override
String get collectionRemoveFromFolder => 'Remove from folder';
@override
String collectionRemoved(String trackName) {
return '\"$trackName\" removed';
}
@override
String collectionAddedToLoved(String trackName) {
return '\"$trackName\" added to Loved';
}
@override
String collectionRemovedFromLoved(String trackName) {
return '\"$trackName\" removed from Loved';
}
@override
String collectionAddedToWishlist(String trackName) {
return '\"$trackName\" added to Wishlist';
}
@override
String collectionRemovedFromWishlist(String trackName) {
return '\"$trackName\" removed from Wishlist';
}
@override
String get trackOptionAddToLoved => 'Add to Loved';
@override
String get trackOptionRemoveFromLoved => 'Remove from Loved';
@override
String get trackOptionAddToWishlist => 'Add to Wishlist';
@override
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
@override
String get collectionPlaylistChangeCover => 'Change cover image';
@override
String get collectionPlaylistRemoveCover => 'Remove cover image';
@override
String selectionShareCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Share $count $_temp0';
}
@override
String get selectionShareNoFiles => 'No shareable files found';
@override
String selectionConvertCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0';
}
@override
String get selectionConvertNoConvertible => 'No convertible tracks selected';
@override
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
@override
String selectionBatchConvertConfirmMessage(
int count,
String format,
String bitrate,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
}
@override
String selectionBatchConvertSuccess(int success, int total, String format) {
return 'Converted $success of $total tracks to $format';
}
@override
String get setupModeSelectionTitle => 'Choose Your Mode';
@override
String get setupModeSelectionDescription =>
'How would you like to use SpotiFLAC? You can always change this later in Settings.';
@override
String get setupModeDownloaderTitle => 'Downloader';
@override
String get setupModeDownloaderFeature1 =>
'Download tracks in lossless FLAC quality';
@override
String get setupModeDownloaderFeature2 =>
'Save music to your device for offline listening';
@override
String get setupModeDownloaderFeature3 => 'Manage your local music library';
@override
String get setupModeStreamingTitle => 'Streaming';
@override
String get setupModeStreamingFeature1 =>
'Stream tracks instantly without downloading';
@override
String get setupModeStreamingFeature2 =>
'Smart Queue auto-discovers new music for you';
@override
String get setupModeStreamingFeature3 =>
'Play any track on demand with playback controls';
@override
String get setupModeChangeableLater =>
'You can switch between modes anytime in Settings.';
}
+344 -2
View File
@@ -248,6 +248,33 @@ class AppLocalizationsEs extends AppLocalizations {
String get optionsAutoFallbackSubtitle =>
'Try other services if download fails';
@override
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
@override
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
'Automatically skip to the next queue track when a stream cannot be resolved.';
@override
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
'Stop on failed track resolution and show an error.';
@override
String get optionsInteractionMode => 'Interaction Mode';
@override
String get modeDownloader => 'Downloader Mode';
@override
String get modeDownloaderSubtitle =>
'Tap tracks to add them to download queue';
@override
String get modeStreaming => 'Streaming Mode';
@override
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
@override
String get optionsUseExtensionProviders => 'Use Extension Providers';
@@ -1041,6 +1068,10 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get errorSeekNotSupported =>
'Seeking is not supported for this live stream';
@override
String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source';
@@ -1557,6 +1588,11 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Download All ($count)';
}
@override
String playAllCount(int count) {
return 'Play All ($count)';
}
@override
String tracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
@@ -2235,9 +2271,15 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyPlay => 'Play Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String get discographyPlayAll => 'Play All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
@@ -2282,6 +2324,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String get discographyPlaySelected => 'Play Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
@@ -2407,6 +2452,17 @@ class AppLocalizationsEs extends AppLocalizations {
return '$count tracks';
}
@override
String libraryTracksUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$_temp0';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
@@ -2886,7 +2942,7 @@ class AppLocalizationsEs extends AppLocalizations {
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Re-enrich Metadata';
String get trackReEnrich => 'Re-enrich';
@override
String get trackReEnrichSubtitle =>
@@ -2966,6 +3022,251 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get actionCreate => 'Create';
@override
String get collectionFoldersTitle => 'My folders';
@override
String get collectionWishlist => 'Wishlist';
@override
String get collectionLoved => 'Loved';
@override
String get collectionPlaylists => 'Playlists';
@override
String get collectionPlaylist => 'Playlist';
@override
String get collectionAddToPlaylist => 'Add to playlist';
@override
String get collectionCreatePlaylist => 'Create playlist';
@override
String get collectionNoPlaylistsYet => 'No playlists yet';
@override
String get collectionNoPlaylistsSubtitle =>
'Create a playlist to start categorizing tracks';
@override
String collectionPlaylistTracks(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String collectionAddedToPlaylist(String playlistName) {
return 'Added to \"$playlistName\"';
}
@override
String collectionAlreadyInPlaylist(String playlistName) {
return 'Already in \"$playlistName\"';
}
@override
String get collectionPlaylistCreated => 'Playlist created';
@override
String get collectionPlaylistNameHint => 'Playlist name';
@override
String get collectionPlaylistNameRequired => 'Playlist name is required';
@override
String get collectionRenamePlaylist => 'Rename playlist';
@override
String get collectionDeletePlaylist => 'Delete playlist';
@override
String collectionDeletePlaylistMessage(String playlistName) {
return 'Delete \"$playlistName\" and all tracks inside it?';
}
@override
String get collectionPlaylistDeleted => 'Playlist deleted';
@override
String get collectionPlaylistRenamed => 'Playlist renamed';
@override
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
@override
String get collectionWishlistEmptySubtitle =>
'Tap + on tracks to save what you want to download later';
@override
String get collectionLovedEmptyTitle => 'Loved folder is empty';
@override
String get collectionLovedEmptySubtitle =>
'Tap love on tracks to keep your favorites';
@override
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
@override
String get collectionPlaylistEmptySubtitle =>
'Long-press + on any track to add it here';
@override
String get collectionRemoveFromPlaylist => 'Remove from playlist';
@override
String get collectionRemoveFromFolder => 'Remove from folder';
@override
String collectionRemoved(String trackName) {
return '\"$trackName\" removed';
}
@override
String collectionAddedToLoved(String trackName) {
return '\"$trackName\" added to Loved';
}
@override
String collectionRemovedFromLoved(String trackName) {
return '\"$trackName\" removed from Loved';
}
@override
String collectionAddedToWishlist(String trackName) {
return '\"$trackName\" added to Wishlist';
}
@override
String collectionRemovedFromWishlist(String trackName) {
return '\"$trackName\" removed from Wishlist';
}
@override
String get trackOptionAddToLoved => 'Add to Loved';
@override
String get trackOptionRemoveFromLoved => 'Remove from Loved';
@override
String get trackOptionAddToWishlist => 'Add to Wishlist';
@override
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
@override
String get collectionPlaylistChangeCover => 'Change cover image';
@override
String get collectionPlaylistRemoveCover => 'Remove cover image';
@override
String selectionShareCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Share $count $_temp0';
}
@override
String get selectionShareNoFiles => 'No shareable files found';
@override
String selectionConvertCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0';
}
@override
String get selectionConvertNoConvertible => 'No convertible tracks selected';
@override
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
@override
String selectionBatchConvertConfirmMessage(
int count,
String format,
String bitrate,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
}
@override
String selectionBatchConvertSuccess(int success, int total, String format) {
return 'Converted $success of $total tracks to $format';
}
@override
String get setupModeSelectionTitle => 'Elige tu modo';
@override
String get setupModeSelectionDescription =>
'¿Cómo te gustaría usar SpotiFLAC? Puedes cambiarlo más tarde en Ajustes.';
@override
String get setupModeDownloaderTitle => 'Descargador';
@override
String get setupModeDownloaderFeature1 =>
'Descarga pistas en calidad FLAC sin pérdida';
@override
String get setupModeDownloaderFeature2 =>
'Guarda música en tu dispositivo para escuchar sin conexión';
@override
String get setupModeDownloaderFeature3 =>
'Gestiona tu biblioteca de música local';
@override
String get setupModeStreamingTitle => 'Streaming';
@override
String get setupModeStreamingFeature1 =>
'Transmite pistas al instante sin descargar';
@override
String get setupModeStreamingFeature2 =>
'Smart Queue descubre automáticamente nueva música para ti';
@override
String get setupModeStreamingFeature3 =>
'Reproduce cualquier pista bajo demanda con controles de reproducción';
@override
String get setupModeChangeableLater =>
'Puedes cambiar entre modos en cualquier momento en Ajustes.';
}
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
@@ -5852,7 +6153,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Re-enrich Metadata';
String get trackReEnrich => 'Re-enrich';
@override
String get trackReEnrichSubtitle =>
@@ -5932,4 +6233,45 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get setupModeSelectionTitle => 'Elige tu modo';
@override
String get setupModeSelectionDescription =>
'¿Cómo te gustaría usar SpotiFLAC? Puedes cambiarlo más tarde en Ajustes.';
@override
String get setupModeDownloaderTitle => 'Descargador';
@override
String get setupModeDownloaderFeature1 =>
'Descarga pistas en calidad FLAC sin pérdida';
@override
String get setupModeDownloaderFeature2 =>
'Guarda música en tu dispositivo para escuchar sin conexión';
@override
String get setupModeDownloaderFeature3 =>
'Gestiona tu biblioteca de música local';
@override
String get setupModeStreamingTitle => 'Streaming';
@override
String get setupModeStreamingFeature1 =>
'Transmite pistas al instante sin descargar';
@override
String get setupModeStreamingFeature2 =>
'Smart Queue descubre automáticamente nueva música para ti';
@override
String get setupModeStreamingFeature3 =>
'Reproduce cualquier pista bajo demanda con controles de reproducción';
@override
String get setupModeChangeableLater =>
'Puedes cambiar entre modos en cualquier momento en Ajustes.';
}
+302 -1
View File
@@ -253,6 +253,33 @@ class AppLocalizationsFr extends AppLocalizations {
String get optionsAutoFallbackSubtitle =>
'Essayez d\'autres services si le téléchargement échoue';
@override
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
@override
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
'Automatically skip to the next queue track when a stream cannot be resolved.';
@override
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
'Stop on failed track resolution and show an error.';
@override
String get optionsInteractionMode => 'Interaction Mode';
@override
String get modeDownloader => 'Downloader Mode';
@override
String get modeDownloaderSubtitle =>
'Tap tracks to add them to download queue';
@override
String get modeStreaming => 'Streaming Mode';
@override
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
@override
String get optionsUseExtensionProviders =>
'Utiliser des fournisseurs d\'extension';
@@ -1047,6 +1074,10 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get errorSeekNotSupported =>
'Seeking is not supported for this live stream';
@override
String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source';
@@ -1563,6 +1594,11 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Download All ($count)';
}
@override
String playAllCount(int count) {
return 'Play All ($count)';
}
@override
String tracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
@@ -2241,9 +2277,15 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyPlay => 'Play Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String get discographyPlayAll => 'Play All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
@@ -2288,6 +2330,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String get discographyPlaySelected => 'Play Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
@@ -2413,6 +2458,17 @@ class AppLocalizationsFr extends AppLocalizations {
return '$count tracks';
}
@override
String libraryTracksUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$_temp0';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
@@ -2892,7 +2948,7 @@ class AppLocalizationsFr extends AppLocalizations {
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Re-enrich Metadata';
String get trackReEnrich => 'Re-enrich';
@override
String get trackReEnrichSubtitle =>
@@ -2972,4 +3028,249 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get actionCreate => 'Create';
@override
String get collectionFoldersTitle => 'My folders';
@override
String get collectionWishlist => 'Wishlist';
@override
String get collectionLoved => 'Loved';
@override
String get collectionPlaylists => 'Playlists';
@override
String get collectionPlaylist => 'Playlist';
@override
String get collectionAddToPlaylist => 'Add to playlist';
@override
String get collectionCreatePlaylist => 'Create playlist';
@override
String get collectionNoPlaylistsYet => 'No playlists yet';
@override
String get collectionNoPlaylistsSubtitle =>
'Create a playlist to start categorizing tracks';
@override
String collectionPlaylistTracks(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String collectionAddedToPlaylist(String playlistName) {
return 'Added to \"$playlistName\"';
}
@override
String collectionAlreadyInPlaylist(String playlistName) {
return 'Already in \"$playlistName\"';
}
@override
String get collectionPlaylistCreated => 'Playlist created';
@override
String get collectionPlaylistNameHint => 'Playlist name';
@override
String get collectionPlaylistNameRequired => 'Playlist name is required';
@override
String get collectionRenamePlaylist => 'Rename playlist';
@override
String get collectionDeletePlaylist => 'Delete playlist';
@override
String collectionDeletePlaylistMessage(String playlistName) {
return 'Delete \"$playlistName\" and all tracks inside it?';
}
@override
String get collectionPlaylistDeleted => 'Playlist deleted';
@override
String get collectionPlaylistRenamed => 'Playlist renamed';
@override
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
@override
String get collectionWishlistEmptySubtitle =>
'Tap + on tracks to save what you want to download later';
@override
String get collectionLovedEmptyTitle => 'Loved folder is empty';
@override
String get collectionLovedEmptySubtitle =>
'Tap love on tracks to keep your favorites';
@override
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
@override
String get collectionPlaylistEmptySubtitle =>
'Long-press + on any track to add it here';
@override
String get collectionRemoveFromPlaylist => 'Remove from playlist';
@override
String get collectionRemoveFromFolder => 'Remove from folder';
@override
String collectionRemoved(String trackName) {
return '\"$trackName\" removed';
}
@override
String collectionAddedToLoved(String trackName) {
return '\"$trackName\" added to Loved';
}
@override
String collectionRemovedFromLoved(String trackName) {
return '\"$trackName\" removed from Loved';
}
@override
String collectionAddedToWishlist(String trackName) {
return '\"$trackName\" added to Wishlist';
}
@override
String collectionRemovedFromWishlist(String trackName) {
return '\"$trackName\" removed from Wishlist';
}
@override
String get trackOptionAddToLoved => 'Add to Loved';
@override
String get trackOptionRemoveFromLoved => 'Remove from Loved';
@override
String get trackOptionAddToWishlist => 'Add to Wishlist';
@override
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
@override
String get collectionPlaylistChangeCover => 'Change cover image';
@override
String get collectionPlaylistRemoveCover => 'Remove cover image';
@override
String selectionShareCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Share $count $_temp0';
}
@override
String get selectionShareNoFiles => 'No shareable files found';
@override
String selectionConvertCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0';
}
@override
String get selectionConvertNoConvertible => 'No convertible tracks selected';
@override
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
@override
String selectionBatchConvertConfirmMessage(
int count,
String format,
String bitrate,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
}
@override
String selectionBatchConvertSuccess(int success, int total, String format) {
return 'Converted $success of $total tracks to $format';
}
@override
String get setupModeSelectionTitle => 'Choisissez votre mode';
@override
String get setupModeSelectionDescription =>
'Comment souhaitez-vous utiliser SpotiFLAC ? Vous pouvez toujours changer cela plus tard dans les Paramètres.';
@override
String get setupModeDownloaderTitle => 'Téléchargeur';
@override
String get setupModeDownloaderFeature1 =>
'Téléchargez des pistes en qualité FLAC sans perte';
@override
String get setupModeDownloaderFeature2 =>
'Enregistrez de la musique sur votre appareil pour une écoute hors ligne';
@override
String get setupModeDownloaderFeature3 =>
'Gérez votre bibliothèque musicale locale';
@override
String get setupModeStreamingTitle => 'Streaming';
@override
String get setupModeStreamingFeature1 =>
'Diffusez des pistes instantanément sans télécharger';
@override
String get setupModeStreamingFeature2 =>
'Smart Queue découvre automatiquement de nouvelle musique pour vous';
@override
String get setupModeStreamingFeature3 =>
'Écoutez n\'importe quelle piste à la demande avec les contrôles de lecture';
@override
String get setupModeChangeableLater =>
'Vous pouvez changer de mode à tout moment dans les Paramètres.';
}
+302 -1
View File
@@ -248,6 +248,33 @@ class AppLocalizationsHi extends AppLocalizations {
String get optionsAutoFallbackSubtitle =>
'Try other services if download fails';
@override
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
@override
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
'Automatically skip to the next queue track when a stream cannot be resolved.';
@override
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
'Stop on failed track resolution and show an error.';
@override
String get optionsInteractionMode => 'Interaction Mode';
@override
String get modeDownloader => 'Downloader Mode';
@override
String get modeDownloaderSubtitle =>
'Tap tracks to add them to download queue';
@override
String get modeStreaming => 'Streaming Mode';
@override
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
@override
String get optionsUseExtensionProviders => 'Use Extension Providers';
@@ -1041,6 +1068,10 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get errorSeekNotSupported =>
'Seeking is not supported for this live stream';
@override
String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source';
@@ -1557,6 +1588,11 @@ class AppLocalizationsHi extends AppLocalizations {
return 'Download All ($count)';
}
@override
String playAllCount(int count) {
return 'Play All ($count)';
}
@override
String tracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
@@ -2235,9 +2271,15 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyPlay => 'Play Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String get discographyPlayAll => 'Play All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
@@ -2282,6 +2324,9 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String get discographyPlaySelected => 'Play Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
@@ -2407,6 +2452,17 @@ class AppLocalizationsHi extends AppLocalizations {
return '$count tracks';
}
@override
String libraryTracksUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$_temp0';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
@@ -2886,7 +2942,7 @@ class AppLocalizationsHi extends AppLocalizations {
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Re-enrich Metadata';
String get trackReEnrich => 'Re-enrich';
@override
String get trackReEnrichSubtitle =>
@@ -2966,4 +3022,249 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get actionCreate => 'Create';
@override
String get collectionFoldersTitle => 'My folders';
@override
String get collectionWishlist => 'Wishlist';
@override
String get collectionLoved => 'Loved';
@override
String get collectionPlaylists => 'Playlists';
@override
String get collectionPlaylist => 'Playlist';
@override
String get collectionAddToPlaylist => 'Add to playlist';
@override
String get collectionCreatePlaylist => 'Create playlist';
@override
String get collectionNoPlaylistsYet => 'No playlists yet';
@override
String get collectionNoPlaylistsSubtitle =>
'Create a playlist to start categorizing tracks';
@override
String collectionPlaylistTracks(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String collectionAddedToPlaylist(String playlistName) {
return 'Added to \"$playlistName\"';
}
@override
String collectionAlreadyInPlaylist(String playlistName) {
return 'Already in \"$playlistName\"';
}
@override
String get collectionPlaylistCreated => 'Playlist created';
@override
String get collectionPlaylistNameHint => 'Playlist name';
@override
String get collectionPlaylistNameRequired => 'Playlist name is required';
@override
String get collectionRenamePlaylist => 'Rename playlist';
@override
String get collectionDeletePlaylist => 'Delete playlist';
@override
String collectionDeletePlaylistMessage(String playlistName) {
return 'Delete \"$playlistName\" and all tracks inside it?';
}
@override
String get collectionPlaylistDeleted => 'Playlist deleted';
@override
String get collectionPlaylistRenamed => 'Playlist renamed';
@override
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
@override
String get collectionWishlistEmptySubtitle =>
'Tap + on tracks to save what you want to download later';
@override
String get collectionLovedEmptyTitle => 'Loved folder is empty';
@override
String get collectionLovedEmptySubtitle =>
'Tap love on tracks to keep your favorites';
@override
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
@override
String get collectionPlaylistEmptySubtitle =>
'Long-press + on any track to add it here';
@override
String get collectionRemoveFromPlaylist => 'Remove from playlist';
@override
String get collectionRemoveFromFolder => 'Remove from folder';
@override
String collectionRemoved(String trackName) {
return '\"$trackName\" removed';
}
@override
String collectionAddedToLoved(String trackName) {
return '\"$trackName\" added to Loved';
}
@override
String collectionRemovedFromLoved(String trackName) {
return '\"$trackName\" removed from Loved';
}
@override
String collectionAddedToWishlist(String trackName) {
return '\"$trackName\" added to Wishlist';
}
@override
String collectionRemovedFromWishlist(String trackName) {
return '\"$trackName\" removed from Wishlist';
}
@override
String get trackOptionAddToLoved => 'Add to Loved';
@override
String get trackOptionRemoveFromLoved => 'Remove from Loved';
@override
String get trackOptionAddToWishlist => 'Add to Wishlist';
@override
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
@override
String get collectionPlaylistChangeCover => 'Change cover image';
@override
String get collectionPlaylistRemoveCover => 'Remove cover image';
@override
String selectionShareCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Share $count $_temp0';
}
@override
String get selectionShareNoFiles => 'No shareable files found';
@override
String selectionConvertCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0';
}
@override
String get selectionConvertNoConvertible => 'No convertible tracks selected';
@override
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
@override
String selectionBatchConvertConfirmMessage(
int count,
String format,
String bitrate,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
}
@override
String selectionBatchConvertSuccess(int success, int total, String format) {
return 'Converted $success of $total tracks to $format';
}
@override
String get setupModeSelectionTitle => 'अपना मोड चुनें';
@override
String get setupModeSelectionDescription =>
'आप SpotiFLAC का उपयोग कैसे करना चाहेंगे? आप इसे बाद में सेटिंग्स में कभी भी बदल सकते हैं।';
@override
String get setupModeDownloaderTitle => 'डाउनलोडर';
@override
String get setupModeDownloaderFeature1 =>
'लॉसलेस FLAC गुणवत्ता में ट्रैक डाउनलोड करें';
@override
String get setupModeDownloaderFeature2 =>
'ऑफ़लाइन सुनने के लिए संगीत अपने डिवाइस में सहेजें';
@override
String get setupModeDownloaderFeature3 =>
'अपनी स्थानीय संगीत लाइब्रेरी प्रबंधित करें';
@override
String get setupModeStreamingTitle => 'स्ट्रीमिंग';
@override
String get setupModeStreamingFeature1 =>
'बिना डाउनलोड किए तुरंत ट्रैक स्ट्रीम करें';
@override
String get setupModeStreamingFeature2 =>
'Smart Queue स्वचालित रूप से आपके लिए नया संगीत खोजता है';
@override
String get setupModeStreamingFeature3 =>
'प्लेबैक नियंत्रण के साथ किसी भी ट्रैक को मांग पर चलाएं';
@override
String get setupModeChangeableLater =>
'आप सेटिंग्स में कभी भी मोड बदल सकते हैं।';
}
+304 -1
View File
@@ -251,6 +251,34 @@ class AppLocalizationsId extends AppLocalizations {
String get optionsAutoFallbackSubtitle =>
'Coba layanan lain jika unduhan gagal';
@override
String get optionsAutoSkipUnavailableTracks =>
'Lewati Otomatis Lagu yang Tidak Tersedia';
@override
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
'Otomatis lanjut ke lagu berikutnya di antrean jika stream lagu tidak bisa ditemukan.';
@override
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
'Berhenti di lagu yang gagal dan tampilkan pesan error.';
@override
String get optionsInteractionMode => 'Mode Interaksi';
@override
String get modeDownloader => 'Mode Downloader';
@override
String get modeDownloaderSubtitle =>
'Ketuk lagu untuk menambah ke antrean unduhan';
@override
String get modeStreaming => 'Mode Streaming';
@override
String get modeStreamingSubtitle => 'Ketuk lagu untuk langsung memutar';
@override
String get optionsUseExtensionProviders => 'Gunakan Provider Ekstensi';
@@ -1047,6 +1075,10 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get errorNoTracksFound => 'Tidak ada lagu ditemukan';
@override
String get errorSeekNotSupported =>
'Menggeser posisi lagu tidak didukung untuk live stream ini';
@override
String errorMissingExtensionSource(String item) {
return 'Tidak dapat memuat $item: sumber ekstensi tidak ada';
@@ -1567,6 +1599,11 @@ class AppLocalizationsId extends AppLocalizations {
return 'Unduh Semua ($count)';
}
@override
String playAllCount(int count) {
return 'Putar Semua ($count)';
}
@override
String tracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
@@ -2248,9 +2285,15 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyPlay => 'Putar Diskografi';
@override
String get discographyDownloadAll => 'Unduh Semua';
@override
String get discographyPlayAll => 'Putar Semua';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
@@ -2295,6 +2338,9 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String get discographyPlaySelected => 'Putar Terpilih';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
@@ -2420,6 +2466,17 @@ class AppLocalizationsId extends AppLocalizations {
return '$count tracks';
}
@override
String libraryTracksUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'trek',
one: 'trek',
);
return '$_temp0';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
@@ -2899,7 +2956,7 @@ class AppLocalizationsId extends AppLocalizations {
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Re-enrich Metadata';
String get trackReEnrich => 'Re-enrich';
@override
String get trackReEnrichSubtitle =>
@@ -2979,4 +3036,250 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get actionCreate => 'Buat';
@override
String get collectionFoldersTitle => 'Folder saya';
@override
String get collectionWishlist => 'Wishlist';
@override
String get collectionLoved => 'Loved';
@override
String get collectionPlaylists => 'Playlist';
@override
String get collectionPlaylist => 'Playlist';
@override
String get collectionAddToPlaylist => 'Tambahkan ke playlist';
@override
String get collectionCreatePlaylist => 'Buat playlist';
@override
String get collectionNoPlaylistsYet => 'Belum ada playlist';
@override
String get collectionNoPlaylistsSubtitle =>
'Buat playlist untuk mulai mengategorikan lagu';
@override
String collectionPlaylistTracks(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count lagu',
one: '1 lagu',
);
return '$_temp0';
}
@override
String collectionAddedToPlaylist(String playlistName) {
return 'Ditambahkan ke \"$playlistName\"';
}
@override
String collectionAlreadyInPlaylist(String playlistName) {
return 'Sudah ada di \"$playlistName\"';
}
@override
String get collectionPlaylistCreated => 'Playlist berhasil dibuat';
@override
String get collectionPlaylistNameHint => 'Nama playlist';
@override
String get collectionPlaylistNameRequired => 'Nama playlist wajib diisi';
@override
String get collectionRenamePlaylist => 'Ubah nama playlist';
@override
String get collectionDeletePlaylist => 'Hapus playlist';
@override
String collectionDeletePlaylistMessage(String playlistName) {
return 'Hapus \"$playlistName\" beserta semua lagunya?';
}
@override
String get collectionPlaylistDeleted => 'Playlist dihapus';
@override
String get collectionPlaylistRenamed => 'Nama playlist diperbarui';
@override
String get collectionWishlistEmptyTitle => 'Wishlist masih kosong';
@override
String get collectionWishlistEmptySubtitle =>
'Tap + di lagu untuk menyimpan yang ingin diunduh nanti';
@override
String get collectionLovedEmptyTitle => 'Folder Loved masih kosong';
@override
String get collectionLovedEmptySubtitle =>
'Tap love di lagu untuk menyimpan favoritmu';
@override
String get collectionPlaylistEmptyTitle => 'Playlist masih kosong';
@override
String get collectionPlaylistEmptySubtitle =>
'Tekan lama tombol + pada lagu untuk menambahkannya ke sini';
@override
String get collectionRemoveFromPlaylist => 'Hapus dari playlist';
@override
String get collectionRemoveFromFolder => 'Hapus dari folder';
@override
String collectionRemoved(String trackName) {
return '\"$trackName\" dihapus';
}
@override
String collectionAddedToLoved(String trackName) {
return '\"$trackName\" ditambahkan ke Loved';
}
@override
String collectionRemovedFromLoved(String trackName) {
return '\"$trackName\" dihapus dari Loved';
}
@override
String collectionAddedToWishlist(String trackName) {
return '\"$trackName\" ditambahkan ke Wishlist';
}
@override
String collectionRemovedFromWishlist(String trackName) {
return '\"$trackName\" dihapus dari Wishlist';
}
@override
String get trackOptionAddToLoved => 'Tambahkan ke Loved';
@override
String get trackOptionRemoveFromLoved => 'Hapus dari Loved';
@override
String get trackOptionAddToWishlist => 'Tambahkan ke Wishlist';
@override
String get trackOptionRemoveFromWishlist => 'Hapus dari Wishlist';
@override
String get collectionPlaylistChangeCover => 'Ubah gambar sampul';
@override
String get collectionPlaylistRemoveCover => 'Hapus gambar sampul';
@override
String selectionShareCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'trek',
one: 'trek',
);
return 'Bagikan $count $_temp0';
}
@override
String get selectionShareNoFiles => 'Tidak ada file yang dapat dibagikan';
@override
String selectionConvertCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'trek',
one: 'trek',
);
return 'Konversi $count $_temp0';
}
@override
String get selectionConvertNoConvertible =>
'Tidak ada trek yang dapat dikonversi dipilih';
@override
String get selectionBatchConvertConfirmTitle => 'Konversi Massal';
@override
String selectionBatchConvertConfirmMessage(
int count,
String format,
String bitrate,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'trek',
one: 'trek',
);
return 'Konversi $count $_temp0 ke $format pada $bitrate?\n\nFile asli akan dihapus setelah konversi.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Mengonversi $current dari $total...';
}
@override
String selectionBatchConvertSuccess(int success, int total, String format) {
return 'Berhasil mengonversi $success dari $total trek ke $format';
}
@override
String get setupModeSelectionTitle => 'Pilih Mode Anda';
@override
String get setupModeSelectionDescription =>
'Bagaimana Anda ingin menggunakan SpotiFLAC? Anda dapat mengubahnya nanti di Pengaturan.';
@override
String get setupModeDownloaderTitle => 'Pengunduh';
@override
String get setupModeDownloaderFeature1 =>
'Unduh trek dalam kualitas FLAC lossless';
@override
String get setupModeDownloaderFeature2 =>
'Simpan musik ke perangkat Anda untuk mendengarkan offline';
@override
String get setupModeDownloaderFeature3 =>
'Kelola perpustakaan musik lokal Anda';
@override
String get setupModeStreamingTitle => 'Streaming';
@override
String get setupModeStreamingFeature1 =>
'Streaming trek secara instan tanpa mengunduh';
@override
String get setupModeStreamingFeature2 =>
'Smart Queue secara otomatis menemukan musik baru untuk Anda';
@override
String get setupModeStreamingFeature3 =>
'Putar trek apa pun sesuai permintaan dengan kontrol pemutaran';
@override
String get setupModeChangeableLater =>
'Anda dapat beralih antar mode kapan saja di Pengaturan.';
}
+295 -1
View File
@@ -248,6 +248,33 @@ class AppLocalizationsJa extends AppLocalizations {
String get optionsAutoFallbackSubtitle =>
'Try other services if download fails';
@override
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
@override
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
'Automatically skip to the next queue track when a stream cannot be resolved.';
@override
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
'Stop on failed track resolution and show an error.';
@override
String get optionsInteractionMode => 'Interaction Mode';
@override
String get modeDownloader => 'Downloader Mode';
@override
String get modeDownloaderSubtitle =>
'Tap tracks to add them to download queue';
@override
String get modeStreaming => 'Streaming Mode';
@override
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
@override
String get optionsUseExtensionProviders => '拡張のプロバイダーを使用する';
@@ -1035,6 +1062,10 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get errorNoTracksFound => 'トラックがありません';
@override
String get errorSeekNotSupported =>
'Seeking is not supported for this live stream';
@override
String errorMissingExtensionSource(String item) {
return '$item を読み込めません: 拡張ソースがありません';
@@ -1550,6 +1581,11 @@ class AppLocalizationsJa extends AppLocalizations {
return 'すべてダウンロード ($count)';
}
@override
String playAllCount(int count) {
return 'Play All ($count)';
}
@override
String tracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
@@ -2221,9 +2257,15 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get discographyDownload => 'ディスコグラフィをダウンロード';
@override
String get discographyPlay => 'Play Discography';
@override
String get discographyDownloadAll => 'すべてダウンロード';
@override
String get discographyPlayAll => 'Play All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$albumCount 個のリリースから $count 個のトラック';
@@ -2268,6 +2310,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get discographyDownloadSelected => '選択済みをダウンロード';
@override
String get discographyPlaySelected => 'Play Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
@@ -2393,6 +2438,17 @@ class AppLocalizationsJa extends AppLocalizations {
return '$count tracks';
}
@override
String libraryTracksUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$_temp0';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
@@ -2872,7 +2928,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Re-enrich Metadata';
String get trackReEnrich => 'Re-enrich';
@override
String get trackReEnrichSubtitle =>
@@ -2952,4 +3008,242 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get actionCreate => 'Create';
@override
String get collectionFoldersTitle => 'My folders';
@override
String get collectionWishlist => 'Wishlist';
@override
String get collectionLoved => 'Loved';
@override
String get collectionPlaylists => 'Playlists';
@override
String get collectionPlaylist => 'Playlist';
@override
String get collectionAddToPlaylist => 'Add to playlist';
@override
String get collectionCreatePlaylist => 'Create playlist';
@override
String get collectionNoPlaylistsYet => 'No playlists yet';
@override
String get collectionNoPlaylistsSubtitle =>
'Create a playlist to start categorizing tracks';
@override
String collectionPlaylistTracks(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String collectionAddedToPlaylist(String playlistName) {
return 'Added to \"$playlistName\"';
}
@override
String collectionAlreadyInPlaylist(String playlistName) {
return 'Already in \"$playlistName\"';
}
@override
String get collectionPlaylistCreated => 'Playlist created';
@override
String get collectionPlaylistNameHint => 'Playlist name';
@override
String get collectionPlaylistNameRequired => 'Playlist name is required';
@override
String get collectionRenamePlaylist => 'Rename playlist';
@override
String get collectionDeletePlaylist => 'Delete playlist';
@override
String collectionDeletePlaylistMessage(String playlistName) {
return 'Delete \"$playlistName\" and all tracks inside it?';
}
@override
String get collectionPlaylistDeleted => 'Playlist deleted';
@override
String get collectionPlaylistRenamed => 'Playlist renamed';
@override
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
@override
String get collectionWishlistEmptySubtitle =>
'Tap + on tracks to save what you want to download later';
@override
String get collectionLovedEmptyTitle => 'Loved folder is empty';
@override
String get collectionLovedEmptySubtitle =>
'Tap love on tracks to keep your favorites';
@override
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
@override
String get collectionPlaylistEmptySubtitle =>
'Long-press + on any track to add it here';
@override
String get collectionRemoveFromPlaylist => 'Remove from playlist';
@override
String get collectionRemoveFromFolder => 'Remove from folder';
@override
String collectionRemoved(String trackName) {
return '\"$trackName\" removed';
}
@override
String collectionAddedToLoved(String trackName) {
return '\"$trackName\" added to Loved';
}
@override
String collectionRemovedFromLoved(String trackName) {
return '\"$trackName\" removed from Loved';
}
@override
String collectionAddedToWishlist(String trackName) {
return '\"$trackName\" added to Wishlist';
}
@override
String collectionRemovedFromWishlist(String trackName) {
return '\"$trackName\" removed from Wishlist';
}
@override
String get trackOptionAddToLoved => 'Add to Loved';
@override
String get trackOptionRemoveFromLoved => 'Remove from Loved';
@override
String get trackOptionAddToWishlist => 'Add to Wishlist';
@override
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
@override
String get collectionPlaylistChangeCover => 'Change cover image';
@override
String get collectionPlaylistRemoveCover => 'Remove cover image';
@override
String selectionShareCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Share $count $_temp0';
}
@override
String get selectionShareNoFiles => 'No shareable files found';
@override
String selectionConvertCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0';
}
@override
String get selectionConvertNoConvertible => 'No convertible tracks selected';
@override
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
@override
String selectionBatchConvertConfirmMessage(
int count,
String format,
String bitrate,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
}
@override
String selectionBatchConvertSuccess(int success, int total, String format) {
return 'Converted $success of $total tracks to $format';
}
@override
String get setupModeSelectionTitle => 'モードを選択';
@override
String get setupModeSelectionDescription =>
'SpotiFLACをどのように使いますか?この設定は後からいつでも変更できます。';
@override
String get setupModeDownloaderTitle => 'ダウンローダー';
@override
String get setupModeDownloaderFeature1 => 'ロスレスFLAC品質でトラックをダウンロード';
@override
String get setupModeDownloaderFeature2 => 'オフライン再生用に音楽をデバイスに保存';
@override
String get setupModeDownloaderFeature3 => 'ローカル音楽ライブラリを管理';
@override
String get setupModeStreamingTitle => 'ストリーミング';
@override
String get setupModeStreamingFeature1 => 'ダウンロードせずにトラックを即座にストリーミング';
@override
String get setupModeStreamingFeature2 => 'Smart Queueが自動的に新しい音楽を見つけます';
@override
String get setupModeStreamingFeature3 => '再生コントロールで任意のトラックをオンデマンド再生';
@override
String get setupModeChangeableLater => '設定からいつでもモードを切り替えられます。';
}
+295 -1
View File
@@ -247,6 +247,33 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get optionsAutoFallbackSubtitle => '다운로드가 실패한 경우, 다른 서비스로 재시도';
@override
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
@override
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
'Automatically skip to the next queue track when a stream cannot be resolved.';
@override
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
'Stop on failed track resolution and show an error.';
@override
String get optionsInteractionMode => 'Interaction Mode';
@override
String get modeDownloader => 'Downloader Mode';
@override
String get modeDownloaderSubtitle =>
'Tap tracks to add them to download queue';
@override
String get modeStreaming => 'Streaming Mode';
@override
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
@override
String get optionsUseExtensionProviders => 'Use Extension Providers';
@@ -1040,6 +1067,10 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get errorSeekNotSupported =>
'Seeking is not supported for this live stream';
@override
String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source';
@@ -1556,6 +1587,11 @@ class AppLocalizationsKo extends AppLocalizations {
return 'Download All ($count)';
}
@override
String playAllCount(int count) {
return 'Play All ($count)';
}
@override
String tracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
@@ -2234,9 +2270,15 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyPlay => 'Play Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String get discographyPlayAll => 'Play All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
@@ -2281,6 +2323,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String get discographyPlaySelected => 'Play Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
@@ -2406,6 +2451,17 @@ class AppLocalizationsKo extends AppLocalizations {
return '$count tracks';
}
@override
String libraryTracksUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$_temp0';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
@@ -2885,7 +2941,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Re-enrich Metadata';
String get trackReEnrich => 'Re-enrich';
@override
String get trackReEnrichSubtitle =>
@@ -2965,4 +3021,242 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get actionCreate => 'Create';
@override
String get collectionFoldersTitle => 'My folders';
@override
String get collectionWishlist => 'Wishlist';
@override
String get collectionLoved => 'Loved';
@override
String get collectionPlaylists => 'Playlists';
@override
String get collectionPlaylist => 'Playlist';
@override
String get collectionAddToPlaylist => 'Add to playlist';
@override
String get collectionCreatePlaylist => 'Create playlist';
@override
String get collectionNoPlaylistsYet => 'No playlists yet';
@override
String get collectionNoPlaylistsSubtitle =>
'Create a playlist to start categorizing tracks';
@override
String collectionPlaylistTracks(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String collectionAddedToPlaylist(String playlistName) {
return 'Added to \"$playlistName\"';
}
@override
String collectionAlreadyInPlaylist(String playlistName) {
return 'Already in \"$playlistName\"';
}
@override
String get collectionPlaylistCreated => 'Playlist created';
@override
String get collectionPlaylistNameHint => 'Playlist name';
@override
String get collectionPlaylistNameRequired => 'Playlist name is required';
@override
String get collectionRenamePlaylist => 'Rename playlist';
@override
String get collectionDeletePlaylist => 'Delete playlist';
@override
String collectionDeletePlaylistMessage(String playlistName) {
return 'Delete \"$playlistName\" and all tracks inside it?';
}
@override
String get collectionPlaylistDeleted => 'Playlist deleted';
@override
String get collectionPlaylistRenamed => 'Playlist renamed';
@override
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
@override
String get collectionWishlistEmptySubtitle =>
'Tap + on tracks to save what you want to download later';
@override
String get collectionLovedEmptyTitle => 'Loved folder is empty';
@override
String get collectionLovedEmptySubtitle =>
'Tap love on tracks to keep your favorites';
@override
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
@override
String get collectionPlaylistEmptySubtitle =>
'Long-press + on any track to add it here';
@override
String get collectionRemoveFromPlaylist => 'Remove from playlist';
@override
String get collectionRemoveFromFolder => 'Remove from folder';
@override
String collectionRemoved(String trackName) {
return '\"$trackName\" removed';
}
@override
String collectionAddedToLoved(String trackName) {
return '\"$trackName\" added to Loved';
}
@override
String collectionRemovedFromLoved(String trackName) {
return '\"$trackName\" removed from Loved';
}
@override
String collectionAddedToWishlist(String trackName) {
return '\"$trackName\" added to Wishlist';
}
@override
String collectionRemovedFromWishlist(String trackName) {
return '\"$trackName\" removed from Wishlist';
}
@override
String get trackOptionAddToLoved => 'Add to Loved';
@override
String get trackOptionRemoveFromLoved => 'Remove from Loved';
@override
String get trackOptionAddToWishlist => 'Add to Wishlist';
@override
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
@override
String get collectionPlaylistChangeCover => 'Change cover image';
@override
String get collectionPlaylistRemoveCover => 'Remove cover image';
@override
String selectionShareCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Share $count $_temp0';
}
@override
String get selectionShareNoFiles => 'No shareable files found';
@override
String selectionConvertCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0';
}
@override
String get selectionConvertNoConvertible => 'No convertible tracks selected';
@override
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
@override
String selectionBatchConvertConfirmMessage(
int count,
String format,
String bitrate,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
}
@override
String selectionBatchConvertSuccess(int success, int total, String format) {
return 'Converted $success of $total tracks to $format';
}
@override
String get setupModeSelectionTitle => '모드 선택';
@override
String get setupModeSelectionDescription =>
'SpotiFLAC을 어떻게 사용하시겠습니까? 나중에 설정에서 언제든지 변경할 수 있습니다.';
@override
String get setupModeDownloaderTitle => '다운로더';
@override
String get setupModeDownloaderFeature1 => '무손실 FLAC 품질로 트랙 다운로드';
@override
String get setupModeDownloaderFeature2 => '오프라인 감상을 위해 기기에 음악 저장';
@override
String get setupModeDownloaderFeature3 => '로컬 음악 라이브러리 관리';
@override
String get setupModeStreamingTitle => '스트리밍';
@override
String get setupModeStreamingFeature1 => '다운로드 없이 트랙을 즉시 스트리밍';
@override
String get setupModeStreamingFeature2 => 'Smart Queue가 자동으로 새로운 음악을 발견합니다';
@override
String get setupModeStreamingFeature3 => '재생 컨트롤로 원하는 트랙을 온디맨드 재생';
@override
String get setupModeChangeableLater => '설정에서 언제든지 모드를 전환할 수 있습니다.';
}
+302 -1
View File
@@ -248,6 +248,33 @@ class AppLocalizationsNl extends AppLocalizations {
String get optionsAutoFallbackSubtitle =>
'Try other services if download fails';
@override
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
@override
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
'Automatically skip to the next queue track when a stream cannot be resolved.';
@override
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
'Stop on failed track resolution and show an error.';
@override
String get optionsInteractionMode => 'Interaction Mode';
@override
String get modeDownloader => 'Downloader Mode';
@override
String get modeDownloaderSubtitle =>
'Tap tracks to add them to download queue';
@override
String get modeStreaming => 'Streaming Mode';
@override
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
@override
String get optionsUseExtensionProviders => 'Use Extension Providers';
@@ -1041,6 +1068,10 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get errorSeekNotSupported =>
'Seeking is not supported for this live stream';
@override
String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source';
@@ -1557,6 +1588,11 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Download All ($count)';
}
@override
String playAllCount(int count) {
return 'Play All ($count)';
}
@override
String tracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
@@ -2235,9 +2271,15 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyPlay => 'Play Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String get discographyPlayAll => 'Play All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
@@ -2282,6 +2324,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String get discographyPlaySelected => 'Play Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
@@ -2407,6 +2452,17 @@ class AppLocalizationsNl extends AppLocalizations {
return '$count tracks';
}
@override
String libraryTracksUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$_temp0';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
@@ -2886,7 +2942,7 @@ class AppLocalizationsNl extends AppLocalizations {
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Re-enrich Metadata';
String get trackReEnrich => 'Re-enrich';
@override
String get trackReEnrichSubtitle =>
@@ -2966,4 +3022,249 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get actionCreate => 'Create';
@override
String get collectionFoldersTitle => 'My folders';
@override
String get collectionWishlist => 'Wishlist';
@override
String get collectionLoved => 'Loved';
@override
String get collectionPlaylists => 'Playlists';
@override
String get collectionPlaylist => 'Playlist';
@override
String get collectionAddToPlaylist => 'Add to playlist';
@override
String get collectionCreatePlaylist => 'Create playlist';
@override
String get collectionNoPlaylistsYet => 'No playlists yet';
@override
String get collectionNoPlaylistsSubtitle =>
'Create a playlist to start categorizing tracks';
@override
String collectionPlaylistTracks(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String collectionAddedToPlaylist(String playlistName) {
return 'Added to \"$playlistName\"';
}
@override
String collectionAlreadyInPlaylist(String playlistName) {
return 'Already in \"$playlistName\"';
}
@override
String get collectionPlaylistCreated => 'Playlist created';
@override
String get collectionPlaylistNameHint => 'Playlist name';
@override
String get collectionPlaylistNameRequired => 'Playlist name is required';
@override
String get collectionRenamePlaylist => 'Rename playlist';
@override
String get collectionDeletePlaylist => 'Delete playlist';
@override
String collectionDeletePlaylistMessage(String playlistName) {
return 'Delete \"$playlistName\" and all tracks inside it?';
}
@override
String get collectionPlaylistDeleted => 'Playlist deleted';
@override
String get collectionPlaylistRenamed => 'Playlist renamed';
@override
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
@override
String get collectionWishlistEmptySubtitle =>
'Tap + on tracks to save what you want to download later';
@override
String get collectionLovedEmptyTitle => 'Loved folder is empty';
@override
String get collectionLovedEmptySubtitle =>
'Tap love on tracks to keep your favorites';
@override
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
@override
String get collectionPlaylistEmptySubtitle =>
'Long-press + on any track to add it here';
@override
String get collectionRemoveFromPlaylist => 'Remove from playlist';
@override
String get collectionRemoveFromFolder => 'Remove from folder';
@override
String collectionRemoved(String trackName) {
return '\"$trackName\" removed';
}
@override
String collectionAddedToLoved(String trackName) {
return '\"$trackName\" added to Loved';
}
@override
String collectionRemovedFromLoved(String trackName) {
return '\"$trackName\" removed from Loved';
}
@override
String collectionAddedToWishlist(String trackName) {
return '\"$trackName\" added to Wishlist';
}
@override
String collectionRemovedFromWishlist(String trackName) {
return '\"$trackName\" removed from Wishlist';
}
@override
String get trackOptionAddToLoved => 'Add to Loved';
@override
String get trackOptionRemoveFromLoved => 'Remove from Loved';
@override
String get trackOptionAddToWishlist => 'Add to Wishlist';
@override
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
@override
String get collectionPlaylistChangeCover => 'Change cover image';
@override
String get collectionPlaylistRemoveCover => 'Remove cover image';
@override
String selectionShareCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Share $count $_temp0';
}
@override
String get selectionShareNoFiles => 'No shareable files found';
@override
String selectionConvertCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0';
}
@override
String get selectionConvertNoConvertible => 'No convertible tracks selected';
@override
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
@override
String selectionBatchConvertConfirmMessage(
int count,
String format,
String bitrate,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
}
@override
String selectionBatchConvertSuccess(int success, int total, String format) {
return 'Converted $success of $total tracks to $format';
}
@override
String get setupModeSelectionTitle => 'Kies je modus';
@override
String get setupModeSelectionDescription =>
'Hoe wil je SpotiFLAC gebruiken? Je kunt dit later altijd wijzigen in Instellingen.';
@override
String get setupModeDownloaderTitle => 'Downloader';
@override
String get setupModeDownloaderFeature1 =>
'Download nummers in lossless FLAC-kwaliteit';
@override
String get setupModeDownloaderFeature2 =>
'Sla muziek op je apparaat op om offline te luisteren';
@override
String get setupModeDownloaderFeature3 =>
'Beheer je lokale muziekbibliotheek';
@override
String get setupModeStreamingTitle => 'Streaming';
@override
String get setupModeStreamingFeature1 =>
'Stream nummers direct zonder te downloaden';
@override
String get setupModeStreamingFeature2 =>
'Smart Queue ontdekt automatisch nieuwe muziek voor je';
@override
String get setupModeStreamingFeature3 =>
'Speel elk nummer op aanvraag af met afspeelbediening';
@override
String get setupModeChangeableLater =>
'Je kunt op elk moment wisselen tussen modi in Instellingen.';
}
+344 -2
View File
@@ -248,6 +248,33 @@ class AppLocalizationsPt extends AppLocalizations {
String get optionsAutoFallbackSubtitle =>
'Try other services if download fails';
@override
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
@override
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
'Automatically skip to the next queue track when a stream cannot be resolved.';
@override
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
'Stop on failed track resolution and show an error.';
@override
String get optionsInteractionMode => 'Interaction Mode';
@override
String get modeDownloader => 'Downloader Mode';
@override
String get modeDownloaderSubtitle =>
'Tap tracks to add them to download queue';
@override
String get modeStreaming => 'Streaming Mode';
@override
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
@override
String get optionsUseExtensionProviders => 'Use Extension Providers';
@@ -1041,6 +1068,10 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get errorSeekNotSupported =>
'Seeking is not supported for this live stream';
@override
String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source';
@@ -1557,6 +1588,11 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Download All ($count)';
}
@override
String playAllCount(int count) {
return 'Play All ($count)';
}
@override
String tracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
@@ -2235,9 +2271,15 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyPlay => 'Play Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String get discographyPlayAll => 'Play All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
@@ -2282,6 +2324,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String get discographyPlaySelected => 'Play Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
@@ -2407,6 +2452,17 @@ class AppLocalizationsPt extends AppLocalizations {
return '$count tracks';
}
@override
String libraryTracksUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$_temp0';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
@@ -2886,7 +2942,7 @@ class AppLocalizationsPt extends AppLocalizations {
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Re-enrich Metadata';
String get trackReEnrich => 'Re-enrich';
@override
String get trackReEnrichSubtitle =>
@@ -2966,6 +3022,251 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get actionCreate => 'Create';
@override
String get collectionFoldersTitle => 'My folders';
@override
String get collectionWishlist => 'Wishlist';
@override
String get collectionLoved => 'Loved';
@override
String get collectionPlaylists => 'Playlists';
@override
String get collectionPlaylist => 'Playlist';
@override
String get collectionAddToPlaylist => 'Add to playlist';
@override
String get collectionCreatePlaylist => 'Create playlist';
@override
String get collectionNoPlaylistsYet => 'No playlists yet';
@override
String get collectionNoPlaylistsSubtitle =>
'Create a playlist to start categorizing tracks';
@override
String collectionPlaylistTracks(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String collectionAddedToPlaylist(String playlistName) {
return 'Added to \"$playlistName\"';
}
@override
String collectionAlreadyInPlaylist(String playlistName) {
return 'Already in \"$playlistName\"';
}
@override
String get collectionPlaylistCreated => 'Playlist created';
@override
String get collectionPlaylistNameHint => 'Playlist name';
@override
String get collectionPlaylistNameRequired => 'Playlist name is required';
@override
String get collectionRenamePlaylist => 'Rename playlist';
@override
String get collectionDeletePlaylist => 'Delete playlist';
@override
String collectionDeletePlaylistMessage(String playlistName) {
return 'Delete \"$playlistName\" and all tracks inside it?';
}
@override
String get collectionPlaylistDeleted => 'Playlist deleted';
@override
String get collectionPlaylistRenamed => 'Playlist renamed';
@override
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
@override
String get collectionWishlistEmptySubtitle =>
'Tap + on tracks to save what you want to download later';
@override
String get collectionLovedEmptyTitle => 'Loved folder is empty';
@override
String get collectionLovedEmptySubtitle =>
'Tap love on tracks to keep your favorites';
@override
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
@override
String get collectionPlaylistEmptySubtitle =>
'Long-press + on any track to add it here';
@override
String get collectionRemoveFromPlaylist => 'Remove from playlist';
@override
String get collectionRemoveFromFolder => 'Remove from folder';
@override
String collectionRemoved(String trackName) {
return '\"$trackName\" removed';
}
@override
String collectionAddedToLoved(String trackName) {
return '\"$trackName\" added to Loved';
}
@override
String collectionRemovedFromLoved(String trackName) {
return '\"$trackName\" removed from Loved';
}
@override
String collectionAddedToWishlist(String trackName) {
return '\"$trackName\" added to Wishlist';
}
@override
String collectionRemovedFromWishlist(String trackName) {
return '\"$trackName\" removed from Wishlist';
}
@override
String get trackOptionAddToLoved => 'Add to Loved';
@override
String get trackOptionRemoveFromLoved => 'Remove from Loved';
@override
String get trackOptionAddToWishlist => 'Add to Wishlist';
@override
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
@override
String get collectionPlaylistChangeCover => 'Change cover image';
@override
String get collectionPlaylistRemoveCover => 'Remove cover image';
@override
String selectionShareCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Share $count $_temp0';
}
@override
String get selectionShareNoFiles => 'No shareable files found';
@override
String selectionConvertCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0';
}
@override
String get selectionConvertNoConvertible => 'No convertible tracks selected';
@override
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
@override
String selectionBatchConvertConfirmMessage(
int count,
String format,
String bitrate,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
}
@override
String selectionBatchConvertSuccess(int success, int total, String format) {
return 'Converted $success of $total tracks to $format';
}
@override
String get setupModeSelectionTitle => 'Escolha seu modo';
@override
String get setupModeSelectionDescription =>
'Como você gostaria de usar o SpotiFLAC? Você pode alterar isso depois nas Configurações.';
@override
String get setupModeDownloaderTitle => 'Downloader';
@override
String get setupModeDownloaderFeature1 =>
'Baixe faixas em qualidade FLAC lossless';
@override
String get setupModeDownloaderFeature2 =>
'Salve músicas no seu dispositivo para ouvir offline';
@override
String get setupModeDownloaderFeature3 =>
'Gerencie sua biblioteca de músicas local';
@override
String get setupModeStreamingTitle => 'Streaming';
@override
String get setupModeStreamingFeature1 =>
'Transmita faixas instantaneamente sem baixar';
@override
String get setupModeStreamingFeature2 =>
'Smart Queue descobre automaticamente novas músicas para você';
@override
String get setupModeStreamingFeature3 =>
'Reproduza qualquer faixa sob demanda com controles de reprodução';
@override
String get setupModeChangeableLater =>
'Você pode alternar entre os modos a qualquer momento nas Configurações.';
}
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
@@ -5846,7 +6147,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Re-enrich Metadata';
String get trackReEnrich => 'Re-enrich';
@override
String get trackReEnrichSubtitle =>
@@ -5926,4 +6227,45 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get setupModeSelectionTitle => 'Escolha o seu modo';
@override
String get setupModeSelectionDescription =>
'Como gostaria de utilizar o SpotiFLAC? Pode alterar isto mais tarde nas Definições.';
@override
String get setupModeDownloaderTitle => 'Transferência';
@override
String get setupModeDownloaderFeature1 =>
'Transfira faixas em qualidade FLAC sem perdas';
@override
String get setupModeDownloaderFeature2 =>
'Guarde música no seu dispositivo para ouvir offline';
@override
String get setupModeDownloaderFeature3 =>
'Faça a gestão da sua biblioteca de música local';
@override
String get setupModeStreamingTitle => 'Streaming';
@override
String get setupModeStreamingFeature1 =>
'Transmita faixas instantaneamente sem transferir';
@override
String get setupModeStreamingFeature2 =>
'Smart Queue descobre automaticamente novas músicas para si';
@override
String get setupModeStreamingFeature3 =>
'Reproduza qualquer faixa a pedido com controlos de reprodução';
@override
String get setupModeChangeableLater =>
'Pode alternar entre modos a qualquer momento nas Definições.';
}
+302 -1
View File
@@ -255,6 +255,33 @@ class AppLocalizationsRu extends AppLocalizations {
String get optionsAutoFallbackSubtitle =>
'Попробовать другие сервисы при сбое загрузки';
@override
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
@override
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
'Automatically skip to the next queue track when a stream cannot be resolved.';
@override
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
'Stop on failed track resolution and show an error.';
@override
String get optionsInteractionMode => 'Interaction Mode';
@override
String get modeDownloader => 'Downloader Mode';
@override
String get modeDownloaderSubtitle =>
'Tap tracks to add them to download queue';
@override
String get modeStreaming => 'Streaming Mode';
@override
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
@override
String get optionsUseExtensionProviders =>
'Использовать провайдера расширений';
@@ -1066,6 +1093,10 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get errorNoTracksFound => 'Треки не найдены';
@override
String get errorSeekNotSupported =>
'Seeking is not supported for this live stream';
@override
String errorMissingExtensionSource(String item) {
return 'Невозможно загрузить $item: отсутствует источник расширения';
@@ -1587,6 +1618,11 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Скачать все ($count)';
}
@override
String playAllCount(int count) {
return 'Play All ($count)';
}
@override
String tracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
@@ -2283,9 +2319,15 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get discographyDownload => 'Скачать дискографию';
@override
String get discographyPlay => 'Play Discography';
@override
String get discographyDownloadAll => 'Скачать всё';
@override
String get discographyPlayAll => 'Play All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count треков из $albumCount релизов';
@@ -2330,6 +2372,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get discographyDownloadSelected => 'Скачать выбранное';
@override
String get discographyPlaySelected => 'Play Selected';
@override
String discographyAddedToQueue(int count) {
return 'Добавлено $count треков в очередь';
@@ -2466,6 +2511,17 @@ class AppLocalizationsRu extends AppLocalizations {
return '$count $_temp0';
}
@override
String libraryTracksUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$_temp0';
}
@override
String libraryLastScanned(String time) {
return 'Последнее сканирование: $time';
@@ -2983,7 +3039,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Re-enrich Metadata';
String get trackReEnrich => 'Re-enrich';
@override
String get trackReEnrichSubtitle =>
@@ -3064,4 +3120,249 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get actionCreate => 'Create';
@override
String get collectionFoldersTitle => 'My folders';
@override
String get collectionWishlist => 'Wishlist';
@override
String get collectionLoved => 'Loved';
@override
String get collectionPlaylists => 'Playlists';
@override
String get collectionPlaylist => 'Playlist';
@override
String get collectionAddToPlaylist => 'Add to playlist';
@override
String get collectionCreatePlaylist => 'Create playlist';
@override
String get collectionNoPlaylistsYet => 'No playlists yet';
@override
String get collectionNoPlaylistsSubtitle =>
'Create a playlist to start categorizing tracks';
@override
String collectionPlaylistTracks(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String collectionAddedToPlaylist(String playlistName) {
return 'Added to \"$playlistName\"';
}
@override
String collectionAlreadyInPlaylist(String playlistName) {
return 'Already in \"$playlistName\"';
}
@override
String get collectionPlaylistCreated => 'Playlist created';
@override
String get collectionPlaylistNameHint => 'Playlist name';
@override
String get collectionPlaylistNameRequired => 'Playlist name is required';
@override
String get collectionRenamePlaylist => 'Rename playlist';
@override
String get collectionDeletePlaylist => 'Delete playlist';
@override
String collectionDeletePlaylistMessage(String playlistName) {
return 'Delete \"$playlistName\" and all tracks inside it?';
}
@override
String get collectionPlaylistDeleted => 'Playlist deleted';
@override
String get collectionPlaylistRenamed => 'Playlist renamed';
@override
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
@override
String get collectionWishlistEmptySubtitle =>
'Tap + on tracks to save what you want to download later';
@override
String get collectionLovedEmptyTitle => 'Loved folder is empty';
@override
String get collectionLovedEmptySubtitle =>
'Tap love on tracks to keep your favorites';
@override
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
@override
String get collectionPlaylistEmptySubtitle =>
'Long-press + on any track to add it here';
@override
String get collectionRemoveFromPlaylist => 'Remove from playlist';
@override
String get collectionRemoveFromFolder => 'Remove from folder';
@override
String collectionRemoved(String trackName) {
return '\"$trackName\" removed';
}
@override
String collectionAddedToLoved(String trackName) {
return '\"$trackName\" added to Loved';
}
@override
String collectionRemovedFromLoved(String trackName) {
return '\"$trackName\" removed from Loved';
}
@override
String collectionAddedToWishlist(String trackName) {
return '\"$trackName\" added to Wishlist';
}
@override
String collectionRemovedFromWishlist(String trackName) {
return '\"$trackName\" removed from Wishlist';
}
@override
String get trackOptionAddToLoved => 'Add to Loved';
@override
String get trackOptionRemoveFromLoved => 'Remove from Loved';
@override
String get trackOptionAddToWishlist => 'Add to Wishlist';
@override
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
@override
String get collectionPlaylistChangeCover => 'Change cover image';
@override
String get collectionPlaylistRemoveCover => 'Remove cover image';
@override
String selectionShareCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Share $count $_temp0';
}
@override
String get selectionShareNoFiles => 'No shareable files found';
@override
String selectionConvertCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0';
}
@override
String get selectionConvertNoConvertible => 'No convertible tracks selected';
@override
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
@override
String selectionBatchConvertConfirmMessage(
int count,
String format,
String bitrate,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
}
@override
String selectionBatchConvertSuccess(int success, int total, String format) {
return 'Converted $success of $total tracks to $format';
}
@override
String get setupModeSelectionTitle => 'Выберите режим';
@override
String get setupModeSelectionDescription =>
'Как вы хотите использовать SpotiFLAC? Вы всегда можете изменить это позже в Настройках.';
@override
String get setupModeDownloaderTitle => 'Загрузчик';
@override
String get setupModeDownloaderFeature1 =>
'Скачивайте треки в качестве FLAC без потерь';
@override
String get setupModeDownloaderFeature2 =>
'Сохраняйте музыку на устройство для прослушивания офлайн';
@override
String get setupModeDownloaderFeature3 =>
'Управляйте своей локальной музыкальной библиотекой';
@override
String get setupModeStreamingTitle => 'Стриминг';
@override
String get setupModeStreamingFeature1 =>
'Слушайте треки мгновенно без скачивания';
@override
String get setupModeStreamingFeature2 =>
'Smart Queue автоматически подбирает новую музыку для вас';
@override
String get setupModeStreamingFeature3 =>
'Воспроизводите любой трек по запросу с элементами управления';
@override
String get setupModeChangeableLater =>
'Вы можете переключаться между режимами в любое время в Настройках.';
}
+301 -1
View File
@@ -252,6 +252,33 @@ class AppLocalizationsTr extends AppLocalizations {
String get optionsAutoFallbackSubtitle =>
'İndirme başarısız olursa diğer hizmetleri dene';
@override
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
@override
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
'Automatically skip to the next queue track when a stream cannot be resolved.';
@override
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
'Stop on failed track resolution and show an error.';
@override
String get optionsInteractionMode => 'Interaction Mode';
@override
String get modeDownloader => 'Downloader Mode';
@override
String get modeDownloaderSubtitle =>
'Tap tracks to add them to download queue';
@override
String get modeStreaming => 'Streaming Mode';
@override
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
@override
String get optionsUseExtensionProviders => 'Eklenti sağlayıcılarını kullan';
@@ -1048,6 +1075,10 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get errorNoTracksFound => 'Parça bulunamadı';
@override
String get errorSeekNotSupported =>
'Seeking is not supported for this live stream';
@override
String errorMissingExtensionSource(String item) {
return '$item yüklenemedi: Eksik eklenti kaynağı';
@@ -1570,6 +1601,11 @@ class AppLocalizationsTr extends AppLocalizations {
return 'Tümünü İndir ($count)';
}
@override
String playAllCount(int count) {
return 'Play All ($count)';
}
@override
String tracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
@@ -2250,9 +2286,15 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyPlay => 'Play Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String get discographyPlayAll => 'Play All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
@@ -2297,6 +2339,9 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String get discographyPlaySelected => 'Play Selected';
@override
String discographyAddedToQueue(int count) {
return '$count şarkı kuyruğa eklendi';
@@ -2422,6 +2467,17 @@ class AppLocalizationsTr extends AppLocalizations {
return '$count tracks';
}
@override
String libraryTracksUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$_temp0';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
@@ -2901,7 +2957,7 @@ class AppLocalizationsTr extends AppLocalizations {
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Re-enrich Metadata';
String get trackReEnrich => 'Re-enrich';
@override
String get trackReEnrichSubtitle =>
@@ -2981,4 +3037,248 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get actionCreate => 'Create';
@override
String get collectionFoldersTitle => 'My folders';
@override
String get collectionWishlist => 'Wishlist';
@override
String get collectionLoved => 'Loved';
@override
String get collectionPlaylists => 'Playlists';
@override
String get collectionPlaylist => 'Playlist';
@override
String get collectionAddToPlaylist => 'Add to playlist';
@override
String get collectionCreatePlaylist => 'Create playlist';
@override
String get collectionNoPlaylistsYet => 'No playlists yet';
@override
String get collectionNoPlaylistsSubtitle =>
'Create a playlist to start categorizing tracks';
@override
String collectionPlaylistTracks(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String collectionAddedToPlaylist(String playlistName) {
return 'Added to \"$playlistName\"';
}
@override
String collectionAlreadyInPlaylist(String playlistName) {
return 'Already in \"$playlistName\"';
}
@override
String get collectionPlaylistCreated => 'Playlist created';
@override
String get collectionPlaylistNameHint => 'Playlist name';
@override
String get collectionPlaylistNameRequired => 'Playlist name is required';
@override
String get collectionRenamePlaylist => 'Rename playlist';
@override
String get collectionDeletePlaylist => 'Delete playlist';
@override
String collectionDeletePlaylistMessage(String playlistName) {
return 'Delete \"$playlistName\" and all tracks inside it?';
}
@override
String get collectionPlaylistDeleted => 'Playlist deleted';
@override
String get collectionPlaylistRenamed => 'Playlist renamed';
@override
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
@override
String get collectionWishlistEmptySubtitle =>
'Tap + on tracks to save what you want to download later';
@override
String get collectionLovedEmptyTitle => 'Loved folder is empty';
@override
String get collectionLovedEmptySubtitle =>
'Tap love on tracks to keep your favorites';
@override
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
@override
String get collectionPlaylistEmptySubtitle =>
'Long-press + on any track to add it here';
@override
String get collectionRemoveFromPlaylist => 'Remove from playlist';
@override
String get collectionRemoveFromFolder => 'Remove from folder';
@override
String collectionRemoved(String trackName) {
return '\"$trackName\" removed';
}
@override
String collectionAddedToLoved(String trackName) {
return '\"$trackName\" added to Loved';
}
@override
String collectionRemovedFromLoved(String trackName) {
return '\"$trackName\" removed from Loved';
}
@override
String collectionAddedToWishlist(String trackName) {
return '\"$trackName\" added to Wishlist';
}
@override
String collectionRemovedFromWishlist(String trackName) {
return '\"$trackName\" removed from Wishlist';
}
@override
String get trackOptionAddToLoved => 'Add to Loved';
@override
String get trackOptionRemoveFromLoved => 'Remove from Loved';
@override
String get trackOptionAddToWishlist => 'Add to Wishlist';
@override
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
@override
String get collectionPlaylistChangeCover => 'Change cover image';
@override
String get collectionPlaylistRemoveCover => 'Remove cover image';
@override
String selectionShareCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Share $count $_temp0';
}
@override
String get selectionShareNoFiles => 'No shareable files found';
@override
String selectionConvertCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0';
}
@override
String get selectionConvertNoConvertible => 'No convertible tracks selected';
@override
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
@override
String selectionBatchConvertConfirmMessage(
int count,
String format,
String bitrate,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
}
@override
String selectionBatchConvertSuccess(int success, int total, String format) {
return 'Converted $success of $total tracks to $format';
}
@override
String get setupModeSelectionTitle => 'Modunuzu Seçin';
@override
String get setupModeSelectionDescription =>
'SpotiFLAC\'ı nasıl kullanmak istersiniz? Bunu daha sonra Ayarlar\'dan değiştirebilirsiniz.';
@override
String get setupModeDownloaderTitle => 'İndirici';
@override
String get setupModeDownloaderFeature1 =>
'Kayıpsız FLAC kalitesinde parça indirin';
@override
String get setupModeDownloaderFeature2 =>
'Çevrimdışı dinlemek için müziği cihazınıza kaydedin';
@override
String get setupModeDownloaderFeature3 => 'Yerel müzik kütüphanenizi yönetin';
@override
String get setupModeStreamingTitle => 'Yayın Akışı';
@override
String get setupModeStreamingFeature1 =>
'İndirmeden parçaları anında yayınlayın';
@override
String get setupModeStreamingFeature2 =>
'Smart Queue sizin için otomatik olarak yeni müzik keşfeder';
@override
String get setupModeStreamingFeature3 =>
'İstediğiniz parçayı oynatma kontrolleriyle çalın';
@override
String get setupModeChangeableLater =>
'Ayarlar\'dan istediğiniz zaman modlar arasında geçiş yapabilirsiniz.';
}
+362 -3
View File
@@ -248,6 +248,33 @@ class AppLocalizationsZh extends AppLocalizations {
String get optionsAutoFallbackSubtitle =>
'Try other services if download fails';
@override
String get optionsAutoSkipUnavailableTracks => 'Auto Skip Unavailable Tracks';
@override
String get optionsAutoSkipUnavailableTracksSubtitleOn =>
'Automatically skip to the next queue track when a stream cannot be resolved.';
@override
String get optionsAutoSkipUnavailableTracksSubtitleOff =>
'Stop on failed track resolution and show an error.';
@override
String get optionsInteractionMode => 'Interaction Mode';
@override
String get modeDownloader => 'Downloader Mode';
@override
String get modeDownloaderSubtitle =>
'Tap tracks to add them to download queue';
@override
String get modeStreaming => 'Streaming Mode';
@override
String get modeStreamingSubtitle => 'Tap tracks to play instantly';
@override
String get optionsUseExtensionProviders => 'Use Extension Providers';
@@ -1041,6 +1068,10 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
@override
String get errorSeekNotSupported =>
'Seeking is not supported for this live stream';
@override
String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source';
@@ -1557,6 +1588,11 @@ class AppLocalizationsZh extends AppLocalizations {
return 'Download All ($count)';
}
@override
String playAllCount(int count) {
return 'Play All ($count)';
}
@override
String tracksCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
@@ -2235,9 +2271,15 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get discographyDownload => 'Download Discography';
@override
String get discographyPlay => 'Play Discography';
@override
String get discographyDownloadAll => 'Download All';
@override
String get discographyPlayAll => 'Play All';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
return '$count tracks from $albumCount releases';
@@ -2282,6 +2324,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get discographyDownloadSelected => 'Download Selected';
@override
String get discographyPlaySelected => 'Play Selected';
@override
String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue';
@@ -2407,6 +2452,17 @@ class AppLocalizationsZh extends AppLocalizations {
return '$count tracks';
}
@override
String libraryTracksUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$_temp0';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
@@ -2886,7 +2942,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Re-enrich Metadata';
String get trackReEnrich => 'Re-enrich';
@override
String get trackReEnrichSubtitle =>
@@ -2966,6 +3022,243 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get actionCreate => 'Create';
@override
String get collectionFoldersTitle => 'My folders';
@override
String get collectionWishlist => 'Wishlist';
@override
String get collectionLoved => 'Loved';
@override
String get collectionPlaylists => 'Playlists';
@override
String get collectionPlaylist => 'Playlist';
@override
String get collectionAddToPlaylist => 'Add to playlist';
@override
String get collectionCreatePlaylist => 'Create playlist';
@override
String get collectionNoPlaylistsYet => 'No playlists yet';
@override
String get collectionNoPlaylistsSubtitle =>
'Create a playlist to start categorizing tracks';
@override
String collectionPlaylistTracks(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String collectionAddedToPlaylist(String playlistName) {
return 'Added to \"$playlistName\"';
}
@override
String collectionAlreadyInPlaylist(String playlistName) {
return 'Already in \"$playlistName\"';
}
@override
String get collectionPlaylistCreated => 'Playlist created';
@override
String get collectionPlaylistNameHint => 'Playlist name';
@override
String get collectionPlaylistNameRequired => 'Playlist name is required';
@override
String get collectionRenamePlaylist => 'Rename playlist';
@override
String get collectionDeletePlaylist => 'Delete playlist';
@override
String collectionDeletePlaylistMessage(String playlistName) {
return 'Delete \"$playlistName\" and all tracks inside it?';
}
@override
String get collectionPlaylistDeleted => 'Playlist deleted';
@override
String get collectionPlaylistRenamed => 'Playlist renamed';
@override
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
@override
String get collectionWishlistEmptySubtitle =>
'Tap + on tracks to save what you want to download later';
@override
String get collectionLovedEmptyTitle => 'Loved folder is empty';
@override
String get collectionLovedEmptySubtitle =>
'Tap love on tracks to keep your favorites';
@override
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
@override
String get collectionPlaylistEmptySubtitle =>
'Long-press + on any track to add it here';
@override
String get collectionRemoveFromPlaylist => 'Remove from playlist';
@override
String get collectionRemoveFromFolder => 'Remove from folder';
@override
String collectionRemoved(String trackName) {
return '\"$trackName\" removed';
}
@override
String collectionAddedToLoved(String trackName) {
return '\"$trackName\" added to Loved';
}
@override
String collectionRemovedFromLoved(String trackName) {
return '\"$trackName\" removed from Loved';
}
@override
String collectionAddedToWishlist(String trackName) {
return '\"$trackName\" added to Wishlist';
}
@override
String collectionRemovedFromWishlist(String trackName) {
return '\"$trackName\" removed from Wishlist';
}
@override
String get trackOptionAddToLoved => 'Add to Loved';
@override
String get trackOptionRemoveFromLoved => 'Remove from Loved';
@override
String get trackOptionAddToWishlist => 'Add to Wishlist';
@override
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
@override
String get collectionPlaylistChangeCover => 'Change cover image';
@override
String get collectionPlaylistRemoveCover => 'Remove cover image';
@override
String selectionShareCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Share $count $_temp0';
}
@override
String get selectionShareNoFiles => 'No shareable files found';
@override
String selectionConvertCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0';
}
@override
String get selectionConvertNoConvertible => 'No convertible tracks selected';
@override
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
@override
String selectionBatchConvertConfirmMessage(
int count,
String format,
String bitrate,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
}
@override
String selectionBatchConvertSuccess(int success, int total, String format) {
return 'Converted $success of $total tracks to $format';
}
@override
String get setupModeSelectionTitle => '选择您的模式';
@override
String get setupModeSelectionDescription => '您想如何使用 SpotiFLAC?您可以稍后在设置中随时更改。';
@override
String get setupModeDownloaderTitle => '下载器';
@override
String get setupModeDownloaderFeature1 => '以无损 FLAC 品质下载曲目';
@override
String get setupModeDownloaderFeature2 => '将音乐保存到设备以供离线收听';
@override
String get setupModeDownloaderFeature3 => '管理您的本地音乐库';
@override
String get setupModeStreamingTitle => '流媒体';
@override
String get setupModeStreamingFeature1 => '无需下载即可即时播放曲目';
@override
String get setupModeStreamingFeature2 => 'Smart Queue 自动为您发现新音乐';
@override
String get setupModeStreamingFeature3 => '通过播放控件随时点播任意曲目';
@override
String get setupModeChangeableLater => '您可以随时在设置中切换模式。';
}
/// The translations for Chinese, as used in China (`zh_CN`).
@@ -5819,7 +6112,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Re-enrich Metadata';
String get trackReEnrich => 'Re-enrich';
@override
String get trackReEnrichSubtitle =>
@@ -5899,6 +6192,39 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get setupModeSelectionTitle => '选择您的模式';
@override
String get setupModeSelectionDescription => '您想如何使用 SpotiFLAC?您可以稍后在设置中随时更改。';
@override
String get setupModeDownloaderTitle => '下载器';
@override
String get setupModeDownloaderFeature1 => '以无损 FLAC 品质下载曲目';
@override
String get setupModeDownloaderFeature2 => '将音乐保存到设备以供离线收听';
@override
String get setupModeDownloaderFeature3 => '管理您的本地音乐库';
@override
String get setupModeStreamingTitle => '流媒体';
@override
String get setupModeStreamingFeature1 => '无需下载即可即时播放曲目';
@override
String get setupModeStreamingFeature2 => 'Smart Queue 自动为您发现新音乐';
@override
String get setupModeStreamingFeature3 => '通过播放控件随时点播任意曲目';
@override
String get setupModeChangeableLater => '您可以随时在设置中切换模式。';
}
/// The translations for Chinese, as used in Taiwan (`zh_TW`).
@@ -8752,7 +9078,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
String get trackSaveLyricsProgress => 'Saving lyrics...';
@override
String get trackReEnrich => 'Re-enrich Metadata';
String get trackReEnrich => 'Re-enrich';
@override
String get trackReEnrichSubtitle =>
@@ -8832,4 +9158,37 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String get trackConvertFailed => 'Conversion failed';
@override
String get setupModeSelectionTitle => '選擇您的模式';
@override
String get setupModeSelectionDescription => '您想如何使用 SpotiFLAC?您可以稍後在設定中隨時變更。';
@override
String get setupModeDownloaderTitle => '下載器';
@override
String get setupModeDownloaderFeature1 => '以無損 FLAC 品質下載曲目';
@override
String get setupModeDownloaderFeature2 => '將音樂儲存到裝置以供離線收聽';
@override
String get setupModeDownloaderFeature3 => '管理您的本機音樂庫';
@override
String get setupModeStreamingTitle => '串流';
@override
String get setupModeStreamingFeature1 => '無需下載即可即時串流曲目';
@override
String get setupModeStreamingFeature2 => 'Smart Queue 自動為您探索新音樂';
@override
String get setupModeStreamingFeature3 => '透過播放控制項隨時點播任意曲目';
@override
String get setupModeChangeableLater => '您可以隨時在設定中切換模式。';
}
+13 -2
View File
@@ -3750,7 +3750,7 @@
"@trackSaveLyricsProgress": {
"description": "Snackbar while saving lyrics to file"
},
"trackReEnrich": "Re-enrich Metadata",
"trackReEnrich": "Re-enrich",
"@trackReEnrich": {
"description": "Menu action - re-embed metadata into audio file"
},
@@ -3868,5 +3868,16 @@
"trackConvertFailed": "Conversion failed",
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
}
},
"setupModeSelectionTitle": "Wähle deinen Modus",
"setupModeSelectionDescription": "Wie möchtest du SpotiFLAC nutzen? Du kannst dies später jederzeit in den Einstellungen ändern.",
"setupModeDownloaderTitle": "Downloader",
"setupModeDownloaderFeature1": "Lade Titel in verlustfreier FLAC-Qualität herunter",
"setupModeDownloaderFeature2": "Speichere Musik auf deinem Gerät zum Offline-Hören",
"setupModeDownloaderFeature3": "Verwalte deine lokale Musikbibliothek",
"setupModeStreamingTitle": "Streaming",
"setupModeStreamingFeature1": "Streame Titel sofort ohne Herunterladen",
"setupModeStreamingFeature2": "Smart Queue entdeckt automatisch neue Musik für dich",
"setupModeStreamingFeature3": "Spiele jeden Titel auf Abruf mit Wiedergabesteuerung",
"setupModeChangeableLater": "Du kannst jederzeit in den Einstellungen zwischen den Modi wechseln."
}
+239 -2
View File
@@ -175,6 +175,22 @@
"@optionsAutoFallback": {"description": "Auto-retry with other services"},
"optionsAutoFallbackSubtitle": "Try other services if download fails",
"@optionsAutoFallbackSubtitle": {"description": "Subtitle for auto fallback"},
"optionsAutoSkipUnavailableTracks": "Auto Skip Unavailable Tracks",
"@optionsAutoSkipUnavailableTracks": {"description": "Toggle to skip to the next queue track when current track stream resolution fails"},
"optionsAutoSkipUnavailableTracksSubtitleOn": "Automatically skip to the next queue track when a stream cannot be resolved.",
"@optionsAutoSkipUnavailableTracksSubtitleOn": {"description": "Subtitle when auto skip on resolve failure is enabled"},
"optionsAutoSkipUnavailableTracksSubtitleOff": "Stop on failed track resolution and show an error.",
"@optionsAutoSkipUnavailableTracksSubtitleOff": {"description": "Subtitle when auto skip on resolve failure is disabled"},
"optionsInteractionMode": "Interaction Mode",
"@optionsInteractionMode": {"description": "Tap behavior mode for track lists"},
"modeDownloader": "Downloader Mode",
"@modeDownloader": {"description": "Interaction mode where taps queue downloads"},
"modeDownloaderSubtitle": "Tap tracks to add them to download queue",
"@modeDownloaderSubtitle": {"description": "Subtitle for downloader interaction mode"},
"modeStreaming": "Streaming Mode",
"@modeStreaming": {"description": "Interaction mode where taps start playback"},
"modeStreamingSubtitle": "Tap tracks to play instantly",
"@modeStreamingSubtitle": {"description": "Subtitle for streaming interaction mode"},
"optionsUseExtensionProviders": "Use Extension Providers",
"@optionsUseExtensionProviders": {"description": "Enable extension download providers"},
"optionsUseExtensionProvidersOn": "Extensions will be tried first",
@@ -759,6 +775,8 @@
},
"errorNoTracksFound": "No tracks found",
"@errorNoTracksFound": {"description": "Error - search returned no results"},
"errorSeekNotSupported": "Seeking is not supported for this live stream",
"@errorSeekNotSupported": {"description": "Error - seek disabled for live decrypted stream"},
"errorMissingExtensionSource": "Cannot load {item}: missing extension source",
"@errorMissingExtensionSource": {
"description": "Error - extension source not available",
@@ -1151,6 +1169,13 @@
"count": {"type": "int"}
}
},
"playAllCount": "Play All ({count})",
"@playAllCount": {
"description": "Play all button with count",
"placeholders": {
"count": {"type": "int"}
}
},
"tracksCount": "{count, plural, =1{1 track} other{{count} tracks}}",
"@tracksCount": {
"description": "Track count display",
@@ -1669,8 +1694,12 @@
"discographyDownload": "Download Discography",
"@discographyDownload": {"description": "Button - download artist discography"},
"discographyPlay": "Play Discography",
"@discographyPlay": {"description": "Button - play artist discography"},
"discographyDownloadAll": "Download All",
"@discographyDownloadAll": {"description": "Option - download entire discography"},
"discographyPlayAll": "Play All",
"@discographyPlayAll": {"description": "Option - play entire discography"},
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
"@discographyDownloadAllSubtitle": {
"description": "Subtitle showing total tracks and albums",
@@ -1722,6 +1751,8 @@
},
"discographyDownloadSelected": "Download Selected",
"@discographyDownloadSelected": {"description": "Button - download selected albums"},
"discographyPlaySelected": "Play Selected",
"@discographyPlaySelected": {"description": "Button - play selected albums"},
"discographyAddedToQueue": "Added {count} tracks to queue",
"@discographyAddedToQueue": {
"description": "Snackbar - tracks added from discography",
@@ -1814,6 +1845,13 @@
"count": {"type": "int"}
}
},
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
"@libraryTracksUnit": {
"description": "Unit label for tracks count (without the number itself)",
"placeholders": {
"count": {"type": "int"}
}
},
"libraryLastScanned": "Last scanned: {time}",
"@libraryLastScanned": {
"description": "Last scan time display",
@@ -2187,7 +2225,7 @@
"@trackSaveLyricsSubtitle": {"description": "Subtitle for save lyrics action"},
"trackSaveLyricsProgress": "Saving lyrics...",
"@trackSaveLyricsProgress": {"description": "Snackbar while saving lyrics to file"},
"trackReEnrich": "Re-enrich Metadata",
"trackReEnrich": "Re-enrich",
"@trackReEnrich": {"description": "Menu action - re-embed metadata into audio file"},
"trackReEnrichSubtitle": "Re-embed metadata without re-downloading",
"@trackReEnrichSubtitle": {"description": "Subtitle for re-enrich metadata action"},
@@ -2258,5 +2296,204 @@
}
},
"trackConvertFailed": "Conversion failed",
"@trackConvertFailed": {"description": "Snackbar when conversion fails"}
"@trackConvertFailed": {"description": "Snackbar when conversion fails"},
"actionCreate": "Create",
"@actionCreate": {"description": "Generic action button - create"},
"collectionFoldersTitle": "My folders",
"@collectionFoldersTitle": {"description": "Library section title for custom folders"},
"collectionWishlist": "Wishlist",
"@collectionWishlist": {"description": "Custom folder for saved tracks to download later"},
"collectionLoved": "Loved",
"@collectionLoved": {"description": "Custom folder for favorite tracks"},
"collectionPlaylists": "Playlists",
"@collectionPlaylists": {"description": "Custom user playlists folder"},
"collectionPlaylist": "Playlist",
"@collectionPlaylist": {"description": "Single playlist label"},
"collectionAddToPlaylist": "Add to playlist",
"@collectionAddToPlaylist": {"description": "Action to add a track to user playlist"},
"collectionCreatePlaylist": "Create playlist",
"@collectionCreatePlaylist": {"description": "Action to create a new playlist"},
"collectionNoPlaylistsYet": "No playlists yet",
"@collectionNoPlaylistsYet": {"description": "Empty state title when user has no playlists"},
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
"@collectionNoPlaylistsSubtitle": {"description": "Empty state subtitle when user has no playlists"},
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
"@collectionPlaylistTracks": {
"description": "Track count label for custom playlists",
"placeholders": {
"count": {"type": "int"}
}
},
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
"@collectionAddedToPlaylist": {
"description": "Snackbar after adding track to playlist",
"placeholders": {
"playlistName": {"type": "String"}
}
},
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
"@collectionAlreadyInPlaylist": {
"description": "Snackbar when track already exists in playlist",
"placeholders": {
"playlistName": {"type": "String"}
}
},
"collectionPlaylistCreated": "Playlist created",
"@collectionPlaylistCreated": {"description": "Snackbar after creating playlist"},
"collectionPlaylistNameHint": "Playlist name",
"@collectionPlaylistNameHint": {"description": "Hint text for playlist name input"},
"collectionPlaylistNameRequired": "Playlist name is required",
"@collectionPlaylistNameRequired": {"description": "Validation error for empty playlist name"},
"collectionRenamePlaylist": "Rename playlist",
"@collectionRenamePlaylist": {"description": "Action to rename playlist"},
"collectionDeletePlaylist": "Delete playlist",
"@collectionDeletePlaylist": {"description": "Action to delete playlist"},
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
"@collectionDeletePlaylistMessage": {
"description": "Confirmation message for deleting playlist",
"placeholders": {
"playlistName": {"type": "String"}
}
},
"collectionPlaylistDeleted": "Playlist deleted",
"@collectionPlaylistDeleted": {"description": "Snackbar after deleting playlist"},
"collectionPlaylistRenamed": "Playlist renamed",
"@collectionPlaylistRenamed": {"description": "Snackbar after renaming playlist"},
"collectionWishlistEmptyTitle": "Wishlist is empty",
"@collectionWishlistEmptyTitle": {"description": "Wishlist empty state title"},
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
"@collectionWishlistEmptySubtitle": {"description": "Wishlist empty state subtitle"},
"collectionLovedEmptyTitle": "Loved folder is empty",
"@collectionLovedEmptyTitle": {"description": "Loved empty state title"},
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
"@collectionLovedEmptySubtitle": {"description": "Loved empty state subtitle"},
"collectionPlaylistEmptyTitle": "Playlist is empty",
"@collectionPlaylistEmptyTitle": {"description": "Playlist empty state title"},
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
"@collectionPlaylistEmptySubtitle": {"description": "Playlist empty state subtitle"},
"collectionRemoveFromPlaylist": "Remove from playlist",
"@collectionRemoveFromPlaylist": {"description": "Tooltip for removing track from playlist"},
"collectionRemoveFromFolder": "Remove from folder",
"@collectionRemoveFromFolder": {"description": "Tooltip for removing track from wishlist/loved folder"},
"collectionRemoved": "\"{trackName}\" removed",
"@collectionRemoved": {
"description": "Snackbar after removing a track from a collection",
"placeholders": {
"trackName": {"type": "String"}
}
},
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
"@collectionAddedToLoved": {
"description": "Snackbar after adding track to loved folder",
"placeholders": {
"trackName": {"type": "String"}
}
},
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
"@collectionRemovedFromLoved": {
"description": "Snackbar after removing track from loved folder",
"placeholders": {
"trackName": {"type": "String"}
}
},
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
"@collectionAddedToWishlist": {
"description": "Snackbar after adding track to wishlist",
"placeholders": {
"trackName": {"type": "String"}
}
},
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
"@collectionRemovedFromWishlist": {
"description": "Snackbar after removing track from wishlist",
"placeholders": {
"trackName": {"type": "String"}
}
},
"trackOptionAddToLoved": "Add to Loved",
"@trackOptionAddToLoved": {"description": "Bottom sheet action label - add track to loved folder"},
"trackOptionRemoveFromLoved": "Remove from Loved",
"@trackOptionRemoveFromLoved": {"description": "Bottom sheet action label - remove track from loved folder"},
"trackOptionAddToWishlist": "Add to Wishlist",
"@trackOptionAddToWishlist": {"description": "Bottom sheet action label - add track to wishlist"},
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
"@trackOptionRemoveFromWishlist": {"description": "Bottom sheet action label - remove track from wishlist"},
"collectionPlaylistChangeCover": "Change cover image",
"@collectionPlaylistChangeCover": {"description": "Bottom sheet action to pick a custom cover image for a playlist"},
"collectionPlaylistRemoveCover": "Remove cover image",
"@collectionPlaylistRemoveCover": {"description": "Bottom sheet action to remove custom cover image from a playlist"},
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
"@selectionShareCount": {
"description": "Share button text with count in selection mode",
"placeholders": {
"count": {"type": "int"}
}
},
"selectionShareNoFiles": "No shareable files found",
"@selectionShareNoFiles": {"description": "Snackbar when no selected files exist on disk"},
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
"@selectionConvertCount": {
"description": "Convert button text with count in selection mode",
"placeholders": {
"count": {"type": "int"}
}
},
"selectionConvertNoConvertible": "No convertible tracks selected",
"@selectionConvertNoConvertible": {"description": "Snackbar when no selected tracks support conversion"},
"selectionBatchConvertConfirmTitle": "Batch Convert",
"@selectionBatchConvertConfirmTitle": {"description": "Confirmation dialog title for batch conversion"},
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
"@selectionBatchConvertConfirmMessage": {
"description": "Confirmation dialog message for batch conversion",
"placeholders": {
"count": {"type": "int"},
"format": {"type": "String"},
"bitrate": {"type": "String"}
}
},
"selectionBatchConvertProgress": "Converting {current} of {total}...",
"@selectionBatchConvertProgress": {
"description": "Snackbar during batch conversion progress",
"placeholders": {
"current": {"type": "int"},
"total": {"type": "int"}
}
},
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
"@selectionBatchConvertSuccess": {
"description": "Snackbar after batch conversion completes",
"placeholders": {
"success": {"type": "int"},
"total": {"type": "int"},
"format": {"type": "String"}
}
},
"setupModeSelectionTitle": "Choose Your Mode",
"@setupModeSelectionTitle": {"description": "Title for mode selection step in setup wizard"},
"setupModeSelectionDescription": "How would you like to use SpotiFLAC? You can always change this later in Settings.",
"@setupModeSelectionDescription": {"description": "Description for mode selection step"},
"setupModeDownloaderTitle": "Downloader",
"@setupModeDownloaderTitle": {"description": "Title for downloader mode option"},
"setupModeDownloaderFeature1": "Download tracks in lossless FLAC quality",
"@setupModeDownloaderFeature1": {"description": "Downloader mode feature 1"},
"setupModeDownloaderFeature2": "Save music to your device for offline listening",
"@setupModeDownloaderFeature2": {"description": "Downloader mode feature 2"},
"setupModeDownloaderFeature3": "Manage your local music library",
"@setupModeDownloaderFeature3": {"description": "Downloader mode feature 3"},
"setupModeStreamingTitle": "Streaming",
"@setupModeStreamingTitle": {"description": "Title for streaming mode option"},
"setupModeStreamingFeature1": "Stream tracks instantly without downloading",
"@setupModeStreamingFeature1": {"description": "Streaming mode feature 1"},
"setupModeStreamingFeature2": "Smart Queue auto-discovers new music for you",
"@setupModeStreamingFeature2": {"description": "Streaming mode feature 2"},
"setupModeStreamingFeature3": "Play any track on demand with playback controls",
"@setupModeStreamingFeature3": {"description": "Streaming mode feature 3"},
"setupModeChangeableLater": "You can switch between modes anytime in Settings.",
"@setupModeChangeableLater": {"description": "Hint that mode can be changed later"}
}
+12 -1
View File
@@ -2565,5 +2565,16 @@
"utilityFunctions": "Utility Functions",
"@utilityFunctions": {
"description": "Extension capability - utility functions"
}
},
"setupModeSelectionTitle": "Elige tu modo",
"setupModeSelectionDescription": "¿Cómo te gustaría usar SpotiFLAC? Puedes cambiarlo más tarde en Ajustes.",
"setupModeDownloaderTitle": "Descargador",
"setupModeDownloaderFeature1": "Descarga pistas en calidad FLAC sin pérdida",
"setupModeDownloaderFeature2": "Guarda música en tu dispositivo para escuchar sin conexión",
"setupModeDownloaderFeature3": "Gestiona tu biblioteca de música local",
"setupModeStreamingTitle": "Streaming",
"setupModeStreamingFeature1": "Transmite pistas al instante sin descargar",
"setupModeStreamingFeature2": "Smart Queue descubre automáticamente nueva música para ti",
"setupModeStreamingFeature3": "Reproduce cualquier pista bajo demanda con controles de reproducción",
"setupModeChangeableLater": "Puedes cambiar entre modos en cualquier momento en Ajustes."
}
+13 -2
View File
@@ -3750,7 +3750,7 @@
"@trackSaveLyricsProgress": {
"description": "Snackbar while saving lyrics to file"
},
"trackReEnrich": "Re-enrich Metadata",
"trackReEnrich": "Re-enrich",
"@trackReEnrich": {
"description": "Menu action - re-embed metadata into audio file"
},
@@ -3868,5 +3868,16 @@
"trackConvertFailed": "Conversion failed",
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
}
},
"setupModeSelectionTitle": "Elige tu modo",
"setupModeSelectionDescription": "¿Cómo te gustaría usar SpotiFLAC? Puedes cambiarlo más tarde en Ajustes.",
"setupModeDownloaderTitle": "Descargador",
"setupModeDownloaderFeature1": "Descarga pistas en calidad FLAC sin pérdida",
"setupModeDownloaderFeature2": "Guarda música en tu dispositivo para escuchar sin conexión",
"setupModeDownloaderFeature3": "Gestiona tu biblioteca de música local",
"setupModeStreamingTitle": "Streaming",
"setupModeStreamingFeature1": "Transmite pistas al instante sin descargar",
"setupModeStreamingFeature2": "Smart Queue descubre automáticamente nueva música para ti",
"setupModeStreamingFeature3": "Reproduce cualquier pista bajo demanda con controles de reproducción",
"setupModeChangeableLater": "Puedes cambiar entre modos en cualquier momento en Ajustes."
}
+13 -2
View File
@@ -3750,7 +3750,7 @@
"@trackSaveLyricsProgress": {
"description": "Snackbar while saving lyrics to file"
},
"trackReEnrich": "Re-enrich Metadata",
"trackReEnrich": "Re-enrich",
"@trackReEnrich": {
"description": "Menu action - re-embed metadata into audio file"
},
@@ -3868,5 +3868,16 @@
"trackConvertFailed": "Conversion failed",
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
}
},
"setupModeSelectionTitle": "Choisissez votre mode",
"setupModeSelectionDescription": "Comment souhaitez-vous utiliser SpotiFLAC ? Vous pouvez toujours changer cela plus tard dans les Paramètres.",
"setupModeDownloaderTitle": "Téléchargeur",
"setupModeDownloaderFeature1": "Téléchargez des pistes en qualité FLAC sans perte",
"setupModeDownloaderFeature2": "Enregistrez de la musique sur votre appareil pour une écoute hors ligne",
"setupModeDownloaderFeature3": "Gérez votre bibliothèque musicale locale",
"setupModeStreamingTitle": "Streaming",
"setupModeStreamingFeature1": "Diffusez des pistes instantanément sans télécharger",
"setupModeStreamingFeature2": "Smart Queue découvre automatiquement de nouvelle musique pour vous",
"setupModeStreamingFeature3": "Écoutez n'importe quelle piste à la demande avec les contrôles de lecture",
"setupModeChangeableLater": "Vous pouvez changer de mode à tout moment dans les Paramètres."
}
+13 -2
View File
@@ -3750,7 +3750,7 @@
"@trackSaveLyricsProgress": {
"description": "Snackbar while saving lyrics to file"
},
"trackReEnrich": "Re-enrich Metadata",
"trackReEnrich": "Re-enrich",
"@trackReEnrich": {
"description": "Menu action - re-embed metadata into audio file"
},
@@ -3868,5 +3868,16 @@
"trackConvertFailed": "Conversion failed",
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
}
},
"setupModeSelectionTitle": "अपना मोड चुनें",
"setupModeSelectionDescription": "आप SpotiFLAC का उपयोग कैसे करना चाहेंगे? आप इसे बाद में सेटिंग्स में कभी भी बदल सकते हैं।",
"setupModeDownloaderTitle": "डाउनलोडर",
"setupModeDownloaderFeature1": "लॉसलेस FLAC गुणवत्ता में ट्रैक डाउनलोड करें",
"setupModeDownloaderFeature2": "ऑफ़लाइन सुनने के लिए संगीत अपने डिवाइस में सहेजें",
"setupModeDownloaderFeature3": "अपनी स्थानीय संगीत लाइब्रेरी प्रबंधित करें",
"setupModeStreamingTitle": "स्ट्रीमिंग",
"setupModeStreamingFeature1": "बिना डाउनलोड किए तुरंत ट्रैक स्ट्रीम करें",
"setupModeStreamingFeature2": "Smart Queue स्वचालित रूप से आपके लिए नया संगीत खोजता है",
"setupModeStreamingFeature3": "प्लेबैक नियंत्रण के साथ किसी भी ट्रैक को मांग पर चलाएं",
"setupModeChangeableLater": "आप सेटिंग्स में कभी भी मोड बदल सकते हैं।"
}
+369 -50
View File
@@ -300,15 +300,47 @@
"@optionsSwitchBack": {
"description": "Hint to switch back to built-in providers"
},
"optionsAutoFallback": "Auto Fallback",
"@optionsAutoFallback": {
"description": "Auto-retry with other services"
},
"optionsAutoFallbackSubtitle": "Coba layanan lain jika unduhan gagal",
"@optionsAutoFallbackSubtitle": {
"description": "Subtitle for auto fallback"
},
"optionsUseExtensionProviders": "Gunakan Provider Ekstensi",
"optionsAutoFallback": "Auto Fallback",
"@optionsAutoFallback": {
"description": "Auto-retry with other services"
},
"optionsAutoFallbackSubtitle": "Coba layanan lain jika unduhan gagal",
"@optionsAutoFallbackSubtitle": {
"description": "Subtitle for auto fallback"
},
"optionsAutoSkipUnavailableTracks": "Lewati Otomatis Lagu yang Tidak Tersedia",
"@optionsAutoSkipUnavailableTracks": {
"description": "Toggle to skip to the next queue track when current track stream resolution fails"
},
"optionsAutoSkipUnavailableTracksSubtitleOn": "Otomatis lanjut ke lagu berikutnya di antrean jika stream lagu tidak bisa ditemukan.",
"@optionsAutoSkipUnavailableTracksSubtitleOn": {
"description": "Subtitle when auto skip on resolve failure is enabled"
},
"optionsAutoSkipUnavailableTracksSubtitleOff": "Berhenti di lagu yang gagal dan tampilkan pesan error.",
"@optionsAutoSkipUnavailableTracksSubtitleOff": {
"description": "Subtitle when auto skip on resolve failure is disabled"
},
"optionsInteractionMode": "Mode Interaksi",
"@optionsInteractionMode": {
"description": "Tap behavior mode for track lists"
},
"modeDownloader": "Mode Downloader",
"@modeDownloader": {
"description": "Interaction mode where taps queue downloads"
},
"modeDownloaderSubtitle": "Ketuk lagu untuk menambah ke antrean unduhan",
"@modeDownloaderSubtitle": {
"description": "Subtitle for downloader interaction mode"
},
"modeStreaming": "Mode Streaming",
"@modeStreaming": {
"description": "Interaction mode where taps start playback"
},
"modeStreamingSubtitle": "Ketuk lagu untuk langsung memutar",
"@modeStreamingSubtitle": {
"description": "Subtitle for streaming interaction mode"
},
"optionsUseExtensionProviders": "Gunakan Provider Ekstensi",
"@optionsUseExtensionProviders": {
"description": "Enable extension download providers"
},
@@ -1336,11 +1368,15 @@
}
}
},
"errorNoTracksFound": "Tidak ada lagu ditemukan",
"@errorNoTracksFound": {
"description": "Error - search returned no results"
},
"errorMissingExtensionSource": "Tidak dapat memuat {item}: sumber ekstensi tidak ada",
"errorNoTracksFound": "Tidak ada lagu ditemukan",
"@errorNoTracksFound": {
"description": "Error - search returned no results"
},
"errorSeekNotSupported": "Menggeser posisi lagu tidak didukung untuk live stream ini",
"@errorSeekNotSupported": {
"description": "Error - seek disabled for live decrypted stream"
},
"errorMissingExtensionSource": "Tidak dapat memuat {item}: sumber ekstensi tidak ada",
"@errorMissingExtensionSource": {
"description": "Error - extension source not available",
"placeholders": {
@@ -2013,16 +2049,25 @@
"@tracksHeader": {
"description": "Section header for track list"
},
"downloadAllCount": "Unduh Semua ({count})",
"@downloadAllCount": {
"description": "Download all button with count",
"placeholders": {
"count": {
"type": "int"
}
}
},
"tracksCount": "{count, plural, =1{1 lagu} other{{count} lagu}}",
"downloadAllCount": "Unduh Semua ({count})",
"@downloadAllCount": {
"description": "Download all button with count",
"placeholders": {
"count": {
"type": "int"
}
}
},
"playAllCount": "Putar Semua ({count})",
"@playAllCount": {
"description": "Play all button with count",
"placeholders": {
"count": {
"type": "int"
}
}
},
"tracksCount": "{count, plural, =1{1 lagu} other{{count} lagu}}",
"@tracksCount": {
"description": "Track count display",
"placeholders": {
@@ -2927,14 +2972,22 @@
}
}
},
"discographyDownload": "Download Discography",
"@discographyDownload": {
"description": "Button - download artist discography"
},
"discographyDownloadAll": "Unduh Semua",
"@discographyDownloadAll": {
"description": "Option - download entire discography"
},
"discographyDownload": "Download Discography",
"@discographyDownload": {
"description": "Button - download artist discography"
},
"discographyPlay": "Putar Diskografi",
"@discographyPlay": {
"description": "Button - play artist discography"
},
"discographyDownloadAll": "Unduh Semua",
"@discographyDownloadAll": {
"description": "Option - download entire discography"
},
"discographyPlayAll": "Putar Semua",
"@discographyPlayAll": {
"description": "Option - play entire discography"
},
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
"@discographyDownloadAllSubtitle": {
"description": "Subtitle showing total tracks and albums",
@@ -3012,10 +3065,14 @@
}
}
},
"discographyDownloadSelected": "Download Selected",
"@discographyDownloadSelected": {
"description": "Button - download selected albums"
},
"discographyDownloadSelected": "Download Selected",
"@discographyDownloadSelected": {
"description": "Button - download selected albums"
},
"discographyPlaySelected": "Putar Terpilih",
"@discographyPlaySelected": {
"description": "Button - play selected albums"
},
"discographyAddedToQueue": "Added {count} tracks to queue",
"@discographyAddedToQueue": {
"description": "Snackbar - tracks added from discography",
@@ -3173,15 +3230,24 @@
"@libraryAboutDescription": {
"description": "Description of local library feature"
},
"libraryTracksCount": "{count} tracks",
"@libraryTracksCount": {
"description": "Track count in library",
"placeholders": {
"count": {
"type": "int"
}
}
},
"libraryTracksCount": "{count} tracks",
"@libraryTracksCount": {
"description": "Track count in library",
"placeholders": {
"count": {
"type": "int"
}
}
},
"libraryTracksUnit": "{count, plural, =1{trek} other{trek}}",
"@libraryTracksUnit": {
"description": "Unit label for tracks count (without the number itself)",
"placeholders": {
"count": {
"type": "int"
}
}
},
"libraryLastScanned": "Last scanned: {time}",
"@libraryLastScanned": {
"description": "Last scan time display",
@@ -3809,7 +3875,7 @@
"@trackSaveLyricsProgress": {
"description": "Snackbar while saving lyrics to file"
},
"trackReEnrich": "Re-enrich Metadata",
"trackReEnrich": "Re-enrich",
"@trackReEnrich": {
"description": "Menu action - re-embed metadata into audio file"
},
@@ -3924,8 +3990,261 @@
}
}
},
"trackConvertFailed": "Conversion failed",
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
}
"trackConvertFailed": "Conversion failed",
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
},
"actionCreate": "Buat",
"@actionCreate": {
"description": "Generic action button - create"
},
"collectionFoldersTitle": "Folder saya",
"@collectionFoldersTitle": {
"description": "Library section title for custom folders"
},
"collectionWishlist": "Wishlist",
"@collectionWishlist": {
"description": "Custom folder for saved tracks to download later"
},
"collectionLoved": "Loved",
"@collectionLoved": {
"description": "Custom folder for favorite tracks"
},
"collectionPlaylists": "Playlist",
"@collectionPlaylists": {
"description": "Custom user playlists folder"
},
"collectionPlaylist": "Playlist",
"@collectionPlaylist": {
"description": "Single playlist label"
},
"collectionAddToPlaylist": "Tambahkan ke playlist",
"@collectionAddToPlaylist": {
"description": "Action to add a track to user playlist"
},
"collectionCreatePlaylist": "Buat playlist",
"@collectionCreatePlaylist": {
"description": "Action to create a new playlist"
},
"collectionNoPlaylistsYet": "Belum ada playlist",
"@collectionNoPlaylistsYet": {
"description": "Empty state title when user has no playlists"
},
"collectionNoPlaylistsSubtitle": "Buat playlist untuk mulai mengategorikan lagu",
"@collectionNoPlaylistsSubtitle": {
"description": "Empty state subtitle when user has no playlists"
},
"collectionPlaylistTracks": "{count, plural, =1{1 lagu} other{{count} lagu}}",
"@collectionPlaylistTracks": {
"description": "Track count label for custom playlists",
"placeholders": {
"count": {
"type": "int"
}
}
},
"collectionAddedToPlaylist": "Ditambahkan ke \"{playlistName}\"",
"@collectionAddedToPlaylist": {
"description": "Snackbar after adding track to playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionAlreadyInPlaylist": "Sudah ada di \"{playlistName}\"",
"@collectionAlreadyInPlaylist": {
"description": "Snackbar when track already exists in playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistCreated": "Playlist berhasil dibuat",
"@collectionPlaylistCreated": {
"description": "Snackbar after creating playlist"
},
"collectionPlaylistNameHint": "Nama playlist",
"@collectionPlaylistNameHint": {
"description": "Hint text for playlist name input"
},
"collectionPlaylistNameRequired": "Nama playlist wajib diisi",
"@collectionPlaylistNameRequired": {
"description": "Validation error for empty playlist name"
},
"collectionRenamePlaylist": "Ubah nama playlist",
"@collectionRenamePlaylist": {
"description": "Action to rename playlist"
},
"collectionDeletePlaylist": "Hapus playlist",
"@collectionDeletePlaylist": {
"description": "Action to delete playlist"
},
"collectionDeletePlaylistMessage": "Hapus \"{playlistName}\" beserta semua lagunya?",
"@collectionDeletePlaylistMessage": {
"description": "Confirmation message for deleting playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistDeleted": "Playlist dihapus",
"@collectionPlaylistDeleted": {
"description": "Snackbar after deleting playlist"
},
"collectionPlaylistRenamed": "Nama playlist diperbarui",
"@collectionPlaylistRenamed": {
"description": "Snackbar after renaming playlist"
},
"collectionWishlistEmptyTitle": "Wishlist masih kosong",
"@collectionWishlistEmptyTitle": {
"description": "Wishlist empty state title"
},
"collectionWishlistEmptySubtitle": "Tap + di lagu untuk menyimpan yang ingin diunduh nanti",
"@collectionWishlistEmptySubtitle": {
"description": "Wishlist empty state subtitle"
},
"collectionLovedEmptyTitle": "Folder Loved masih kosong",
"@collectionLovedEmptyTitle": {
"description": "Loved empty state title"
},
"collectionLovedEmptySubtitle": "Tap love di lagu untuk menyimpan favoritmu",
"@collectionLovedEmptySubtitle": {
"description": "Loved empty state subtitle"
},
"collectionPlaylistEmptyTitle": "Playlist masih kosong",
"@collectionPlaylistEmptyTitle": {
"description": "Playlist empty state title"
},
"collectionPlaylistEmptySubtitle": "Tekan lama tombol + pada lagu untuk menambahkannya ke sini",
"@collectionPlaylistEmptySubtitle": {
"description": "Playlist empty state subtitle"
},
"collectionRemoveFromPlaylist": "Hapus dari playlist",
"@collectionRemoveFromPlaylist": {
"description": "Tooltip for removing track from playlist"
},
"collectionRemoveFromFolder": "Hapus dari folder",
"@collectionRemoveFromFolder": {
"description": "Tooltip for removing track from wishlist/loved folder"
},
"collectionRemoved": "\"{trackName}\" dihapus",
"@collectionRemoved": {
"description": "Snackbar after removing a track from a collection",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToLoved": "\"{trackName}\" ditambahkan ke Loved",
"@collectionAddedToLoved": {
"description": "Snackbar after adding track to loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromLoved": "\"{trackName}\" dihapus dari Loved",
"@collectionRemovedFromLoved": {
"description": "Snackbar after removing track from loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToWishlist": "\"{trackName}\" ditambahkan ke Wishlist",
"@collectionAddedToWishlist": {
"description": "Snackbar after adding track to wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromWishlist": "\"{trackName}\" dihapus dari Wishlist",
"@collectionRemovedFromWishlist": {
"description": "Snackbar after removing track from wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"trackOptionAddToLoved": "Tambahkan ke Loved",
"@trackOptionAddToLoved": {"description": "Bottom sheet action label - add track to loved folder"},
"trackOptionRemoveFromLoved": "Hapus dari Loved",
"@trackOptionRemoveFromLoved": {"description": "Bottom sheet action label - remove track from loved folder"},
"trackOptionAddToWishlist": "Tambahkan ke Wishlist",
"@trackOptionAddToWishlist": {"description": "Bottom sheet action label - add track to wishlist"},
"trackOptionRemoveFromWishlist": "Hapus dari Wishlist",
"@trackOptionRemoveFromWishlist": {"description": "Bottom sheet action label - remove track from wishlist"},
"collectionPlaylistChangeCover": "Ubah gambar sampul",
"@collectionPlaylistChangeCover": {"description": "Bottom sheet action to pick a custom cover image for a playlist"},
"collectionPlaylistRemoveCover": "Hapus gambar sampul",
"@collectionPlaylistRemoveCover": {"description": "Bottom sheet action to remove custom cover image from a playlist"},
"setupModeSelectionTitle": "Pilih Mode Anda",
"setupModeSelectionDescription": "Bagaimana Anda ingin menggunakan SpotiFLAC? Anda dapat mengubahnya nanti di Pengaturan.",
"setupModeDownloaderTitle": "Pengunduh",
"setupModeDownloaderFeature1": "Unduh trek dalam kualitas FLAC lossless",
"setupModeDownloaderFeature2": "Simpan musik ke perangkat Anda untuk mendengarkan offline",
"setupModeDownloaderFeature3": "Kelola perpustakaan musik lokal Anda",
"setupModeStreamingTitle": "Streaming",
"setupModeStreamingFeature1": "Streaming trek secara instan tanpa mengunduh",
"setupModeStreamingFeature2": "Smart Queue secara otomatis menemukan musik baru untuk Anda",
"setupModeStreamingFeature3": "Putar trek apa pun sesuai permintaan dengan kontrol pemutaran",
"setupModeChangeableLater": "Anda dapat beralih antar mode kapan saja di Pengaturan.",
"selectionShareCount": "Bagikan {count} {count, plural, =1{trek} other{trek}}",
"@selectionShareCount": {
"description": "Share button text with count in selection mode",
"placeholders": {
"count": {"type": "int"}
}
},
"selectionShareNoFiles": "Tidak ada file yang dapat dibagikan",
"@selectionShareNoFiles": {"description": "Snackbar when no selected files exist on disk"},
"selectionConvertCount": "Konversi {count} {count, plural, =1{trek} other{trek}}",
"@selectionConvertCount": {
"description": "Convert button text with count in selection mode",
"placeholders": {
"count": {"type": "int"}
}
},
"selectionConvertNoConvertible": "Tidak ada trek yang dapat dikonversi dipilih",
"@selectionConvertNoConvertible": {"description": "Snackbar when no selected tracks support conversion"},
"selectionBatchConvertConfirmTitle": "Konversi Massal",
"@selectionBatchConvertConfirmTitle": {"description": "Confirmation dialog title for batch conversion"},
"selectionBatchConvertConfirmMessage": "Konversi {count} {count, plural, =1{trek} other{trek}} ke {format} pada {bitrate}?\n\nFile asli akan dihapus setelah konversi.",
"@selectionBatchConvertConfirmMessage": {
"description": "Confirmation dialog message for batch conversion",
"placeholders": {
"count": {"type": "int"},
"format": {"type": "String"},
"bitrate": {"type": "String"}
}
},
"selectionBatchConvertProgress": "Mengonversi {current} dari {total}...",
"@selectionBatchConvertProgress": {
"description": "Snackbar during batch conversion progress",
"placeholders": {
"current": {"type": "int"},
"total": {"type": "int"}
}
},
"selectionBatchConvertSuccess": "Berhasil mengonversi {success} dari {total} trek ke {format}",
"@selectionBatchConvertSuccess": {
"description": "Snackbar after batch conversion completes",
"placeholders": {
"success": {"type": "int"},
"total": {"type": "int"},
"format": {"type": "String"}
}
}
}
+13 -2
View File
@@ -3750,7 +3750,7 @@
"@trackSaveLyricsProgress": {
"description": "Snackbar while saving lyrics to file"
},
"trackReEnrich": "Re-enrich Metadata",
"trackReEnrich": "Re-enrich",
"@trackReEnrich": {
"description": "Menu action - re-embed metadata into audio file"
},
@@ -3868,5 +3868,16 @@
"trackConvertFailed": "Conversion failed",
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
}
},
"setupModeSelectionTitle": "モードを選択",
"setupModeSelectionDescription": "SpotiFLACをどのように使いますか?この設定は後からいつでも変更できます。",
"setupModeDownloaderTitle": "ダウンローダー",
"setupModeDownloaderFeature1": "ロスレスFLAC品質でトラックをダウンロード",
"setupModeDownloaderFeature2": "オフライン再生用に音楽をデバイスに保存",
"setupModeDownloaderFeature3": "ローカル音楽ライブラリを管理",
"setupModeStreamingTitle": "ストリーミング",
"setupModeStreamingFeature1": "ダウンロードせずにトラックを即座にストリーミング",
"setupModeStreamingFeature2": "Smart Queueが自動的に新しい音楽を見つけます",
"setupModeStreamingFeature3": "再生コントロールで任意のトラックをオンデマンド再生",
"setupModeChangeableLater": "設定からいつでもモードを切り替えられます。"
}
+13 -2
View File
@@ -3750,7 +3750,7 @@
"@trackSaveLyricsProgress": {
"description": "Snackbar while saving lyrics to file"
},
"trackReEnrich": "Re-enrich Metadata",
"trackReEnrich": "Re-enrich",
"@trackReEnrich": {
"description": "Menu action - re-embed metadata into audio file"
},
@@ -3868,5 +3868,16 @@
"trackConvertFailed": "Conversion failed",
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
}
},
"setupModeSelectionTitle": "모드 선택",
"setupModeSelectionDescription": "SpotiFLAC을 어떻게 사용하시겠습니까? 나중에 설정에서 언제든지 변경할 수 있습니다.",
"setupModeDownloaderTitle": "다운로더",
"setupModeDownloaderFeature1": "무손실 FLAC 품질로 트랙 다운로드",
"setupModeDownloaderFeature2": "오프라인 감상을 위해 기기에 음악 저장",
"setupModeDownloaderFeature3": "로컬 음악 라이브러리 관리",
"setupModeStreamingTitle": "스트리밍",
"setupModeStreamingFeature1": "다운로드 없이 트랙을 즉시 스트리밍",
"setupModeStreamingFeature2": "Smart Queue가 자동으로 새로운 음악을 발견합니다",
"setupModeStreamingFeature3": "재생 컨트롤로 원하는 트랙을 온디맨드 재생",
"setupModeChangeableLater": "설정에서 언제든지 모드를 전환할 수 있습니다."
}
+13 -2
View File
@@ -3750,7 +3750,7 @@
"@trackSaveLyricsProgress": {
"description": "Snackbar while saving lyrics to file"
},
"trackReEnrich": "Re-enrich Metadata",
"trackReEnrich": "Re-enrich",
"@trackReEnrich": {
"description": "Menu action - re-embed metadata into audio file"
},
@@ -3868,5 +3868,16 @@
"trackConvertFailed": "Conversion failed",
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
}
},
"setupModeSelectionTitle": "Kies je modus",
"setupModeSelectionDescription": "Hoe wil je SpotiFLAC gebruiken? Je kunt dit later altijd wijzigen in Instellingen.",
"setupModeDownloaderTitle": "Downloader",
"setupModeDownloaderFeature1": "Download nummers in lossless FLAC-kwaliteit",
"setupModeDownloaderFeature2": "Sla muziek op je apparaat op om offline te luisteren",
"setupModeDownloaderFeature3": "Beheer je lokale muziekbibliotheek",
"setupModeStreamingTitle": "Streaming",
"setupModeStreamingFeature1": "Stream nummers direct zonder te downloaden",
"setupModeStreamingFeature2": "Smart Queue ontdekt automatisch nieuwe muziek voor je",
"setupModeStreamingFeature3": "Speel elk nummer op aanvraag af met afspeelbediening",
"setupModeChangeableLater": "Je kunt op elk moment wisselen tussen modi in Instellingen."
}
+12 -1
View File
@@ -2565,5 +2565,16 @@
"utilityFunctions": "Utility Functions",
"@utilityFunctions": {
"description": "Extension capability - utility functions"
}
},
"setupModeSelectionTitle": "Escolha seu modo",
"setupModeSelectionDescription": "Como você gostaria de usar o SpotiFLAC? Você pode alterar isso depois nas Configurações.",
"setupModeDownloaderTitle": "Downloader",
"setupModeDownloaderFeature1": "Baixe faixas em qualidade FLAC lossless",
"setupModeDownloaderFeature2": "Salve músicas no seu dispositivo para ouvir offline",
"setupModeDownloaderFeature3": "Gerencie sua biblioteca de músicas local",
"setupModeStreamingTitle": "Streaming",
"setupModeStreamingFeature1": "Transmita faixas instantaneamente sem baixar",
"setupModeStreamingFeature2": "Smart Queue descobre automaticamente novas músicas para você",
"setupModeStreamingFeature3": "Reproduza qualquer faixa sob demanda com controles de reprodução",
"setupModeChangeableLater": "Você pode alternar entre os modos a qualquer momento nas Configurações."
}
+13 -2
View File
@@ -3750,7 +3750,7 @@
"@trackSaveLyricsProgress": {
"description": "Snackbar while saving lyrics to file"
},
"trackReEnrich": "Re-enrich Metadata",
"trackReEnrich": "Re-enrich",
"@trackReEnrich": {
"description": "Menu action - re-embed metadata into audio file"
},
@@ -3868,5 +3868,16 @@
"trackConvertFailed": "Conversion failed",
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
}
},
"setupModeSelectionTitle": "Escolha o seu modo",
"setupModeSelectionDescription": "Como gostaria de utilizar o SpotiFLAC? Pode alterar isto mais tarde nas Definições.",
"setupModeDownloaderTitle": "Transferência",
"setupModeDownloaderFeature1": "Transfira faixas em qualidade FLAC sem perdas",
"setupModeDownloaderFeature2": "Guarde música no seu dispositivo para ouvir offline",
"setupModeDownloaderFeature3": "Faça a gestão da sua biblioteca de música local",
"setupModeStreamingTitle": "Streaming",
"setupModeStreamingFeature1": "Transmita faixas instantaneamente sem transferir",
"setupModeStreamingFeature2": "Smart Queue descobre automaticamente novas músicas para si",
"setupModeStreamingFeature3": "Reproduza qualquer faixa a pedido com controlos de reprodução",
"setupModeChangeableLater": "Pode alternar entre modos a qualquer momento nas Definições."
}
+13 -2
View File
@@ -3750,7 +3750,7 @@
"@trackSaveLyricsProgress": {
"description": "Snackbar while saving lyrics to file"
},
"trackReEnrich": "Re-enrich Metadata",
"trackReEnrich": "Re-enrich",
"@trackReEnrich": {
"description": "Menu action - re-embed metadata into audio file"
},
@@ -3868,5 +3868,16 @@
"trackConvertFailed": "Conversion failed",
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
}
},
"setupModeSelectionTitle": "Выберите режим",
"setupModeSelectionDescription": "Как вы хотите использовать SpotiFLAC? Вы всегда можете изменить это позже в Настройках.",
"setupModeDownloaderTitle": "Загрузчик",
"setupModeDownloaderFeature1": "Скачивайте треки в качестве FLAC без потерь",
"setupModeDownloaderFeature2": "Сохраняйте музыку на устройство для прослушивания офлайн",
"setupModeDownloaderFeature3": "Управляйте своей локальной музыкальной библиотекой",
"setupModeStreamingTitle": "Стриминг",
"setupModeStreamingFeature1": "Слушайте треки мгновенно без скачивания",
"setupModeStreamingFeature2": "Smart Queue автоматически подбирает новую музыку для вас",
"setupModeStreamingFeature3": "Воспроизводите любой трек по запросу с элементами управления",
"setupModeChangeableLater": "Вы можете переключаться между режимами в любое время в Настройках."
}
+13 -2
View File
@@ -3750,7 +3750,7 @@
"@trackSaveLyricsProgress": {
"description": "Snackbar while saving lyrics to file"
},
"trackReEnrich": "Re-enrich Metadata",
"trackReEnrich": "Re-enrich",
"@trackReEnrich": {
"description": "Menu action - re-embed metadata into audio file"
},
@@ -3868,5 +3868,16 @@
"trackConvertFailed": "Conversion failed",
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
}
},
"setupModeSelectionTitle": "Modunuzu Seçin",
"setupModeSelectionDescription": "SpotiFLAC'ı nasıl kullanmak istersiniz? Bunu daha sonra Ayarlar'dan değiştirebilirsiniz.",
"setupModeDownloaderTitle": "İndirici",
"setupModeDownloaderFeature1": "Kayıpsız FLAC kalitesinde parça indirin",
"setupModeDownloaderFeature2": "Çevrimdışı dinlemek için müziği cihazınıza kaydedin",
"setupModeDownloaderFeature3": "Yerel müzik kütüphanenizi yönetin",
"setupModeStreamingTitle": "Yayın Akışı",
"setupModeStreamingFeature1": "İndirmeden parçaları anında yayınlayın",
"setupModeStreamingFeature2": "Smart Queue sizin için otomatik olarak yeni müzik keşfeder",
"setupModeStreamingFeature3": "İstediğiniz parçayı oynatma kontrolleriyle çalın",
"setupModeChangeableLater": "Ayarlar'dan istediğiniz zaman modlar arasında geçiş yapabilirsiniz."
}
+12 -1
View File
@@ -2565,5 +2565,16 @@
"utilityFunctions": "Utility Functions",
"@utilityFunctions": {
"description": "Extension capability - utility functions"
}
},
"setupModeSelectionTitle": "选择您的模式",
"setupModeSelectionDescription": "您想如何使用 SpotiFLAC?您可以稍后在设置中随时更改。",
"setupModeDownloaderTitle": "下载器",
"setupModeDownloaderFeature1": "以无损 FLAC 品质下载曲目",
"setupModeDownloaderFeature2": "将音乐保存到设备以供离线收听",
"setupModeDownloaderFeature3": "管理您的本地音乐库",
"setupModeStreamingTitle": "流媒体",
"setupModeStreamingFeature1": "无需下载即可即时播放曲目",
"setupModeStreamingFeature2": "Smart Queue 自动为您发现新音乐",
"setupModeStreamingFeature3": "通过播放控件随时点播任意曲目",
"setupModeChangeableLater": "您可以随时在设置中切换模式。"
}
+13 -2
View File
@@ -3750,7 +3750,7 @@
"@trackSaveLyricsProgress": {
"description": "Snackbar while saving lyrics to file"
},
"trackReEnrich": "Re-enrich Metadata",
"trackReEnrich": "Re-enrich",
"@trackReEnrich": {
"description": "Menu action - re-embed metadata into audio file"
},
@@ -3868,5 +3868,16 @@
"trackConvertFailed": "Conversion failed",
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
}
},
"setupModeSelectionTitle": "选择您的模式",
"setupModeSelectionDescription": "您想如何使用 SpotiFLAC?您可以稍后在设置中随时更改。",
"setupModeDownloaderTitle": "下载器",
"setupModeDownloaderFeature1": "以无损 FLAC 品质下载曲目",
"setupModeDownloaderFeature2": "将音乐保存到设备以供离线收听",
"setupModeDownloaderFeature3": "管理您的本地音乐库",
"setupModeStreamingTitle": "流媒体",
"setupModeStreamingFeature1": "无需下载即可即时播放曲目",
"setupModeStreamingFeature2": "Smart Queue 自动为您发现新音乐",
"setupModeStreamingFeature3": "通过播放控件随时点播任意曲目",
"setupModeChangeableLater": "您可以随时在设置中切换模式。"
}
+13 -2
View File
@@ -3750,7 +3750,7 @@
"@trackSaveLyricsProgress": {
"description": "Snackbar while saving lyrics to file"
},
"trackReEnrich": "Re-enrich Metadata",
"trackReEnrich": "Re-enrich",
"@trackReEnrich": {
"description": "Menu action - re-embed metadata into audio file"
},
@@ -3868,5 +3868,16 @@
"trackConvertFailed": "Conversion failed",
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
}
},
"setupModeSelectionTitle": "選擇您的模式",
"setupModeSelectionDescription": "您想如何使用 SpotiFLAC?您可以稍後在設定中隨時變更。",
"setupModeDownloaderTitle": "下載器",
"setupModeDownloaderFeature1": "以無損 FLAC 品質下載曲目",
"setupModeDownloaderFeature2": "將音樂儲存到裝置以供離線收聽",
"setupModeDownloaderFeature3": "管理您的本機音樂庫",
"setupModeStreamingTitle": "串流",
"setupModeStreamingFeature1": "無需下載即可即時串流曲目",
"setupModeStreamingFeature2": "Smart Queue 自動為您探索新音樂",
"setupModeStreamingFeature3": "透過播放控制項隨時點播任意曲目",
"setupModeChangeableLater": "您可以隨時在設定中切換模式。"
}
+4
View File
@@ -6,6 +6,8 @@ import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/app.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/services/share_intent_service.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
@@ -93,6 +95,8 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
_initializeAppServices();
_initializeExtensions();
ref.read(downloadHistoryProvider);
ref.read(localLibraryProvider);
ref.read(libraryCollectionsProvider);
}
Future<void> _initializeAppServices() async {
+27 -1
View File
@@ -11,6 +11,7 @@ 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 embedLyrics;
final bool maxQualityCover;
final bool isFirstLaunch;
@@ -49,6 +50,10 @@ class AppSettings {
autoExportFailedDownloads; // Auto export failed downloads to TXT file
final String
downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only
final bool
networkCompatibilityMode; // Try HTTP + allow invalid TLS cert for API requests
final String
songLinkRegion; // SongLink userCountry region code used for platform lookup
// Local Library Settings
final bool localLibraryEnabled; // Enable local library scanning
@@ -72,6 +77,10 @@ class AppSettings {
final String
musixmatchLanguage; // Optional ISO language code for Musixmatch localized lyrics
// Version upgrade tracking
final String
lastSeenVersion; // Last app version the user has acknowledged (e.g. '3.7.0')
const AppSettings({
this.defaultService = 'tidal',
this.audioQuality = 'LOSSLESS',
@@ -80,6 +89,7 @@ class AppSettings {
this.storageMode = 'app',
this.downloadTreeUri = '',
this.autoFallback = true,
this.embedMetadata = true,
this.embedLyrics = true,
this.maxQualityCover = true,
this.isFirstLaunch = true,
@@ -112,6 +122,8 @@ class AppSettings {
this.useAllFilesAccess = false,
this.autoExportFailedDownloads = false,
this.downloadNetworkMode = 'any',
this.networkCompatibilityMode = false,
this.songLinkRegion = 'US',
// Local Library defaults
this.localLibraryEnabled = false,
this.localLibraryPath = '',
@@ -121,6 +133,7 @@ class AppSettings {
// Lyrics providers default order
this.lyricsProviders = const [
'lrclib',
'spotify_api',
'musixmatch',
'netease',
'apple_music',
@@ -130,6 +143,8 @@ class AppSettings {
this.lyricsIncludeRomanizationNetease = false,
this.lyricsMultiPersonWordByWord = false,
this.musixmatchLanguage = '',
// Version upgrade tracking
this.lastSeenVersion = '',
});
AppSettings copyWith({
@@ -139,7 +154,8 @@ class AppSettings {
String? downloadDirectory,
String? storageMode,
String? downloadTreeUri,
bool? autoFallback,
bool? autoFallback,
bool? embedMetadata,
bool? embedLyrics,
bool? maxQualityCover,
bool? isFirstLaunch,
@@ -173,6 +189,8 @@ class AppSettings {
bool? useAllFilesAccess,
bool? autoExportFailedDownloads,
String? downloadNetworkMode,
bool? networkCompatibilityMode,
String? songLinkRegion,
// Local Library
bool? localLibraryEnabled,
String? localLibraryPath,
@@ -185,6 +203,8 @@ class AppSettings {
bool? lyricsIncludeRomanizationNetease,
bool? lyricsMultiPersonWordByWord,
String? musixmatchLanguage,
// Version upgrade tracking
String? lastSeenVersion,
}) {
return AppSettings(
defaultService: defaultService ?? this.defaultService,
@@ -194,6 +214,7 @@ class AppSettings {
storageMode: storageMode ?? this.storageMode,
downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri,
autoFallback: autoFallback ?? this.autoFallback,
embedMetadata: embedMetadata ?? this.embedMetadata,
embedLyrics: embedLyrics ?? this.embedLyrics,
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
@@ -235,6 +256,9 @@ class AppSettings {
autoExportFailedDownloads:
autoExportFailedDownloads ?? this.autoExportFailedDownloads,
downloadNetworkMode: downloadNetworkMode ?? this.downloadNetworkMode,
networkCompatibilityMode:
networkCompatibilityMode ?? this.networkCompatibilityMode,
songLinkRegion: songLinkRegion ?? this.songLinkRegion,
// Local Library
localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled,
localLibraryPath: localLibraryPath ?? this.localLibraryPath,
@@ -253,6 +277,8 @@ class AppSettings {
lyricsMultiPersonWordByWord:
lyricsMultiPersonWordByWord ?? this.lyricsMultiPersonWordByWord,
musixmatchLanguage: musixmatchLanguage ?? this.musixmatchLanguage,
// Version upgrade tracking
lastSeenVersion: lastSeenVersion ?? this.lastSeenVersion,
);
}
+16 -1
View File
@@ -14,6 +14,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
storageMode: json['storageMode'] as String? ?? 'app',
downloadTreeUri: json['downloadTreeUri'] as String? ?? '',
autoFallback: json['autoFallback'] as bool? ?? true,
embedMetadata: json['embedMetadata'] as bool? ?? true,
embedLyrics: json['embedLyrics'] as bool? ?? true,
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
@@ -50,6 +51,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
autoExportFailedDownloads:
json['autoExportFailedDownloads'] as bool? ?? false,
downloadNetworkMode: json['downloadNetworkMode'] as String? ?? 'any',
networkCompatibilityMode: json['networkCompatibilityMode'] as bool? ?? false,
songLinkRegion: json['songLinkRegion'] as String? ?? 'US',
localLibraryEnabled: json['localLibraryEnabled'] as bool? ?? false,
localLibraryPath: json['localLibraryPath'] as String? ?? '',
localLibraryShowDuplicates:
@@ -59,7 +62,14 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
(json['lyricsProviders'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
const ['lrclib', 'musixmatch', 'netease', 'apple_music', 'qqmusic'],
const [
'lrclib',
'spotify_api',
'musixmatch',
'netease',
'apple_music',
'qqmusic',
],
lyricsIncludeTranslationNetease:
json['lyricsIncludeTranslationNetease'] as bool? ?? false,
lyricsIncludeRomanizationNetease:
@@ -67,6 +77,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
lyricsMultiPersonWordByWord:
json['lyricsMultiPersonWordByWord'] as bool? ?? false,
musixmatchLanguage: json['musixmatchLanguage'] as String? ?? '',
lastSeenVersion: json['lastSeenVersion'] as String? ?? '',
);
Map<String, dynamic> _$AppSettingsToJson(
@@ -79,6 +90,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'storageMode': instance.storageMode,
'downloadTreeUri': instance.downloadTreeUri,
'autoFallback': instance.autoFallback,
'embedMetadata': instance.embedMetadata,
'embedLyrics': instance.embedLyrics,
'maxQualityCover': instance.maxQualityCover,
'isFirstLaunch': instance.isFirstLaunch,
@@ -112,6 +124,8 @@ Map<String, dynamic> _$AppSettingsToJson(
'useAllFilesAccess': instance.useAllFilesAccess,
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
'downloadNetworkMode': instance.downloadNetworkMode,
'networkCompatibilityMode': instance.networkCompatibilityMode,
'songLinkRegion': instance.songLinkRegion,
'localLibraryEnabled': instance.localLibraryEnabled,
'localLibraryPath': instance.localLibraryPath,
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
@@ -121,4 +135,5 @@ Map<String, dynamic> _$AppSettingsToJson(
'lyricsIncludeRomanizationNetease': instance.lyricsIncludeRomanizationNetease,
'lyricsMultiPersonWordByWord': instance.lyricsMultiPersonWordByWord,
'musixmatchLanguage': instance.musixmatchLanguage,
'lastSeenVersion': instance.lastSeenVersion,
};
+4
View File
@@ -9,6 +9,8 @@ class Track {
final String artistName;
final String albumName;
final String? albumArtist;
final String? artistId;
final String? albumId;
final String? coverUrl;
final String? isrc;
final int duration;
@@ -27,6 +29,8 @@ class Track {
required this.artistName,
required this.albumName,
this.albumArtist,
this.artistId,
this.albumId,
this.coverUrl,
this.isrc,
required this.duration,
+4
View File
@@ -12,6 +12,8 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
artistName: json['artistName'] as String,
albumName: json['albumName'] as String,
albumArtist: json['albumArtist'] as String?,
artistId: json['artistId'] as String?,
albumId: json['albumId'] as String?,
coverUrl: json['coverUrl'] as String?,
isrc: json['isrc'] as String?,
duration: (json['duration'] as num).toInt(),
@@ -35,6 +37,8 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
'artistName': instance.artistName,
'albumName': instance.albumName,
'albumArtist': instance.albumArtist,
'artistId': instance.artistId,
'albumId': instance.albumId,
'coverUrl': instance.coverUrl,
'isrc': instance.isrc,
'duration': instance.duration,
File diff suppressed because it is too large Load Diff
+230 -83
View File
@@ -1,5 +1,9 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
@@ -9,6 +13,7 @@ final _log = AppLogger('ExtensionProvider');
const _metadataProviderPriorityKey = 'metadata_provider_priority';
const _providerPriorityKey = 'provider_priority';
const _spotifyWebExtensionId = 'spotify-web';
class Extension {
final String id;
@@ -27,12 +32,14 @@ class Extension {
final bool hasMetadataProvider;
final bool hasDownloadProvider;
final bool hasLyricsProvider;
final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
final bool
skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
final SearchBehavior? searchBehavior;
final URLHandler? urlHandler;
final TrackMatching? trackMatching;
final PostProcessing? postProcessing;
final Map<String, dynamic> capabilities; // Extension capabilities (homeFeed, browseCategories, etc.)
final Map<String, dynamic>
capabilities; // Extension capabilities (homeFeed, browseCategories, etc.)
const Extension({
required this.id,
@@ -63,7 +70,8 @@ class Extension {
return Extension(
id: json['id'] as String? ?? '',
name: json['name'] as String? ?? '',
displayName: json['display_name'] as String? ?? json['name'] as String? ?? '',
displayName:
json['display_name'] as String? ?? json['name'] as String? ?? '',
version: json['version'] as String? ?? '0.0.0',
author: json['author'] as String? ?? 'Unknown',
description: json['description'] as String? ?? '',
@@ -71,28 +79,40 @@ class Extension {
status: json['status'] as String? ?? 'loaded',
errorMessage: json['error_message'] as String?,
iconPath: json['icon_path'] as String?,
permissions: (json['permissions'] as List<dynamic>?)?.cast<String>() ?? [],
settings: (json['settings'] as List<dynamic>?)
?.map((s) => ExtensionSetting.fromJson(s as Map<String, dynamic>))
.toList() ?? [],
qualityOptions: (json['quality_options'] as List<dynamic>?)
?.map((q) => QualityOption.fromJson(q as Map<String, dynamic>))
.toList() ?? [],
permissions:
(json['permissions'] as List<dynamic>?)?.cast<String>() ?? [],
settings:
(json['settings'] as List<dynamic>?)
?.map((s) => ExtensionSetting.fromJson(s as Map<String, dynamic>))
.toList() ??
[],
qualityOptions:
(json['quality_options'] as List<dynamic>?)
?.map((q) => QualityOption.fromJson(q as Map<String, dynamic>))
.toList() ??
[],
hasMetadataProvider: json['has_metadata_provider'] as bool? ?? false,
hasDownloadProvider: json['has_download_provider'] as bool? ?? false,
hasLyricsProvider: json['has_lyrics_provider'] as bool? ?? false,
skipMetadataEnrichment: json['skip_metadata_enrichment'] as bool? ?? false,
searchBehavior: json['search_behavior'] != null
? SearchBehavior.fromJson(json['search_behavior'] as Map<String, dynamic>)
skipMetadataEnrichment:
json['skip_metadata_enrichment'] as bool? ?? false,
searchBehavior: json['search_behavior'] != null
? SearchBehavior.fromJson(
json['search_behavior'] as Map<String, dynamic>,
)
: null,
urlHandler: json['url_handler'] != null
? URLHandler.fromJson(json['url_handler'] as Map<String, dynamic>)
: null,
trackMatching: json['track_matching'] != null
? TrackMatching.fromJson(json['track_matching'] as Map<String, dynamic>)
? TrackMatching.fromJson(
json['track_matching'] as Map<String, dynamic>,
)
: null,
postProcessing: json['post_processing'] != null
? PostProcessing.fromJson(json['post_processing'] as Map<String, dynamic>)
? PostProcessing.fromJson(
json['post_processing'] as Map<String, dynamic>,
)
: null,
capabilities: (json['capabilities'] as Map<String, dynamic>?) ?? const {},
);
@@ -139,7 +159,8 @@ class Extension {
hasMetadataProvider: hasMetadataProvider ?? this.hasMetadataProvider,
hasDownloadProvider: hasDownloadProvider ?? this.hasDownloadProvider,
hasLyricsProvider: hasLyricsProvider ?? this.hasLyricsProvider,
skipMetadataEnrichment: skipMetadataEnrichment ?? this.skipMetadataEnrichment,
skipMetadataEnrichment:
skipMetadataEnrichment ?? this.skipMetadataEnrichment,
searchBehavior: searchBehavior ?? this.searchBehavior,
urlHandler: urlHandler ?? this.urlHandler,
trackMatching: trackMatching ?? this.trackMatching,
@@ -161,11 +182,7 @@ class SearchFilter {
final String? label;
final String? icon;
const SearchFilter({
required this.id,
this.label,
this.icon,
});
const SearchFilter({required this.id, this.label, this.icon});
factory SearchFilter.fromJson(Map<String, dynamic> json) {
return SearchFilter(
@@ -181,10 +198,12 @@ class SearchBehavior {
final String? placeholder;
final bool primary;
final String? icon;
final String? thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3)
final String?
thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3)
final int? thumbnailWidth;
final int? thumbnailHeight;
final List<SearchFilter> filters; // Available search filters (e.g., track, album, artist, playlist)
final List<SearchFilter>
filters; // Available search filters (e.g., track, album, artist, playlist)
const SearchBehavior({
required this.enabled,
@@ -206,9 +225,11 @@ class SearchBehavior {
thumbnailRatio: json['thumbnailRatio'] as String?,
thumbnailWidth: json['thumbnailWidth'] as int?,
thumbnailHeight: json['thumbnailHeight'] as int?,
filters: (json['filters'] as List<dynamic>?)
?.map((f) => SearchFilter.fromJson(f as Map<String, dynamic>))
.toList() ?? [],
filters:
(json['filters'] as List<dynamic>?)
?.map((f) => SearchFilter.fromJson(f as Map<String, dynamic>))
.toList() ??
[],
);
}
@@ -216,7 +237,7 @@ class SearchBehavior {
if (thumbnailWidth != null && thumbnailHeight != null) {
return (thumbnailWidth!.toDouble(), thumbnailHeight!.toDouble());
}
switch (thumbnailRatio) {
case 'wide': // 16:9 - YouTube style
return (defaultSize * 16 / 9, defaultSize);
@@ -253,17 +274,18 @@ class PostProcessing {
final bool enabled;
final List<PostProcessingHook> hooks;
const PostProcessing({
required this.enabled,
this.hooks = const [],
});
const PostProcessing({required this.enabled, this.hooks = const []});
factory PostProcessing.fromJson(Map<String, dynamic> json) {
return PostProcessing(
enabled: json['enabled'] as bool? ?? false,
hooks: (json['hooks'] as List<dynamic>?)
?.map((h) => PostProcessingHook.fromJson(h as Map<String, dynamic>))
.toList() ?? [],
hooks:
(json['hooks'] as List<dynamic>?)
?.map(
(h) => PostProcessingHook.fromJson(h as Map<String, dynamic>),
)
.toList() ??
[],
);
}
}
@@ -273,10 +295,7 @@ class URLHandler {
final bool enabled;
final List<String> patterns;
const URLHandler({
required this.enabled,
this.patterns = const [],
});
const URLHandler({required this.enabled, this.patterns = const []});
factory URLHandler.fromJson(Map<String, dynamic> json) {
return URLHandler(
@@ -319,7 +338,8 @@ class PostProcessingHook {
name: json['name'] as String? ?? '',
description: json['description'] as String?,
defaultEnabled: json['defaultEnabled'] as bool? ?? false,
supportedFormats: (json['supportedFormats'] as List<dynamic>?)?.cast<String>() ?? [],
supportedFormats:
(json['supportedFormats'] as List<dynamic>?)?.cast<String>() ?? [],
);
}
}
@@ -342,9 +362,14 @@ class QualityOption {
id: json['id'] as String? ?? '',
label: json['label'] as String? ?? '',
description: json['description'] as String?,
settings: (json['settings'] as List<dynamic>?)
?.map((s) => QualitySpecificSetting.fromJson(s as Map<String, dynamic>))
.toList() ?? [],
settings:
(json['settings'] as List<dynamic>?)
?.map(
(s) =>
QualitySpecificSetting.fromJson(s as Map<String, dynamic>),
)
.toList() ??
[],
);
}
}
@@ -447,7 +472,8 @@ class ExtensionState {
return ExtensionState(
extensions: extensions ?? this.extensions,
providerPriority: providerPriority ?? this.providerPriority,
metadataProviderPriority: metadataProviderPriority ?? this.metadataProviderPriority,
metadataProviderPriority:
metadataProviderPriority ?? this.metadataProviderPriority,
isLoading: isLoading ?? this.isLoading,
error: error,
isInitialized: isInitialized ?? this.isInitialized,
@@ -455,18 +481,44 @@ class ExtensionState {
}
}
class ExtensionNotifier extends Notifier<ExtensionState> {
AppLifecycleListener? _appLifecycleListener;
bool _cleanupInFlight = false;
@override
ExtensionState build() {
_appLifecycleListener ??= AppLifecycleListener(
onDetach: _scheduleLifecycleCleanup,
);
ref.onDispose(() {
_appLifecycleListener?.dispose();
_appLifecycleListener = null;
});
return const ExtensionState();
}
void _scheduleLifecycleCleanup() {
if (_cleanupInFlight) return;
_cleanupInFlight = true;
unawaited(_cleanupExtensions(reason: 'lifecycle detach'));
}
Future<void> _cleanupExtensions({required String reason}) async {
try {
await PlatformBridge.cleanupExtensions();
_log.d('Extensions cleaned up ($reason)');
} catch (e) {
_log.w('Extension cleanup failed ($reason): $e');
} finally {
_cleanupInFlight = false;
}
}
Future<void> initialize(String extensionsDir, String dataDir) async {
if (state.isInitialized) return;
state = state.copyWith(isLoading: true, error: null);
try {
await PlatformBridge.initExtensionSystem(extensionsDir, dataDir);
await loadExtensions(extensionsDir);
@@ -482,7 +534,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
Future<void> loadExtensions(String dirPath) async {
state = state.copyWith(isLoading: true, error: null);
try {
final result = await PlatformBridge.loadExtensionsFromDir(dirPath);
_log.d('Load extensions result: $result');
@@ -500,10 +552,12 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
final extensions = list.map((e) => Extension.fromJson(e)).toList();
state = state.copyWith(extensions: extensions);
_log.d('Loaded ${extensions.length} extensions');
for (final ext in extensions) {
if (ext.searchBehavior != null) {
_log.d('Extension ${ext.id}: thumbnailRatio=${ext.searchBehavior!.thumbnailRatio}');
_log.d(
'Extension ${ext.id}: thumbnailRatio=${ext.searchBehavior!.thumbnailRatio}',
);
}
}
} catch (e) {
@@ -512,14 +566,13 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
void clearError() {
state = state.copyWith(error: null);
}
Future<bool> installExtension(String filePath) async {
state = state.copyWith(isLoading: true, error: null);
try {
final result = await PlatformBridge.loadExtensionFromPath(filePath);
_log.i('Installed extension: ${result['name']}');
@@ -544,10 +597,12 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
Future<bool> upgradeExtension(String filePath) async {
state = state.copyWith(isLoading: true, error: null);
try {
final result = await PlatformBridge.upgradeExtension(filePath);
_log.i('Upgraded extension: ${result['display_name']} to v${result['version']}');
_log.i(
'Upgraded extension: ${result['display_name']} to v${result['version']}',
);
await refreshExtensions();
state = state.copyWith(isLoading: false);
return true;
@@ -560,7 +615,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
Future<bool> removeExtension(String extensionId) async {
state = state.copyWith(isLoading: true, error: null);
try {
await PlatformBridge.removeExtension(extensionId);
_log.i('Removed extension: $extensionId');
@@ -574,35 +629,40 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
Future<void> setExtensionEnabled(String extensionId, bool enabled) async {
try {
await PlatformBridge.setExtensionEnabled(extensionId, enabled);
_log.d('Set extension $extensionId enabled: $enabled');
final ext = state.extensions.where((e) => e.id == extensionId).firstOrNull;
final ext = state.extensions
.where((e) => e.id == extensionId)
.firstOrNull;
final extensions = state.extensions.map((e) {
if (e.id == extensionId) {
return e.copyWith(enabled: enabled);
}
return e;
}).toList();
state = state.copyWith(extensions: extensions);
if (!enabled && ext != null) {
final settings = ref.read(settingsProvider);
if (settings.searchProvider == extensionId) {
ref.read(settingsProvider.notifier).setSearchProvider(null);
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
_log.d('Cleared search provider and reset to Deezer because extension $extensionId was disabled');
_log.d(
'Cleared search provider and reset to Deezer because extension $extensionId was disabled',
);
}
if (ext.hasDownloadProvider && settings.defaultService == extensionId) {
ref.read(settingsProvider.notifier).setDefaultService('tidal');
_log.d('Reset default service to Tidal because extension $extensionId was disabled');
_log.d(
'Reset default service to Tidal because extension $extensionId was disabled',
);
}
}
} catch (e) {
@@ -611,6 +671,68 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
Future<bool> ensureSpotifyWebExtensionReady({
bool setAsSearchProvider = true,
}) async {
try {
await refreshExtensions();
var ext = state.extensions
.where((e) => e.id == _spotifyWebExtensionId)
.firstOrNull;
if (ext == null) {
final cacheDir = await getTemporaryDirectory();
await PlatformBridge.initExtensionStore(cacheDir.path);
final tempRoot = await getTemporaryDirectory();
final installDir = await Directory(
'${tempRoot.path}/spotiflac_bootstrap_spotify_web',
).create(recursive: true);
final downloadPath = await PlatformBridge.downloadStoreExtension(
_spotifyWebExtensionId,
installDir.path,
);
final installed = await installExtension(downloadPath);
if (!installed) {
_log.w('Failed to install spotify-web extension from store');
return false;
}
await refreshExtensions();
ext = state.extensions
.where((e) => e.id == _spotifyWebExtensionId)
.firstOrNull;
}
if (ext == null) {
_log.w('spotify-web extension is still not available after install');
return false;
}
if (!ext.enabled) {
await setExtensionEnabled(_spotifyWebExtensionId, true);
}
if (setAsSearchProvider) {
final settings = ref.read(settingsProvider);
if (settings.searchProvider != _spotifyWebExtensionId) {
ref
.read(settingsProvider.notifier)
.setSearchProvider(_spotifyWebExtensionId);
}
}
_log.i('spotify-web extension is ready');
return true;
} catch (e) {
_log.w('Failed to ensure spotify-web extension is ready: $e');
return false;
}
}
Future<Map<String, dynamic>> getExtensionSettings(String extensionId) async {
try {
return await PlatformBridge.getExtensionSettings(extensionId);
@@ -620,7 +742,10 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
Future<void> setExtensionSettings(String extensionId, Map<String, dynamic> settings) async {
Future<void> setExtensionSettings(
String extensionId,
Map<String, dynamic> settings,
) async {
try {
await PlatformBridge.setExtensionSettings(extensionId, settings);
_log.d('Updated settings for extension: $extensionId');
@@ -635,49 +760,72 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
// Load from SharedPreferences first (persisted)
final prefs = await SharedPreferences.getInstance();
final savedJson = prefs.getString(_providerPriorityKey);
List<String> priority;
if (savedJson != null) {
final saved = jsonDecode(savedJson) as List<dynamic>;
priority = saved.map((e) => e as String).toList();
priority = _sanitizeDownloadProviderPriority(priority);
_log.d('Loaded provider priority from prefs: $priority');
await prefs.setString(_providerPriorityKey, jsonEncode(priority));
// Sync to Go backend
await PlatformBridge.setProviderPriority(priority);
} else {
// Fallback to Go backend default
priority = await PlatformBridge.getProviderPriority();
priority = _sanitizeDownloadProviderPriority(priority);
await PlatformBridge.setProviderPriority(priority);
_log.d('Using default provider priority: $priority');
}
state = state.copyWith(providerPriority: priority);
} catch (e) {
_log.e('Failed to load provider priority: $e');
}
}
Future<void> setProviderPriority(List<String> priority) async {
try {
final sanitized = _sanitizeDownloadProviderPriority(priority);
// Save to SharedPreferences for persistence
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_providerPriorityKey, jsonEncode(priority));
await prefs.setString(_providerPriorityKey, jsonEncode(sanitized));
// Sync to Go backend
await PlatformBridge.setProviderPriority(priority);
state = state.copyWith(providerPriority: priority);
_log.d('Saved provider priority: $priority');
await PlatformBridge.setProviderPriority(sanitized);
state = state.copyWith(providerPriority: sanitized);
_log.d('Saved provider priority: $sanitized');
} catch (e) {
_log.e('Failed to set provider priority: $e');
state = state.copyWith(error: e.toString());
}
}
List<String> _sanitizeDownloadProviderPriority(List<String> input) {
final allowed = getAllDownloadProviders().toSet();
final result = <String>[];
for (final provider in input) {
if (allowed.contains(provider) && !result.contains(provider)) {
result.add(provider);
}
}
for (final provider in const ['tidal', 'qobuz', 'amazon']) {
if (!result.contains(provider)) {
result.add(provider);
}
}
return result;
}
Future<void> loadMetadataProviderPriority() async {
try {
// Load from SharedPreferences first (persisted)
final prefs = await SharedPreferences.getInstance();
final savedJson = prefs.getString(_metadataProviderPriorityKey);
List<String> priority;
if (savedJson != null) {
final saved = jsonDecode(savedJson) as List<dynamic>;
@@ -690,7 +838,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
priority = await PlatformBridge.getMetadataProviderPriority();
_log.d('Using default metadata provider priority: $priority');
}
state = state.copyWith(metadataProviderPriority: priority);
} catch (e) {
_log.e('Failed to load metadata provider priority: $e');
@@ -702,7 +850,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
// Save to SharedPreferences for persistence
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_metadataProviderPriorityKey, jsonEncode(priority));
// Sync to Go backend
await PlatformBridge.setMetadataProviderPriority(priority);
state = state.copyWith(metadataProviderPriority: priority);
@@ -714,12 +862,9 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
Future<void> cleanup() async {
try {
await PlatformBridge.cleanupExtensions();
_log.d('Extensions cleaned up');
} catch (e) {
_log.e('Failed to cleanup extensions: $e');
}
if (_cleanupInFlight) return;
_cleanupInFlight = true;
await _cleanupExtensions(reason: 'manual');
}
Extension? getExtension(String extensionId) {
@@ -755,7 +900,9 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
List<Extension> get searchProviders {
return state.extensions.where((ext) => ext.enabled && ext.hasCustomSearch).toList();
return state.extensions
.where((ext) => ext.enabled && ext.hasCustomSearch)
.toList();
}
}
@@ -0,0 +1,714 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/library_collections_database.dart';
String trackCollectionKey(Track track) {
final isrc = track.isrc?.trim();
if (isrc != null && isrc.isNotEmpty) {
return 'isrc:${isrc.toUpperCase()}';
}
final source = (track.source?.trim().isNotEmpty ?? false)
? track.source!.trim()
: 'builtin';
return '$source:${track.id}';
}
class CollectionTrackEntry {
final String key;
final Track track;
final DateTime addedAt;
const CollectionTrackEntry({
required this.key,
required this.track,
required this.addedAt,
});
Map<String, dynamic> toJson() => {
'key': key,
'track': track.toJson(),
'addedAt': addedAt.toIso8601String(),
};
factory CollectionTrackEntry.fromJson(Map<String, dynamic> json) {
final addedAtRaw = json['addedAt'] as String?;
return CollectionTrackEntry(
key: json['key'] as String,
track: Track.fromJson(Map<String, dynamic>.from(json['track'] as Map)),
addedAt: DateTime.tryParse(addedAtRaw ?? '') ?? DateTime.now(),
);
}
}
class UserPlaylistCollection {
final String id;
final String name;
final String? coverImagePath;
final DateTime createdAt;
final DateTime updatedAt;
final List<CollectionTrackEntry> tracks;
final Set<String> _trackKeys;
UserPlaylistCollection({
required this.id,
required this.name,
this.coverImagePath,
required this.createdAt,
required this.updatedAt,
required this.tracks,
Set<String>? trackKeys,
}) : _trackKeys = trackKeys ?? tracks.map((entry) => entry.key).toSet();
UserPlaylistCollection copyWith({
String? id,
String? name,
String? Function()? coverImagePath,
DateTime? createdAt,
DateTime? updatedAt,
List<CollectionTrackEntry>? tracks,
}) {
final nextTracks = tracks ?? this.tracks;
final keepTrackIndex = identical(nextTracks, this.tracks);
return UserPlaylistCollection(
id: id ?? this.id,
name: name ?? this.name,
coverImagePath: coverImagePath != null
? coverImagePath()
: this.coverImagePath,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
tracks: nextTracks,
trackKeys: keepTrackIndex ? _trackKeys : null,
);
}
bool containsTrack(Track track) {
final key = trackCollectionKey(track);
return _trackKeys.contains(key);
}
bool containsTrackKey(String trackKey) {
return _trackKeys.contains(trackKey);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
if (coverImagePath != null) 'coverImagePath': coverImagePath,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
'tracks': tracks.map((e) => e.toJson()).toList(),
};
factory UserPlaylistCollection.fromJson(Map<String, dynamic> json) {
final createdAtRaw = json['createdAt'] as String?;
final updatedAtRaw = json['updatedAt'] as String?;
final createdAt = DateTime.tryParse(createdAtRaw ?? '') ?? DateTime.now();
final updatedAt = DateTime.tryParse(updatedAtRaw ?? '') ?? createdAt;
final tracksRaw = (json['tracks'] as List?) ?? const [];
return UserPlaylistCollection(
id: json['id'] as String,
name: json['name'] as String? ?? '',
coverImagePath: json['coverImagePath'] as String?,
createdAt: createdAt,
updatedAt: updatedAt,
tracks: tracksRaw
.whereType<Map>()
.map(
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
)
.toList(growable: false),
);
}
}
class LibraryCollectionsState {
final List<CollectionTrackEntry> wishlist;
final List<CollectionTrackEntry> loved;
final List<UserPlaylistCollection> playlists;
final bool isLoaded;
final Set<String> _wishlistKeys;
final Set<String> _lovedKeys;
final Map<String, UserPlaylistCollection> _playlistsById;
final Set<String> _allPlaylistTrackKeys;
LibraryCollectionsState({
this.wishlist = const [],
this.loved = const [],
this.playlists = const [],
this.isLoaded = false,
Set<String>? wishlistKeys,
Set<String>? lovedKeys,
Map<String, UserPlaylistCollection>? playlistsById,
Set<String>? allPlaylistTrackKeys,
}) : _wishlistKeys =
wishlistKeys ?? wishlist.map((entry) => entry.key).toSet(),
_lovedKeys = lovedKeys ?? loved.map((entry) => entry.key).toSet(),
_playlistsById =
playlistsById ??
Map.fromEntries(
playlists.map((playlist) => MapEntry(playlist.id, playlist)),
),
_allPlaylistTrackKeys =
allPlaylistTrackKeys ?? _buildPlaylistTrackKeys(playlists);
int get wishlistCount => wishlist.length;
int get lovedCount => loved.length;
int get playlistCount => playlists.length;
bool isInWishlist(Track track) {
final key = trackCollectionKey(track);
return _wishlistKeys.contains(key);
}
bool isLoved(Track track) {
final key = trackCollectionKey(track);
return _lovedKeys.contains(key);
}
bool containsWishlistKey(String trackKey) {
return _wishlistKeys.contains(trackKey);
}
bool containsLovedKey(String trackKey) {
return _lovedKeys.contains(trackKey);
}
UserPlaylistCollection? playlistById(String playlistId) {
return _playlistsById[playlistId];
}
bool playlistContainsTrack(String playlistId, String trackKey) {
final playlist = _playlistsById[playlistId];
if (playlist == null) return false;
return playlist.containsTrackKey(trackKey);
}
bool isTrackInAnyPlaylist(String trackKey) {
return _allPlaylistTrackKeys.contains(trackKey);
}
bool get hasPlaylistTracks => _allPlaylistTrackKeys.isNotEmpty;
LibraryCollectionsState copyWith({
List<CollectionTrackEntry>? wishlist,
List<CollectionTrackEntry>? loved,
List<UserPlaylistCollection>? playlists,
bool? isLoaded,
}) {
final nextWishlist = wishlist ?? this.wishlist;
final nextLoved = loved ?? this.loved;
final nextPlaylists = playlists ?? this.playlists;
final keepWishlistIndex = identical(nextWishlist, this.wishlist);
final keepLovedIndex = identical(nextLoved, this.loved);
final keepPlaylistIndex = identical(nextPlaylists, this.playlists);
return LibraryCollectionsState(
wishlist: nextWishlist,
loved: nextLoved,
playlists: nextPlaylists,
isLoaded: isLoaded ?? this.isLoaded,
wishlistKeys: keepWishlistIndex ? _wishlistKeys : null,
lovedKeys: keepLovedIndex ? _lovedKeys : null,
playlistsById: keepPlaylistIndex ? _playlistsById : null,
allPlaylistTrackKeys: keepPlaylistIndex ? _allPlaylistTrackKeys : null,
);
}
Map<String, dynamic> toJson() => {
'wishlist': wishlist.map((e) => e.toJson()).toList(),
'loved': loved.map((e) => e.toJson()).toList(),
'playlists': playlists.map((e) => e.toJson()).toList(),
};
factory LibraryCollectionsState.fromJson(Map<String, dynamic> json) {
final wishlistRaw = (json['wishlist'] as List?) ?? const [];
final lovedRaw = (json['loved'] as List?) ?? const [];
final playlistsRaw = (json['playlists'] as List?) ?? const [];
return LibraryCollectionsState(
wishlist: wishlistRaw
.whereType<Map>()
.map(
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
)
.toList(growable: false),
loved: lovedRaw
.whereType<Map>()
.map(
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
)
.toList(growable: false),
playlists: playlistsRaw
.whereType<Map>()
.map(
(e) =>
UserPlaylistCollection.fromJson(Map<String, dynamic>.from(e)),
)
.toList(growable: false),
isLoaded: true,
);
}
}
Set<String> _buildPlaylistTrackKeys(List<UserPlaylistCollection> playlists) {
final keys = <String>{};
for (final playlist in playlists) {
for (final entry in playlist.tracks) {
keys.add(entry.key);
}
}
return keys;
}
class PlaylistAddBatchResult {
final int addedCount;
final int alreadyInPlaylistCount;
const PlaylistAddBatchResult({
required this.addedCount,
required this.alreadyInPlaylistCount,
});
}
class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
final LibraryCollectionsDatabase _db = LibraryCollectionsDatabase.instance;
Future<void>? _loadFuture;
@override
LibraryCollectionsState build() {
_loadFuture = _load();
return LibraryCollectionsState();
}
Future<void> _load() async {
try {
await _db.migrateFromSharedPreferences();
final snapshot = await _db.loadSnapshot();
final wishlist = <CollectionTrackEntry>[];
for (final row in snapshot.wishlistRows) {
final parsed = _parseTrackEntryRow(row);
if (parsed != null) {
wishlist.add(parsed);
}
}
final loved = <CollectionTrackEntry>[];
for (final row in snapshot.lovedRows) {
final parsed = _parseTrackEntryRow(row);
if (parsed != null) {
loved.add(parsed);
}
}
final tracksByPlaylist = <String, List<CollectionTrackEntry>>{};
for (final row in snapshot.playlistTrackRows) {
final playlistId = row['playlist_id'] as String?;
if (playlistId == null || playlistId.isEmpty) continue;
final parsed = _parseTrackEntryRow(row);
if (parsed == null) continue;
tracksByPlaylist.putIfAbsent(playlistId, () => []).add(parsed);
}
final playlists = <UserPlaylistCollection>[];
for (final row in snapshot.playlistRows) {
final id = row['id'] as String?;
if (id == null || id.isEmpty) continue;
final createdAtRaw = row['created_at'] as String?;
final updatedAtRaw = row['updated_at'] as String?;
final createdAt =
DateTime.tryParse(createdAtRaw ?? '') ?? DateTime.now();
final updatedAt = DateTime.tryParse(updatedAtRaw ?? '') ?? createdAt;
playlists.add(
UserPlaylistCollection(
id: id,
name: row['name'] as String? ?? '',
coverImagePath: row['cover_image_path'] as String?,
createdAt: createdAt,
updatedAt: updatedAt,
tracks: tracksByPlaylist[id] ?? const <CollectionTrackEntry>[],
),
);
}
state = LibraryCollectionsState(
wishlist: wishlist,
loved: loved,
playlists: playlists,
isLoaded: true,
);
} catch (_) {
state = state.copyWith(isLoaded: true);
}
}
Future<void> _ensureLoaded() async {
if (state.isLoaded) return;
await (_loadFuture ?? _load());
}
CollectionTrackEntry? _parseTrackEntryRow(Map<String, dynamic> row) {
final key = row['track_key'] as String?;
final trackJson = row['track_json'] as String?;
if (key == null || key.isEmpty || trackJson == null || trackJson.isEmpty) {
return null;
}
try {
final decoded = jsonDecode(trackJson);
if (decoded is! Map) return null;
final track = Track.fromJson(Map<String, dynamic>.from(decoded));
final addedAtRaw = row['added_at'] as String?;
return CollectionTrackEntry(
key: key,
track: track,
addedAt: DateTime.tryParse(addedAtRaw ?? '') ?? DateTime.now(),
);
} catch (_) {
return null;
}
}
bool _replacePlaylistById(
String playlistId,
UserPlaylistCollection Function(UserPlaylistCollection playlist) update,
) {
final playlist = state.playlistById(playlistId);
if (playlist == null) return false;
final playlistIndex = state.playlists.indexWhere((p) => p.id == playlistId);
if (playlistIndex < 0) return false;
final nextPlaylist = update(playlist);
if (identical(nextPlaylist, playlist)) return false;
final updatedPlaylists = [...state.playlists];
updatedPlaylists[playlistIndex] = nextPlaylist;
state = state.copyWith(playlists: updatedPlaylists);
return true;
}
Future<bool> toggleWishlist(Track track) async {
await _ensureLoaded();
final key = trackCollectionKey(track);
if (state.containsWishlistKey(key)) {
await _db.deleteWishlistEntry(key);
final updated = state.wishlist
.where((entry) => entry.key != key)
.toList(growable: false);
state = state.copyWith(wishlist: updated);
return false;
}
final entry = CollectionTrackEntry(
key: key,
track: track,
addedAt: DateTime.now(),
);
await _db.upsertWishlistEntry(
trackKey: key,
trackJson: jsonEncode(track.toJson()),
addedAt: entry.addedAt.toIso8601String(),
);
final updated = [entry, ...state.wishlist];
state = state.copyWith(wishlist: updated);
return true;
}
Future<bool> toggleLoved(Track track) async {
await _ensureLoaded();
final key = trackCollectionKey(track);
if (state.containsLovedKey(key)) {
await _db.deleteLovedEntry(key);
final updated = state.loved
.where((entry) => entry.key != key)
.toList(growable: false);
state = state.copyWith(loved: updated);
return false;
}
final entry = CollectionTrackEntry(
key: key,
track: track,
addedAt: DateTime.now(),
);
await _db.upsertLovedEntry(
trackKey: key,
trackJson: jsonEncode(track.toJson()),
addedAt: entry.addedAt.toIso8601String(),
);
final updated = [entry, ...state.loved];
state = state.copyWith(loved: updated);
return true;
}
Future<void> removeFromWishlist(String trackKey) async {
await _ensureLoaded();
if (!state.containsWishlistKey(trackKey)) return;
await _db.deleteWishlistEntry(trackKey);
final updated = state.wishlist
.where((entry) => entry.key != trackKey)
.toList(growable: false);
state = state.copyWith(wishlist: updated);
}
Future<void> removeFromLoved(String trackKey) async {
await _ensureLoaded();
if (!state.containsLovedKey(trackKey)) return;
await _db.deleteLovedEntry(trackKey);
final updated = state.loved
.where((entry) => entry.key != trackKey)
.toList(growable: false);
state = state.copyWith(loved: updated);
}
Future<String> createPlaylist(String name) async {
await _ensureLoaded();
final now = DateTime.now();
final id = 'pl_${now.microsecondsSinceEpoch}';
final trimmedName = name.trim();
final playlist = UserPlaylistCollection(
id: id,
name: trimmedName,
createdAt: now,
updatedAt: now,
tracks: const [],
);
await _db.upsertPlaylist(
id: id,
name: trimmedName,
coverImagePath: null,
createdAt: now.toIso8601String(),
updatedAt: now.toIso8601String(),
);
state = state.copyWith(playlists: [playlist, ...state.playlists]);
return id;
}
Future<void> renamePlaylist(String playlistId, String newName) async {
await _ensureLoaded();
final trimmed = newName.trim();
if (trimmed.isEmpty) return;
final playlist = state.playlistById(playlistId);
if (playlist == null || playlist.name == trimmed) return;
final now = DateTime.now();
await _db.renamePlaylist(
playlistId: playlistId,
name: trimmed,
updatedAt: now.toIso8601String(),
);
_replacePlaylistById(playlistId, (playlist) {
return playlist.copyWith(name: trimmed, updatedAt: now);
});
}
Future<void> deletePlaylist(String playlistId) async {
await _ensureLoaded();
final playlistIndex = state.playlists.indexWhere((p) => p.id == playlistId);
if (playlistIndex < 0) return;
await _db.deletePlaylist(playlistId);
final updatedPlaylists = [...state.playlists]..removeAt(playlistIndex);
state = state.copyWith(playlists: updatedPlaylists);
}
Future<bool> addTrackToPlaylist(String playlistId, Track track) async {
await _ensureLoaded();
final playlist = state.playlistById(playlistId);
if (playlist == null) return false;
final key = trackCollectionKey(track);
if (playlist.containsTrackKey(key)) return false;
final now = DateTime.now();
final entry = CollectionTrackEntry(key: key, track: track, addedAt: now);
await _db.upsertPlaylistTrack(
playlistId: playlistId,
trackKey: key,
trackJson: jsonEncode(track.toJson()),
addedAt: entry.addedAt.toIso8601String(),
playlistUpdatedAt: now.toIso8601String(),
);
final changed = _replacePlaylistById(playlistId, (playlist) {
if (playlist.containsTrackKey(key)) return playlist;
return playlist.copyWith(
tracks: [entry, ...playlist.tracks],
updatedAt: now,
);
});
if (!changed) return false;
return true;
}
Future<PlaylistAddBatchResult> addTracksToPlaylist(
String playlistId,
Iterable<Track> tracks,
) async {
await _ensureLoaded();
final playlist = state.playlistById(playlistId);
if (playlist == null) {
return const PlaylistAddBatchResult(
addedCount: 0,
alreadyInPlaylistCount: 0,
);
}
final now = DateTime.now();
final knownKeys = <String>{...playlist._trackKeys};
final entriesToAdd = <CollectionTrackEntry>[];
var alreadyInPlaylistCount = 0;
for (final track in tracks) {
final key = trackCollectionKey(track);
if (!knownKeys.add(key)) {
alreadyInPlaylistCount++;
continue;
}
entriesToAdd.add(
CollectionTrackEntry(key: key, track: track, addedAt: now),
);
}
if (entriesToAdd.isEmpty) {
return PlaylistAddBatchResult(
addedCount: 0,
alreadyInPlaylistCount: alreadyInPlaylistCount,
);
}
await _db.upsertPlaylistTracksBatch(
playlistId: playlistId,
playlistUpdatedAt: now.toIso8601String(),
tracks: entriesToAdd
.map(
(entry) => <String, String>{
'track_key': entry.key,
'track_json': jsonEncode(entry.track.toJson()),
'added_at': entry.addedAt.toIso8601String(),
},
)
.toList(growable: false),
);
final changed = _replacePlaylistById(playlistId, (current) {
return current.copyWith(
tracks: [...entriesToAdd.reversed, ...current.tracks],
updatedAt: now,
);
});
if (!changed) {
return PlaylistAddBatchResult(
addedCount: 0,
alreadyInPlaylistCount: alreadyInPlaylistCount,
);
}
return PlaylistAddBatchResult(
addedCount: entriesToAdd.length,
alreadyInPlaylistCount: alreadyInPlaylistCount,
);
}
Future<void> removeTrackFromPlaylist(
String playlistId,
String trackKey,
) async {
await _ensureLoaded();
final playlist = state.playlistById(playlistId);
if (playlist == null || !playlist.containsTrackKey(trackKey)) return;
final now = DateTime.now();
await _db.deletePlaylistTrack(
playlistId: playlistId,
trackKey: trackKey,
playlistUpdatedAt: now.toIso8601String(),
);
_replacePlaylistById(playlistId, (playlist) {
final nextTracks = playlist.tracks
.where((entry) => entry.key != trackKey)
.toList(growable: false);
if (nextTracks.length == playlist.tracks.length) return playlist;
return playlist.copyWith(tracks: nextTracks, updatedAt: now);
});
}
Future<Directory> _playlistCoversDir() async {
final appDir = await getApplicationSupportDirectory();
final dir = Directory(p.join(appDir.path, 'playlist_covers'));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
Future<void> setPlaylistCover(
String playlistId,
String sourceFilePath,
) async {
await _ensureLoaded();
final playlist = state.playlistById(playlistId);
if (playlist == null) return;
final coversDir = await _playlistCoversDir();
final ext = p.extension(sourceFilePath).toLowerCase();
final destPath = p.join(coversDir.path, '$playlistId$ext');
if (playlist.coverImagePath == destPath) return;
// Copy image to persistent location
await File(sourceFilePath).copy(destPath);
final now = DateTime.now();
await _db.updatePlaylistCover(
playlistId: playlistId,
coverImagePath: destPath,
updatedAt: now.toIso8601String(),
);
_replacePlaylistById(playlistId, (playlist) {
if (playlist.coverImagePath == destPath) return playlist;
return playlist.copyWith(coverImagePath: () => destPath, updatedAt: now);
});
}
Future<void> removePlaylistCover(String playlistId) async {
await _ensureLoaded();
final playlist = state.playlistById(playlistId);
if (playlist == null || playlist.coverImagePath == null) return;
// Delete the file if it exists
final path = playlist.coverImagePath;
if (path != null) {
final file = File(path);
if (await file.exists()) {
await file.delete();
}
}
final now = DateTime.now();
await _db.updatePlaylistCover(
playlistId: playlistId,
coverImagePath: null,
updatedAt: now.toIso8601String(),
);
_replacePlaylistById(playlistId, (playlist) {
if (playlist.coverImagePath == null) return playlist;
return playlist.copyWith(coverImagePath: () => null, updatedAt: now);
});
}
}
final libraryCollectionsProvider =
NotifierProvider<LibraryCollectionsNotifier, LibraryCollectionsState>(
LibraryCollectionsNotifier.new,
);
+219 -52
View File
@@ -121,15 +121,25 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
final NotificationService _notificationService = NotificationService();
static const _progressPollingInterval = Duration(milliseconds: 800);
Timer? _progressTimer;
Timer? _progressStreamBootstrapTimer;
StreamSubscription<Map<String, dynamic>>? _progressStreamSub;
bool _isLoaded = false;
bool _scanCancelRequested = false;
int _progressPollingErrorCount = 0;
bool _isProgressPollingInFlight = false;
bool _hasReceivedProgressStreamEvent = false;
bool _usingProgressStream = false;
static const _scanNotificationHeartbeat = Duration(seconds: 4);
int _lastScanNotificationPercent = -1;
int _lastScanNotificationTotalFiles = -1;
DateTime _lastScanNotificationAt = DateTime.fromMillisecondsSinceEpoch(0);
@override
LocalLibraryState build() {
ref.onDispose(() {
_progressTimer?.cancel();
_progressStreamBootstrapTimer?.cancel();
_progressStreamSub?.cancel();
});
Future.microtask(() async {
@@ -188,7 +198,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
if (raw.isEmpty) return const {};
final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7) : raw;
final keys = <String>{cleaned};
final keys = <String>{};
void addNormalized(String value) {
final trimmed = value.trim();
@@ -207,18 +217,42 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
keys.add(decoded.toLowerCase());
} catch (_) {}
}
Uri? parsed;
try {
parsed = Uri.parse(trimmed);
} catch (_) {}
if (parsed != null && parsed.hasScheme) {
final noQueryOrFragment = parsed.replace(query: null, fragment: null);
keys.add(noQueryOrFragment.toString());
keys.add(noQueryOrFragment.toString().toLowerCase());
if (parsed.scheme == 'file') {
try {
final fileOnly = parsed.toFilePath();
if (fileOnly.isNotEmpty) {
keys.add(fileOnly);
keys.add(fileOnly.toLowerCase());
if (fileOnly.contains('\\')) {
final slash = fileOnly.replaceAll('\\', '/');
keys.add(slash);
keys.add(slash.toLowerCase());
}
}
} catch (_) {}
}
} else if (trimmed.startsWith('/')) {
try {
final asFileUri = Uri.file(trimmed).toString();
keys.add(asFileUri);
keys.add(asFileUri.toLowerCase());
} catch (_) {}
}
}
addNormalized(cleaned);
if (cleaned.startsWith('content://')) {
try {
final uri = Uri.parse(cleaned);
addNormalized(uri.toString());
addNormalized(uri.replace(query: null, fragment: null).toString());
} catch (_) {}
}
return keys;
}
@@ -257,12 +291,19 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
scanErrorCount: 0,
scanWasCancelled: false,
);
await _showScanProgressNotification(
_resetScanNotificationTracking();
if (_shouldShowScanProgressNotification(
progress: 0,
scannedFiles: 0,
totalFiles: 0,
currentFile: null,
);
isComplete: false,
)) {
await _showScanProgressNotification(
progress: 0,
scannedFiles: 0,
totalFiles: 0,
currentFile: null,
);
}
try {
final appSupportDir = await getApplicationSupportDirectory();
@@ -328,7 +369,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_log.i('Skipped $skippedDownloads files already in download history');
}
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
// Full scan should replace library index entirely.
await _db.clearAll();
if (items.isNotEmpty) {
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
}
final now = DateTime.now();
try {
@@ -420,10 +465,24 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
final currentByPath = <String, LocalLibraryItem>{
for (final item in state.items) item.filePath: item,
};
final existingDownloadedPaths = <String>[];
currentByPath.removeWhere((path, _) {
final shouldExclude = _isDownloadedPath(path, downloadedPathKeys);
if (shouldExclude) {
existingDownloadedPaths.add(path);
}
return shouldExclude;
});
if (existingDownloadedPaths.isNotEmpty) {
final removed = await _db.deleteByPaths(existingDownloadedPaths);
_log.i(
'Removed $removed downloaded tracks already present in local library index',
);
}
// Upsert new/modified items (excluding downloaded files)
final updatedItems = <LocalLibraryItem>[];
int skippedDownloads = 0;
int skippedDownloads = existingDownloadedPaths.length;
if (scannedList.isNotEmpty) {
for (final json in scannedList) {
final map = json as Map<String, dynamic>;
@@ -499,49 +558,75 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
}
void _startProgressPolling() {
_progressTimer?.cancel();
_progressStreamBootstrapTimer?.cancel();
_progressStreamBootstrapTimer = null;
_progressStreamSub?.cancel();
_progressStreamSub = null;
_hasReceivedProgressStreamEvent = false;
_usingProgressStream = false;
if (Platform.isAndroid || Platform.isIOS) {
_progressStreamSub = PlatformBridge.libraryScanProgressStream().listen(
(progress) async {
_hasReceivedProgressStreamEvent = true;
_usingProgressStream = true;
_progressStreamBootstrapTimer?.cancel();
_progressStreamBootstrapTimer = null;
if (_isProgressPollingInFlight) return;
_isProgressPollingInFlight = true;
try {
await _handleLibraryScanProgress(progress);
_progressPollingErrorCount = 0;
} catch (e) {
_progressPollingErrorCount++;
if (_progressPollingErrorCount <= 3) {
_log.w('Library scan progress stream processing failed: $e');
}
} finally {
_isProgressPollingInFlight = false;
}
},
onError: (Object error, StackTrace stackTrace) {
if (_usingProgressStream) {
_log.w(
'Library scan progress stream failed, fallback to polling: $error',
);
}
_progressStreamSub?.cancel();
_progressStreamSub = null;
_usingProgressStream = false;
_progressStreamBootstrapTimer?.cancel();
_progressStreamBootstrapTimer = null;
_startProgressPollingTimer();
},
cancelOnError: false,
);
_progressStreamBootstrapTimer = Timer(const Duration(seconds: 3), () {
if (_hasReceivedProgressStreamEvent) {
return;
}
_log.w('Library scan progress stream timeout, fallback to polling');
_progressStreamSub?.cancel();
_progressStreamSub = null;
_usingProgressStream = false;
_startProgressPollingTimer();
});
return;
}
_startProgressPollingTimer();
}
void _startProgressPollingTimer() {
_progressTimer?.cancel();
_progressTimer = Timer.periodic(_progressPollingInterval, (_) async {
if (_isProgressPollingInFlight) return;
_isProgressPollingInFlight = true;
try {
final progress = await PlatformBridge.getLibraryScanProgress();
final nextProgress =
(progress['progress_pct'] as num?)?.toDouble() ?? 0;
final normalizedProgress = ((nextProgress * 10).round() / 10).clamp(
0.0,
100.0,
);
final currentFile = progress['current_file'] as String?;
final totalFiles = progress['total_files'] as int? ?? 0;
final scannedFiles = progress['scanned_files'] as int? ?? 0;
final errorCount = progress['error_count'] as int? ?? 0;
final shouldUpdateState =
state.scanProgress != normalizedProgress ||
state.scanCurrentFile != currentFile ||
state.scanTotalFiles != totalFiles ||
state.scannedFiles != scannedFiles ||
state.scanErrorCount != errorCount;
if (shouldUpdateState) {
state = state.copyWith(
scanProgress: normalizedProgress,
scanCurrentFile: currentFile,
scanTotalFiles: totalFiles,
scannedFiles: scannedFiles,
scanErrorCount: errorCount,
);
await _showScanProgressNotification(
progress: normalizedProgress,
scannedFiles: scannedFiles,
totalFiles: totalFiles,
currentFile: currentFile,
);
}
if (progress['is_complete'] == true) {
_stopProgressPolling();
}
await _handleLibraryScanProgress(progress);
_progressPollingErrorCount = 0;
} catch (e) {
_progressPollingErrorCount++;
@@ -554,11 +639,93 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
});
}
Future<void> _handleLibraryScanProgress(Map<String, dynamic> progress) async {
final nextProgress = (progress['progress_pct'] as num?)?.toDouble() ?? 0;
final normalizedProgress = ((nextProgress * 10).round() / 10).clamp(
0.0,
100.0,
);
final currentFile = progress['current_file'] as String?;
final totalFiles = (progress['total_files'] as num?)?.toInt() ?? 0;
final scannedFiles = (progress['scanned_files'] as num?)?.toInt() ?? 0;
final errorCount = (progress['error_count'] as num?)?.toInt() ?? 0;
final isComplete = progress['is_complete'] == true;
final shouldUpdateState =
state.scanProgress != normalizedProgress ||
state.scanCurrentFile != currentFile ||
state.scanTotalFiles != totalFiles ||
state.scannedFiles != scannedFiles ||
state.scanErrorCount != errorCount;
if (shouldUpdateState) {
state = state.copyWith(
scanProgress: normalizedProgress,
scanCurrentFile: currentFile,
scanTotalFiles: totalFiles,
scannedFiles: scannedFiles,
scanErrorCount: errorCount,
);
}
if (_shouldShowScanProgressNotification(
progress: normalizedProgress,
totalFiles: totalFiles,
isComplete: isComplete,
)) {
await _showScanProgressNotification(
progress: normalizedProgress,
scannedFiles: scannedFiles,
totalFiles: totalFiles,
currentFile: currentFile,
);
}
if (isComplete) {
_stopProgressPolling();
}
}
void _stopProgressPolling() {
_progressTimer?.cancel();
_progressStreamBootstrapTimer?.cancel();
_progressStreamSub?.cancel();
_progressTimer = null;
_progressStreamBootstrapTimer = null;
_progressStreamSub = null;
_progressPollingErrorCount = 0;
_isProgressPollingInFlight = false;
_hasReceivedProgressStreamEvent = false;
_usingProgressStream = false;
_resetScanNotificationTracking();
}
void _resetScanNotificationTracking() {
_lastScanNotificationPercent = -1;
_lastScanNotificationTotalFiles = -1;
_lastScanNotificationAt = DateTime.fromMillisecondsSinceEpoch(0);
}
bool _shouldShowScanProgressNotification({
required double progress,
required int totalFiles,
required bool isComplete,
}) {
final now = DateTime.now();
final percent = progress.round().clamp(0, 100);
final percentChanged = percent != _lastScanNotificationPercent;
final totalFilesChanged = totalFiles != _lastScanNotificationTotalFiles;
final heartbeatDue =
now.difference(_lastScanNotificationAt) >= _scanNotificationHeartbeat;
if (!percentChanged && !totalFilesChanged && !isComplete && !heartbeatDue) {
return false;
}
_lastScanNotificationPercent = percent;
_lastScanNotificationTotalFiles = totalFiles;
_lastScanNotificationAt = now;
return true;
}
Future<void> cancelScan() async {
+167
View File
@@ -0,0 +1,167 @@
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/services/library_database.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('PlaybackProvider');
class PlaybackState {
const PlaybackState();
}
class PlaybackController extends Notifier<PlaybackState> {
@override
PlaybackState build() => const PlaybackState();
Future<void> playLocalPath({
required String path,
required String title,
required String artist,
String album = '',
String coverUrl = '',
Track? track,
}) async {
_log.d('Opening external player for "$title" by $artist: $path');
await openFile(path);
}
Future<void> playTrackList(List<Track> tracks, {int startIndex = 0}) async {
if (tracks.isEmpty) return;
final orderedTracks = _orderedTracksFromStartIndex(tracks, startIndex);
for (final track in orderedTracks) {
final resolvedPath = await _resolveTrackPath(track);
if (resolvedPath == null) {
continue;
}
_log.d(
'Opening first available external track for list playback: '
'"${track.name}" by ${track.artistName} -> $resolvedPath',
);
await openFile(resolvedPath);
return;
}
throw Exception(
'No local audio file is available to open. Download the track first.',
);
}
List<Track> _orderedTracksFromStartIndex(List<Track> tracks, int startIndex) {
final safeStart = startIndex.clamp(0, tracks.length - 1);
if (safeStart == 0) {
return List<Track>.from(tracks, growable: false);
}
return <Track>[
...tracks.sublist(safeStart),
...tracks.sublist(0, safeStart),
];
}
Future<String?> _resolveTrackPath(Track track) async {
final localState = ref.read(localLibraryProvider);
final historyState = ref.read(downloadHistoryProvider);
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
final localItem = _findLocalLibraryItemForTrack(track, localState);
if (localItem != null && await fileExists(localItem.filePath)) {
return localItem.filePath;
}
final historyItem = _findDownloadHistoryItemForTrack(track, historyState);
if (historyItem != null) {
if (await fileExists(historyItem.filePath)) {
return historyItem.filePath;
}
historyNotifier.removeFromHistory(historyItem.id);
}
return null;
}
LocalLibraryItem? _findLocalLibraryItemForTrack(
Track track,
LocalLibraryState localState,
) {
final isLocalSource = (track.source ?? '').toLowerCase() == 'local';
if (isLocalSource) {
for (final item in localState.items) {
if (item.id == track.id) {
return item;
}
}
}
final isrc = track.isrc?.trim();
if (isrc != null && isrc.isNotEmpty) {
final byIsrc = localState.getByIsrc(isrc);
if (byIsrc != null) {
return byIsrc;
}
}
return localState.findByTrackAndArtist(track.name, track.artistName);
}
DownloadHistoryItem? _findDownloadHistoryItemForTrack(
Track track,
DownloadHistoryState historyState,
) {
for (final candidateId in _spotifyIdLookupCandidates(track.id)) {
final bySpotifyId = historyState.getBySpotifyId(candidateId);
if (bySpotifyId != null) {
return bySpotifyId;
}
}
final isrc = track.isrc?.trim();
if (isrc != null && isrc.isNotEmpty) {
final byIsrc = historyState.getByIsrc(isrc);
if (byIsrc != null) {
return byIsrc;
}
}
return historyState.findByTrackAndArtist(track.name, track.artistName);
}
List<String> _spotifyIdLookupCandidates(String rawId) {
final trimmed = rawId.trim();
if (trimmed.isEmpty) {
return const [];
}
final candidates = <String>{trimmed};
final lowered = trimmed.toLowerCase();
if (lowered.startsWith('spotify:track:')) {
final compact = trimmed.split(':').last.trim();
if (compact.isNotEmpty) {
candidates.add(compact);
}
} else if (!trimmed.contains(':')) {
candidates.add('spotify:track:$trimmed');
}
final uri = Uri.tryParse(trimmed);
final segments = uri?.pathSegments ?? const <String>[];
final trackIndex = segments.indexOf('track');
if (trackIndex >= 0 && trackIndex + 1 < segments.length) {
final pathId = segments[trackIndex + 1].trim();
if (pathId.isNotEmpty) {
candidates.add(pathId);
candidates.add('spotify:track:$pathId');
}
}
return candidates.toList(growable: false);
}
}
final playbackProvider = NotifierProvider<PlaybackController, PlaybackState>(
PlaybackController.new,
);
+98 -89
View File
@@ -1,18 +1,12 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/services/app_state_database.dart';
const _recentAccessKey = 'recent_access_history';
const _hiddenDownloadsKey = 'hidden_downloads_in_recents';
const _maxRecentItems = 20;
/// Types of items that can be accessed
enum RecentAccessType {
artist,
album,
track,
playlist,
}
enum RecentAccessType { artist, album, track, playlist }
/// Represents a recently accessed item
class RecentAccessItem {
@@ -100,7 +94,7 @@ class RecentAccessState {
/// Provider for managing recent access history
class RecentAccessNotifier extends Notifier<RecentAccessState> {
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
final AppStateDatabase _appStateDb = AppStateDatabase.instance;
@override
RecentAccessState build() {
@@ -109,40 +103,36 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
}
Future<void> _loadHistory() async {
final prefs = await _prefs;
final json = prefs.getString(_recentAccessKey);
final hiddenJson = prefs.getStringList(_hiddenDownloadsKey);
List<RecentAccessItem> items = [];
Set<String> hiddenIds = {};
if (json != null) {
try {
final List<dynamic> decoded = jsonDecode(json);
items = decoded
.map((e) => RecentAccessItem.fromJson(e as Map<String, dynamic>))
.toList();
} catch (_) {
// Ignore JSON parse errors, use empty list
try {
await _appStateDb.migrateRecentAccessFromSharedPreferences();
final rows = await _appStateDb.getRecentAccessRows(
limit: _maxRecentItems,
);
final hiddenIds = await _appStateDb.getHiddenRecentDownloadIds();
final items = <RecentAccessItem>[];
for (final row in rows) {
final itemJson = row['item_json'] as String?;
if (itemJson == null || itemJson.isEmpty) continue;
try {
final decoded = jsonDecode(itemJson);
if (decoded is! Map) continue;
items.add(
RecentAccessItem.fromJson(Map<String, dynamic>.from(decoded)),
);
} catch (_) {
continue;
}
}
}
if (hiddenJson != null) {
hiddenIds = hiddenJson.toSet();
}
state = state.copyWith(items: items, hiddenDownloadIds: hiddenIds, isLoaded: true);
}
Future<void> _saveHistory() async {
final prefs = await _prefs;
final json = jsonEncode(state.items.map((e) => e.toJson()).toList());
await prefs.setString(_recentAccessKey, json);
}
Future<void> _saveHiddenDownloads() async {
final prefs = await _prefs;
await prefs.setStringList(_hiddenDownloadsKey, state.hiddenDownloadIds.toList());
state = state.copyWith(
items: items,
hiddenDownloadIds: hiddenIds,
isLoaded: true,
);
} catch (_) {
state = state.copyWith(isLoaded: true);
}
}
/// Record an access to an artist
@@ -152,14 +142,16 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
String? imageUrl,
String? providerId,
}) {
_recordAccess(RecentAccessItem(
id: id,
name: name,
imageUrl: imageUrl,
type: RecentAccessType.artist,
accessedAt: DateTime.now(),
providerId: providerId,
));
_recordAccess(
RecentAccessItem(
id: id,
name: name,
imageUrl: imageUrl,
type: RecentAccessType.artist,
accessedAt: DateTime.now(),
providerId: providerId,
),
);
}
/// Record an access to an album
@@ -170,15 +162,17 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
String? imageUrl,
String? providerId,
}) {
_recordAccess(RecentAccessItem(
id: id,
name: name,
subtitle: artistName,
imageUrl: imageUrl,
type: RecentAccessType.album,
accessedAt: DateTime.now(),
providerId: providerId,
));
_recordAccess(
RecentAccessItem(
id: id,
name: name,
subtitle: artistName,
imageUrl: imageUrl,
type: RecentAccessType.album,
accessedAt: DateTime.now(),
providerId: providerId,
),
);
}
/// Record an access to a track
@@ -189,15 +183,17 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
String? imageUrl,
String? providerId,
}) {
_recordAccess(RecentAccessItem(
id: id,
name: name,
subtitle: artistName,
imageUrl: imageUrl,
type: RecentAccessType.track,
accessedAt: DateTime.now(),
providerId: providerId,
));
_recordAccess(
RecentAccessItem(
id: id,
name: name,
subtitle: artistName,
imageUrl: imageUrl,
type: RecentAccessType.track,
accessedAt: DateTime.now(),
providerId: providerId,
),
);
}
/// Record an access to a playlist
@@ -208,30 +204,42 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
String? imageUrl,
String? providerId,
}) {
_recordAccess(RecentAccessItem(
id: id,
name: name,
subtitle: ownerName,
imageUrl: imageUrl,
type: RecentAccessType.playlist,
accessedAt: DateTime.now(),
providerId: providerId,
));
_recordAccess(
RecentAccessItem(
id: id,
name: name,
subtitle: ownerName,
imageUrl: imageUrl,
type: RecentAccessType.playlist,
accessedAt: DateTime.now(),
providerId: providerId,
),
);
}
void _recordAccess(RecentAccessItem item) {
final updatedItems = state.items
.where((e) => e.uniqueKey != item.uniqueKey)
.toList();
updatedItems.insert(0, item);
RecentAccessItem? removedTail;
if (updatedItems.length > _maxRecentItems) {
updatedItems.removeRange(_maxRecentItems, updatedItems.length);
removedTail = updatedItems.removeLast();
}
state = state.copyWith(items: updatedItems);
_saveHistory();
unawaited(
_appStateDb.upsertRecentAccessRow(
uniqueKey: item.uniqueKey,
itemJson: jsonEncode(item.toJson()),
accessedAt: item.accessedAt.toIso8601String(),
),
);
if (removedTail != null) {
unawaited(_appStateDb.deleteRecentAccessRow(removedTail.uniqueKey));
}
}
/// Remove a specific item from history
@@ -240,14 +248,14 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
.where((e) => e.uniqueKey != item.uniqueKey)
.toList();
state = state.copyWith(items: updatedItems);
_saveHistory();
unawaited(_appStateDb.deleteRecentAccessRow(item.uniqueKey));
}
/// Hide a download item from recents (without deleting the actual download)
void hideDownloadFromRecents(String downloadId) {
final updatedHidden = {...state.hiddenDownloadIds, downloadId};
state = state.copyWith(hiddenDownloadIds: updatedHidden);
_saveHiddenDownloads();
unawaited(_appStateDb.addHiddenRecentDownloadId(downloadId));
}
/// Check if a download is hidden from recents
@@ -258,16 +266,17 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
/// Clear all history
void clearHistory() {
state = state.copyWith(items: []);
_saveHistory();
unawaited(_appStateDb.clearRecentAccessRows());
}
/// Clear hidden downloads (show all again)
void clearHiddenDownloads() {
state = state.copyWith(hiddenDownloadIds: {});
_saveHiddenDownloads();
unawaited(_appStateDb.clearHiddenRecentDownloadIds());
}
}
final recentAccessProvider = NotifierProvider<RecentAccessNotifier, RecentAccessState>(
RecentAccessNotifier.new,
);
final recentAccessProvider =
NotifierProvider<RecentAccessNotifier, RecentAccessState>(
RecentAccessNotifier.new,
);
+57 -1
View File
@@ -3,18 +3,20 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
const _settingsKey = 'app_settings';
const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 2;
const _currentMigrationVersion = 4;
const _spotifyClientSecretKey = 'spotify_client_secret';
final _log = AppLogger('SettingsProvider');
class SettingsNotifier extends Notifier<AppSettings> {
static const List<int> _youtubeOpusSupportedBitrates = [128, 256];
static const List<int> _youtubeMp3SupportedBitrates = [128, 256, 320];
static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$');
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
@@ -36,6 +38,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
await _runMigrations(prefs);
await _normalizeYouTubeBitratesIfNeeded();
await _normalizeSongLinkRegionIfNeeded();
}
await _loadSpotifyClientSecret(prefs);
@@ -45,6 +48,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
LogBuffer.loggingEnabled = state.enableLogging;
_syncLyricsSettingsToBackend();
_syncNetworkCompatibilitySettingsToBackend();
}
void _syncLyricsSettingsToBackend() {
@@ -62,6 +66,16 @@ class SettingsNotifier extends Notifier<AppSettings> {
});
}
void _syncNetworkCompatibilitySettingsToBackend() {
final compatibilityMode = state.networkCompatibilityMode;
PlatformBridge.setNetworkCompatibilityOptions(
allowHttp: compatibilityMode,
insecureTls: compatibilityMode,
).catchError((e) {
_log.w('Failed to sync network compatibility options to backend: $e');
});
}
Future<void> _runMigrations(SharedPreferences prefs) async {
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
@@ -80,6 +94,18 @@ class SettingsNotifier extends Notifier<AppSettings> {
if (!state.isFirstLaunch && !state.hasCompletedTutorial) {
state = state.copyWith(hasCompletedTutorial: true);
}
// Migration 4: include Spotify Lyrics API in provider order for existing users
if (!state.lyricsProviders.contains('spotify_api')) {
final updatedProviders = List<String>.from(state.lyricsProviders);
final lrclibIndex = updatedProviders.indexOf('lrclib');
if (lrclibIndex >= 0) {
updatedProviders.insert(lrclibIndex + 1, 'spotify_api');
} else {
updatedProviders.add('spotify_api');
}
state = state.copyWith(lyricsProviders: updatedProviders);
}
state = state.copyWith(lastSeenVersion: AppInfo.version);
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
await _saveSettings();
}
@@ -154,6 +180,19 @@ class SettingsNotifier extends Notifier<AppSettings> {
await _saveSettings();
}
String _normalizeSongLinkRegion(String region) {
final normalized = region.trim().toUpperCase();
if (_isoRegionPattern.hasMatch(normalized)) return normalized;
return 'US';
}
Future<void> _normalizeSongLinkRegionIfNeeded() async {
final normalized = _normalizeSongLinkRegion(state.songLinkRegion);
if (normalized == state.songLinkRegion) return;
state = state.copyWith(songLinkRegion: normalized);
await _saveSettings();
}
Future<void> _loadSpotifyClientSecret(SharedPreferences prefs) async {
final storedSecret = await _secureStorage.read(
key: _spotifyClientSecretKey,
@@ -245,6 +284,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setEmbedMetadata(bool enabled) {
state = state.copyWith(embedMetadata: enabled);
_saveSettings();
}
void setLyricsMode(String mode) {
if (mode == 'embed' || mode == 'external' || mode == 'both') {
state = state.copyWith(lyricsMode: mode);
@@ -466,6 +510,18 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setNetworkCompatibilityMode(bool enabled) {
state = state.copyWith(networkCompatibilityMode: enabled);
_saveSettings();
_syncNetworkCompatibilitySettingsToBackend();
}
void setSongLinkRegion(String region) {
final normalized = _normalizeSongLinkRegion(region);
state = state.copyWith(songLinkRegion: normalized);
_saveSettings();
}
void setLocalLibraryEnabled(bool enabled) {
state = state.copyWith(localLibraryEnabled: enabled);
_saveSettings();
+42 -8
View File
@@ -551,6 +551,7 @@ class TrackNotifier extends Notifier<TrackState> {
state = TrackState(
isLoading: true,
hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess,
selectedSearchFilter: currentFilter,
);
@@ -713,6 +714,7 @@ class TrackNotifier extends Notifier<TrackState> {
searchPlaylists: playlists,
isLoading: false,
hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess,
selectedSearchFilter: currentFilter, // Preserve filter in results
);
} catch (e, stackTrace) {
@@ -722,6 +724,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
error: e.toString(),
hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess,
selectedSearchFilter: currentFilter,
);
}
@@ -737,6 +740,7 @@ class TrackNotifier extends Notifier<TrackState> {
state = TrackState(
isLoading: true,
hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess,
selectedSearchFilter:
state.selectedSearchFilter, // Preserve filter during loading
);
@@ -776,6 +780,7 @@ class TrackNotifier extends Notifier<TrackState> {
searchArtists: [],
isLoading: false,
hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess,
searchExtensionId: extensionId, // Store which extension was used
selectedSearchFilter:
state.selectedSearchFilter, // Preserve selected filter
@@ -787,6 +792,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
error: e.toString(),
hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess,
);
}
}
@@ -808,6 +814,8 @@ class TrackNotifier extends Notifier<TrackState> {
artistName: track.artistName,
albumName: track.albumName,
albumArtist: track.albumArtist,
artistId: track.artistId,
albumId: track.albumId,
coverUrl: track.coverUrl,
isrc: track.isrc,
duration: track.duration,
@@ -876,19 +884,23 @@ class TrackNotifier extends Notifier<TrackState> {
playlistName: playlistName,
coverUrl: coverUrl,
hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess,
);
}
Track _parseTrack(Map<String, dynamic> data) {
final durationMs = _extractDurationMs(data);
return Track(
id: data['spotify_id'] as String? ?? '',
name: data['name'] as String? ?? '',
artistName: data['artists'] as String? ?? '',
albumName: data['album_name'] as String? ?? '',
albumArtist: data['album_artist'] as String?,
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
albumId: data['album_id']?.toString(),
coverUrl: data['images'] as String?,
isrc: data['isrc'] as String?,
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
releaseDate: data['release_date'] as String?,
@@ -896,13 +908,7 @@ class TrackNotifier extends Notifier<TrackState> {
}
Track _parseSearchTrack(Map<String, dynamic> data, {String? source}) {
int durationMs = 0;
final durationValue = data['duration_ms'];
if (durationValue is int) {
durationMs = durationValue;
} else if (durationValue is double) {
durationMs = durationValue.toInt();
}
final durationMs = _extractDurationMs(data);
final itemType = data['item_type']?.toString();
@@ -912,6 +918,8 @@ class TrackNotifier extends Notifier<TrackState> {
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
albumArtist: data['album_artist']?.toString(),
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
albumId: data['album_id']?.toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
@@ -927,6 +935,32 @@ class TrackNotifier extends Notifier<TrackState> {
);
}
int _extractDurationMs(Map<String, dynamic> data) {
final durationMsRaw = data['duration_ms'];
if (durationMsRaw is num && durationMsRaw > 0) {
return durationMsRaw.toInt();
}
if (durationMsRaw is String) {
final parsed = num.tryParse(durationMsRaw.trim());
if (parsed != null && parsed > 0) {
return parsed.toInt();
}
}
final durationSecRaw = data['duration'];
if (durationSecRaw is num && durationSecRaw > 0) {
return (durationSecRaw * 1000).toInt();
}
if (durationSecRaw is String) {
final parsed = num.tryParse(durationSecRaw.trim());
if (parsed != null && parsed > 0) {
return (parsed * 1000).toInt();
}
}
return 0;
}
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
return ArtistAlbum(
id: data['id'] as String? ?? '',
+363 -428
View File
@@ -1,22 +1,21 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/recent_access_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/screens/artist_screen.dart';
import 'package:spotiflac_android/screens/home_tab.dart'
show ExtensionArtistScreen;
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
class _AlbumCache {
static final Map<String, _CacheEntry> _cache = {};
@@ -117,12 +116,39 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
void _onScroll() {
final shouldShow = _scrollController.offset > 280;
final expandedHeight = _calculateExpandedHeight(context);
final shouldShow =
_scrollController.offset > (expandedHeight - kToolbarHeight - 20);
if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow);
}
}
double _calculateExpandedHeight(BuildContext context) {
final mediaSize = MediaQuery.of(context).size;
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
}
/// Upgrade cover URL to a reasonable resolution for full-screen display.
/// Spotify CDN only has 300, 640, ~2000 we stay at 640 (no intermediate).
/// Deezer CDN: upgrade to 1000x1000 (available: 56, 250, 500, 1000, 1400, 1800).
String? _highResCoverUrl(String? url) {
if (url == null) return null;
// Spotify CDN: upgrade 300 640 only (no intermediate between 640 and 2000)
if (url.contains('ab67616d00001e02')) {
return url.replaceAll('ab67616d00001e02', 'ab67616d0000b273');
}
// Deezer CDN: upgrade to 1000x1000
final deezerRegex = RegExp(r'/(\d+)x(\d+)-(\d+)-(\d+)-(\d+)-(\d+)\.jpg$');
if (url.contains('cdn-images.dzcdn.net') && deezerRegex.hasMatch(url)) {
return url.replaceAllMapped(
deezerRegex,
(m) => '/1000x1000-${m[3]}-${m[4]}-${m[5]}-${m[6]}.jpg',
);
}
return url;
}
String _formatReleaseDate(String date) {
if (date.length >= 10) {
final parts = date.substring(0, 10).split('-');
@@ -160,7 +186,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
.toList();
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = albumInfo?['artist_id'] as String?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString();
_AlbumCache.set(widget.albumId, tracks);
@@ -188,6 +215,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
artistName: data['artists'] as String? ?? '',
albumName: data['album_name'] as String? ?? '',
albumArtist: data['album_artist'] as String?,
artistId:
(data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId,
albumId: data['album_id']?.toString() ?? widget.albumId,
coverUrl: data['images'] as String?,
isrc: data['isrc'] as String?,
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
@@ -201,12 +231,14 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final tracks = _tracks ?? [];
final pageBackgroundColor = colorScheme.surface;
return Scaffold(
backgroundColor: pageBackgroundColor,
body: CustomScrollView(
controller: _scrollController,
slivers: [
_buildAppBar(context, colorScheme),
_buildAppBar(context, colorScheme, pageBackgroundColor),
_buildInfoCard(context, colorScheme),
if (_isLoading)
const SliverToBoxAdapter(
@@ -223,7 +255,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
),
),
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
_buildTrackListHeader(context, colorScheme),
_buildTrackList(context, colorScheme, tracks),
],
const SliverToBoxAdapter(child: SizedBox(height: 32)),
@@ -232,21 +263,21 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
);
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final mediaSize = MediaQuery.of(context).size;
final screenWidth = mediaSize.width;
final shortestSide = mediaSize.shortestSide;
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
Widget _buildAppBar(
BuildContext context,
ColorScheme colorScheme,
Color pageBackgroundColor,
) {
final expandedHeight = _calculateExpandedHeight(context);
final tracks = _tracks ?? [];
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
return SliverAppBar(
expandedHeight: expandedHeight,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
backgroundColor: pageBackgroundColor,
surfaceTintColor: Colors.transparent,
title: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
@@ -268,25 +299,18 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3;
final dpr = MediaQuery.devicePixelRatioOf(
context,
).clamp(1.0, 3.0).toDouble();
final backgroundMemCacheWidth = (constraints.maxWidth * dpr)
.round()
.clamp(720, 1440)
.toInt();
return FlexibleSpaceBar(
collapseMode: CollapseMode.none,
collapseMode: CollapseMode.pin,
background: Stack(
fit: StackFit.expand,
children: [
// Blurred cover background
// Full-screen cover background (no blur, full resolution)
if (widget.coverUrl != null)
CachedNetworkImage(
imageUrl: widget.coverUrl!,
imageUrl:
_highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: backgroundMemCacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) =>
Container(color: colorScheme.surface),
@@ -294,80 +318,175 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
Container(color: colorScheme.surface),
)
else
Container(color: colorScheme.surface),
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(
color: colorScheme.surface.withValues(alpha: 0.4),
Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
size: 80,
color: colorScheme.onSurfaceVariant,
),
),
),
// Bottom gradient for readability
Positioned(
left: 0,
right: 0,
bottom: 0,
height: bottomGradientHeight,
height: expandedHeight * 0.65,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.surface.withValues(alpha: 0.0),
colorScheme.surface,
Colors.transparent,
Colors.black.withValues(alpha: 0.85),
],
),
),
),
),
AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: showContent ? 1.0 : 0.0,
child: Center(
child: Padding(
padding: EdgeInsets.only(top: coverTopPadding),
child: Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.4),
blurRadius: 30,
offset: const Offset(0, 15),
),
],
// Album info overlay at bottom
Positioned(
left: 20,
right: 20,
bottom: 40,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: showContent ? 1.0 : 0.0,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.albumName,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
height: 1.2,
),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: widget.coverUrl != null
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(),
cacheManager: CoverCacheManager.instance,
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
if (artistName != null && artistName.isNotEmpty) ...[
const SizedBox(height: 6),
ClickableArtistName(
artistName: artistName,
artistId: _artistId,
coverUrl: widget.coverUrl,
extensionId: widget.extensionId,
style: TextStyle(
color: colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
maxLines: 1,
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,
),
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),
FilledButton.icon(
onPressed: () => _downloadAll(context),
icon: Icon(Icons.download, size: 18),
label: Text(
context.l10n.downloadAllCount(tracks.length),
),
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
),
const SizedBox(width: 12),
_buildAddToPlaylistButton(context),
],
),
],
],
),
),
),
],
),
stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
],
stretchModes: const [StretchMode.zoomBackground],
);
},
),
@@ -375,10 +494,10 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.8),
color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
child: const Icon(Icons.arrow_back, color: Colors.white),
),
onPressed: () => Navigator.pop(context),
),
@@ -386,151 +505,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
final tracks = _tracks ?? [];
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.albumName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
if (artistName != null && artistName.isNotEmpty) ...[
const SizedBox(height: 4),
GestureDetector(
onTap: () => _navigateToArtist(context, artistName),
child: Text(
artistName,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.primary,
),
),
),
],
const SizedBox(height: 12),
if (tracks.isNotEmpty)
Wrap(
spacing: 8,
runSpacing: 8,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.music_note,
size: 14,
color: colorScheme.onSecondaryContainer,
),
const SizedBox(width: 4),
Text(
context.l10n.tracksCount(tracks.length),
style: TextStyle(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
if (releaseDate != null && releaseDate.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.calendar_today,
size: 14,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 4),
Text(
_formatReleaseDate(releaseDate),
style: TextStyle(
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
],
),
if (tracks.isNotEmpty) ...[
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () => _downloadAll(context),
icon: const Icon(Icons.download, size: 18),
label: Text(context.l10n.downloadAllCount(tracks.length)),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
),
],
],
),
),
),
),
);
}
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
child: Row(
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text(
context.l10n.tracksHeader,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
],
),
),
);
// Info is now displayed in the full-screen cover overlay
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
Widget _buildTrackList(
@@ -615,47 +591,94 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
}
void _navigateToArtist(BuildContext context, String artistName) {
final artistId =
_artistId ??
(widget.albumId.startsWith('deezer:') ? 'deezer:unknown' : 'unknown');
Widget _buildLoveAllButton() {
final collectionsState = ref.watch(libraryCollectionsProvider);
final tracks = _tracks;
final allLoved =
tracks != null &&
tracks.isNotEmpty &&
tracks.every((t) => collectionsState.isLoved(t));
if (artistId == 'unknown' ||
artistId == 'deezer:unknown' ||
artistId.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Artist information not available')),
);
return;
}
if (widget.extensionId != null) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ExtensionArtistScreen(
extensionId: widget.extensionId!,
artistId: artistId,
artistName: artistName,
coverUrl: widget.coverUrl,
),
),
);
return;
}
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ArtistScreen(
artistId: artistId,
artistName: artistName,
coverUrl: widget.coverUrl,
return Container(
width: 48,
height: 48,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.15),
border: Border.all(
color: Colors.white.withValues(alpha: 0.3),
width: 1,
),
),
child: IconButton(
onPressed: tracks == null || tracks.isEmpty
? null
: () => _loveAll(tracks),
icon: Icon(
allLoved ? Icons.favorite : Icons.favorite_border,
size: 22,
color: allLoved ? Colors.redAccent : Colors.white,
),
tooltip: allLoved ? 'Remove from Loved' : 'Love All',
padding: EdgeInsets.zero,
),
);
}
Widget _buildAddToPlaylistButton(BuildContext context) {
return Container(
width: 48,
height: 48,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.15),
border: Border.all(
color: Colors.white.withValues(alpha: 0.3),
width: 1,
),
),
child: IconButton(
onPressed: _tracks == null || _tracks!.isEmpty
? null
: () => showAddTracksToPlaylistSheet(context, ref, _tracks!),
icon: const Icon(Icons.add, size: 22, color: Colors.white),
tooltip: 'Add to Playlist',
padding: EdgeInsets.zero,
),
);
}
Future<void> _loveAll(List<Track> tracks) async {
final notifier = ref.read(libraryCollectionsProvider.notifier);
final state = ref.read(libraryCollectionsProvider);
final allLoved = tracks.every((t) => state.isLoved(t));
if (allLoved) {
for (final track in tracks) {
final key = trackCollectionKey(track);
await notifier.removeFromLoved(key);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Removed ${tracks.length} tracks from Loved')),
);
}
} else {
int addedCount = 0;
for (final track in tracks) {
if (!state.isLoved(track)) {
await notifier.toggleLoved(track);
addedCount++;
}
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Added $addedCount tracks to Loved')),
);
}
}
}
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
final isRateLimit =
error.contains('429') ||
@@ -739,7 +762,12 @@ class _AlbumTrackItem extends ConsumerWidget {
final isInHistory = ref.watch(
downloadHistoryProvider.select((state) {
return state.isDownloaded(track.id);
if (state.isDownloaded(track.id)) return true;
final isrc = track.isrc?.trim();
if (isrc != null && isrc.isNotEmpty && state.getByIsrc(isrc) != null) {
return true;
}
return state.findByTrackAndArtist(track.name, track.artistName) != null;
}),
);
@@ -761,13 +789,6 @@ class _AlbumTrackItem extends ConsumerWidget {
: false;
final isQueued = queueItem != null;
final isDownloading = queueItem?.status == DownloadStatus.downloading;
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
final isCompleted = queueItem?.status == DownloadStatus.completed;
final progress = queueItem?.progress ?? 0.0;
final showAsDownloaded =
isCompleted || (!isQueued && isInHistory) || isInLocalLibrary;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
@@ -802,14 +823,16 @@ class _AlbumTrackItem extends ConsumerWidget {
subtitle: Row(
children: [
Flexible(
child: Text(
track.artistName,
child: ClickableArtistName(
artistName: track.artistName,
artistId: track.artistId,
coverUrl: track.coverUrl,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
if (isInLocalLibrary) ...[
if (isInLocalLibrary || isInHistory) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
@@ -843,24 +866,12 @@ class _AlbumTrackItem extends ConsumerWidget {
],
],
),
trailing: _buildDownloadButton(
trailing: TrackCollectionQuickActions(track: track),
onTap: () => _handleTap(context, ref, isQueued: isQueued),
onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet(
context,
ref,
colorScheme,
isQueued: isQueued,
isDownloading: isDownloading,
isFinalizing: isFinalizing,
showAsDownloaded: showAsDownloaded,
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
progress: progress,
),
onTap: () => _handleTap(
context,
ref,
isQueued: isQueued,
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
track,
),
),
),
@@ -871,160 +882,84 @@ class _AlbumTrackItem extends ConsumerWidget {
BuildContext context,
WidgetRef ref, {
required bool isQueued,
required bool isInHistory,
required bool isInLocalLibrary,
}) async {
if (isQueued) return;
if (isInLocalLibrary) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)),
),
);
}
final playedLocal = await _playLocalIfAvailable(context, ref);
if (playedLocal) {
return;
}
if (isInHistory) {
final historyItem = ref
.read(downloadHistoryProvider.notifier)
.getBySpotifyId(track.id);
if (historyItem != null) {
final exists = await fileExists(historyItem.filePath);
if (exists) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAlreadyDownloaded(track.name),
),
),
);
}
return;
} else {
ref
.read(downloadHistoryProvider.notifier)
.removeBySpotifyId(track.id);
}
}
}
onDownload();
}
Widget _buildDownloadButton(
Future<bool> _playLocalIfAvailable(
BuildContext context,
WidgetRef ref,
ColorScheme colorScheme, {
required bool isQueued,
required bool isDownloading,
required bool isFinalizing,
required bool showAsDownloaded,
required bool isInHistory,
required bool isInLocalLibrary,
required double progress,
}) {
const double size = 44.0;
const double iconSize = 20.0;
) async {
final localState = ref.read(localLibraryProvider);
final historyState = ref.read(downloadHistoryProvider);
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
if (showAsDownloaded) {
return GestureDetector(
onTap: () => _handleTap(
context,
ref,
isQueued: isQueued,
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.check,
color: colorScheme.onPrimaryContainer,
size: iconSize,
),
),
try {
DownloadHistoryItem? historyItem = historyNotifier.getBySpotifyId(
track.id,
);
} else if (isFinalizing) {
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
strokeWidth: 3,
color: colorScheme.tertiary,
backgroundColor: colorScheme.surfaceContainerHighest,
),
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
],
),
final isrc = track.isrc?.trim();
historyItem ??= (isrc != null && isrc.isNotEmpty)
? historyNotifier.getByIsrc(isrc)
: null;
historyItem ??= historyState.findByTrackAndArtist(
track.name,
track.artistName,
);
} else if (isDownloading) {
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
value: progress > 0 ? progress : null,
strokeWidth: 3,
color: colorScheme.primary,
backgroundColor: colorScheme.surfaceContainerHighest,
),
if (progress > 0)
Text(
'${(progress * 100).toInt()}',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
),
],
),
);
} else if (isQueued) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Icon(
Icons.hourglass_empty,
color: colorScheme.onSurfaceVariant,
size: iconSize,
),
);
} else {
return GestureDetector(
onTap: onDownload,
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.download,
color: colorScheme.onSecondaryContainer,
size: iconSize,
),
),
if (historyItem != null) {
final exists = await fileExists(historyItem.filePath);
if (exists) {
await ref
.read(playbackProvider.notifier)
.playLocalPath(
path: historyItem.filePath,
title: track.name,
artist: track.artistName,
album: track.albumName,
coverUrl: track.coverUrl ?? '',
);
return true;
}
historyNotifier.removeFromHistory(historyItem.id);
}
var localItem = (isrc != null && isrc.isNotEmpty)
? localState.getByIsrc(isrc)
: null;
localItem ??= localState.findByTrackAndArtist(
track.name,
track.artistName,
);
if (localItem != null && await fileExists(localItem.filePath)) {
await ref
.read(playbackProvider.notifier)
.playLocalPath(
path: localItem.filePath,
title: localItem.trackName,
artist: localItem.artistName,
album: localItem.albumName,
coverUrl: localItem.coverPath ?? track.coverUrl ?? '',
);
return true;
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('$e'))),
);
}
return true;
}
return false;
}
}
+199 -223
View File
@@ -6,18 +6,20 @@ import 'package:intl/intl.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/recent_access_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/home_tab.dart'
show ExtensionAlbumScreen;
import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
/// Simple in-memory cache for artist data
class _ArtistCache {
@@ -309,6 +311,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
albumArtist: data['album_artist']?.toString(),
artistId:
(data['artist_id'] ?? data['artistId'])?.toString() ??
widget.artistId,
albumId: data['album_id']?.toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
@@ -675,6 +681,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
@@ -772,7 +779,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
List<ArtistAlbum> albums,
) async {
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
@@ -990,6 +996,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
.toString(),
albumName: album.name,
albumArtist: widget.artistName,
artistId: widget.artistId,
albumId: album.id.isNotEmpty ? album.id : null,
coverUrl: album.coverUrl,
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
@@ -1100,63 +1108,72 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
left: 16,
right: 16,
bottom: 16,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
widget.artistName,
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: [
Shadow(
offset: const Offset(0, 1),
blurRadius: 4,
color: Colors.black.withValues(alpha: 0.5),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.artistName,
style: Theme.of(context).textTheme.headlineLarge
?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: [
Shadow(
offset: const Offset(0, 1),
blurRadius: 4,
color: Colors.black.withValues(alpha: 0.5),
),
],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (listenersText != null) ...[
const SizedBox(height: 4),
Text(
listenersText,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.white.withValues(alpha: 0.8),
shadows: [
Shadow(
offset: const Offset(0, 1),
blurRadius: 2,
color: Colors.black.withValues(alpha: 0.5),
if (listenersText != null) ...[
const SizedBox(height: 4),
Text(
listenersText,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: Colors.white.withValues(alpha: 0.8),
shadows: [
Shadow(
offset: const Offset(0, 1),
blurRadius: 2,
color: Colors.black.withValues(
alpha: 0.5,
),
),
],
),
),
],
),
],
),
],
// Download Discography button
),
// Download Discography button (icon only, right-aligned)
if (hasDiscography && !_isSelectionMode) ...[
const SizedBox(height: 12),
SizedBox(
height: 40,
child: FilledButton.icon(
const SizedBox(width: 12),
Container(
width: 52,
height: 52,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: IconButton(
onPressed: () => _showDiscographyOptions(
context,
colorScheme,
albums,
),
icon: const Icon(Icons.download, size: 18),
label: Text(context.l10n.discographyDownload),
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
padding: const EdgeInsets.symmetric(horizontal: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
icon: const Icon(Icons.download_rounded, size: 26),
color: Colors.black87,
tooltip: context.l10n.discographyDownload,
),
),
],
@@ -1227,9 +1244,17 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
);
final isInHistory = ref.watch(
downloadHistoryProvider.select(
(state) => state.isDownloaded(track.id),
),
downloadHistoryProvider.select((state) {
if (state.isDownloaded(track.id)) return true;
final isrc = track.isrc?.trim();
if (isrc != null &&
isrc.isNotEmpty &&
state.getByIsrc(isrc) != null) {
return true;
}
return state.findByTrackAndArtist(track.name, track.artistName) !=
null;
}),
);
final showLocalLibraryIndicator = ref.watch(
@@ -1250,20 +1275,13 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
: false;
final isQueued = queueItem != null;
final isDownloading = queueItem?.status == DownloadStatus.downloading;
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
final isCompleted = queueItem?.status == DownloadStatus.completed;
final progress = queueItem?.progress ?? 0.0;
final showAsDownloaded =
isCompleted || (!isQueued && isInHistory) || isInLocalLibrary;
return InkWell(
onTap: () => _handlePopularTrackTap(
onTap: () => _handlePopularTrackTap(track, isQueued: isQueued),
onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet(
context,
ref,
track,
isQueued: isQueued,
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -1330,28 +1348,66 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (track.albumName.isNotEmpty)
Text(
track.albumName,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
if (track.albumName.isNotEmpty ||
isInLocalLibrary ||
isInHistory)
Row(
children: [
if (track.albumName.isNotEmpty)
Expanded(
child: ClickableAlbumName(
albumName: track.albumName,
albumId: track.albumId,
artistName: track.artistName,
coverUrl: track.coverUrl,
extensionId: widget.extensionId,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (isInLocalLibrary || isInHistory) ...[
if (track.albumName.isNotEmpty)
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.folder_outlined,
size: 10,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 3),
Text(
context.l10n.libraryInLibrary,
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w500,
color: colorScheme.onTertiaryContainer,
),
),
],
),
),
],
],
),
],
),
),
_buildPopularDownloadButton(
track: track,
colorScheme: colorScheme,
isQueued: isQueued,
isDownloading: isDownloading,
isFinalizing: isFinalizing,
showAsDownloaded: showAsDownloaded,
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
progress: progress,
),
TrackCollectionQuickActions(track: track),
],
),
),
@@ -1361,162 +1417,82 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
}
/// Handle tap on popular track item
void _handlePopularTrackTap(
Track track, {
required bool isQueued,
required bool isInHistory,
required bool isInLocalLibrary,
}) async {
void _handlePopularTrackTap(Track track, {required bool isQueued}) async {
if (isQueued) return;
if (isInLocalLibrary) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)),
),
);
}
final playedLocal = await _playLocalIfAvailable(track);
if (playedLocal) {
return;
}
if (isInHistory) {
final historyItem = ref
.read(downloadHistoryProvider.notifier)
.getBySpotifyId(track.id);
if (historyItem != null) {
final exists = await fileExists(historyItem.filePath);
if (exists) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAlreadyDownloaded(track.name),
),
),
);
}
return;
} else {
ref
.read(downloadHistoryProvider.notifier)
.removeBySpotifyId(track.id);
}
}
}
_downloadTrack(track);
}
Widget _buildPopularDownloadButton({
required Track track,
required ColorScheme colorScheme,
required bool isQueued,
required bool isDownloading,
required bool isFinalizing,
required bool showAsDownloaded,
required bool isInHistory,
required bool isInLocalLibrary,
required double progress,
}) {
const double size = 40.0;
const double iconSize = 20.0;
Future<bool> _playLocalIfAvailable(Track track) async {
final localState = ref.read(localLibraryProvider);
final historyState = ref.read(downloadHistoryProvider);
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
if (showAsDownloaded) {
return GestureDetector(
onTap: () => _handlePopularTrackTap(
track,
isQueued: isQueued,
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.check,
color: colorScheme.onPrimaryContainer,
size: iconSize,
),
),
try {
DownloadHistoryItem? historyItem = historyNotifier.getBySpotifyId(
track.id,
);
} else if (isFinalizing) {
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
strokeWidth: 2.5,
color: colorScheme.tertiary,
backgroundColor: colorScheme.surfaceContainerHighest,
),
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 14),
],
),
final isrc = track.isrc?.trim();
historyItem ??= (isrc != null && isrc.isNotEmpty)
? historyNotifier.getByIsrc(isrc)
: null;
historyItem ??= historyState.findByTrackAndArtist(
track.name,
track.artistName,
);
} else if (isDownloading) {
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
value: progress > 0 ? progress : null,
strokeWidth: 2.5,
color: colorScheme.primary,
backgroundColor: colorScheme.surfaceContainerHighest,
),
if (progress > 0)
Text(
'${(progress * 100).toInt()}',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
),
],
),
);
} else if (isQueued) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Icon(
Icons.hourglass_empty,
color: colorScheme.onSurfaceVariant,
size: iconSize,
),
);
} else {
return GestureDetector(
onTap: () => _downloadTrack(track),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.download,
color: colorScheme.onSecondaryContainer,
size: iconSize,
),
),
if (historyItem != null) {
final exists = await fileExists(historyItem.filePath);
if (exists) {
await ref
.read(playbackProvider.notifier)
.playLocalPath(
path: historyItem.filePath,
title: track.name,
artist: track.artistName,
album: track.albumName,
coverUrl: track.coverUrl ?? '',
);
return true;
}
historyNotifier.removeFromHistory(historyItem.id);
}
var localItem = (isrc != null && isrc.isNotEmpty)
? localState.getByIsrc(isrc)
: null;
localItem ??= localState.findByTrackAndArtist(
track.name,
track.artistName,
);
if (localItem != null && await fileExists(localItem.filePath)) {
await ref
.read(playbackProvider.notifier)
.playLocalPath(
path: localItem.filePath,
title: localItem.trackName,
artist: localItem.artistName,
album: localItem.albumName,
coverUrl: localItem.coverPath ?? track.coverUrl ?? '',
);
return true;
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('$e'))),
);
}
return true;
}
return false;
}
void _downloadTrack(Track track) {
File diff suppressed because it is too large Load Diff
+594 -526
View File
File diff suppressed because it is too large Load Diff
+585
View File
@@ -0,0 +1,585 @@
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
class LibraryPlaylistsScreen extends ConsumerWidget {
const LibraryPlaylistsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final playlists = ref.watch(
libraryCollectionsProvider.select((state) => state.playlists),
);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = normalizedHeaderTopPadding(context);
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
context.l10n.collectionPlaylists,
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
if (playlists.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.playlist_play,
size: 60,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 12),
Text(
context.l10n.collectionNoPlaylistsYet,
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
context.l10n.collectionNoPlaylistsSubtitle,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
)
else
SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
// Even indices = playlist tiles, odd indices = dividers
if (index.isOdd) {
return const Divider(height: 1);
}
final playlistIndex = index ~/ 2;
final playlist = playlists[playlistIndex];
return ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 2,
),
leading: _buildPlaylistThumbnail(context, playlist),
title: Text(playlist.name),
subtitle: Text(
context.l10n.collectionPlaylistTracks(
playlist.tracks.length,
),
),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => LibraryTracksFolderScreen(
mode: LibraryTracksFolderMode.playlist,
playlistId: playlist.id,
),
),
);
},
onLongPress: () =>
_showPlaylistOptionsSheet(context, ref, playlist),
);
}, childCount: playlists.length * 2 - 1),
),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _showCreatePlaylistDialog(context, ref),
icon: const Icon(Icons.add),
label: Text(context.l10n.collectionCreatePlaylist),
),
);
}
void _showPlaylistOptionsSheet(
BuildContext context,
WidgetRef ref,
UserPlaylistCollection playlist,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header: drag handle + thumbnail + playlist info
Column(
children: [
const SizedBox(height: 8),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Row(
children: [
_buildPlaylistThumbnail(context, playlist),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
playlist.name,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w600),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
context.l10n.collectionPlaylistTracks(
playlist.tracks.length,
),
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
],
),
Divider(
height: 1,
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
// Rename
_PlaylistOptionTile(
icon: Icons.edit_outlined,
title: context.l10n.collectionRenamePlaylist,
onTap: () {
Navigator.pop(sheetContext);
_showRenamePlaylistDialog(
context,
ref,
playlist.id,
playlist.name,
);
},
),
// Change cover
_PlaylistOptionTile(
icon: Icons.image_outlined,
title: context.l10n.collectionPlaylistChangeCover,
onTap: () {
Navigator.pop(sheetContext);
_pickCoverImage(context, ref, playlist.id);
},
),
// Delete
_PlaylistOptionTile(
icon: Icons.delete_outline,
iconColor: colorScheme.error,
title: context.l10n.collectionDeletePlaylist,
onTap: () {
Navigator.pop(sheetContext);
_confirmDeletePlaylist(
context,
ref,
playlist.id,
playlist.name,
);
},
),
const SizedBox(height: 16),
],
),
),
);
}
Widget _buildPlaylistThumbnail(
BuildContext context,
UserPlaylistCollection playlist,
) {
final colorScheme = Theme.of(context).colorScheme;
const double size = 48;
final borderRadius = BorderRadius.circular(8);
final dpr = MediaQuery.devicePixelRatioOf(context);
final cacheWidth = (size * dpr).round().clamp(64, 512);
final placeholder = _playlistIconFallback(colorScheme, size);
// Priority: custom cover > first track cover URL > icon fallback
final customCoverPath = playlist.coverImagePath;
if (customCoverPath != null && customCoverPath.isNotEmpty) {
return ClipRRect(
borderRadius: borderRadius,
child: Image.file(
File(customCoverPath),
width: size,
height: size,
fit: BoxFit.cover,
cacheWidth: cacheWidth,
gaplessPlayback: true,
filterQuality: FilterQuality.low,
frameBuilder: (_, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) return child;
return placeholder;
},
errorBuilder: (_, _, _) => placeholder,
),
);
}
String? firstCoverUrl;
for (final entry in playlist.tracks) {
final coverUrl = entry.track.coverUrl;
if (coverUrl != null && coverUrl.isNotEmpty) {
firstCoverUrl = coverUrl;
break;
}
}
if (firstCoverUrl != null) {
final isLocalPath =
!firstCoverUrl.startsWith('http://') &&
!firstCoverUrl.startsWith('https://');
if (isLocalPath) {
return ClipRRect(
borderRadius: borderRadius,
child: Image.file(
File(firstCoverUrl),
width: size,
height: size,
fit: BoxFit.cover,
cacheWidth: cacheWidth,
gaplessPlayback: true,
filterQuality: FilterQuality.low,
frameBuilder: (_, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) return child;
return placeholder;
},
errorBuilder: (_, _, _) => placeholder,
),
);
}
return ClipRRect(
borderRadius: borderRadius,
child: CachedNetworkImage(
imageUrl: firstCoverUrl,
width: size,
height: size,
fit: BoxFit.cover,
memCacheWidth: cacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => placeholder,
errorWidget: (_, _, _) => placeholder,
),
);
}
return placeholder;
}
Widget _playlistIconFallback(ColorScheme colorScheme, double size) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.queue_music, color: colorScheme.onSurfaceVariant),
);
}
Future<void> _pickCoverImage(
BuildContext context,
WidgetRef ref,
String playlistId,
) async {
final result = await FilePicker.platform.pickFiles(
type: FileType.image,
allowMultiple: false,
);
if (result == null || result.files.isEmpty) return;
final path = result.files.first.path;
if (path == null || path.isEmpty) return;
await ref
.read(libraryCollectionsProvider.notifier)
.setPlaylistCover(playlistId, path);
}
Future<void> _showCreatePlaylistDialog(
BuildContext context,
WidgetRef ref,
) async {
final controller = TextEditingController();
final formKey = GlobalKey<FormState>();
final playlistName = await showDialog<String>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text(dialogContext.l10n.collectionCreatePlaylist),
content: Form(
key: formKey,
child: TextFormField(
controller: controller,
autofocus: true,
decoration: InputDecoration(
hintText: dialogContext.l10n.collectionPlaylistNameHint,
),
validator: (value) {
final trimmed = value?.trim() ?? '';
if (trimmed.isEmpty) {
return dialogContext.l10n.collectionPlaylistNameRequired;
}
return null;
},
onFieldSubmitted: (_) {
if (formKey.currentState?.validate() != true) return;
Navigator.of(dialogContext).pop(controller.text.trim());
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(dialogContext.l10n.dialogCancel),
),
FilledButton(
onPressed: () {
if (formKey.currentState?.validate() != true) return;
Navigator.of(dialogContext).pop(controller.text.trim());
},
child: Text(dialogContext.l10n.actionCreate),
),
],
);
},
);
if (playlistName == null ||
playlistName.trim().isEmpty ||
!context.mounted) {
return;
}
await ref
.read(libraryCollectionsProvider.notifier)
.createPlaylist(playlistName.trim());
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.collectionPlaylistCreated)),
);
}
Future<void> _showRenamePlaylistDialog(
BuildContext context,
WidgetRef ref,
String playlistId,
String currentName,
) async {
final controller = TextEditingController(text: currentName);
final formKey = GlobalKey<FormState>();
final nextName = await showDialog<String>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text(dialogContext.l10n.collectionRenamePlaylist),
content: Form(
key: formKey,
child: TextFormField(
controller: controller,
autofocus: true,
decoration: InputDecoration(
hintText: dialogContext.l10n.collectionPlaylistNameHint,
),
validator: (value) {
final trimmed = value?.trim() ?? '';
if (trimmed.isEmpty) {
return dialogContext.l10n.collectionPlaylistNameRequired;
}
return null;
},
onFieldSubmitted: (_) {
if (formKey.currentState?.validate() != true) return;
Navigator.of(dialogContext).pop(controller.text.trim());
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(dialogContext.l10n.dialogCancel),
),
FilledButton(
onPressed: () {
if (formKey.currentState?.validate() != true) return;
Navigator.of(dialogContext).pop(controller.text.trim());
},
child: Text(dialogContext.l10n.dialogSave),
),
],
);
},
);
if (nextName == null || nextName.trim().isEmpty || !context.mounted) {
return;
}
await ref
.read(libraryCollectionsProvider.notifier)
.renamePlaylist(playlistId, nextName.trim());
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.collectionPlaylistRenamed)),
);
}
Future<void> _confirmDeletePlaylist(
BuildContext context,
WidgetRef ref,
String playlistId,
String playlistName,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text(dialogContext.l10n.collectionDeletePlaylist),
content: Text(
dialogContext.l10n.collectionDeletePlaylistMessage(playlistName),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(dialogContext.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(dialogContext.l10n.dialogDelete),
),
],
);
},
);
if (confirmed != true || !context.mounted) return;
await ref
.read(libraryCollectionsProvider.notifier)
.deletePlaylist(playlistId);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.collectionPlaylistDeleted)),
);
}
}
/// Styled like _OptionTile in track_collection_quick_actions.dart
class _PlaylistOptionTile extends StatelessWidget {
final IconData icon;
final Color? iconColor;
final String title;
final VoidCallback onTap;
const _PlaylistOptionTile({
required this.icon,
this.iconColor,
required this.title,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
icon,
color: iconColor ?? colorScheme.onPrimaryContainer,
size: 20,
),
),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
onTap: onTap,
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+172 -50
View File
@@ -16,6 +16,7 @@ import 'package:spotiflac_android/screens/store_tab.dart';
import 'package:spotiflac_android/screens/queue_tab.dart';
import 'package:spotiflac_android/screens/settings/settings_tab.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/shell_navigation_service.dart';
import 'package:spotiflac_android/services/share_intent_service.dart';
import 'package:spotiflac_android/services/update_checker.dart';
import 'package:spotiflac_android/widgets/update_dialog.dart';
@@ -36,11 +37,21 @@ class _MainShellState extends ConsumerState<MainShell> {
bool _hasCheckedUpdate = false;
StreamSubscription<String>? _shareSubscription;
DateTime? _lastBackPress;
final GlobalKey<NavigatorState> _homeTabNavigatorKey =
ShellNavigationService.homeTabNavigatorKey;
final GlobalKey<NavigatorState> _libraryTabNavigatorKey =
ShellNavigationService.libraryTabNavigatorKey;
final GlobalKey<NavigatorState> _storeTabNavigatorKey =
ShellNavigationService.storeTabNavigatorKey;
@override
void initState() {
super.initState();
_pageController = PageController(initialPage: _currentIndex);
ShellNavigationService.syncState(
currentTabIndex: _currentIndex,
showStoreTab: false,
);
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkForUpdates();
_setupShareListener();
@@ -86,6 +97,7 @@ class _MainShellState extends ConsumerState<MainShell> {
if (!mounted) return;
Navigator.of(context).popUntil((route) => route.isFirst);
_homeTabNavigatorKey.currentState?.popUntil((route) => route.isFirst);
if (_currentIndex != 0) {
_onNavTap(0);
@@ -213,10 +225,34 @@ class _MainShellState extends ConsumerState<MainShell> {
super.dispose();
}
void _resetHomeToMain() {
final showStore = ref.read(
settingsProvider.select((s) => s.showExtensionStore),
);
final homeNavigator = _navigatorForTab(0, showStore);
homeNavigator?.popUntil((route) => route.isFirst);
// Unfocus BEFORE clear so _onTrackStateChanged can properly
// clear _urlController (it checks !_searchFocusNode.hasFocus)
FocusManager.instance.primaryFocus?.unfocus();
ref.read(trackProvider.notifier).clear();
}
void _onNavTap(int index) {
if (index == 0 && _currentIndex == 0) {
_resetHomeToMain();
return;
}
if (_currentIndex != index) {
HapticFeedback.selectionClick();
setState(() => _currentIndex = index);
final showStore = ref.read(
settingsProvider.select((s) => s.showExtensionStore),
);
ShellNavigationService.syncState(
currentTabIndex: _currentIndex,
showStoreTab: showStore,
);
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 250),
@@ -226,48 +262,121 @@ class _MainShellState extends ConsumerState<MainShell> {
}
void _onPageChanged(int index) {
final previousIndex = _currentIndex;
if (_currentIndex != index) {
setState(() => _currentIndex = index);
final showStore = ref.read(
settingsProvider.select((s) => s.showExtensionStore),
);
ShellNavigationService.syncState(
currentTabIndex: _currentIndex,
showStoreTab: showStore,
);
FocusManager.instance.primaryFocus?.unfocus();
if (index == 0 && previousIndex != 0) {
_resetHomeToMain();
}
}
}
void _handleBackPress() {
final rootNavigator = Navigator.of(context, rootNavigator: true);
if (rootNavigator.canPop()) {
_log.i('Back: step 1 - root navigator pop');
rootNavigator.pop();
_lastBackPress = null;
return;
}
final showStore = ref.read(
settingsProvider.select((s) => s.showExtensionStore),
);
final currentNavigator = _navigatorForTab(_currentIndex, showStore);
if (currentNavigator != null && currentNavigator.canPop()) {
_log.i('Back: step 2 - tab navigator pop (tab=$_currentIndex)');
currentNavigator.pop();
_lastBackPress = null;
return;
}
final trackState = ref.read(trackProvider);
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
if (isKeyboardVisible) {
_log.d(
'Back: state check - tab=$_currentIndex, '
'isShowingRecentAccess=${trackState.isShowingRecentAccess}, '
'hasSearchText=${trackState.hasSearchText}, '
'hasContent=${trackState.hasContent}, '
'isLoading=${trackState.isLoading}, '
'isKeyboardVisible=$isKeyboardVisible',
);
if (_currentIndex == 0 &&
trackState.isShowingRecentAccess &&
!trackState.isLoading &&
(trackState.hasSearchText || trackState.hasContent)) {
// Has recent access AND search content clear everything at once
_log.i(
'Back: step 3a - dismiss recent access + clear search/content '
'(hasSearchText=${trackState.hasSearchText}, hasContent=${trackState.hasContent})',
);
FocusManager.instance.primaryFocus?.unfocus();
ref.read(trackProvider.notifier).clear();
_lastBackPress = null;
return;
}
if (_currentIndex == 0 && trackState.isShowingRecentAccess) {
// Recent access overlay only (no search content) just dismiss it
_log.i('Back: step 3b - dismiss recent access only');
ref.read(trackProvider.notifier).setShowingRecentAccess(false);
FocusManager.instance.primaryFocus?.unfocus();
_lastBackPress = null;
return;
}
if (_currentIndex == 0 &&
!trackState.isLoading &&
(trackState.hasSearchText || trackState.hasContent)) {
_log.i(
'Back: step 4 - clear search/content '
'(hasSearchText=${trackState.hasSearchText}, hasContent=${trackState.hasContent})',
);
// Unfocus BEFORE clear so _onTrackStateChanged can properly
// clear _urlController (it checks !_searchFocusNode.hasFocus)
FocusManager.instance.primaryFocus?.unfocus();
ref.read(trackProvider.notifier).clear();
_lastBackPress = null;
return;
}
if (_currentIndex == 0 && isKeyboardVisible) {
_log.i('Back: step 5 - dismiss keyboard');
FocusManager.instance.primaryFocus?.unfocus();
_lastBackPress = null;
return;
}
if (_currentIndex != 0) {
_log.i('Back: step 6 - switch to home tab from tab=$_currentIndex');
_onNavTap(0);
_lastBackPress = null;
return;
}
if (trackState.isLoading) {
_log.i('Back: blocked - loading in progress');
return;
}
final now = DateTime.now();
if (_lastBackPress != null &&
now.difference(_lastBackPress!) < const Duration(seconds: 2)) {
SystemNavigator.pop();
_log.i('Back: step 8 - double-tap exit');
unawaited(PlatformBridge.exitApp());
} else {
_log.i('Back: step 7 - first tap, showing exit snackbar');
_lastBackPress = now;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -279,46 +388,46 @@ class _MainShellState extends ConsumerState<MainShell> {
}
}
NavigatorState? _navigatorForTab(int index, bool showStore) {
if (index == 0) return _homeTabNavigatorKey.currentState;
if (index == 1) return _libraryTabNavigatorKey.currentState;
if (showStore && index == 2) return _storeTabNavigatorKey.currentState;
return null;
}
@override
Widget build(BuildContext context) {
final queueState = ref.watch(
downloadQueueProvider.select((s) => s.queuedCount),
);
final trackHasSearchText = ref.watch(
trackProvider.select((s) => s.hasSearchText),
);
final trackHasContent = ref.watch(
trackProvider.select((s) => s.hasContent),
);
final trackIsLoading = ref.watch(trackProvider.select((s) => s.isLoading));
final trackIsShowingRecentAccess = ref.watch(
trackProvider.select((s) => s.isShowingRecentAccess),
);
final showStore = ref.watch(
settingsProvider.select((s) => s.showExtensionStore),
);
ShellNavigationService.syncState(
currentTabIndex: _currentIndex,
showStoreTab: showStore,
);
final storeUpdatesCount = ref.watch(
storeProvider.select((s) => s.updatesAvailableCount),
);
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
final canPop =
_currentIndex == 0 &&
!trackHasSearchText &&
!trackHasContent &&
!trackIsLoading &&
!trackIsShowingRecentAccess &&
!isKeyboardVisible;
final tabs = <Widget>[
const HomeTab(),
QueueTab(
parentPageController: _pageController,
parentPageIndex: 1,
nextPageIndex: showStore ? 2 : 3,
_TabNavigator(
key: const ValueKey('tab-home'),
navigatorKey: _homeTabNavigatorKey,
child: const HomeTab(),
),
if (showStore) const StoreTab(),
_TabNavigator(
key: const ValueKey('tab-library'),
navigatorKey: _libraryTabNavigatorKey,
child: _LibraryTabRoot(parentPageController: _pageController),
),
if (showStore)
_TabNavigator(
key: const ValueKey('tab-store'),
navigatorKey: _storeTabNavigatorKey,
child: const StoreTab(),
),
const SettingsTab(),
];
@@ -377,22 +486,16 @@ class _MainShellState extends ConsumerState<MainShell> {
});
}
return PopScope(
canPop: canPop,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) {
return;
}
return BackButtonListener(
onBackButtonPressed: () async {
_handleBackPress();
return true;
},
child: Scaffold(
body: PageView(
controller: _pageController,
onPageChanged: _onPageChanged,
physics: (_currentIndex == 0 && trackIsShowingRecentAccess)
? const _NoSwipeRightPhysics()
: const ClampingScrollPhysics(),
physics: const NeverScrollableScrollPhysics(),
children: tabs,
),
bottomNavigationBar: NavigationBar(
@@ -415,23 +518,42 @@ class _MainShellState extends ConsumerState<MainShell> {
}
}
/// Custom physics that blocks swiping to the right (next page) while
/// still allowing vertical scrolling inside the page content.
class _NoSwipeRightPhysics extends ScrollPhysics {
const _NoSwipeRightPhysics({super.parent});
class _TabNavigator extends StatelessWidget {
final GlobalKey<NavigatorState> navigatorKey;
final Widget child;
const _TabNavigator({
super.key,
required this.navigatorKey,
required this.child,
});
@override
_NoSwipeRightPhysics applyTo(ScrollPhysics? ancestor) {
return _NoSwipeRightPhysics(parent: buildParent(ancestor));
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
onGenerateInitialRoutes: (_, _) => [
MaterialPageRoute<void>(builder: (_) => child),
],
);
}
}
class _LibraryTabRoot extends ConsumerWidget {
final PageController parentPageController;
const _LibraryTabRoot({required this.parentPageController});
@override
double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
// In a horizontal PageView, a negative offset means the user is
// dragging left (i.e. trying to go to the next page / right).
// Block that direction only.
if (offset < 0) return 0.0;
return super.applyPhysicsToUserOffset(position, offset);
Widget build(BuildContext context, WidgetRef ref) {
final showStore = ref.watch(
settingsProvider.select((s) => s.showExtensionStore),
);
return QueueTab(
parentPageController: parentPageController,
parentPageIndex: 1,
nextPageIndex: showStore ? 2 : 3,
);
}
}
+342 -342
View File
@@ -1,5 +1,3 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
@@ -7,12 +5,15 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
class PlaylistScreen extends ConsumerStatefulWidget {
final String playlistName;
@@ -110,6 +111,8 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
albumArtist: data['album_artist']?.toString(),
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
albumId: data['album_id']?.toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
@@ -120,12 +123,37 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
}
void _onScroll() {
final shouldShow = _scrollController.offset > 280;
final expandedHeight = _calculateExpandedHeight(context);
final shouldShow =
_scrollController.offset > (expandedHeight - kToolbarHeight - 20);
if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow);
}
}
double _calculateExpandedHeight(BuildContext context) {
final mediaSize = MediaQuery.of(context).size;
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
}
/// Upgrade cover URL to a reasonable resolution for full-screen display.
String? _highResCoverUrl(String? url) {
if (url == null) return null;
// Spotify CDN: upgrade 300 640 only
if (url.contains('ab67616d00001e02')) {
return url.replaceAll('ab67616d00001e02', 'ab67616d0000b273');
}
// Deezer CDN: upgrade to 1000x1000
final deezerRegex = RegExp(r'/(\d+)x(\d+)-(\d+)-(\d+)-(\d+)-(\d+)\.jpg$');
if (url.contains('cdn-images.dzcdn.net') && deezerRegex.hasMatch(url)) {
return url.replaceAllMapped(
deezerRegex,
(m) => '/1000x1000-${m[3]}-${m[4]}-${m[5]}-${m[6]}.jpg',
);
}
return url;
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
@@ -136,7 +164,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
slivers: [
_buildAppBar(context, colorScheme),
_buildInfoCard(context, colorScheme),
_buildTrackListHeader(context, colorScheme),
_buildTrackList(context, colorScheme),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
@@ -145,21 +172,13 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final mediaSize = MediaQuery.of(context).size;
final screenWidth = mediaSize.width;
final shortestSide = mediaSize.shortestSide;
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
final expandedHeight = _calculateExpandedHeight(context);
return SliverAppBar(
expandedHeight: expandedHeight,
pinned: true,
stretch: true,
backgroundColor:
colorScheme.surface, // Use theme color for collapsed state
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
title: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
@@ -181,25 +200,18 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3;
final dpr = MediaQuery.devicePixelRatioOf(
context,
).clamp(1.0, 3.0).toDouble();
final backgroundMemCacheWidth = (constraints.maxWidth * dpr)
.round()
.clamp(720, 1440)
.toInt();
return FlexibleSpaceBar(
collapseMode: CollapseMode.none,
collapseMode: CollapseMode.pin,
background: Stack(
fit: StackFit.expand,
children: [
// Blurred cover background
// Full-screen cover background
if (widget.coverUrl != null)
CachedNetworkImage(
imageUrl: widget.coverUrl!,
imageUrl:
_highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: backgroundMemCacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) =>
Container(color: colorScheme.surface),
@@ -207,81 +219,107 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
Container(color: colorScheme.surface),
)
else
Container(color: colorScheme.surface),
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(
color: colorScheme.surface.withValues(alpha: 0.4),
Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.playlist_play,
size: 80,
color: colorScheme.onSurfaceVariant,
),
),
),
// Bottom gradient for readability
Positioned(
left: 0,
right: 0,
bottom: 0,
height: bottomGradientHeight,
height: expandedHeight * 0.65,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.surface.withValues(alpha: 0.0),
colorScheme.surface,
Colors.transparent,
Colors.black.withValues(alpha: 0.85),
],
),
),
),
),
// Cover image centered - fade out when collapsing
AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: showContent ? 1.0 : 0.0,
child: Center(
child: Padding(
padding: EdgeInsets.only(top: coverTopPadding),
child: Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.4),
blurRadius: 30,
offset: const Offset(0, 15),
),
],
// Playlist info overlay at bottom
Positioned(
left: 20,
right: 20,
bottom: 40,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: showContent ? 1.0 : 0.0,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.playlistName,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
height: 1.2,
),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: widget.coverUrl != null
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(),
cacheManager: CoverCacheManager.instance,
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.playlist_play,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
if (_tracks.isNotEmpty) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.playlist_play,
size: 14,
color: Colors.white,
),
const SizedBox(width: 4),
Text(
context.l10n.tracksCount(_tracks.length),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
),
],
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildLoveAllButton(),
const SizedBox(width: 12),
_buildDownloadAllCenterButton(context),
const SizedBox(width: 12),
_buildAddToPlaylistButton(context),
],
),
],
],
),
),
),
],
),
stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
],
stretchModes: const [StretchMode.zoomBackground],
);
},
),
@@ -289,10 +327,10 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.8),
color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
child: const Icon(Icons.arrow_back, color: Colors.white),
),
onPressed: () => Navigator.pop(context),
),
@@ -300,98 +338,8 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.playlistName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.playlist_play,
size: 14,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 4),
Text(
context.l10n.tracksCount(_tracks.length),
style: TextStyle(
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _tracks.isEmpty
? null
: () => _downloadAll(context),
icon: const Icon(Icons.download, size: 18),
label: Text(context.l10n.downloadAllCount(_tracks.length)),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
),
],
),
),
),
),
);
}
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
child: Row(
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text(
context.l10n.tracksHeader,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
],
),
),
);
// Info is now displayed in the full-screen cover overlay
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme) {
@@ -460,6 +408,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
void _downloadTrack(BuildContext context, Track track) {
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
@@ -487,22 +436,165 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
}
}
void _downloadAll(BuildContext context) {
// Shuffle / Love / Download buttons
Widget _buildCircleButton({
required IconData icon,
required String tooltip,
required VoidCallback? onPressed,
}) {
return Container(
width: 48,
height: 48,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.15),
border: Border.all(
color: Colors.white.withValues(alpha: 0.3),
width: 1,
),
),
child: IconButton(
onPressed: onPressed,
icon: Icon(icon, size: 22, color: Colors.white),
tooltip: tooltip,
padding: EdgeInsets.zero,
),
);
}
Widget _buildLoveAllButton() {
final collectionsState = ref.watch(libraryCollectionsProvider);
final allLoved =
_tracks.isNotEmpty && _tracks.every((t) => collectionsState.isLoved(t));
return Container(
width: 48,
height: 48,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.15),
border: Border.all(
color: Colors.white.withValues(alpha: 0.3),
width: 1,
),
),
child: IconButton(
onPressed: _tracks.isEmpty ? null : () => _loveAll(_tracks),
icon: Icon(
allLoved ? Icons.favorite : Icons.favorite_border,
size: 22,
color: allLoved ? Colors.redAccent : Colors.white,
),
tooltip: allLoved ? 'Remove from Loved' : 'Love All',
padding: EdgeInsets.zero,
),
);
}
Widget _buildDownloadAllCenterButton(BuildContext context) {
return FilledButton.icon(
onPressed: _tracks.isEmpty ? null : () => _confirmDownloadAll(context),
icon: const Icon(Icons.download_rounded, size: 18),
label: Text(context.l10n.downloadAllCount(_tracks.length)),
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
);
}
Widget _buildAddToPlaylistButton(BuildContext context) {
return _buildCircleButton(
icon: Icons.playlist_add,
tooltip: 'Add to Playlist',
onPressed: _tracks.isEmpty
? null
: () => showAddTracksToPlaylistSheet(context, ref, _tracks),
);
}
void _confirmDownloadAll(BuildContext context) {
if (_tracks.isEmpty) return;
showDialog(
context: context,
builder: (dialogContext) {
final colorScheme = Theme.of(dialogContext).colorScheme;
return AlertDialog(
backgroundColor: colorScheme.surfaceContainerHigh,
title: const Text('Download All'),
content: Text('Download ${_tracks.length} tracks?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () {
Navigator.pop(dialogContext);
_downloadAll(context);
},
child: const Text('Download'),
),
],
);
},
);
}
Future<void> _loveAll(List<Track> tracks) async {
final notifier = ref.read(libraryCollectionsProvider.notifier);
final state = ref.read(libraryCollectionsProvider);
final allLoved = tracks.every((t) => state.isLoved(t));
if (allLoved) {
for (final track in tracks) {
final key = trackCollectionKey(track);
await notifier.removeFromLoved(key);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Removed ${tracks.length} tracks from Loved')),
);
}
} else {
int addedCount = 0;
for (final track in tracks) {
if (!state.isLoved(track)) {
await notifier.toggleLoved(track);
addedCount++;
}
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Added $addedCount tracks to Loved')),
);
}
}
}
void _downloadAll(BuildContext context) {
_downloadTracks(context, _tracks);
}
void _downloadTracks(BuildContext context, List<Track> tracks) {
if (tracks.isEmpty) return;
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
trackName: '${_tracks.length} tracks',
trackName: '${tracks.length} tracks',
artistName: widget.playlistName,
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(_tracks, service, qualityOverride: quality);
.addMultipleToQueue(tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAddedTracksToQueue(_tracks.length),
context.l10n.snackbarAddedTracksToQueue(tracks.length),
),
),
);
@@ -511,12 +603,10 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
} else {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(_tracks, settings.defaultService);
.addMultipleToQueue(tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAddedTracksToQueue(_tracks.length),
),
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
),
);
}
@@ -542,7 +632,12 @@ class _PlaylistTrackItem extends ConsumerWidget {
final isInHistory = ref.watch(
downloadHistoryProvider.select((state) {
return state.isDownloaded(track.id);
if (state.isDownloaded(track.id)) return true;
final isrc = track.isrc?.trim();
if (isrc != null && isrc.isNotEmpty && state.getByIsrc(isrc) != null) {
return true;
}
return state.findByTrackAndArtist(track.name, track.artistName) != null;
}),
);
@@ -565,13 +660,6 @@ class _PlaylistTrackItem extends ConsumerWidget {
: false;
final isQueued = queueItem != null;
final isDownloading = queueItem?.status == DownloadStatus.downloading;
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
final isCompleted = queueItem?.status == DownloadStatus.completed;
final progress = queueItem?.progress ?? 0.0;
final showAsDownloaded =
isCompleted || (!isQueued && isInHistory) || isInLocalLibrary;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
@@ -625,7 +713,7 @@ class _PlaylistTrackItem extends ConsumerWidget {
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
if (isInLocalLibrary) ...[
if (isInLocalLibrary || isInHistory) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
@@ -659,24 +747,12 @@ class _PlaylistTrackItem extends ConsumerWidget {
],
],
),
trailing: _buildDownloadButton(
trailing: TrackCollectionQuickActions(track: track),
onTap: () => _handleTap(context, ref, isQueued: isQueued),
onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet(
context,
ref,
colorScheme,
isQueued: isQueued,
isDownloading: isDownloading,
isFinalizing: isFinalizing,
showAsDownloaded: showAsDownloaded,
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
progress: progress,
),
onTap: () => _handleTap(
context,
ref,
isQueued: isQueued,
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
track,
),
),
),
@@ -687,160 +763,84 @@ class _PlaylistTrackItem extends ConsumerWidget {
BuildContext context,
WidgetRef ref, {
required bool isQueued,
required bool isInHistory,
required bool isInLocalLibrary,
}) async {
if (isQueued) return;
if (isInLocalLibrary) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)),
),
);
}
final playedLocal = await _playLocalIfAvailable(context, ref);
if (playedLocal) {
return;
}
if (isInHistory) {
final historyItem = ref
.read(downloadHistoryProvider.notifier)
.getBySpotifyId(track.id);
if (historyItem != null) {
final exists = await fileExists(historyItem.filePath);
if (exists) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAlreadyDownloaded(track.name),
),
),
);
}
return;
} else {
ref
.read(downloadHistoryProvider.notifier)
.removeBySpotifyId(track.id);
}
}
}
onDownload();
}
Widget _buildDownloadButton(
Future<bool> _playLocalIfAvailable(
BuildContext context,
WidgetRef ref,
ColorScheme colorScheme, {
required bool isQueued,
required bool isDownloading,
required bool isFinalizing,
required bool showAsDownloaded,
required bool isInHistory,
required bool isInLocalLibrary,
required double progress,
}) {
const double size = 44.0;
const double iconSize = 20.0;
) async {
final localState = ref.read(localLibraryProvider);
final historyState = ref.read(downloadHistoryProvider);
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
if (showAsDownloaded) {
return GestureDetector(
onTap: () => _handleTap(
context,
ref,
isQueued: isQueued,
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.check,
color: colorScheme.onPrimaryContainer,
size: iconSize,
),
),
try {
DownloadHistoryItem? historyItem = historyNotifier.getBySpotifyId(
track.id,
);
} else if (isFinalizing) {
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
strokeWidth: 3,
color: colorScheme.tertiary,
backgroundColor: colorScheme.surfaceContainerHighest,
),
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
],
),
final isrc = track.isrc?.trim();
historyItem ??= (isrc != null && isrc.isNotEmpty)
? historyNotifier.getByIsrc(isrc)
: null;
historyItem ??= historyState.findByTrackAndArtist(
track.name,
track.artistName,
);
} else if (isDownloading) {
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
value: progress > 0 ? progress : null,
strokeWidth: 3,
color: colorScheme.primary,
backgroundColor: colorScheme.surfaceContainerHighest,
),
if (progress > 0)
Text(
'${(progress * 100).toInt()}',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
),
],
),
);
} else if (isQueued) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Icon(
Icons.hourglass_empty,
color: colorScheme.onSurfaceVariant,
size: iconSize,
),
);
} else {
return GestureDetector(
onTap: onDownload,
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.download,
color: colorScheme.onSecondaryContainer,
size: iconSize,
),
),
if (historyItem != null) {
final exists = await fileExists(historyItem.filePath);
if (exists) {
await ref
.read(playbackProvider.notifier)
.playLocalPath(
path: historyItem.filePath,
title: track.name,
artist: track.artistName,
album: track.albumName,
coverUrl: track.coverUrl ?? '',
);
return true;
}
historyNotifier.removeFromHistory(historyItem.id);
}
var localItem = (isrc != null && isrc.isNotEmpty)
? localState.getByIsrc(isrc)
: null;
localItem ??= localState.findByTrackAndArtist(
track.name,
track.artistName,
);
if (localItem != null && await fileExists(localItem.filePath)) {
await ref
.read(playbackProvider.notifier)
.playLocalPath(
path: localItem.filePath,
title: localItem.trackName,
artist: localItem.artistName,
album: localItem.albumName,
coverUrl: localItem.coverPath ?? track.coverUrl ?? '',
);
return true;
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('$e'))),
);
}
return true;
}
return false;
}
}
+2581 -415
View File
File diff suppressed because it is too large Load Diff
+31 -16
View File
@@ -6,6 +6,8 @@ import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
class SearchScreen extends ConsumerStatefulWidget {
final String query;
@@ -61,9 +63,10 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
@override
Widget build(BuildContext context) {
final trackState = ref.watch(trackProvider);
final tracks = ref.watch(trackProvider.select((s) => s.tracks));
final isLoading = ref.watch(trackProvider.select((s) => s.isLoading));
final error = ref.watch(trackProvider.select((s) => s.error));
final colorScheme = Theme.of(context).colorScheme;
final tracks = trackState.tracks;
return Scaffold(
appBar: AppBar(
@@ -86,15 +89,11 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
),
body: Column(
children: [
if (trackState.isLoading)
LinearProgressIndicator(color: colorScheme.primary),
if (trackState.error != null)
if (isLoading) LinearProgressIndicator(color: colorScheme.primary),
if (error != null)
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
trackState.error!,
style: TextStyle(color: colorScheme.error),
),
child: Text(error, style: TextStyle(color: colorScheme.error)),
),
Expanded(
child: tracks.isEmpty
@@ -159,14 +158,19 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
track.artistName,
ClickableArtistName(
artistName: track.artistName,
artistId: track.artistId,
coverUrl: track.coverUrl,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
Text(
track.albumName,
ClickableAlbumName(
albumName: track.albumName,
albumId: track.albumId,
artistName: track.artistName,
coverUrl: track.coverUrl,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
@@ -175,9 +179,20 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
),
],
),
trailing: IconButton(
icon: Icon(Icons.download, color: colorScheme.primary),
onPressed: () => _downloadTrack(track),
onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet(
context,
ref,
track,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.download_rounded),
tooltip: 'Download',
onPressed: () => _downloadTrack(track),
),
],
),
onTap: () => _downloadTrack(track),
);
+6
View File
@@ -149,6 +149,12 @@ class AboutPage extends StatelessWidget {
subtitle:
'Partner lyrics proxy for Apple Music and QQ Music sources',
onTap: () => _launchUrl('https://lyrics.paxsenix.org'),
showDivider: true,
),
_ContributorItem(
name: 'Ruubiiiii',
description: 'Provided Qobuz API for the project',
githubUsername: 'Ruubiiiii',
showDivider: false,
),
],
@@ -763,6 +763,7 @@ class _LanguageSelector extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),

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