chore: rebuild dev history without streaming-era commits

This commit is contained in:
zarzet
2026-02-27 13:17:32 +07:00
parent c89600591c
commit ab26d84632
111 changed files with 18282 additions and 3459 deletions
+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.
+99
View File
@@ -1,5 +1,104 @@
# Changelog
## [4.0.1] - 2026-02-26
### Added
- **Clickable Metadata Navigation**: Added reusable `ClickableArtistName` and `ClickableAlbumName`
- **Love Action in Media Notification**: Added custom notification action (`toggle_love`) with new Android favorite/favorite-border status icons
### Changed
- **Track Metadata Model Expansion**: `Track` now carries `artistId` and `albumId`, propagated across search, queue, playback, CSV import, and extension mapping flows
- **Full-Screen Player UX**: Top bar now supports swipe-down dismiss; artist/album text is now tappable; and in-player love toggle is available next to track metadata
- **Playlist Picker Flow Refactor**: Reworked playlist picker sheet into stateful multi-select flow with explicit Done action and improved create-playlist handling
- **CSV Import Interaction Flow**: Added single-flight import guard, more reliable progress dialog lifecycle, and safer local navigator usage
- **Amazon API**: Amazon metadata fetch `amzn.afkarxyz.fun`
- **Qobuz URL Resolution Strategy**: Removed legacy/Jumo fallback path; now uses standard API pool (deeb)
- **Update Checker Asset Targeting**: Update selection now prioritizes arm64/universal assets only
- **Donate Page Supporters**: Updated highlighted donor/supporter list entries
### Fixed
- **FLAC External Lyrics Output**: External `.lrc` writing now works consistently for lyrics mode `external`/`both`, with SAF conversion paths avoiding duplicate writes
- **Loved-State Notification Sync**: Playback notification controls now refresh correctly when loved state changes
- **Queue Selection Touch Handling**: Selection overlays/check indicators no longer block tap gestures in queue and playlist selection modes
- **Vorbis-to-ID3 Tag Mapping Robustness**: FFmpeg metadata conversion now normalizes keys and handles aliases like `TRCK` and `TPOS`
- **Nested Dialog Navigation Safety**: Adjusted dialog navigator scope in CSV import and track-delete flows to prevent navigator mismatch issues
- **Artist/Album Routing Reliability**: Track metadata routing now reuses resolved artist/album IDs across album/artist/home/search/queue/player surfaces
- **Release Workflow Go Toolchain**: Pinned CI release workflow Go version to `1.25.7` for consistent build behavior
---
## [4.0.0] - 2026-02-22
> **Major update warning:** This release introduces a large streaming-focused refactor and broad cross-app behavior changes.
>
> **Diff scope (`cdc583678558223ecbb552176b53727d303ae218..HEAD`):** 121 files changed, 28,354 insertions(+), 4,598 deletions(-).
### Added
- **End-to-End Streaming Mode**: Full streaming playback flow with full-screen player, synced lyrics, media controls, and queue-aware tap behavior across album, artist, playlist, home, and search screens
- **Smart Queue System**: ML-based queue auto-curation with related artist discovery, plus a dedicated playback queue view
- **DASH Streaming Pipeline**: Native DASH manifest playback support with local proxy integration and FFmpeg tunnel fallback for unsupported paths
- **Playback State Persistence**: Player state and queue continuity restored across app restarts
- **Adaptive Playback Engine**: EventChannel-driven playback/progress updates (replacing polling) and adaptive prefetch behavior
- **Queue Reliability Controls**: New auto-skip unavailable tracks option during queue playback
- **Player Quick Action**: New download button in full-screen player top bar
- **Metadata Control**: New global master switch for embed metadata behavior
- **Setup Flow Update**: Initial setup now prioritizes mode selection instead of Spotify API setup
- **Library Workflow Expansion**: Playlist-first library redesign, drag-and-drop categorization, folder multi-select, and batch playlist picker flows
- **SongLink Region Setting**: Region selection support for metadata/linking behavior
- **Track Interaction UX**: Long-press context menus for track actions across major collection screens
- **Batch Tools**: Multi-select share, batch convert, and batch re-enrich improvements for downloaded/local/queue workflows
### Changed
- **Global Mode-Driven Actions**: Interaction mode now drives behavior app-wide (download-oriented vs streaming-oriented actions)
- **UI Redesign and Responsiveness**: Full-screen cover/parallax rollout and responsive fixes for filter sheets and full-screen player in small screens/landscape
- **Performance Optimizations**: Granular Riverpod consumers, selective provider watching, computation caching, debounced extension storage writes, and lifecycle cleanups
- **Lyrics Loading Strategy**: Lyrics are now lazy-loaded only when the lyrics view is visible
- **Persistence Backend Refactor**: Core persistence paths migrated to SQLite-backed stores for app state and library collections
- **Shared Code Refactor**: Duplicated logic extracted into shared Dart/Go utilities for cleaner boundaries and maintainability
### Fixed
- **iOS Build Compatibility**: Resolved `RepeatMode` naming collision with Flutter SDK symbols
- **Playback Completion Handling**: Fixed track completion restart issues and queue-end completion synchronization
- **Streaming Stability**: Added guards for playback race conditions during queue/stream state transitions
- **Provider I/O Safety**: Improved Android/Go file descriptor handling for SAF-based outputs
- **Metadata Matching Robustness**: Improved title matching with strict emoji handling and name+artist fallback lookup behavior
- **Navigation Behavior**: Back button now exits app correctly instead of unexpectedly returning to home
---
## [4.0.0] - 2026-02-22
### Added
- **Interaction Mode Setting**: New "Interaction Mode" toggle in Options settings to switch between Downloader Mode (tap to queue downloads) and Streaming Mode (tap to play instantly)
- Affects album, artist discography, playlist, home explore, and search screens
- All action buttons (Download All, Download Selected, Download Discography) dynamically switch to Play equivalents when in Streaming Mode
- **Streaming Playback Integration**: Tapping tracks in Streaming Mode plays them via `playTrackStreamAndSetQueue` with full queue support across all collection screens (album, artist, playlist, home, search)
- **Long-Press Track Context Menus**: Added `onLongPress` handler on track items across album, artist, home, playlist, and search screens to open the track options bottom sheet via `TrackCollectionQuickActions.showTrackOptionsSheet`
- **USDT TRC20 Crypto Donation**: Added USDT (TRC20) wallet address to Donate page with tap-to-copy-to-clipboard functionality and snackbar confirmation
- **Localization**: Added interaction mode and streaming playback strings across all 14 supported locales (`optionsInteractionMode`, `modeDownloader`, `modeDownloaderSubtitle`, `modeStreaming`, `modeStreamingSubtitle`, `playAllCount`, `discographyPlay`, `discographyPlayAll`, `discographyPlaySelected`)
- **Indonesian (ID) Localization**: Full translations for all new streaming mode strings
### Changed
- **Mini Player Bar Layout**: Media section (cover art / lyrics) now uses fixed-height `SizedBox` (50% screen height, clamped 300560px) instead of `Expanded` for more consistent layout
- **Lyrics Font Size Increase**: Synced lyrics current line 22→24px, non-current 18→19px; word-by-word highlight 22→24px; unsynced 18→19px
- **Playback Media Controls**: Removed stop button from notification media controls for cleaner transport bar
- **Playback Queue Exhaustion**: Player now properly syncs `ProcessingState.completed` state when queue is exhausted instead of silently stopping
- **`TrackCollectionQuickActions.showTrackOptionsSheet` Made Static**: Extracted to a public static method so all screens can invoke it directly for long-press handling
- **Bottom Spacing in Mini Player**: Reduced from 16px to 4px for tighter layout
### Fixed
- **Playback State Not Updating on Queue End**: Fixed playback notification staying in "playing" state when all tracks in queue have finished
---
## [3.7.0] - 2026-02-19
### 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]
+10
View File
@@ -80,6 +80,16 @@
-keep class io.flutter.plugins.pathprovider.** { *; }
-keep class dev.flutter.pigeon.** { *; }
# Audio Service (media playback notification) - CRITICAL for release builds
-keep class com.ryanheise.audioservice.** { *; }
-keep class com.ryanheise.audio_session.** { *; }
-keep class com.ryanheise.just_audio.** { *; }
# AndroidX Media / MediaSession (used by audio_service)
-keep class androidx.media.** { *; }
-keep class android.support.v4.media.** { *; }
-dontwarn android.support.v4.media.**
# Local Notifications
-keep class com.dexterous.** { *; }
-keep class com.dexterous.flutterlocalnotifications.** { *; }
+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,20 +4,25 @@ import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Build
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.documentfile.provider.DocumentFile
import com.ryanheise.audioservice.AudioServiceFragmentActivity
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
@@ -27,13 +32,24 @@ import java.io.FileInputStream
import java.io.FileOutputStream
import java.util.Locale
class MainActivity: FlutterFragmentActivity() {
class MainActivity: AudioServiceFragmentActivity() {
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
@Volatile private var safScanCancel = false
@Volatile private var safScanActive = false
private val safTreeLauncher = registerForActivityResult(
@@ -380,6 +396,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()) {
@@ -1252,16 +1340,79 @@ 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.
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
flutterEngine.navigationChannel.popRoute()
}
})
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) {
@@ -1296,6 +1447,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") ?: ""
@@ -1973,6 +2132,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,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M16.5,3C14.76,3 13.09,3.81 12,5.09C10.91,3.81 9.24,3 7.5,3C4.42,3 2,5.42 2,8.5C2,12.28 5.4,15.36 10.55,20.03L12,21.35L13.45,20.03C18.6,15.36 22,12.28 22,8.5C22,5.42 19.58,3 16.5,3ZM12.1,18.55L12,18.65L11.9,18.55C7.14,14.24 4,11.39 4,8.5C4,6.5 5.5,5 7.5,5C9.04,5 10.54,5.99 11.07,7.36H12.94C13.46,5.99 14.96,5 16.5,5C18.5,5 20,6.5 20,8.5C20,11.39 16.86,14.24 12.1,18.55Z"/>
</vector>
+3
View File
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@drawable/ic_stat_favorite,@drawable/ic_stat_favorite_border" />
@@ -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
}
+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
}
+94 -3
View File
@@ -123,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)
@@ -159,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"`
@@ -467,6 +497,24 @@ 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 {
@@ -592,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"
@@ -680,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) {
@@ -1162,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()
@@ -3145,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() {
@@ -3156,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)
}
+21 -3
View File
@@ -631,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))
@@ -815,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 {
@@ -1013,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 {
@@ -1147,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)
}
+88 -8
View File
@@ -88,18 +88,48 @@ 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
@@ -166,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"])
}
}
+190
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)
+105 -177
View File
@@ -3,7 +3,6 @@ package gobackend
import (
"bufio"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -28,6 +27,11 @@ 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="
)
type QobuzTrack struct {
ID int64 `json:"id"`
Title string `json:"title"`
@@ -185,13 +189,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 +341,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,46 +367,10 @@ func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
}
func (q *QobuzDownloader) GetAvailableAPIs() []string {
encodedAPIs := []string{
"ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==",
"ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=",
"cW9idXouc3F1aWQud3RmL2FwaS9kb3dubG9hZC1tdXNpYz90cmFja19pZD0=",
return []string{
"https://dab.yeet.su/api/stream?trackId=",
"https://dabmusic.xyz/api/stream?trackId=",
}
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
}
}
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)
}
func extractQobuzDownloadURLFromBody(body []byte) (string, error) {
@@ -436,67 +409,8 @@ func extractQobuzDownloadURLFromBody(body []byte) (string, error) {
return "", 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)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", getRandomUserAgent())
req.Header.Set("Referer", "https://jumo-dl.pages.dev/")
resp, err := client.Do(req)
if err != nil {
return "", err
}
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)
}
}
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
}
}
if linkVal, ok := result["link"].(string); ok && linkVal != "" {
GoLog("[Qobuz] Jumo API returned URL successfully (from link)\n")
return linkVal, nil
}
return "", fmt.Errorf("URL not found in Jumo response")
}
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 +452,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 +534,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 +585,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 {
@@ -799,26 +710,8 @@ 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, "")
}
@@ -964,34 +857,43 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
return "", fmt.Errorf("no Qobuz API available")
}
_, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, quality)
qualityCode := strings.TrimSpace(quality)
if qualityCode == "" || qualityCode == "5" {
qualityCode = "6"
}
downloadFunc := func(qual string) (string, error) {
_, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, qual)
if err != nil {
return "", err
}
return downloadURL, nil
}
downloadURL, err := downloadFunc(qualityCode)
if err == nil {
return downloadURL, 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
downloadURL, err = downloadFunc("7")
if err == nil {
return downloadURL, 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
downloadURL, err = downloadFunc("6")
if err == nil {
return downloadURL, nil
}
}
return "", fmt.Errorf("all Qobuz APIs and Jumo fallback failed: %w", err)
return "", fmt.Errorf("all Qobuz APIs failed: %w", err)
}
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
@@ -1087,14 +989,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 +1004,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 +1020,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 +1031,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 +1055,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 +1072,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 +1086,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,
@@ -1241,13 +1159,19 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, 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),
)
}()
@@ -1297,8 +1221,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 +1265,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
}
+21 -18
View File
@@ -561,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)
}
}
@@ -658,16 +659,17 @@ 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)
}
}
@@ -805,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
+146
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]
@@ -282,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
@@ -840,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.7.0';
static const String buildNumber = '83';
static const String version = '4.0.1';
static const String buildNumber = '102';
static const String fullVersion = '$version+$buildNumber';
+456
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:
@@ -5555,6 +5633,384 @@ abstract class AppLocalizations {
/// 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;
/// Title for Smart Queue toggle in settings
///
/// In en, this message translates to:
/// **'Smart Queue'**
String get settingsSmartQueueTitle;
/// Subtitle for Smart Queue toggle in settings
///
/// In en, this message translates to:
/// **'Automatically discover and add similar tracks to your queue'**
String get settingsSmartQueueSubtitle;
/// Title for the What's New screen
///
/// In en, this message translates to:
/// **'What\'s New in 4.0'**
String get whatsNewTitle;
/// Subtitle for the What's New screen
///
/// In en, this message translates to:
/// **'SpotiFLAC has evolved — here\'s what changed since 3.x'**
String get whatsNewSubtitle;
/// Welcome page title in What's New screen
///
/// In en, this message translates to:
/// **'SpotiFLAC Mobile 4.0'**
String get whatsNewWelcomeTitle;
/// Welcome page description in What's New screen
///
/// In en, this message translates to:
/// **'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.'**
String get whatsNewWelcomeDesc;
/// Welcome page tip 1
///
/// In en, this message translates to:
/// **'New streaming mode with instant playback'**
String get whatsNewWelcomeTip1;
/// Welcome page tip 2
///
/// In en, this message translates to:
/// **'Redesigned library and full-screen player'**
String get whatsNewWelcomeTip2;
/// Welcome page tip 3
///
/// In en, this message translates to:
/// **'Batch tools, performance boosts, and more'**
String get whatsNewWelcomeTip3;
/// What's New feature: Streaming Mode title
///
/// In en, this message translates to:
/// **'Streaming Mode'**
String get whatsNewStreamingTitle;
/// What's New feature: Streaming Mode description
///
/// In en, this message translates to:
/// **'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.'**
String get whatsNewStreamingDesc;
/// What's New feature: Smart Queue title
///
/// In en, this message translates to:
/// **'Smart Queue'**
String get whatsNewSmartQueueTitle;
/// What's New feature: Smart Queue description
///
/// In en, this message translates to:
/// **'Your queue auto-curates with related tracks and artist discovery. Never run out of music.'**
String get whatsNewSmartQueueDesc;
/// What's New feature: Dual Mode title
///
/// In en, this message translates to:
/// **'Dual Mode'**
String get whatsNewDualModeTitle;
/// What's New feature: Dual Mode description
///
/// In en, this message translates to:
/// **'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.'**
String get whatsNewDualModeDesc;
/// What's New feature: Library redesign title
///
/// In en, this message translates to:
/// **'Redesigned Library'**
String get whatsNewLibraryTitle;
/// What's New feature: Library redesign description
///
/// In en, this message translates to:
/// **'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.'**
String get whatsNewLibraryDesc;
/// What's New feature: Full-Screen Player title
///
/// In en, this message translates to:
/// **'Full-Screen Player'**
String get whatsNewPlayerTitle;
/// What's New feature: Full-Screen Player description
///
/// In en, this message translates to:
/// **'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.'**
String get whatsNewPlayerDesc;
/// What's New feature: Context Menus title
///
/// In en, this message translates to:
/// **'Long-Press Menus'**
String get whatsNewContextMenuTitle;
/// What's New feature: Context Menus description
///
/// In en, this message translates to:
/// **'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.'**
String get whatsNewContextMenuDesc;
/// What's New feature: Performance title
///
/// In en, this message translates to:
/// **'Performance'**
String get whatsNewPerformanceTitle;
/// What's New feature: Performance description
///
/// In en, this message translates to:
/// **'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.'**
String get whatsNewPerformanceDesc;
/// What's New feature: Batch Tools title
///
/// In en, this message translates to:
/// **'Batch Tools'**
String get whatsNewBatchToolsTitle;
/// What's New feature: Batch Tools description
///
/// In en, this message translates to:
/// **'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.'**
String get whatsNewBatchToolsDesc;
/// What's New tip: streaming instant play
///
/// In en, this message translates to:
/// **'Tap any track to start playing instantly'**
String get whatsNewStreamingTip1;
/// What's New tip: streaming synced lyrics
///
/// In en, this message translates to:
/// **'Synced lyrics in the full-screen player'**
String get whatsNewStreamingTip2;
/// What's New tip: streaming download from player
///
/// In en, this message translates to:
/// **'Download tracks directly from the player'**
String get whatsNewStreamingTip3;
/// What's New tip: smart queue auto-fill
///
/// In en, this message translates to:
/// **'Queue auto-fills with related tracks'**
String get whatsNewSmartQueueTip1;
/// What's New tip: smart queue artist discovery
///
/// In en, this message translates to:
/// **'Discover new artists as you listen'**
String get whatsNewSmartQueueTip2;
/// What's New tip: smart queue endless
///
/// In en, this message translates to:
/// **'Never run out of music to play'**
String get whatsNewSmartQueueTip3;
/// What's New tip: dual mode switch
///
/// In en, this message translates to:
/// **'Switch modes anytime in Settings'**
String get whatsNewDualModeTip1;
/// What's New tip: dual mode adaptive UI
///
/// In en, this message translates to:
/// **'UI buttons adapt to your current mode'**
String get whatsNewDualModeTip2;
/// What's New tip: dual mode use cases
///
/// In en, this message translates to:
/// **'Download for offline, stream for instant play'**
String get whatsNewDualModeTip3;
/// What's New tip: library drag and drop
///
/// In en, this message translates to:
/// **'Drag and drop to organize playlists'**
String get whatsNewLibraryTip1;
/// What's New tip: library custom covers
///
/// In en, this message translates to:
/// **'Set custom cover images for playlists'**
String get whatsNewLibraryTip2;
/// What's New tip: library multi-select
///
/// In en, this message translates to:
/// **'Multi-select tracks for batch actions'**
String get whatsNewLibraryTip3;
/// What's New tip: player parallax
///
/// In en, this message translates to:
/// **'Cover art with parallax scrolling effect'**
String get whatsNewPlayerTip1;
/// What's New tip: player persistence
///
/// In en, this message translates to:
/// **'Playback persists across app restarts'**
String get whatsNewPlayerTip2;
/// What's New tip: player lyrics
///
/// In en, this message translates to:
/// **'Synced lyrics while you listen'**
String get whatsNewPlayerTip3;
/// What's New tip: context menu add to playlist
///
/// In en, this message translates to:
/// **'Add tracks to any playlist instantly'**
String get whatsNewContextMenuTip1;
/// What's New tip: context menu share/convert
///
/// In en, this message translates to:
/// **'Share or convert with one tap'**
String get whatsNewContextMenuTip2;
/// What's New tip: context menu re-enrich
///
/// In en, this message translates to:
/// **'Re-enrich metadata when needed'**
String get whatsNewContextMenuTip3;
/// What's New tip: batch share
///
/// In en, this message translates to:
/// **'Share multiple tracks at once'**
String get whatsNewBatchToolsTip1;
/// What's New tip: batch convert
///
/// In en, this message translates to:
/// **'Batch convert to MP3 or Opus format'**
String get whatsNewBatchToolsTip2;
/// What's New tip: batch re-enrich
///
/// In en, this message translates to:
/// **'Re-enrich metadata across your library'**
String get whatsNewBatchToolsTip3;
/// What's New tip: performance startup
///
/// In en, this message translates to:
/// **'Faster app startup time'**
String get whatsNewPerformanceTip1;
/// What's New tip: performance memory
///
/// In en, this message translates to:
/// **'Reduced memory usage during playback'**
String get whatsNewPerformanceTip2;
/// What's New tip: performance SQLite
///
/// In en, this message translates to:
/// **'SQLite-backed storage for reliability'**
String get whatsNewPerformanceTip3;
/// Ready card message on last What's New page
///
/// In en, this message translates to:
/// **'You\'re all set — enjoy the new SpotiFLAC!'**
String get whatsNewReadyMessage;
/// Button text to dismiss What's New screen
///
/// In en, this message translates to:
/// **'Let\'s Go'**
String get whatsNewGetStarted;
/// Page indicator text in What's New screen
///
/// In en, this message translates to:
/// **'{current} of {total}'**
String whatsNewPageIndicator(int current, int total);
}
class _AppLocalizationsDelegate
+259
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';
@@ -3202,4 +3247,218 @@ class AppLocalizationsDe extends AppLocalizations {
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.';
@override
String get settingsSmartQueueTitle => 'Smart Queue';
@override
String get settingsSmartQueueSubtitle =>
'Automatisch ähnliche Titel entdecken und zu deiner Warteschlange hinzufügen';
@override
String get whatsNewTitle => 'What\'s New in 4.0';
@override
String get whatsNewSubtitle =>
'SpotiFLAC has evolved — here\'s what changed since 3.x';
@override
String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0';
@override
String get whatsNewWelcomeDesc =>
'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.';
@override
String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback';
@override
String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player';
@override
String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more';
@override
String get whatsNewStreamingTitle => 'Streaming Mode';
@override
String get whatsNewStreamingDesc =>
'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.';
@override
String get whatsNewSmartQueueTitle => 'Smart Queue';
@override
String get whatsNewSmartQueueDesc =>
'Your queue auto-curates with related tracks and artist discovery. Never run out of music.';
@override
String get whatsNewDualModeTitle => 'Dual Mode';
@override
String get whatsNewDualModeDesc =>
'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.';
@override
String get whatsNewLibraryTitle => 'Redesigned Library';
@override
String get whatsNewLibraryDesc =>
'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.';
@override
String get whatsNewPlayerTitle => 'Full-Screen Player';
@override
String get whatsNewPlayerDesc =>
'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.';
@override
String get whatsNewContextMenuTitle => 'Long-Press Menus';
@override
String get whatsNewContextMenuDesc =>
'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.';
@override
String get whatsNewPerformanceTitle => 'Performance';
@override
String get whatsNewPerformanceDesc =>
'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.';
@override
String get whatsNewBatchToolsTitle => 'Batch Tools';
@override
String get whatsNewBatchToolsDesc =>
'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.';
@override
String get whatsNewStreamingTip1 =>
'Tap any track to start playing instantly';
@override
String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player';
@override
String get whatsNewStreamingTip3 =>
'Download tracks directly from the player';
@override
String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks';
@override
String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen';
@override
String get whatsNewSmartQueueTip3 => 'Never run out of music to play';
@override
String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings';
@override
String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode';
@override
String get whatsNewDualModeTip3 =>
'Download for offline, stream for instant play';
@override
String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists';
@override
String get whatsNewLibraryTip2 => 'Set custom cover images for playlists';
@override
String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions';
@override
String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect';
@override
String get whatsNewPlayerTip2 => 'Playback persists across app restarts';
@override
String get whatsNewPlayerTip3 => 'Synced lyrics while you listen';
@override
String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly';
@override
String get whatsNewContextMenuTip2 => 'Share or convert with one tap';
@override
String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed';
@override
String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once';
@override
String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format';
@override
String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library';
@override
String get whatsNewPerformanceTip1 => 'Faster app startup time';
@override
String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback';
@override
String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability';
@override
String get whatsNewReadyMessage =>
'You\'re all set — enjoy the new SpotiFLAC!';
@override
String get whatsNewGetStarted => 'Let\'s Go';
@override
String whatsNewPageIndicator(int current, int total) {
return '$current of $total';
}
}
+258
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';
@@ -3181,4 +3226,217 @@ class AppLocalizationsEn extends AppLocalizations {
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.';
@override
String get settingsSmartQueueTitle => 'Smart Queue';
@override
String get settingsSmartQueueSubtitle =>
'Automatically discover and add similar tracks to your queue';
@override
String get whatsNewTitle => 'What\'s New in 4.0';
@override
String get whatsNewSubtitle =>
'SpotiFLAC has evolved — here\'s what changed since 3.x';
@override
String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0';
@override
String get whatsNewWelcomeDesc =>
'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.';
@override
String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback';
@override
String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player';
@override
String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more';
@override
String get whatsNewStreamingTitle => 'Streaming Mode';
@override
String get whatsNewStreamingDesc =>
'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.';
@override
String get whatsNewSmartQueueTitle => 'Smart Queue';
@override
String get whatsNewSmartQueueDesc =>
'Your queue auto-curates with related tracks and artist discovery. Never run out of music.';
@override
String get whatsNewDualModeTitle => 'Dual Mode';
@override
String get whatsNewDualModeDesc =>
'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.';
@override
String get whatsNewLibraryTitle => 'Redesigned Library';
@override
String get whatsNewLibraryDesc =>
'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.';
@override
String get whatsNewPlayerTitle => 'Full-Screen Player';
@override
String get whatsNewPlayerDesc =>
'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.';
@override
String get whatsNewContextMenuTitle => 'Long-Press Menus';
@override
String get whatsNewContextMenuDesc =>
'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.';
@override
String get whatsNewPerformanceTitle => 'Performance';
@override
String get whatsNewPerformanceDesc =>
'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.';
@override
String get whatsNewBatchToolsTitle => 'Batch Tools';
@override
String get whatsNewBatchToolsDesc =>
'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.';
@override
String get whatsNewStreamingTip1 =>
'Tap any track to start playing instantly';
@override
String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player';
@override
String get whatsNewStreamingTip3 =>
'Download tracks directly from the player';
@override
String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks';
@override
String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen';
@override
String get whatsNewSmartQueueTip3 => 'Never run out of music to play';
@override
String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings';
@override
String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode';
@override
String get whatsNewDualModeTip3 =>
'Download for offline, stream for instant play';
@override
String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists';
@override
String get whatsNewLibraryTip2 => 'Set custom cover images for playlists';
@override
String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions';
@override
String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect';
@override
String get whatsNewPlayerTip2 => 'Playback persists across app restarts';
@override
String get whatsNewPlayerTip3 => 'Synced lyrics while you listen';
@override
String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly';
@override
String get whatsNewContextMenuTip2 => 'Share or convert with one tap';
@override
String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed';
@override
String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once';
@override
String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format';
@override
String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library';
@override
String get whatsNewPerformanceTip1 => 'Faster app startup time';
@override
String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback';
@override
String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability';
@override
String get whatsNewReadyMessage =>
'You\'re all set — enjoy the new SpotiFLAC!';
@override
String get whatsNewGetStarted => 'Let\'s Go';
@override
String whatsNewPageIndicator(int current, int total) {
return '$current of $total';
}
}
+307
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';
@@ -3181,6 +3226,220 @@ class AppLocalizationsEs extends AppLocalizations {
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.';
@override
String get settingsSmartQueueTitle => 'Smart Queue';
@override
String get settingsSmartQueueSubtitle =>
'Descubre y añade automáticamente pistas similares a tu cola de reproducción';
@override
String get whatsNewTitle => 'What\'s New in 4.0';
@override
String get whatsNewSubtitle =>
'SpotiFLAC has evolved — here\'s what changed since 3.x';
@override
String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0';
@override
String get whatsNewWelcomeDesc =>
'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.';
@override
String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback';
@override
String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player';
@override
String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more';
@override
String get whatsNewStreamingTitle => 'Streaming Mode';
@override
String get whatsNewStreamingDesc =>
'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.';
@override
String get whatsNewSmartQueueTitle => 'Smart Queue';
@override
String get whatsNewSmartQueueDesc =>
'Your queue auto-curates with related tracks and artist discovery. Never run out of music.';
@override
String get whatsNewDualModeTitle => 'Dual Mode';
@override
String get whatsNewDualModeDesc =>
'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.';
@override
String get whatsNewLibraryTitle => 'Redesigned Library';
@override
String get whatsNewLibraryDesc =>
'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.';
@override
String get whatsNewPlayerTitle => 'Full-Screen Player';
@override
String get whatsNewPlayerDesc =>
'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.';
@override
String get whatsNewContextMenuTitle => 'Long-Press Menus';
@override
String get whatsNewContextMenuDesc =>
'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.';
@override
String get whatsNewPerformanceTitle => 'Performance';
@override
String get whatsNewPerformanceDesc =>
'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.';
@override
String get whatsNewBatchToolsTitle => 'Batch Tools';
@override
String get whatsNewBatchToolsDesc =>
'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.';
@override
String get whatsNewStreamingTip1 =>
'Tap any track to start playing instantly';
@override
String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player';
@override
String get whatsNewStreamingTip3 =>
'Download tracks directly from the player';
@override
String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks';
@override
String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen';
@override
String get whatsNewSmartQueueTip3 => 'Never run out of music to play';
@override
String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings';
@override
String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode';
@override
String get whatsNewDualModeTip3 =>
'Download for offline, stream for instant play';
@override
String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists';
@override
String get whatsNewLibraryTip2 => 'Set custom cover images for playlists';
@override
String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions';
@override
String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect';
@override
String get whatsNewPlayerTip2 => 'Playback persists across app restarts';
@override
String get whatsNewPlayerTip3 => 'Synced lyrics while you listen';
@override
String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly';
@override
String get whatsNewContextMenuTip2 => 'Share or convert with one tap';
@override
String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed';
@override
String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once';
@override
String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format';
@override
String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library';
@override
String get whatsNewPerformanceTip1 => 'Faster app startup time';
@override
String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback';
@override
String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability';
@override
String get whatsNewReadyMessage =>
'You\'re all set — enjoy the new SpotiFLAC!';
@override
String get whatsNewGetStarted => 'Let\'s Go';
@override
String whatsNewPageIndicator(int current, int total) {
return '$current of $total';
}
}
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
@@ -6147,4 +6406,52 @@ 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.';
@override
String get settingsSmartQueueTitle => 'Smart Queue';
@override
String get settingsSmartQueueSubtitle =>
'Descubre y añade automáticamente pistas similares a tu cola de reproducción';
}
+259
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';
@@ -3187,4 +3232,218 @@ class AppLocalizationsFr extends AppLocalizations {
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.';
@override
String get settingsSmartQueueTitle => 'Smart Queue';
@override
String get settingsSmartQueueSubtitle =>
'Découvrir et ajouter automatiquement des pistes similaires à votre file d\'attente';
@override
String get whatsNewTitle => 'What\'s New in 4.0';
@override
String get whatsNewSubtitle =>
'SpotiFLAC has evolved — here\'s what changed since 3.x';
@override
String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0';
@override
String get whatsNewWelcomeDesc =>
'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.';
@override
String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback';
@override
String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player';
@override
String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more';
@override
String get whatsNewStreamingTitle => 'Streaming Mode';
@override
String get whatsNewStreamingDesc =>
'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.';
@override
String get whatsNewSmartQueueTitle => 'Smart Queue';
@override
String get whatsNewSmartQueueDesc =>
'Your queue auto-curates with related tracks and artist discovery. Never run out of music.';
@override
String get whatsNewDualModeTitle => 'Dual Mode';
@override
String get whatsNewDualModeDesc =>
'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.';
@override
String get whatsNewLibraryTitle => 'Redesigned Library';
@override
String get whatsNewLibraryDesc =>
'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.';
@override
String get whatsNewPlayerTitle => 'Full-Screen Player';
@override
String get whatsNewPlayerDesc =>
'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.';
@override
String get whatsNewContextMenuTitle => 'Long-Press Menus';
@override
String get whatsNewContextMenuDesc =>
'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.';
@override
String get whatsNewPerformanceTitle => 'Performance';
@override
String get whatsNewPerformanceDesc =>
'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.';
@override
String get whatsNewBatchToolsTitle => 'Batch Tools';
@override
String get whatsNewBatchToolsDesc =>
'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.';
@override
String get whatsNewStreamingTip1 =>
'Tap any track to start playing instantly';
@override
String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player';
@override
String get whatsNewStreamingTip3 =>
'Download tracks directly from the player';
@override
String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks';
@override
String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen';
@override
String get whatsNewSmartQueueTip3 => 'Never run out of music to play';
@override
String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings';
@override
String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode';
@override
String get whatsNewDualModeTip3 =>
'Download for offline, stream for instant play';
@override
String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists';
@override
String get whatsNewLibraryTip2 => 'Set custom cover images for playlists';
@override
String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions';
@override
String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect';
@override
String get whatsNewPlayerTip2 => 'Playback persists across app restarts';
@override
String get whatsNewPlayerTip3 => 'Synced lyrics while you listen';
@override
String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly';
@override
String get whatsNewContextMenuTip2 => 'Share or convert with one tap';
@override
String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed';
@override
String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once';
@override
String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format';
@override
String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library';
@override
String get whatsNewPerformanceTip1 => 'Faster app startup time';
@override
String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback';
@override
String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability';
@override
String get whatsNewReadyMessage =>
'You\'re all set — enjoy the new SpotiFLAC!';
@override
String get whatsNewGetStarted => 'Let\'s Go';
@override
String whatsNewPageIndicator(int current, int total) {
return '$current of $total';
}
}
+259
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';
@@ -3181,4 +3226,218 @@ class AppLocalizationsHi extends AppLocalizations {
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 =>
'आप सेटिंग्स में कभी भी मोड बदल सकते हैं।';
@override
String get settingsSmartQueueTitle => 'Smart Queue';
@override
String get settingsSmartQueueSubtitle =>
'स्वचालित रूप से समान ट्रैक खोजें और अपनी कतार में जोड़ें';
@override
String get whatsNewTitle => 'What\'s New in 4.0';
@override
String get whatsNewSubtitle =>
'SpotiFLAC has evolved — here\'s what changed since 3.x';
@override
String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0';
@override
String get whatsNewWelcomeDesc =>
'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.';
@override
String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback';
@override
String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player';
@override
String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more';
@override
String get whatsNewStreamingTitle => 'Streaming Mode';
@override
String get whatsNewStreamingDesc =>
'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.';
@override
String get whatsNewSmartQueueTitle => 'Smart Queue';
@override
String get whatsNewSmartQueueDesc =>
'Your queue auto-curates with related tracks and artist discovery. Never run out of music.';
@override
String get whatsNewDualModeTitle => 'Dual Mode';
@override
String get whatsNewDualModeDesc =>
'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.';
@override
String get whatsNewLibraryTitle => 'Redesigned Library';
@override
String get whatsNewLibraryDesc =>
'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.';
@override
String get whatsNewPlayerTitle => 'Full-Screen Player';
@override
String get whatsNewPlayerDesc =>
'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.';
@override
String get whatsNewContextMenuTitle => 'Long-Press Menus';
@override
String get whatsNewContextMenuDesc =>
'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.';
@override
String get whatsNewPerformanceTitle => 'Performance';
@override
String get whatsNewPerformanceDesc =>
'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.';
@override
String get whatsNewBatchToolsTitle => 'Batch Tools';
@override
String get whatsNewBatchToolsDesc =>
'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.';
@override
String get whatsNewStreamingTip1 =>
'Tap any track to start playing instantly';
@override
String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player';
@override
String get whatsNewStreamingTip3 =>
'Download tracks directly from the player';
@override
String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks';
@override
String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen';
@override
String get whatsNewSmartQueueTip3 => 'Never run out of music to play';
@override
String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings';
@override
String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode';
@override
String get whatsNewDualModeTip3 =>
'Download for offline, stream for instant play';
@override
String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists';
@override
String get whatsNewLibraryTip2 => 'Set custom cover images for playlists';
@override
String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions';
@override
String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect';
@override
String get whatsNewPlayerTip2 => 'Playback persists across app restarts';
@override
String get whatsNewPlayerTip3 => 'Synced lyrics while you listen';
@override
String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly';
@override
String get whatsNewContextMenuTip2 => 'Share or convert with one tap';
@override
String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed';
@override
String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once';
@override
String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format';
@override
String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library';
@override
String get whatsNewPerformanceTip1 => 'Faster app startup time';
@override
String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback';
@override
String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability';
@override
String get whatsNewReadyMessage =>
'You\'re all set — enjoy the new SpotiFLAC!';
@override
String get whatsNewGetStarted => 'Let\'s Go';
@override
String whatsNewPageIndicator(int current, int total) {
return '$current of $total';
}
}
+284 -14
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';
@@ -3144,31 +3190,32 @@ class AppLocalizationsId extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
other: 'trek',
one: 'trek',
);
return 'Share $count $_temp0';
return 'Bagikan $count $_temp0';
}
@override
String get selectionShareNoFiles => 'No shareable files found';
String get selectionShareNoFiles => 'Tidak ada file yang dapat dibagikan';
@override
String selectionConvertCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
other: 'trek',
one: 'trek',
);
return 'Convert $count $_temp0';
return 'Konversi $count $_temp0';
}
@override
String get selectionConvertNoConvertible => 'No convertible tracks selected';
String get selectionConvertNoConvertible =>
'Tidak ada trek yang dapat dikonversi dipilih';
@override
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
String get selectionBatchConvertConfirmTitle => 'Konversi Massal';
@override
String selectionBatchConvertConfirmMessage(
@@ -3179,19 +3226,242 @@ class AppLocalizationsId extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
other: 'trek',
one: 'trek',
);
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
return 'Konversi $count $_temp0 ke $format pada $bitrate?\n\nFile asli akan dihapus setelah konversi.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
return 'Mengonversi $current dari $total...';
}
@override
String selectionBatchConvertSuccess(int success, int total, String format) {
return 'Converted $success of $total tracks to $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.';
@override
String get settingsSmartQueueTitle => 'Smart Queue';
@override
String get settingsSmartQueueSubtitle =>
'Secara otomatis temukan dan tambahkan trek serupa ke antrean Anda';
@override
String get whatsNewTitle => 'Yang Baru di 4.0';
@override
String get whatsNewSubtitle =>
'SpotiFLAC telah berevolusi — inilah yang berubah sejak 3.x';
@override
String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0';
@override
String get whatsNewWelcomeDesc =>
'Selamat datang kembali! Ini pembaruan besar dengan banyak fitur baru. Geser untuk melihat apa yang berubah.';
@override
String get whatsNewWelcomeTip1 =>
'Mode streaming baru dengan pemutaran instan';
@override
String get whatsNewWelcomeTip2 =>
'Perpustakaan dan pemutar layar penuh yang didesain ulang';
@override
String get whatsNewWelcomeTip3 =>
'Alat massal, peningkatan performa, dan lainnya';
@override
String get whatsNewStreamingTitle => 'Mode Streaming';
@override
String get whatsNewStreamingDesc =>
'Ketuk trek apa pun untuk langsung diputar — tanpa perlu mengunduh. Pemutar layar penuh dengan lirik tersinkron dan kontrol media.';
@override
String get whatsNewSmartQueueTitle => 'Smart Queue';
@override
String get whatsNewSmartQueueDesc =>
'Antrean Anda otomatis mengkurasi trek terkait dan penemuan artis. Tak pernah kehabisan musik.';
@override
String get whatsNewDualModeTitle => 'Mode Ganda';
@override
String get whatsNewDualModeDesc =>
'Beralih antara mode Pengunduh dan Streaming kapan saja. Semua tombol menyesuaikan secara otomatis.';
@override
String get whatsNewLibraryTitle => 'Perpustakaan Baru';
@override
String get whatsNewLibraryDesc =>
'Tata letak berbasis playlist dengan kategorisasi seret-dan-lepas, sampul kustom, dan aksi massal multi-pilih.';
@override
String get whatsNewPlayerTitle => 'Pemutar Layar Penuh';
@override
String get whatsNewPlayerDesc =>
'Paralaks seni sampul, lirik tersinkron, pemutaran tetap tersimpan saat restart, dan tombol unduh di pemutar.';
@override
String get whatsNewContextMenuTitle => 'Menu Tekan Lama';
@override
String get whatsNewContextMenuDesc =>
'Tekan lama trek apa pun untuk aksi cepat — tambah ke playlist, bagikan, konversi, atau perbarui metadata.';
@override
String get whatsNewPerformanceTitle => 'Performa';
@override
String get whatsNewPerformanceDesc =>
'Startup lebih cepat, penggunaan memori berkurang, penyimpanan berbasis SQLite, dan pembaruan UI yang lebih efisien.';
@override
String get whatsNewBatchToolsTitle => 'Alat Massal';
@override
String get whatsNewBatchToolsDesc =>
'Berbagi multi-pilih, konversi massal ke MP3/Opus, dan perbarui metadata secara massal di seluruh perpustakaan.';
@override
String get whatsNewStreamingTip1 =>
'Ketuk trek apa pun untuk langsung memutar';
@override
String get whatsNewStreamingTip2 => 'Lirik tersinkron di pemutar layar penuh';
@override
String get whatsNewStreamingTip3 => 'Unduh trek langsung dari pemutar';
@override
String get whatsNewSmartQueueTip1 =>
'Antrean terisi otomatis dengan trek terkait';
@override
String get whatsNewSmartQueueTip2 => 'Temukan artis baru saat mendengarkan';
@override
String get whatsNewSmartQueueTip3 =>
'Tak pernah kehabisan musik untuk diputar';
@override
String get whatsNewDualModeTip1 => 'Beralih mode kapan saja di Pengaturan';
@override
String get whatsNewDualModeTip2 => 'Tombol UI menyesuaikan dengan mode Anda';
@override
String get whatsNewDualModeTip3 =>
'Unduh untuk offline, streaming untuk putar langsung';
@override
String get whatsNewLibraryTip1 => 'Seret dan lepas untuk mengatur playlist';
@override
String get whatsNewLibraryTip2 => 'Atur gambar sampul kustom untuk playlist';
@override
String get whatsNewLibraryTip3 => 'Pilih banyak trek untuk aksi massal';
@override
String get whatsNewPlayerTip1 => 'Seni sampul dengan efek paralaks';
@override
String get whatsNewPlayerTip2 => 'Pemutaran tetap tersimpan saat restart';
@override
String get whatsNewPlayerTip3 => 'Lirik tersinkron saat mendengarkan';
@override
String get whatsNewContextMenuTip1 =>
'Tambahkan trek ke playlist mana pun langsung';
@override
String get whatsNewContextMenuTip2 =>
'Bagikan atau konversi dengan satu ketukan';
@override
String get whatsNewContextMenuTip3 => 'Perbarui metadata saat diperlukan';
@override
String get whatsNewBatchToolsTip1 => 'Bagikan banyak trek sekaligus';
@override
String get whatsNewBatchToolsTip2 =>
'Konversi massal ke format MP3 atau Opus';
@override
String get whatsNewBatchToolsTip3 =>
'Perbarui metadata di seluruh perpustakaan';
@override
String get whatsNewPerformanceTip1 => 'Waktu startup aplikasi lebih cepat';
@override
String get whatsNewPerformanceTip2 =>
'Penggunaan memori berkurang saat pemutaran';
@override
String get whatsNewPerformanceTip3 =>
'Penyimpanan berbasis SQLite untuk keandalan';
@override
String get whatsNewReadyMessage => 'Siap — nikmati SpotiFLAC yang baru!';
@override
String get whatsNewGetStarted => 'Ayo Mulai';
@override
String whatsNewPageIndicator(int current, int total) {
return '$current dari $total';
}
}
+251
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';
@@ -3167,4 +3212,210 @@ class AppLocalizationsJa extends AppLocalizations {
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 => '設定からいつでもモードを切り替えられます。';
@override
String get settingsSmartQueueTitle => 'Smart Queue';
@override
String get settingsSmartQueueSubtitle => '類似トラックを自動的に検出してキューに追加';
@override
String get whatsNewTitle => 'What\'s New in 4.0';
@override
String get whatsNewSubtitle =>
'SpotiFLAC has evolved — here\'s what changed since 3.x';
@override
String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0';
@override
String get whatsNewWelcomeDesc =>
'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.';
@override
String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback';
@override
String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player';
@override
String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more';
@override
String get whatsNewStreamingTitle => 'Streaming Mode';
@override
String get whatsNewStreamingDesc =>
'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.';
@override
String get whatsNewSmartQueueTitle => 'Smart Queue';
@override
String get whatsNewSmartQueueDesc =>
'Your queue auto-curates with related tracks and artist discovery. Never run out of music.';
@override
String get whatsNewDualModeTitle => 'Dual Mode';
@override
String get whatsNewDualModeDesc =>
'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.';
@override
String get whatsNewLibraryTitle => 'Redesigned Library';
@override
String get whatsNewLibraryDesc =>
'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.';
@override
String get whatsNewPlayerTitle => 'Full-Screen Player';
@override
String get whatsNewPlayerDesc =>
'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.';
@override
String get whatsNewContextMenuTitle => 'Long-Press Menus';
@override
String get whatsNewContextMenuDesc =>
'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.';
@override
String get whatsNewPerformanceTitle => 'Performance';
@override
String get whatsNewPerformanceDesc =>
'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.';
@override
String get whatsNewBatchToolsTitle => 'Batch Tools';
@override
String get whatsNewBatchToolsDesc =>
'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.';
@override
String get whatsNewStreamingTip1 =>
'Tap any track to start playing instantly';
@override
String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player';
@override
String get whatsNewStreamingTip3 =>
'Download tracks directly from the player';
@override
String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks';
@override
String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen';
@override
String get whatsNewSmartQueueTip3 => 'Never run out of music to play';
@override
String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings';
@override
String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode';
@override
String get whatsNewDualModeTip3 =>
'Download for offline, stream for instant play';
@override
String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists';
@override
String get whatsNewLibraryTip2 => 'Set custom cover images for playlists';
@override
String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions';
@override
String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect';
@override
String get whatsNewPlayerTip2 => 'Playback persists across app restarts';
@override
String get whatsNewPlayerTip3 => 'Synced lyrics while you listen';
@override
String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly';
@override
String get whatsNewContextMenuTip2 => 'Share or convert with one tap';
@override
String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed';
@override
String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once';
@override
String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format';
@override
String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library';
@override
String get whatsNewPerformanceTip1 => 'Faster app startup time';
@override
String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback';
@override
String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability';
@override
String get whatsNewReadyMessage =>
'You\'re all set — enjoy the new SpotiFLAC!';
@override
String get whatsNewGetStarted => 'Let\'s Go';
@override
String whatsNewPageIndicator(int current, int total) {
return '$current of $total';
}
}
+251
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';
@@ -3180,4 +3225,210 @@ class AppLocalizationsKo extends AppLocalizations {
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 => '설정에서 언제든지 모드를 전환할 수 있습니다.';
@override
String get settingsSmartQueueTitle => 'Smart Queue';
@override
String get settingsSmartQueueSubtitle => '유사한 트랙을 자동으로 검색하여 대기열에 추가';
@override
String get whatsNewTitle => 'What\'s New in 4.0';
@override
String get whatsNewSubtitle =>
'SpotiFLAC has evolved — here\'s what changed since 3.x';
@override
String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0';
@override
String get whatsNewWelcomeDesc =>
'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.';
@override
String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback';
@override
String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player';
@override
String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more';
@override
String get whatsNewStreamingTitle => 'Streaming Mode';
@override
String get whatsNewStreamingDesc =>
'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.';
@override
String get whatsNewSmartQueueTitle => 'Smart Queue';
@override
String get whatsNewSmartQueueDesc =>
'Your queue auto-curates with related tracks and artist discovery. Never run out of music.';
@override
String get whatsNewDualModeTitle => 'Dual Mode';
@override
String get whatsNewDualModeDesc =>
'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.';
@override
String get whatsNewLibraryTitle => 'Redesigned Library';
@override
String get whatsNewLibraryDesc =>
'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.';
@override
String get whatsNewPlayerTitle => 'Full-Screen Player';
@override
String get whatsNewPlayerDesc =>
'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.';
@override
String get whatsNewContextMenuTitle => 'Long-Press Menus';
@override
String get whatsNewContextMenuDesc =>
'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.';
@override
String get whatsNewPerformanceTitle => 'Performance';
@override
String get whatsNewPerformanceDesc =>
'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.';
@override
String get whatsNewBatchToolsTitle => 'Batch Tools';
@override
String get whatsNewBatchToolsDesc =>
'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.';
@override
String get whatsNewStreamingTip1 =>
'Tap any track to start playing instantly';
@override
String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player';
@override
String get whatsNewStreamingTip3 =>
'Download tracks directly from the player';
@override
String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks';
@override
String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen';
@override
String get whatsNewSmartQueueTip3 => 'Never run out of music to play';
@override
String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings';
@override
String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode';
@override
String get whatsNewDualModeTip3 =>
'Download for offline, stream for instant play';
@override
String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists';
@override
String get whatsNewLibraryTip2 => 'Set custom cover images for playlists';
@override
String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions';
@override
String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect';
@override
String get whatsNewPlayerTip2 => 'Playback persists across app restarts';
@override
String get whatsNewPlayerTip3 => 'Synced lyrics while you listen';
@override
String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly';
@override
String get whatsNewContextMenuTip2 => 'Share or convert with one tap';
@override
String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed';
@override
String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once';
@override
String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format';
@override
String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library';
@override
String get whatsNewPerformanceTip1 => 'Faster app startup time';
@override
String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback';
@override
String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability';
@override
String get whatsNewReadyMessage =>
'You\'re all set — enjoy the new SpotiFLAC!';
@override
String get whatsNewGetStarted => 'Let\'s Go';
@override
String whatsNewPageIndicator(int current, int total) {
return '$current of $total';
}
}
+259
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';
@@ -3181,4 +3226,218 @@ class AppLocalizationsNl extends AppLocalizations {
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.';
@override
String get settingsSmartQueueTitle => 'Smart Queue';
@override
String get settingsSmartQueueSubtitle =>
'Ontdek automatisch vergelijkbare nummers en voeg ze toe aan je wachtrij';
@override
String get whatsNewTitle => 'What\'s New in 4.0';
@override
String get whatsNewSubtitle =>
'SpotiFLAC has evolved — here\'s what changed since 3.x';
@override
String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0';
@override
String get whatsNewWelcomeDesc =>
'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.';
@override
String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback';
@override
String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player';
@override
String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more';
@override
String get whatsNewStreamingTitle => 'Streaming Mode';
@override
String get whatsNewStreamingDesc =>
'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.';
@override
String get whatsNewSmartQueueTitle => 'Smart Queue';
@override
String get whatsNewSmartQueueDesc =>
'Your queue auto-curates with related tracks and artist discovery. Never run out of music.';
@override
String get whatsNewDualModeTitle => 'Dual Mode';
@override
String get whatsNewDualModeDesc =>
'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.';
@override
String get whatsNewLibraryTitle => 'Redesigned Library';
@override
String get whatsNewLibraryDesc =>
'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.';
@override
String get whatsNewPlayerTitle => 'Full-Screen Player';
@override
String get whatsNewPlayerDesc =>
'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.';
@override
String get whatsNewContextMenuTitle => 'Long-Press Menus';
@override
String get whatsNewContextMenuDesc =>
'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.';
@override
String get whatsNewPerformanceTitle => 'Performance';
@override
String get whatsNewPerformanceDesc =>
'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.';
@override
String get whatsNewBatchToolsTitle => 'Batch Tools';
@override
String get whatsNewBatchToolsDesc =>
'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.';
@override
String get whatsNewStreamingTip1 =>
'Tap any track to start playing instantly';
@override
String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player';
@override
String get whatsNewStreamingTip3 =>
'Download tracks directly from the player';
@override
String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks';
@override
String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen';
@override
String get whatsNewSmartQueueTip3 => 'Never run out of music to play';
@override
String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings';
@override
String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode';
@override
String get whatsNewDualModeTip3 =>
'Download for offline, stream for instant play';
@override
String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists';
@override
String get whatsNewLibraryTip2 => 'Set custom cover images for playlists';
@override
String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions';
@override
String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect';
@override
String get whatsNewPlayerTip2 => 'Playback persists across app restarts';
@override
String get whatsNewPlayerTip3 => 'Synced lyrics while you listen';
@override
String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly';
@override
String get whatsNewContextMenuTip2 => 'Share or convert with one tap';
@override
String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed';
@override
String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once';
@override
String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format';
@override
String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library';
@override
String get whatsNewPerformanceTip1 => 'Faster app startup time';
@override
String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback';
@override
String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability';
@override
String get whatsNewReadyMessage =>
'You\'re all set — enjoy the new SpotiFLAC!';
@override
String get whatsNewGetStarted => 'Let\'s Go';
@override
String whatsNewPageIndicator(int current, int total) {
return '$current of $total';
}
}
+307
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';
@@ -3181,6 +3226,220 @@ class AppLocalizationsPt extends AppLocalizations {
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.';
@override
String get settingsSmartQueueTitle => 'Smart Queue';
@override
String get settingsSmartQueueSubtitle =>
'Descubra e adicione automaticamente faixas semelhantes à sua fila';
@override
String get whatsNewTitle => 'What\'s New in 4.0';
@override
String get whatsNewSubtitle =>
'SpotiFLAC has evolved — here\'s what changed since 3.x';
@override
String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0';
@override
String get whatsNewWelcomeDesc =>
'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.';
@override
String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback';
@override
String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player';
@override
String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more';
@override
String get whatsNewStreamingTitle => 'Streaming Mode';
@override
String get whatsNewStreamingDesc =>
'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.';
@override
String get whatsNewSmartQueueTitle => 'Smart Queue';
@override
String get whatsNewSmartQueueDesc =>
'Your queue auto-curates with related tracks and artist discovery. Never run out of music.';
@override
String get whatsNewDualModeTitle => 'Dual Mode';
@override
String get whatsNewDualModeDesc =>
'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.';
@override
String get whatsNewLibraryTitle => 'Redesigned Library';
@override
String get whatsNewLibraryDesc =>
'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.';
@override
String get whatsNewPlayerTitle => 'Full-Screen Player';
@override
String get whatsNewPlayerDesc =>
'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.';
@override
String get whatsNewContextMenuTitle => 'Long-Press Menus';
@override
String get whatsNewContextMenuDesc =>
'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.';
@override
String get whatsNewPerformanceTitle => 'Performance';
@override
String get whatsNewPerformanceDesc =>
'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.';
@override
String get whatsNewBatchToolsTitle => 'Batch Tools';
@override
String get whatsNewBatchToolsDesc =>
'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.';
@override
String get whatsNewStreamingTip1 =>
'Tap any track to start playing instantly';
@override
String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player';
@override
String get whatsNewStreamingTip3 =>
'Download tracks directly from the player';
@override
String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks';
@override
String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen';
@override
String get whatsNewSmartQueueTip3 => 'Never run out of music to play';
@override
String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings';
@override
String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode';
@override
String get whatsNewDualModeTip3 =>
'Download for offline, stream for instant play';
@override
String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists';
@override
String get whatsNewLibraryTip2 => 'Set custom cover images for playlists';
@override
String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions';
@override
String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect';
@override
String get whatsNewPlayerTip2 => 'Playback persists across app restarts';
@override
String get whatsNewPlayerTip3 => 'Synced lyrics while you listen';
@override
String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly';
@override
String get whatsNewContextMenuTip2 => 'Share or convert with one tap';
@override
String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed';
@override
String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once';
@override
String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format';
@override
String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library';
@override
String get whatsNewPerformanceTip1 => 'Faster app startup time';
@override
String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback';
@override
String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability';
@override
String get whatsNewReadyMessage =>
'You\'re all set — enjoy the new SpotiFLAC!';
@override
String get whatsNewGetStarted => 'Let\'s Go';
@override
String whatsNewPageIndicator(int current, int total) {
return '$current of $total';
}
}
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
@@ -6141,4 +6400,52 @@ 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.';
@override
String get settingsSmartQueueTitle => 'Smart Queue';
@override
String get settingsSmartQueueSubtitle =>
'Descubra e adicione automaticamente faixas semelhantes à sua fila';
}
+259
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 треков в очередь';
@@ -3279,4 +3324,218 @@ class AppLocalizationsRu extends AppLocalizations {
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 =>
'Вы можете переключаться между режимами в любое время в Настройках.';
@override
String get settingsSmartQueueTitle => 'Smart Queue';
@override
String get settingsSmartQueueSubtitle =>
'Автоматически находите и добавляйте похожие треки в очередь воспроизведения';
@override
String get whatsNewTitle => 'What\'s New in 4.0';
@override
String get whatsNewSubtitle =>
'SpotiFLAC has evolved — here\'s what changed since 3.x';
@override
String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0';
@override
String get whatsNewWelcomeDesc =>
'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.';
@override
String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback';
@override
String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player';
@override
String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more';
@override
String get whatsNewStreamingTitle => 'Streaming Mode';
@override
String get whatsNewStreamingDesc =>
'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.';
@override
String get whatsNewSmartQueueTitle => 'Smart Queue';
@override
String get whatsNewSmartQueueDesc =>
'Your queue auto-curates with related tracks and artist discovery. Never run out of music.';
@override
String get whatsNewDualModeTitle => 'Dual Mode';
@override
String get whatsNewDualModeDesc =>
'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.';
@override
String get whatsNewLibraryTitle => 'Redesigned Library';
@override
String get whatsNewLibraryDesc =>
'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.';
@override
String get whatsNewPlayerTitle => 'Full-Screen Player';
@override
String get whatsNewPlayerDesc =>
'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.';
@override
String get whatsNewContextMenuTitle => 'Long-Press Menus';
@override
String get whatsNewContextMenuDesc =>
'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.';
@override
String get whatsNewPerformanceTitle => 'Performance';
@override
String get whatsNewPerformanceDesc =>
'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.';
@override
String get whatsNewBatchToolsTitle => 'Batch Tools';
@override
String get whatsNewBatchToolsDesc =>
'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.';
@override
String get whatsNewStreamingTip1 =>
'Tap any track to start playing instantly';
@override
String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player';
@override
String get whatsNewStreamingTip3 =>
'Download tracks directly from the player';
@override
String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks';
@override
String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen';
@override
String get whatsNewSmartQueueTip3 => 'Never run out of music to play';
@override
String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings';
@override
String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode';
@override
String get whatsNewDualModeTip3 =>
'Download for offline, stream for instant play';
@override
String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists';
@override
String get whatsNewLibraryTip2 => 'Set custom cover images for playlists';
@override
String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions';
@override
String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect';
@override
String get whatsNewPlayerTip2 => 'Playback persists across app restarts';
@override
String get whatsNewPlayerTip3 => 'Synced lyrics while you listen';
@override
String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly';
@override
String get whatsNewContextMenuTip2 => 'Share or convert with one tap';
@override
String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed';
@override
String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once';
@override
String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format';
@override
String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library';
@override
String get whatsNewPerformanceTip1 => 'Faster app startup time';
@override
String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback';
@override
String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability';
@override
String get whatsNewReadyMessage =>
'You\'re all set — enjoy the new SpotiFLAC!';
@override
String get whatsNewGetStarted => 'Let\'s Go';
@override
String whatsNewPageIndicator(int current, int total) {
return '$current of $total';
}
}
+258
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';
@@ -3196,4 +3241,217 @@ class AppLocalizationsTr extends AppLocalizations {
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.';
@override
String get settingsSmartQueueTitle => 'Smart Queue';
@override
String get settingsSmartQueueSubtitle =>
'Sıranıza otomatik olarak benzer parçalar keşfedin ve ekleyin';
@override
String get whatsNewTitle => 'What\'s New in 4.0';
@override
String get whatsNewSubtitle =>
'SpotiFLAC has evolved — here\'s what changed since 3.x';
@override
String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0';
@override
String get whatsNewWelcomeDesc =>
'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.';
@override
String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback';
@override
String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player';
@override
String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more';
@override
String get whatsNewStreamingTitle => 'Streaming Mode';
@override
String get whatsNewStreamingDesc =>
'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.';
@override
String get whatsNewSmartQueueTitle => 'Smart Queue';
@override
String get whatsNewSmartQueueDesc =>
'Your queue auto-curates with related tracks and artist discovery. Never run out of music.';
@override
String get whatsNewDualModeTitle => 'Dual Mode';
@override
String get whatsNewDualModeDesc =>
'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.';
@override
String get whatsNewLibraryTitle => 'Redesigned Library';
@override
String get whatsNewLibraryDesc =>
'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.';
@override
String get whatsNewPlayerTitle => 'Full-Screen Player';
@override
String get whatsNewPlayerDesc =>
'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.';
@override
String get whatsNewContextMenuTitle => 'Long-Press Menus';
@override
String get whatsNewContextMenuDesc =>
'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.';
@override
String get whatsNewPerformanceTitle => 'Performance';
@override
String get whatsNewPerformanceDesc =>
'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.';
@override
String get whatsNewBatchToolsTitle => 'Batch Tools';
@override
String get whatsNewBatchToolsDesc =>
'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.';
@override
String get whatsNewStreamingTip1 =>
'Tap any track to start playing instantly';
@override
String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player';
@override
String get whatsNewStreamingTip3 =>
'Download tracks directly from the player';
@override
String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks';
@override
String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen';
@override
String get whatsNewSmartQueueTip3 => 'Never run out of music to play';
@override
String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings';
@override
String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode';
@override
String get whatsNewDualModeTip3 =>
'Download for offline, stream for instant play';
@override
String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists';
@override
String get whatsNewLibraryTip2 => 'Set custom cover images for playlists';
@override
String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions';
@override
String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect';
@override
String get whatsNewPlayerTip2 => 'Playback persists across app restarts';
@override
String get whatsNewPlayerTip3 => 'Synced lyrics while you listen';
@override
String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly';
@override
String get whatsNewContextMenuTip2 => 'Share or convert with one tap';
@override
String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed';
@override
String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once';
@override
String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format';
@override
String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library';
@override
String get whatsNewPerformanceTip1 => 'Faster app startup time';
@override
String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback';
@override
String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability';
@override
String get whatsNewReadyMessage =>
'You\'re all set — enjoy the new SpotiFLAC!';
@override
String get whatsNewGetStarted => 'Let\'s Go';
@override
String whatsNewPageIndicator(int current, int total) {
return '$current of $total';
}
}
+328
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';
@@ -3181,6 +3226,211 @@ class AppLocalizationsZh extends AppLocalizations {
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 => '您可以随时在设置中切换模式。';
@override
String get settingsSmartQueueTitle => 'Smart Queue';
@override
String get settingsSmartQueueSubtitle => '自动发现并将相似曲目添加到您的队列中';
@override
String get whatsNewTitle => 'What\'s New in 4.0';
@override
String get whatsNewSubtitle =>
'SpotiFLAC has evolved — here\'s what changed since 3.x';
@override
String get whatsNewWelcomeTitle => 'SpotiFLAC Mobile 4.0';
@override
String get whatsNewWelcomeDesc =>
'Welcome back! This is a major update packed with new features. Swipe through to see what\'s changed.';
@override
String get whatsNewWelcomeTip1 => 'New streaming mode with instant playback';
@override
String get whatsNewWelcomeTip2 => 'Redesigned library and full-screen player';
@override
String get whatsNewWelcomeTip3 => 'Batch tools, performance boosts, and more';
@override
String get whatsNewStreamingTitle => 'Streaming Mode';
@override
String get whatsNewStreamingDesc =>
'Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.';
@override
String get whatsNewSmartQueueTitle => 'Smart Queue';
@override
String get whatsNewSmartQueueDesc =>
'Your queue auto-curates with related tracks and artist discovery. Never run out of music.';
@override
String get whatsNewDualModeTitle => 'Dual Mode';
@override
String get whatsNewDualModeDesc =>
'Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.';
@override
String get whatsNewLibraryTitle => 'Redesigned Library';
@override
String get whatsNewLibraryDesc =>
'Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.';
@override
String get whatsNewPlayerTitle => 'Full-Screen Player';
@override
String get whatsNewPlayerDesc =>
'Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.';
@override
String get whatsNewContextMenuTitle => 'Long-Press Menus';
@override
String get whatsNewContextMenuDesc =>
'Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.';
@override
String get whatsNewPerformanceTitle => 'Performance';
@override
String get whatsNewPerformanceDesc =>
'Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.';
@override
String get whatsNewBatchToolsTitle => 'Batch Tools';
@override
String get whatsNewBatchToolsDesc =>
'Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.';
@override
String get whatsNewStreamingTip1 =>
'Tap any track to start playing instantly';
@override
String get whatsNewStreamingTip2 => 'Synced lyrics in the full-screen player';
@override
String get whatsNewStreamingTip3 =>
'Download tracks directly from the player';
@override
String get whatsNewSmartQueueTip1 => 'Queue auto-fills with related tracks';
@override
String get whatsNewSmartQueueTip2 => 'Discover new artists as you listen';
@override
String get whatsNewSmartQueueTip3 => 'Never run out of music to play';
@override
String get whatsNewDualModeTip1 => 'Switch modes anytime in Settings';
@override
String get whatsNewDualModeTip2 => 'UI buttons adapt to your current mode';
@override
String get whatsNewDualModeTip3 =>
'Download for offline, stream for instant play';
@override
String get whatsNewLibraryTip1 => 'Drag and drop to organize playlists';
@override
String get whatsNewLibraryTip2 => 'Set custom cover images for playlists';
@override
String get whatsNewLibraryTip3 => 'Multi-select tracks for batch actions';
@override
String get whatsNewPlayerTip1 => 'Cover art with parallax scrolling effect';
@override
String get whatsNewPlayerTip2 => 'Playback persists across app restarts';
@override
String get whatsNewPlayerTip3 => 'Synced lyrics while you listen';
@override
String get whatsNewContextMenuTip1 => 'Add tracks to any playlist instantly';
@override
String get whatsNewContextMenuTip2 => 'Share or convert with one tap';
@override
String get whatsNewContextMenuTip3 => 'Re-enrich metadata when needed';
@override
String get whatsNewBatchToolsTip1 => 'Share multiple tracks at once';
@override
String get whatsNewBatchToolsTip2 => 'Batch convert to MP3 or Opus format';
@override
String get whatsNewBatchToolsTip3 => 'Re-enrich metadata across your library';
@override
String get whatsNewPerformanceTip1 => 'Faster app startup time';
@override
String get whatsNewPerformanceTip2 => 'Reduced memory usage during playback';
@override
String get whatsNewPerformanceTip3 => 'SQLite-backed storage for reliability';
@override
String get whatsNewReadyMessage =>
'You\'re all set — enjoy the new SpotiFLAC!';
@override
String get whatsNewGetStarted => 'Let\'s Go';
@override
String whatsNewPageIndicator(int current, int total) {
return '$current of $total';
}
}
/// The translations for Chinese, as used in China (`zh_CN`).
@@ -6114,6 +6364,45 @@ 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 => '您可以随时在设置中切换模式。';
@override
String get settingsSmartQueueTitle => 'Smart Queue';
@override
String get settingsSmartQueueSubtitle => '自动发现并将相似曲目添加到您的队列中';
}
/// The translations for Chinese, as used in Taiwan (`zh_TW`).
@@ -9047,4 +9336,43 @@ 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 => '您可以隨時在設定中切換模式。';
@override
String get settingsSmartQueueTitle => 'Smart Queue';
@override
String get settingsSmartQueueSubtitle => '自動探索並將相似曲目新增到您的佇列中';
}
+14 -1
View File
@@ -3868,5 +3868,18 @@
"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.",
"settingsSmartQueueTitle": "Smart Queue",
"settingsSmartQueueSubtitle": "Automatisch ähnliche Titel entdecken und zu deiner Warteschlange hinzufügen"
}
+166
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",
@@ -2441,5 +2472,140 @@
"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"},
"settingsSmartQueueTitle": "Smart Queue",
"@settingsSmartQueueTitle": {"description": "Title for Smart Queue toggle in settings"},
"settingsSmartQueueSubtitle": "Automatically discover and add similar tracks to your queue",
"@settingsSmartQueueSubtitle": {"description": "Subtitle for Smart Queue toggle in settings"},
"whatsNewTitle": "What's New in 4.0",
"@whatsNewTitle": {"description": "Title for the What's New screen"},
"whatsNewSubtitle": "SpotiFLAC has evolved — here's what changed since 3.x",
"@whatsNewSubtitle": {"description": "Subtitle for the What's New screen"},
"whatsNewWelcomeTitle": "SpotiFLAC Mobile 4.0",
"@whatsNewWelcomeTitle": {"description": "Welcome page title in What's New screen"},
"whatsNewWelcomeDesc": "Welcome back! This is a major update packed with new features. Swipe through to see what's changed.",
"@whatsNewWelcomeDesc": {"description": "Welcome page description in What's New screen"},
"whatsNewWelcomeTip1": "New streaming mode with instant playback",
"@whatsNewWelcomeTip1": {"description": "Welcome page tip 1"},
"whatsNewWelcomeTip2": "Redesigned library and full-screen player",
"@whatsNewWelcomeTip2": {"description": "Welcome page tip 2"},
"whatsNewWelcomeTip3": "Batch tools, performance boosts, and more",
"@whatsNewWelcomeTip3": {"description": "Welcome page tip 3"},
"whatsNewStreamingTitle": "Streaming Mode",
"@whatsNewStreamingTitle": {"description": "What's New feature: Streaming Mode title"},
"whatsNewStreamingDesc": "Tap any track to play instantly — no download needed. Full-screen player with synced lyrics and media controls.",
"@whatsNewStreamingDesc": {"description": "What's New feature: Streaming Mode description"},
"whatsNewSmartQueueTitle": "Smart Queue",
"@whatsNewSmartQueueTitle": {"description": "What's New feature: Smart Queue title"},
"whatsNewSmartQueueDesc": "Your queue auto-curates with related tracks and artist discovery. Never run out of music.",
"@whatsNewSmartQueueDesc": {"description": "What's New feature: Smart Queue description"},
"whatsNewDualModeTitle": "Dual Mode",
"@whatsNewDualModeTitle": {"description": "What's New feature: Dual Mode title"},
"whatsNewDualModeDesc": "Switch between Downloader and Streaming modes anytime. All buttons adapt automatically.",
"@whatsNewDualModeDesc": {"description": "What's New feature: Dual Mode description"},
"whatsNewLibraryTitle": "Redesigned Library",
"@whatsNewLibraryTitle": {"description": "What's New feature: Library redesign title"},
"whatsNewLibraryDesc": "Playlist-first layout with drag-and-drop categorization, custom covers, and multi-select batch actions.",
"@whatsNewLibraryDesc": {"description": "What's New feature: Library redesign description"},
"whatsNewPlayerTitle": "Full-Screen Player",
"@whatsNewPlayerTitle": {"description": "What's New feature: Full-Screen Player title"},
"whatsNewPlayerDesc": "Cover art parallax, synced lyrics, playback persistence across restarts, and download button in player.",
"@whatsNewPlayerDesc": {"description": "What's New feature: Full-Screen Player description"},
"whatsNewContextMenuTitle": "Long-Press Menus",
"@whatsNewContextMenuTitle": {"description": "What's New feature: Context Menus title"},
"whatsNewContextMenuDesc": "Long-press any track for quick actions — add to playlist, share, convert, or re-enrich metadata.",
"@whatsNewContextMenuDesc": {"description": "What's New feature: Context Menus description"},
"whatsNewPerformanceTitle": "Performance",
"@whatsNewPerformanceTitle": {"description": "What's New feature: Performance title"},
"whatsNewPerformanceDesc": "Faster startup, reduced memory usage, SQLite-backed persistence, and granular UI updates.",
"@whatsNewPerformanceDesc": {"description": "What's New feature: Performance description"},
"whatsNewBatchToolsTitle": "Batch Tools",
"@whatsNewBatchToolsTitle": {"description": "What's New feature: Batch Tools title"},
"whatsNewBatchToolsDesc": "Multi-select share, batch convert to MP3/Opus, and batch re-enrich metadata across your library.",
"@whatsNewBatchToolsDesc": {"description": "What's New feature: Batch Tools description"},
"whatsNewStreamingTip1": "Tap any track to start playing instantly",
"@whatsNewStreamingTip1": {"description": "What's New tip: streaming instant play"},
"whatsNewStreamingTip2": "Synced lyrics in the full-screen player",
"@whatsNewStreamingTip2": {"description": "What's New tip: streaming synced lyrics"},
"whatsNewStreamingTip3": "Download tracks directly from the player",
"@whatsNewStreamingTip3": {"description": "What's New tip: streaming download from player"},
"whatsNewSmartQueueTip1": "Queue auto-fills with related tracks",
"@whatsNewSmartQueueTip1": {"description": "What's New tip: smart queue auto-fill"},
"whatsNewSmartQueueTip2": "Discover new artists as you listen",
"@whatsNewSmartQueueTip2": {"description": "What's New tip: smart queue artist discovery"},
"whatsNewSmartQueueTip3": "Never run out of music to play",
"@whatsNewSmartQueueTip3": {"description": "What's New tip: smart queue endless"},
"whatsNewDualModeTip1": "Switch modes anytime in Settings",
"@whatsNewDualModeTip1": {"description": "What's New tip: dual mode switch"},
"whatsNewDualModeTip2": "UI buttons adapt to your current mode",
"@whatsNewDualModeTip2": {"description": "What's New tip: dual mode adaptive UI"},
"whatsNewDualModeTip3": "Download for offline, stream for instant play",
"@whatsNewDualModeTip3": {"description": "What's New tip: dual mode use cases"},
"whatsNewLibraryTip1": "Drag and drop to organize playlists",
"@whatsNewLibraryTip1": {"description": "What's New tip: library drag and drop"},
"whatsNewLibraryTip2": "Set custom cover images for playlists",
"@whatsNewLibraryTip2": {"description": "What's New tip: library custom covers"},
"whatsNewLibraryTip3": "Multi-select tracks for batch actions",
"@whatsNewLibraryTip3": {"description": "What's New tip: library multi-select"},
"whatsNewPlayerTip1": "Cover art with parallax scrolling effect",
"@whatsNewPlayerTip1": {"description": "What's New tip: player parallax"},
"whatsNewPlayerTip2": "Playback persists across app restarts",
"@whatsNewPlayerTip2": {"description": "What's New tip: player persistence"},
"whatsNewPlayerTip3": "Synced lyrics while you listen",
"@whatsNewPlayerTip3": {"description": "What's New tip: player lyrics"},
"whatsNewContextMenuTip1": "Add tracks to any playlist instantly",
"@whatsNewContextMenuTip1": {"description": "What's New tip: context menu add to playlist"},
"whatsNewContextMenuTip2": "Share or convert with one tap",
"@whatsNewContextMenuTip2": {"description": "What's New tip: context menu share/convert"},
"whatsNewContextMenuTip3": "Re-enrich metadata when needed",
"@whatsNewContextMenuTip3": {"description": "What's New tip: context menu re-enrich"},
"whatsNewBatchToolsTip1": "Share multiple tracks at once",
"@whatsNewBatchToolsTip1": {"description": "What's New tip: batch share"},
"whatsNewBatchToolsTip2": "Batch convert to MP3 or Opus format",
"@whatsNewBatchToolsTip2": {"description": "What's New tip: batch convert"},
"whatsNewBatchToolsTip3": "Re-enrich metadata across your library",
"@whatsNewBatchToolsTip3": {"description": "What's New tip: batch re-enrich"},
"whatsNewPerformanceTip1": "Faster app startup time",
"@whatsNewPerformanceTip1": {"description": "What's New tip: performance startup"},
"whatsNewPerformanceTip2": "Reduced memory usage during playback",
"@whatsNewPerformanceTip2": {"description": "What's New tip: performance memory"},
"whatsNewPerformanceTip3": "SQLite-backed storage for reliability",
"@whatsNewPerformanceTip3": {"description": "What's New tip: performance SQLite"},
"whatsNewReadyMessage": "You're all set — enjoy the new SpotiFLAC!",
"@whatsNewReadyMessage": {"description": "Ready card message on last What's New page"},
"whatsNewGetStarted": "Let's Go",
"@whatsNewGetStarted": {"description": "Button text to dismiss What's New screen"},
"whatsNewPageIndicator": "{current} of {total}",
"@whatsNewPageIndicator": {
"description": "Page indicator text in What's New screen",
"placeholders": {
"current": {"type": "int"},
"total": {"type": "int"}
}
}
}
+14 -1
View File
@@ -2565,5 +2565,18 @@
"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.",
"settingsSmartQueueTitle": "Smart Queue",
"settingsSmartQueueSubtitle": "Descubre y añade automáticamente pistas similares a tu cola de reproducción"
}
+14 -1
View File
@@ -3868,5 +3868,18 @@
"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.",
"settingsSmartQueueTitle": "Smart Queue",
"settingsSmartQueueSubtitle": "Descubre y añade automáticamente pistas similares a tu cola de reproducción"
}
+14 -1
View File
@@ -3868,5 +3868,18 @@
"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.",
"settingsSmartQueueTitle": "Smart Queue",
"settingsSmartQueueSubtitle": "Découvrir et ajouter automatiquement des pistes similaires à votre file d'attente"
}
+14 -1
View File
@@ -3868,5 +3868,18 @@
"trackConvertFailed": "Conversion failed",
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
}
},
"setupModeSelectionTitle": "अपना मोड चुनें",
"setupModeSelectionDescription": "आप SpotiFLAC का उपयोग कैसे करना चाहेंगे? आप इसे बाद में सेटिंग्स में कभी भी बदल सकते हैं।",
"setupModeDownloaderTitle": "डाउनलोडर",
"setupModeDownloaderFeature1": "लॉसलेस FLAC गुणवत्ता में ट्रैक डाउनलोड करें",
"setupModeDownloaderFeature2": "ऑफ़लाइन सुनने के लिए संगीत अपने डिवाइस में सहेजें",
"setupModeDownloaderFeature3": "अपनी स्थानीय संगीत लाइब्रेरी प्रबंधित करें",
"setupModeStreamingTitle": "स्ट्रीमिंग",
"setupModeStreamingFeature1": "बिना डाउनलोड किए तुरंत ट्रैक स्ट्रीम करें",
"setupModeStreamingFeature2": "Smart Queue स्वचालित रूप से आपके लिए नया संगीत खोजता है",
"setupModeStreamingFeature3": "प्लेबैक नियंत्रण के साथ किसी भी ट्रैक को मांग पर चलाएं",
"setupModeChangeableLater": "आप सेटिंग्स में कभी भी मोड बदल सकते हैं।",
"settingsSmartQueueTitle": "Smart Queue",
"settingsSmartQueueSubtitle": "स्वचालित रूप से समान ट्रैक खोजें और अपनी कतार में जोड़ें"
}
+261 -37
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",
@@ -4132,5 +4189,172 @@
"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"}
"@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.",
"settingsSmartQueueTitle": "Smart Queue",
"settingsSmartQueueSubtitle": "Secara otomatis temukan dan tambahkan trek serupa ke antrean Anda",
"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"}
}
},
"whatsNewTitle": "Yang Baru di 4.0",
"@whatsNewTitle": {"description": "Title for the What's New screen"},
"whatsNewSubtitle": "SpotiFLAC telah berevolusi — inilah yang berubah sejak 3.x",
"@whatsNewSubtitle": {"description": "Subtitle for the What's New screen"},
"whatsNewWelcomeTitle": "SpotiFLAC Mobile 4.0",
"@whatsNewWelcomeTitle": {"description": "Welcome page title in What's New screen"},
"whatsNewWelcomeDesc": "Selamat datang kembali! Ini pembaruan besar dengan banyak fitur baru. Geser untuk melihat apa yang berubah.",
"@whatsNewWelcomeDesc": {"description": "Welcome page description in What's New screen"},
"whatsNewWelcomeTip1": "Mode streaming baru dengan pemutaran instan",
"@whatsNewWelcomeTip1": {"description": "Welcome page tip 1"},
"whatsNewWelcomeTip2": "Perpustakaan dan pemutar layar penuh yang didesain ulang",
"@whatsNewWelcomeTip2": {"description": "Welcome page tip 2"},
"whatsNewWelcomeTip3": "Alat massal, peningkatan performa, dan lainnya",
"@whatsNewWelcomeTip3": {"description": "Welcome page tip 3"},
"whatsNewStreamingTitle": "Mode Streaming",
"@whatsNewStreamingTitle": {"description": "What's New feature: Streaming Mode title"},
"whatsNewStreamingDesc": "Ketuk trek apa pun untuk langsung diputar — tanpa perlu mengunduh. Pemutar layar penuh dengan lirik tersinkron dan kontrol media.",
"@whatsNewStreamingDesc": {"description": "What's New feature: Streaming Mode description"},
"whatsNewSmartQueueTitle": "Smart Queue",
"@whatsNewSmartQueueTitle": {"description": "What's New feature: Smart Queue title"},
"whatsNewSmartQueueDesc": "Antrean Anda otomatis mengkurasi trek terkait dan penemuan artis. Tak pernah kehabisan musik.",
"@whatsNewSmartQueueDesc": {"description": "What's New feature: Smart Queue description"},
"whatsNewDualModeTitle": "Mode Ganda",
"@whatsNewDualModeTitle": {"description": "What's New feature: Dual Mode title"},
"whatsNewDualModeDesc": "Beralih antara mode Pengunduh dan Streaming kapan saja. Semua tombol menyesuaikan secara otomatis.",
"@whatsNewDualModeDesc": {"description": "What's New feature: Dual Mode description"},
"whatsNewLibraryTitle": "Perpustakaan Baru",
"@whatsNewLibraryTitle": {"description": "What's New feature: Library redesign title"},
"whatsNewLibraryDesc": "Tata letak berbasis playlist dengan kategorisasi seret-dan-lepas, sampul kustom, dan aksi massal multi-pilih.",
"@whatsNewLibraryDesc": {"description": "What's New feature: Library redesign description"},
"whatsNewPlayerTitle": "Pemutar Layar Penuh",
"@whatsNewPlayerTitle": {"description": "What's New feature: Full-Screen Player title"},
"whatsNewPlayerDesc": "Paralaks seni sampul, lirik tersinkron, pemutaran tetap tersimpan saat restart, dan tombol unduh di pemutar.",
"@whatsNewPlayerDesc": {"description": "What's New feature: Full-Screen Player description"},
"whatsNewContextMenuTitle": "Menu Tekan Lama",
"@whatsNewContextMenuTitle": {"description": "What's New feature: Context Menus title"},
"whatsNewContextMenuDesc": "Tekan lama trek apa pun untuk aksi cepat — tambah ke playlist, bagikan, konversi, atau perbarui metadata.",
"@whatsNewContextMenuDesc": {"description": "What's New feature: Context Menus description"},
"whatsNewPerformanceTitle": "Performa",
"@whatsNewPerformanceTitle": {"description": "What's New feature: Performance title"},
"whatsNewPerformanceDesc": "Startup lebih cepat, penggunaan memori berkurang, penyimpanan berbasis SQLite, dan pembaruan UI yang lebih efisien.",
"@whatsNewPerformanceDesc": {"description": "What's New feature: Performance description"},
"whatsNewBatchToolsTitle": "Alat Massal",
"@whatsNewBatchToolsTitle": {"description": "What's New feature: Batch Tools title"},
"whatsNewBatchToolsDesc": "Berbagi multi-pilih, konversi massal ke MP3/Opus, dan perbarui metadata secara massal di seluruh perpustakaan.",
"@whatsNewBatchToolsDesc": {"description": "What's New feature: Batch Tools description"},
"whatsNewStreamingTip1": "Ketuk trek apa pun untuk langsung memutar",
"@whatsNewStreamingTip1": {"description": "What's New tip: streaming instant play"},
"whatsNewStreamingTip2": "Lirik tersinkron di pemutar layar penuh",
"@whatsNewStreamingTip2": {"description": "What's New tip: streaming synced lyrics"},
"whatsNewStreamingTip3": "Unduh trek langsung dari pemutar",
"@whatsNewStreamingTip3": {"description": "What's New tip: streaming download from player"},
"whatsNewSmartQueueTip1": "Antrean terisi otomatis dengan trek terkait",
"@whatsNewSmartQueueTip1": {"description": "What's New tip: smart queue auto-fill"},
"whatsNewSmartQueueTip2": "Temukan artis baru saat mendengarkan",
"@whatsNewSmartQueueTip2": {"description": "What's New tip: smart queue artist discovery"},
"whatsNewSmartQueueTip3": "Tak pernah kehabisan musik untuk diputar",
"@whatsNewSmartQueueTip3": {"description": "What's New tip: smart queue endless"},
"whatsNewDualModeTip1": "Beralih mode kapan saja di Pengaturan",
"@whatsNewDualModeTip1": {"description": "What's New tip: dual mode switch"},
"whatsNewDualModeTip2": "Tombol UI menyesuaikan dengan mode Anda",
"@whatsNewDualModeTip2": {"description": "What's New tip: dual mode adaptive UI"},
"whatsNewDualModeTip3": "Unduh untuk offline, streaming untuk putar langsung",
"@whatsNewDualModeTip3": {"description": "What's New tip: dual mode use cases"},
"whatsNewLibraryTip1": "Seret dan lepas untuk mengatur playlist",
"@whatsNewLibraryTip1": {"description": "What's New tip: library drag and drop"},
"whatsNewLibraryTip2": "Atur gambar sampul kustom untuk playlist",
"@whatsNewLibraryTip2": {"description": "What's New tip: library custom covers"},
"whatsNewLibraryTip3": "Pilih banyak trek untuk aksi massal",
"@whatsNewLibraryTip3": {"description": "What's New tip: library multi-select"},
"whatsNewPlayerTip1": "Seni sampul dengan efek paralaks",
"@whatsNewPlayerTip1": {"description": "What's New tip: player parallax"},
"whatsNewPlayerTip2": "Pemutaran tetap tersimpan saat restart",
"@whatsNewPlayerTip2": {"description": "What's New tip: player persistence"},
"whatsNewPlayerTip3": "Lirik tersinkron saat mendengarkan",
"@whatsNewPlayerTip3": {"description": "What's New tip: player lyrics"},
"whatsNewContextMenuTip1": "Tambahkan trek ke playlist mana pun langsung",
"@whatsNewContextMenuTip1": {"description": "What's New tip: context menu add to playlist"},
"whatsNewContextMenuTip2": "Bagikan atau konversi dengan satu ketukan",
"@whatsNewContextMenuTip2": {"description": "What's New tip: context menu share/convert"},
"whatsNewContextMenuTip3": "Perbarui metadata saat diperlukan",
"@whatsNewContextMenuTip3": {"description": "What's New tip: context menu re-enrich"},
"whatsNewBatchToolsTip1": "Bagikan banyak trek sekaligus",
"@whatsNewBatchToolsTip1": {"description": "What's New tip: batch share"},
"whatsNewBatchToolsTip2": "Konversi massal ke format MP3 atau Opus",
"@whatsNewBatchToolsTip2": {"description": "What's New tip: batch convert"},
"whatsNewBatchToolsTip3": "Perbarui metadata di seluruh perpustakaan",
"@whatsNewBatchToolsTip3": {"description": "What's New tip: batch re-enrich"},
"whatsNewPerformanceTip1": "Waktu startup aplikasi lebih cepat",
"@whatsNewPerformanceTip1": {"description": "What's New tip: performance startup"},
"whatsNewPerformanceTip2": "Penggunaan memori berkurang saat pemutaran",
"@whatsNewPerformanceTip2": {"description": "What's New tip: performance memory"},
"whatsNewPerformanceTip3": "Penyimpanan berbasis SQLite untuk keandalan",
"@whatsNewPerformanceTip3": {"description": "What's New tip: performance SQLite"},
"whatsNewReadyMessage": "Siap — nikmati SpotiFLAC yang baru!",
"@whatsNewReadyMessage": {"description": "Ready card message on last What's New page"},
"whatsNewGetStarted": "Ayo Mulai",
"@whatsNewGetStarted": {"description": "Button text to dismiss What's New screen"},
"whatsNewPageIndicator": "{current} dari {total}",
"@whatsNewPageIndicator": {
"description": "Page indicator text in What's New screen",
"placeholders": {
"current": {"type": "int"},
"total": {"type": "int"}
}
}
}
+14 -1
View File
@@ -3868,5 +3868,18 @@
"trackConvertFailed": "Conversion failed",
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
}
},
"setupModeSelectionTitle": "モードを選択",
"setupModeSelectionDescription": "SpotiFLACをどのように使いますか?この設定は後からいつでも変更できます。",
"setupModeDownloaderTitle": "ダウンローダー",
"setupModeDownloaderFeature1": "ロスレスFLAC品質でトラックをダウンロード",
"setupModeDownloaderFeature2": "オフライン再生用に音楽をデバイスに保存",
"setupModeDownloaderFeature3": "ローカル音楽ライブラリを管理",
"setupModeStreamingTitle": "ストリーミング",
"setupModeStreamingFeature1": "ダウンロードせずにトラックを即座にストリーミング",
"setupModeStreamingFeature2": "Smart Queueが自動的に新しい音楽を見つけます",
"setupModeStreamingFeature3": "再生コントロールで任意のトラックをオンデマンド再生",
"setupModeChangeableLater": "設定からいつでもモードを切り替えられます。",
"settingsSmartQueueTitle": "Smart Queue",
"settingsSmartQueueSubtitle": "類似トラックを自動的に検出してキューに追加"
}
+14 -1
View File
@@ -3868,5 +3868,18 @@
"trackConvertFailed": "Conversion failed",
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
}
},
"setupModeSelectionTitle": "모드 선택",
"setupModeSelectionDescription": "SpotiFLAC을 어떻게 사용하시겠습니까? 나중에 설정에서 언제든지 변경할 수 있습니다.",
"setupModeDownloaderTitle": "다운로더",
"setupModeDownloaderFeature1": "무손실 FLAC 품질로 트랙 다운로드",
"setupModeDownloaderFeature2": "오프라인 감상을 위해 기기에 음악 저장",
"setupModeDownloaderFeature3": "로컬 음악 라이브러리 관리",
"setupModeStreamingTitle": "스트리밍",
"setupModeStreamingFeature1": "다운로드 없이 트랙을 즉시 스트리밍",
"setupModeStreamingFeature2": "Smart Queue가 자동으로 새로운 음악을 발견합니다",
"setupModeStreamingFeature3": "재생 컨트롤로 원하는 트랙을 온디맨드 재생",
"setupModeChangeableLater": "설정에서 언제든지 모드를 전환할 수 있습니다.",
"settingsSmartQueueTitle": "Smart Queue",
"settingsSmartQueueSubtitle": "유사한 트랙을 자동으로 검색하여 대기열에 추가"
}
+14 -1
View File
@@ -3868,5 +3868,18 @@
"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.",
"settingsSmartQueueTitle": "Smart Queue",
"settingsSmartQueueSubtitle": "Ontdek automatisch vergelijkbare nummers en voeg ze toe aan je wachtrij"
}
+14 -1
View File
@@ -2565,5 +2565,18 @@
"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.",
"settingsSmartQueueTitle": "Smart Queue",
"settingsSmartQueueSubtitle": "Descubra e adicione automaticamente faixas semelhantes à sua fila"
}
+14 -1
View File
@@ -3868,5 +3868,18 @@
"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.",
"settingsSmartQueueTitle": "Smart Queue",
"settingsSmartQueueSubtitle": "Descubra e adicione automaticamente faixas semelhantes à sua fila"
}
+14 -1
View File
@@ -3868,5 +3868,18 @@
"trackConvertFailed": "Conversion failed",
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
}
},
"setupModeSelectionTitle": "Выберите режим",
"setupModeSelectionDescription": "Как вы хотите использовать SpotiFLAC? Вы всегда можете изменить это позже в Настройках.",
"setupModeDownloaderTitle": "Загрузчик",
"setupModeDownloaderFeature1": "Скачивайте треки в качестве FLAC без потерь",
"setupModeDownloaderFeature2": "Сохраняйте музыку на устройство для прослушивания офлайн",
"setupModeDownloaderFeature3": "Управляйте своей локальной музыкальной библиотекой",
"setupModeStreamingTitle": "Стриминг",
"setupModeStreamingFeature1": "Слушайте треки мгновенно без скачивания",
"setupModeStreamingFeature2": "Smart Queue автоматически подбирает новую музыку для вас",
"setupModeStreamingFeature3": "Воспроизводите любой трек по запросу с элементами управления",
"setupModeChangeableLater": "Вы можете переключаться между режимами в любое время в Настройках.",
"settingsSmartQueueTitle": "Smart Queue",
"settingsSmartQueueSubtitle": "Автоматически находите и добавляйте похожие треки в очередь воспроизведения"
}
+14 -1
View File
@@ -3868,5 +3868,18 @@
"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.",
"settingsSmartQueueTitle": "Smart Queue",
"settingsSmartQueueSubtitle": "Sıranıza otomatik olarak benzer parçalar keşfedin ve ekleyin"
}
+14 -1
View File
@@ -2565,5 +2565,18 @@
"utilityFunctions": "Utility Functions",
"@utilityFunctions": {
"description": "Extension capability - utility functions"
}
},
"setupModeSelectionTitle": "选择您的模式",
"setupModeSelectionDescription": "您想如何使用 SpotiFLAC?您可以稍后在设置中随时更改。",
"setupModeDownloaderTitle": "下载器",
"setupModeDownloaderFeature1": "以无损 FLAC 品质下载曲目",
"setupModeDownloaderFeature2": "将音乐保存到设备以供离线收听",
"setupModeDownloaderFeature3": "管理您的本地音乐库",
"setupModeStreamingTitle": "流媒体",
"setupModeStreamingFeature1": "无需下载即可即时播放曲目",
"setupModeStreamingFeature2": "Smart Queue 自动为您发现新音乐",
"setupModeStreamingFeature3": "通过播放控件随时点播任意曲目",
"setupModeChangeableLater": "您可以随时在设置中切换模式。",
"settingsSmartQueueTitle": "Smart Queue",
"settingsSmartQueueSubtitle": "自动发现并将相似曲目添加到您的队列中"
}
+14 -1
View File
@@ -3868,5 +3868,18 @@
"trackConvertFailed": "Conversion failed",
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
}
},
"setupModeSelectionTitle": "选择您的模式",
"setupModeSelectionDescription": "您想如何使用 SpotiFLAC?您可以稍后在设置中随时更改。",
"setupModeDownloaderTitle": "下载器",
"setupModeDownloaderFeature1": "以无损 FLAC 品质下载曲目",
"setupModeDownloaderFeature2": "将音乐保存到设备以供离线收听",
"setupModeDownloaderFeature3": "管理您的本地音乐库",
"setupModeStreamingTitle": "流媒体",
"setupModeStreamingFeature1": "无需下载即可即时播放曲目",
"setupModeStreamingFeature2": "Smart Queue 自动为您发现新音乐",
"setupModeStreamingFeature3": "通过播放控件随时点播任意曲目",
"setupModeChangeableLater": "您可以随时在设置中切换模式。",
"settingsSmartQueueTitle": "Smart Queue",
"settingsSmartQueueSubtitle": "自动发现并将相似曲目添加到您的队列中"
}
+14 -1
View File
@@ -3868,5 +3868,18 @@
"trackConvertFailed": "Conversion failed",
"@trackConvertFailed": {
"description": "Snackbar when conversion fails"
}
},
"setupModeSelectionTitle": "選擇您的模式",
"setupModeSelectionDescription": "您想如何使用 SpotiFLAC?您可以稍後在設定中隨時變更。",
"setupModeDownloaderTitle": "下載器",
"setupModeDownloaderFeature1": "以無損 FLAC 品質下載曲目",
"setupModeDownloaderFeature2": "將音樂儲存到裝置以供離線收聽",
"setupModeDownloaderFeature3": "管理您的本機音樂庫",
"setupModeStreamingTitle": "串流",
"setupModeStreamingFeature1": "無需下載即可即時串流曲目",
"setupModeStreamingFeature2": "Smart Queue 自動為您探索新音樂",
"setupModeStreamingFeature3": "透過播放控制項隨時點播任意曲目",
"setupModeChangeableLater": "您可以隨時在設定中切換模式。",
"settingsSmartQueueTitle": "Smart Queue",
"settingsSmartQueueSubtitle": "自動探索並將相似曲目新增到您的佇列中"
}
+91
View File
@@ -0,0 +1,91 @@
import 'package:spotiflac_android/models/track.dart';
class PlaybackItem {
final String id;
final String title;
final String artist;
final String album;
final String coverUrl;
final String sourceUri;
final bool isLocal;
final String service;
final int durationMs;
// Stream quality metadata
final String format;
final int bitDepth;
final int sampleRate;
final int bitrate;
// Original track reference for queue operations
final Track? track;
const PlaybackItem({
required this.id,
required this.title,
required this.artist,
this.album = '',
this.coverUrl = '',
required this.sourceUri,
this.isLocal = false,
this.service = '',
this.durationMs = 0,
this.format = '',
this.bitDepth = 0,
this.sampleRate = 0,
this.bitrate = 0,
this.track,
});
PlaybackItem copyWith({
String? sourceUri,
String? service,
String? format,
int? bitDepth,
int? sampleRate,
int? bitrate,
}) {
return PlaybackItem(
id: id,
title: title,
artist: artist,
album: album,
coverUrl: coverUrl,
sourceUri: sourceUri ?? this.sourceUri,
isLocal: isLocal,
service: service ?? this.service,
durationMs: durationMs,
format: format ?? this.format,
bitDepth: bitDepth ?? this.bitDepth,
sampleRate: sampleRate ?? this.sampleRate,
bitrate: bitrate ?? this.bitrate,
track: track,
);
}
/// Human-readable quality label for UI display
String get qualityLabel {
final parts = <String>[];
if (format.isNotEmpty) {
parts.add(format.toUpperCase());
}
if (bitDepth > 0 && sampleRate > 0) {
final srKhz = sampleRate >= 1000
? '${(sampleRate / 1000).toStringAsFixed(sampleRate % 1000 == 0 ? 0 : 1)}kHz'
: '${sampleRate}Hz';
parts.add('$bitDepth-bit / $srKhz');
} else if (bitrate > 0) {
parts.add('${bitrate}kbps');
}
return parts.join(' ');
}
/// Whether this item has cover art that is a local file path
bool get hasLocalCover {
if (coverUrl.isEmpty) return false;
return !coverUrl.startsWith('http://') && !coverUrl.startsWith('https://');
}
}
+24
View File
@@ -11,6 +11,9 @@ class AppSettings {
final String storageMode; // 'app' or 'saf'
final String downloadTreeUri; // SAF persistable tree URI
final bool autoFallback;
final bool autoSkipUnavailableTracks;
final bool smartQueueEnabled; // Enable smart curated autoplay queue
final bool embedMetadata; // Master switch for metadata/cover/lyrics embedding
final bool embedLyrics;
final bool maxQualityCover;
final bool isFirstLaunch;
@@ -76,6 +79,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',
@@ -84,6 +91,9 @@ class AppSettings {
this.storageMode = 'app',
this.downloadTreeUri = '',
this.autoFallback = true,
this.autoSkipUnavailableTracks = true,
this.smartQueueEnabled = true,
this.embedMetadata = true,
this.embedLyrics = true,
this.maxQualityCover = true,
this.isFirstLaunch = true,
@@ -127,6 +137,7 @@ class AppSettings {
// Lyrics providers default order
this.lyricsProviders = const [
'lrclib',
'spotify_api',
'musixmatch',
'netease',
'apple_music',
@@ -136,6 +147,8 @@ class AppSettings {
this.lyricsIncludeRomanizationNetease = false,
this.lyricsMultiPersonWordByWord = false,
this.musixmatchLanguage = '',
// Version upgrade tracking
this.lastSeenVersion = '',
});
AppSettings copyWith({
@@ -146,6 +159,9 @@ class AppSettings {
String? storageMode,
String? downloadTreeUri,
bool? autoFallback,
bool? autoSkipUnavailableTracks,
bool? smartQueueEnabled,
bool? embedMetadata,
bool? embedLyrics,
bool? maxQualityCover,
bool? isFirstLaunch,
@@ -193,6 +209,8 @@ class AppSettings {
bool? lyricsIncludeRomanizationNetease,
bool? lyricsMultiPersonWordByWord,
String? musixmatchLanguage,
// Version upgrade tracking
String? lastSeenVersion,
}) {
return AppSettings(
defaultService: defaultService ?? this.defaultService,
@@ -202,6 +220,10 @@ class AppSettings {
storageMode: storageMode ?? this.storageMode,
downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri,
autoFallback: autoFallback ?? this.autoFallback,
autoSkipUnavailableTracks:
autoSkipUnavailableTracks ?? this.autoSkipUnavailableTracks,
smartQueueEnabled: smartQueueEnabled ?? this.smartQueueEnabled,
embedMetadata: embedMetadata ?? this.embedMetadata,
embedLyrics: embedLyrics ?? this.embedLyrics,
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
@@ -264,6 +286,8 @@ class AppSettings {
lyricsMultiPersonWordByWord:
lyricsMultiPersonWordByWord ?? this.lyricsMultiPersonWordByWord,
musixmatchLanguage: musixmatchLanguage ?? this.musixmatchLanguage,
// Version upgrade tracking
lastSeenVersion: lastSeenVersion ?? this.lastSeenVersion,
);
}
+17 -5
View File
@@ -14,6 +14,9 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
storageMode: json['storageMode'] as String? ?? 'app',
downloadTreeUri: json['downloadTreeUri'] as String? ?? '',
autoFallback: json['autoFallback'] as bool? ?? true,
autoSkipUnavailableTracks: json['autoSkipUnavailableTracks'] as bool? ?? true,
smartQueueEnabled: json['smartQueueEnabled'] 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,10 +53,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
autoExportFailedDownloads:
json['autoExportFailedDownloads'] as bool? ?? false,
downloadNetworkMode: json['downloadNetworkMode'] as String? ?? 'any',
networkCompatibilityMode:
json['networkCompatibilityMode'] as bool? ??
json['songLinkCompatibilityMode'] as bool? ??
false,
networkCompatibilityMode: json['networkCompatibilityMode'] as bool? ?? false,
songLinkRegion: json['songLinkRegion'] as String? ?? 'US',
localLibraryEnabled: json['localLibraryEnabled'] as bool? ?? false,
localLibraryPath: json['localLibraryPath'] as String? ?? '',
@@ -64,7 +64,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:
@@ -72,6 +79,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(
@@ -84,6 +92,9 @@ Map<String, dynamic> _$AppSettingsToJson(
'storageMode': instance.storageMode,
'downloadTreeUri': instance.downloadTreeUri,
'autoFallback': instance.autoFallback,
'autoSkipUnavailableTracks': instance.autoSkipUnavailableTracks,
'smartQueueEnabled': instance.smartQueueEnabled,
'embedMetadata': instance.embedMetadata,
'embedLyrics': instance.embedLyrics,
'maxQualityCover': instance.maxQualityCover,
'isFirstLaunch': instance.isFirstLaunch,
@@ -128,4 +139,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,
+401 -211
View File
@@ -702,10 +702,13 @@ class _ProgressUpdate {
class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
Timer? _progressTimer;
Timer? _progressStreamBootstrapTimer;
Timer? _queuePersistDebounce;
StreamSubscription<Map<String, dynamic>>? _progressStreamSub;
int _downloadCount = 0;
static const _cleanupInterval = 50;
static const _progressPollingInterval = Duration(milliseconds: 800);
static const _idleProgressPollEveryTicks = 3;
static const _queueSchedulingInterval = Duration(milliseconds: 250);
static const _queuePersistDebounceDuration = Duration(milliseconds: 350);
static const _bytesUiStep = 104857; // ~0.1 MiB, matches one-decimal MB UI.
@@ -718,6 +721,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final Set<String> _ensuredDirs = {};
int _progressPollingErrorCount = 0;
bool _isProgressPollingInFlight = false;
int _idleProgressPollTick = 0;
bool _hasReceivedProgressStreamEvent = false;
bool _usingProgressStream = false;
String? _lastServiceTrackName;
String? _lastServiceArtistName;
int _lastServicePercent = -1;
@@ -788,7 +794,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
ref.onDispose(() {
_progressTimer?.cancel();
_progressStreamBootstrapTimer?.cancel();
_progressStreamSub?.cancel();
_progressTimer = null;
_progressStreamBootstrapTimer = null;
_progressStreamSub = null;
if (_queuePersistDebounce?.isActive == true) {
_queuePersistDebounce?.cancel();
unawaited(_flushQueueToStorage());
@@ -894,213 +904,105 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
void _startMultiProgressPolling() {
_progressTimer?.cancel();
_progressStreamBootstrapTimer?.cancel();
_progressStreamBootstrapTimer = null;
_progressStreamSub?.cancel();
_progressStreamSub = null;
_hasReceivedProgressStreamEvent = false;
_usingProgressStream = false;
_idleProgressPollTick = 0;
if (Platform.isAndroid || Platform.isIOS) {
_attachDownloadProgressStream();
return;
}
_startMultiProgressPollingTimer();
}
void _attachDownloadProgressStream() {
_progressStreamSub = PlatformBridge.downloadProgressStream().listen(
(allProgress) {
_hasReceivedProgressStreamEvent = true;
_usingProgressStream = true;
_progressStreamBootstrapTimer?.cancel();
_progressStreamBootstrapTimer = null;
if (_isProgressPollingInFlight) return;
_isProgressPollingInFlight = true;
try {
_processAllDownloadProgress(allProgress);
_progressPollingErrorCount = 0;
} catch (e) {
_progressPollingErrorCount++;
if (_progressPollingErrorCount <= 3) {
_log.w('Progress stream processing failed: $e');
}
} finally {
_isProgressPollingInFlight = false;
}
},
onError: (Object error, StackTrace stackTrace) {
if (_usingProgressStream) {
_log.w(
'Download progress stream failed, fallback to polling: $error',
);
}
_progressStreamSub?.cancel();
_progressStreamSub = null;
_usingProgressStream = false;
_progressStreamBootstrapTimer?.cancel();
_progressStreamBootstrapTimer = null;
_startMultiProgressPollingTimer();
},
cancelOnError: false,
);
_progressStreamBootstrapTimer = Timer(const Duration(seconds: 3), () {
if (_hasReceivedProgressStreamEvent) {
return;
}
_log.w('Download progress stream timeout, fallback to polling');
_progressStreamSub?.cancel();
_progressStreamSub = null;
_usingProgressStream = false;
_startMultiProgressPollingTimer();
});
}
void _startMultiProgressPollingTimer() {
_progressTimer?.cancel();
_progressTimer = Timer.periodic(_progressPollingInterval, (timer) async {
if (_isProgressPollingInFlight) return;
_isProgressPollingInFlight = true;
try {
final allProgress = await PlatformBridge.getAllDownloadProgress();
final items = allProgress['items'] as Map<String, dynamic>? ?? {};
final currentItems = state.items;
final itemsById = <String, DownloadItem>{};
final itemIndexById = <String, int>{};
int queuedCount = 0;
int downloadingCount = 0;
DownloadItem? firstDownloading;
for (int i = 0; i < currentItems.length; i++) {
final item = currentItems[i];
itemsById[item.id] = item;
itemIndexById[item.id] = i;
if (item.status == DownloadStatus.downloading) {
downloadingCount++;
firstDownloading ??= item;
}
if (item.status == DownloadStatus.queued ||
item.status == DownloadStatus.downloading) {
queuedCount++;
}
}
final progressUpdates = <String, _ProgressUpdate>{};
final hasQueuedItems = currentItems.any(
(item) => item.status == DownloadStatus.queued,
);
final hasActiveItems = currentItems.any(
(item) =>
item.status == DownloadStatus.downloading ||
item.status == DownloadStatus.finalizing,
);
bool hasFinalizingItem = false;
String? finalizingTrackName;
String? finalizingArtistName;
for (final entry in items.entries) {
final itemId = entry.key;
final localItem = itemsById[itemId];
if (localItem == null) {
continue;
}
if (localItem.status == DownloadStatus.skipped) {
PlatformBridge.clearItemProgress(itemId).catchError((_) {});
continue;
}
if (localItem.status == DownloadStatus.completed ||
localItem.status == DownloadStatus.failed) {
continue;
}
final itemProgress = entry.value as Map<String, dynamic>;
final bytesReceived = itemProgress['bytes_received'] as int? ?? 0;
final bytesTotal = itemProgress['bytes_total'] as int? ?? 0;
final speedMBps =
(itemProgress['speed_mbps'] as num?)?.toDouble() ?? 0.0;
final isDownloading =
itemProgress['is_downloading'] as bool? ?? false;
final status = itemProgress['status'] as String? ?? 'downloading';
if (status == 'finalizing' && bytesTotal > 0) {
progressUpdates[itemId] = const _ProgressUpdate(
status: DownloadStatus.finalizing,
progress: 1.0,
);
hasFinalizingItem = true;
finalizingTrackName = localItem.track.name;
finalizingArtistName = localItem.track.artistName;
continue;
if (!hasActiveItems) {
if (state.isPaused || !hasQueuedItems) {
_idleProgressPollTick = 0;
return;
}
final progressFromBackend =
(itemProgress['progress'] as num?)?.toDouble() ?? 0.0;
if (isDownloading) {
double percentage = 0.0;
if (bytesTotal > 0) {
percentage = bytesReceived / bytesTotal;
} else {
percentage = progressFromBackend;
}
final normalizedProgress = _normalizeProgressForUi(percentage);
final normalizedSpeed = _normalizeSpeedForUi(speedMBps);
final normalizedBytes = _normalizeBytesForUi(bytesReceived);
progressUpdates[itemId] = _ProgressUpdate(
status: DownloadStatus.downloading,
progress: normalizedProgress,
speedMBps: normalizedSpeed,
bytesReceived: normalizedBytes,
);
if (LogBuffer.loggingEnabled) {
final mbReceived = bytesReceived / (1024 * 1024);
final mbTotal = bytesTotal / (1024 * 1024);
if (bytesTotal > 0) {
_log.d(
'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s',
);
} else {
_log.d(
'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (DASH segments/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s',
);
}
}
_idleProgressPollTick =
(_idleProgressPollTick + 1) % _idleProgressPollEveryTicks;
if (_idleProgressPollTick != 0) {
return;
}
} else {
_idleProgressPollTick = 0;
}
if (progressUpdates.isNotEmpty) {
var updatedItems = currentItems;
bool changed = false;
for (final entry in progressUpdates.entries) {
final index = itemIndexById[entry.key];
if (index == null) continue;
final current = updatedItems[index];
if (current.status == DownloadStatus.skipped ||
current.status == DownloadStatus.completed ||
current.status == DownloadStatus.failed) {
continue;
}
final update = entry.value;
final next = current.copyWith(
status: update.status,
progress: update.progress,
speedMBps: update.speedMBps ?? current.speedMBps,
bytesReceived: update.bytesReceived ?? current.bytesReceived,
);
if (current.status != next.status ||
current.progress != next.progress ||
current.speedMBps != next.speedMBps ||
current.bytesReceived != next.bytesReceived) {
if (!changed) {
updatedItems = List<DownloadItem>.from(updatedItems);
changed = true;
}
updatedItems[index] = next;
}
}
if (changed) {
state = state.copyWith(items: updatedItems);
}
}
if (hasFinalizingItem && finalizingTrackName != null) {
final safeArtistName = finalizingArtistName ?? '';
if (finalizingTrackName != _lastFinalizingTrackName ||
safeArtistName != _lastFinalizingArtistName) {
_notificationService.showDownloadFinalizing(
trackName: finalizingTrackName,
artistName: safeArtistName,
);
_lastFinalizingTrackName = finalizingTrackName;
_lastFinalizingArtistName = safeArtistName;
}
return;
}
_lastFinalizingTrackName = null;
_lastFinalizingArtistName = null;
if (items.isNotEmpty) {
final firstEntry = items.entries.first;
final firstProgress = firstEntry.value as Map<String, dynamic>;
final bytesReceived = firstProgress['bytes_received'] as int? ?? 0;
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
if (downloadingCount > 0 && firstDownloading != null) {
final trackName = downloadingCount == 1
? firstDownloading.track.name
: '$downloadingCount downloads';
final artistName = downloadingCount == 1
? firstDownloading.track.artistName
: 'Downloading...';
int notifProgress = bytesReceived;
int notifTotal = bytesTotal;
if (bytesTotal <= 0) {
final progressPercent =
(firstProgress['progress'] as num?)?.toDouble() ?? 0.0;
notifProgress = (progressPercent * 100).toInt();
notifTotal = 100;
}
final safeNotifTotal = notifTotal > 0 ? notifTotal : 1;
if (_shouldUpdateProgressNotification(
trackName: trackName,
artistName: artistName,
progress: notifProgress,
total: safeNotifTotal,
queueCount: queuedCount,
)) {
_notificationService.showDownloadProgress(
trackName: trackName,
artistName: artistName,
progress: notifProgress,
total: safeNotifTotal,
);
}
if (Platform.isAndroid) {
_maybeUpdateAndroidDownloadService(
trackName: firstDownloading.track.name,
artistName: firstDownloading.track.artistName,
progress: notifProgress,
total: safeNotifTotal,
queueCount: queuedCount,
);
}
}
}
final allProgress = await PlatformBridge.getAllDownloadProgress();
_processAllDownloadProgress(allProgress);
_progressPollingErrorCount = 0;
} catch (e) {
_progressPollingErrorCount++;
@@ -1113,6 +1015,221 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
});
}
void _processAllDownloadProgress(Map<String, dynamic> allProgress) {
final rawItems = allProgress['items'];
final items = rawItems is Map
? rawItems.map((key, value) => MapEntry(key.toString(), value))
: const <String, dynamic>{};
final currentItems = state.items;
final itemsById = <String, DownloadItem>{};
final itemIndexById = <String, int>{};
int queuedCount = 0;
int downloadingCount = 0;
DownloadItem? firstDownloading;
for (int i = 0; i < currentItems.length; i++) {
final item = currentItems[i];
itemsById[item.id] = item;
itemIndexById[item.id] = i;
if (item.status == DownloadStatus.downloading) {
downloadingCount++;
firstDownloading ??= item;
}
if (item.status == DownloadStatus.queued ||
item.status == DownloadStatus.downloading) {
queuedCount++;
}
}
final progressUpdates = <String, _ProgressUpdate>{};
bool hasFinalizingItem = false;
String? finalizingTrackName;
String? finalizingArtistName;
for (final entry in items.entries) {
final itemId = entry.key;
final localItem = itemsById[itemId];
if (localItem == null) {
continue;
}
if (localItem.status == DownloadStatus.skipped) {
PlatformBridge.clearItemProgress(itemId).catchError((_) {});
continue;
}
if (localItem.status == DownloadStatus.completed ||
localItem.status == DownloadStatus.failed) {
continue;
}
final rawItemProgress = entry.value;
if (rawItemProgress is! Map) {
continue;
}
final itemProgress = Map<String, dynamic>.from(rawItemProgress);
final bytesReceived =
(itemProgress['bytes_received'] as num?)?.toInt() ?? 0;
final bytesTotal = (itemProgress['bytes_total'] as num?)?.toInt() ?? 0;
final speedMBps = (itemProgress['speed_mbps'] as num?)?.toDouble() ?? 0.0;
final isDownloading = itemProgress['is_downloading'] as bool? ?? false;
final status = itemProgress['status'] as String? ?? 'downloading';
if (status == 'finalizing' && bytesTotal > 0) {
progressUpdates[itemId] = const _ProgressUpdate(
status: DownloadStatus.finalizing,
progress: 1.0,
);
hasFinalizingItem = true;
finalizingTrackName = localItem.track.name;
finalizingArtistName = localItem.track.artistName;
continue;
}
final progressFromBackend =
(itemProgress['progress'] as num?)?.toDouble() ?? 0.0;
if (isDownloading) {
double percentage = 0.0;
if (bytesTotal > 0) {
percentage = bytesReceived / bytesTotal;
} else {
percentage = progressFromBackend;
}
final normalizedProgress = _normalizeProgressForUi(percentage);
final normalizedSpeed = _normalizeSpeedForUi(speedMBps);
final normalizedBytes = _normalizeBytesForUi(bytesReceived);
progressUpdates[itemId] = _ProgressUpdate(
status: DownloadStatus.downloading,
progress: normalizedProgress,
speedMBps: normalizedSpeed,
bytesReceived: normalizedBytes,
);
if (LogBuffer.loggingEnabled) {
final mbReceived = bytesReceived / (1024 * 1024);
final mbTotal = bytesTotal / (1024 * 1024);
if (bytesTotal > 0) {
_log.d(
'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s',
);
} else {
_log.d(
'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (DASH segments/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s',
);
}
}
}
}
if (progressUpdates.isNotEmpty) {
var updatedItems = currentItems;
bool changed = false;
for (final entry in progressUpdates.entries) {
final index = itemIndexById[entry.key];
if (index == null) continue;
final current = updatedItems[index];
if (current.status == DownloadStatus.skipped ||
current.status == DownloadStatus.completed ||
current.status == DownloadStatus.failed) {
continue;
}
final update = entry.value;
final next = current.copyWith(
status: update.status,
progress: update.progress,
speedMBps: update.speedMBps ?? current.speedMBps,
bytesReceived: update.bytesReceived ?? current.bytesReceived,
);
if (current.status != next.status ||
current.progress != next.progress ||
current.speedMBps != next.speedMBps ||
current.bytesReceived != next.bytesReceived) {
if (!changed) {
updatedItems = List<DownloadItem>.from(updatedItems);
changed = true;
}
updatedItems[index] = next;
}
}
if (changed) {
state = state.copyWith(items: updatedItems);
}
}
if (hasFinalizingItem && finalizingTrackName != null) {
final safeArtistName = finalizingArtistName ?? '';
if (finalizingTrackName != _lastFinalizingTrackName ||
safeArtistName != _lastFinalizingArtistName) {
_notificationService.showDownloadFinalizing(
trackName: finalizingTrackName,
artistName: safeArtistName,
);
_lastFinalizingTrackName = finalizingTrackName;
_lastFinalizingArtistName = safeArtistName;
}
return;
}
_lastFinalizingTrackName = null;
_lastFinalizingArtistName = null;
if (items.isNotEmpty) {
final firstEntry = items.entries.first;
final rawFirstProgress = firstEntry.value;
if (rawFirstProgress is! Map) {
return;
}
final firstProgress = Map<String, dynamic>.from(rawFirstProgress);
final bytesReceived =
(firstProgress['bytes_received'] as num?)?.toInt() ?? 0;
final bytesTotal = (firstProgress['bytes_total'] as num?)?.toInt() ?? 0;
if (downloadingCount > 0 && firstDownloading != null) {
final trackName = downloadingCount == 1
? firstDownloading.track.name
: '$downloadingCount downloads';
final artistName = downloadingCount == 1
? firstDownloading.track.artistName
: 'Downloading...';
int notifProgress = bytesReceived;
int notifTotal = bytesTotal;
if (bytesTotal <= 0) {
final progressPercent =
(firstProgress['progress'] as num?)?.toDouble() ?? 0.0;
notifProgress = (progressPercent * 100).toInt();
notifTotal = 100;
}
final safeNotifTotal = notifTotal > 0 ? notifTotal : 1;
if (_shouldUpdateProgressNotification(
trackName: trackName,
artistName: artistName,
progress: notifProgress,
total: safeNotifTotal,
queueCount: queuedCount,
)) {
_notificationService.showDownloadProgress(
trackName: trackName,
artistName: artistName,
progress: notifProgress,
total: safeNotifTotal,
);
}
if (Platform.isAndroid) {
_maybeUpdateAndroidDownloadService(
trackName: firstDownloading.track.name,
artistName: firstDownloading.track.artistName,
progress: notifProgress,
total: safeNotifTotal,
queueCount: queuedCount,
);
}
}
}
}
void _maybeUpdateAndroidDownloadService({
required String trackName,
required String artistName,
@@ -1156,9 +1273,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
void _stopProgressPolling() {
_progressTimer?.cancel();
_progressStreamBootstrapTimer?.cancel();
_progressStreamSub?.cancel();
_progressTimer = null;
_progressStreamBootstrapTimer = null;
_progressStreamSub = null;
_progressPollingErrorCount = 0;
_isProgressPollingInFlight = false;
_idleProgressPollTick = 0;
_hasReceivedProgressStreamEvent = false;
_usingProgressStream = false;
_lastServiceTrackName = null;
_lastServiceArtistName = null;
_lastServicePercent = -1;
@@ -1926,6 +2050,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
artistName: baseTrack.artistName,
albumName: backendAlbum ?? baseTrack.albumName,
albumArtist: resolvedAlbumArtist,
artistId: baseTrack.artistId,
albumId: baseTrack.albumId,
coverUrl: baseTrack.coverUrl,
duration: baseTrack.duration,
isrc: baseTrack.isrc,
@@ -1945,8 +2071,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String? genre,
String? label,
String? copyright,
bool writeExternalLrc = true,
}) async {
final settings = ref.read(settingsProvider);
if (!settings.embedMetadata) {
_log.d('Metadata embedding disabled, skipping FLAC metadata/cover embed');
return;
}
String? coverPath;
var coverUrl = track.coverUrl;
@@ -2030,12 +2161,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final shouldEmbedLyrics =
settings.embedLyrics &&
(lyricsMode == 'embed' || lyricsMode == 'both');
final shouldSaveExternalLyrics =
settings.embedLyrics &&
(lyricsMode == 'external' || lyricsMode == 'both');
final shouldFetchLyrics = shouldEmbedLyrics || shouldSaveExternalLyrics;
String? lrcContent;
if (shouldEmbedLyrics) {
if (shouldFetchLyrics) {
try {
final durationMs = track.duration * 1000;
final lrcContent = await PlatformBridge.getLyricsLRC(
final fetchedLrc = await PlatformBridge.getLyricsLRC(
track.id,
track.name,
track.artistName,
@@ -2043,20 +2179,46 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
durationMs: durationMs,
);
if (lrcContent.isNotEmpty && lrcContent != '[instrumental:true]') {
metadata['LYRICS'] = lrcContent;
metadata['UNSYNCEDLYRICS'] = lrcContent;
_log.d('Lyrics fetched for embedding (${lrcContent.length} chars)');
} else if (lrcContent == '[instrumental:true]') {
_log.d('Track is instrumental, skipping lyrics embedding');
if (fetchedLrc.isNotEmpty && fetchedLrc != '[instrumental:true]') {
lrcContent = fetchedLrc;
_log.d('Lyrics fetched for FLAC (${fetchedLrc.length} chars)');
} else if (fetchedLrc == '[instrumental:true]') {
_log.d('Track is instrumental, skipping lyrics handling');
} else {
_log.d('No lyrics returned for FLAC download');
}
} catch (e) {
_log.w('Failed to fetch lyrics for embedding: $e');
_log.w('Failed to fetch lyrics for FLAC: $e');
}
}
if (shouldEmbedLyrics) {
if (lrcContent != null) {
metadata['LYRICS'] = lrcContent;
metadata['UNSYNCEDLYRICS'] = lrcContent;
_log.d('Lyrics added to FLAC metadata');
} else {
_log.d('No lyrics available for FLAC embedding');
}
} else {
metadata['LYRICS'] = '';
metadata['UNSYNCEDLYRICS'] = '';
_log.d('Lyrics embedding disabled by settings, skipping lyric fetch');
_log.d(
'Lyrics embedding disabled by settings, skipping lyric embedding',
);
}
if (writeExternalLrc && shouldSaveExternalLyrics && lrcContent != null) {
try {
final replacedPath = flacPath.replaceAll(RegExp(r'\.[^.]+$'), '.lrc');
final lrcPath = replacedPath == flacPath
? '$flacPath.lrc'
: replacedPath;
await File(lrcPath).writeAsString(lrcContent);
_log.d('External LRC file saved: $lrcPath');
} catch (e) {
_log.w('Failed to save external LRC file for FLAC: $e');
}
}
_log.d('Generating tags for FLAC: $metadata');
@@ -2098,6 +2260,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String? copyright,
}) async {
final settings = ref.read(settingsProvider);
if (!settings.embedMetadata) {
_log.d('Metadata embedding disabled, skipping MP3 metadata/cover embed');
return;
}
String? coverPath;
var coverUrl = track.coverUrl;
@@ -2262,6 +2428,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String? copyright,
}) async {
final settings = ref.read(settingsProvider);
if (!settings.embedMetadata) {
_log.d('Metadata embedding disabled, skipping Opus metadata/cover embed');
return;
}
String? coverPath;
var coverUrl = track.coverUrl;
@@ -2743,6 +2913,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
try {
final settings = ref.read(settingsProvider);
final metadataEmbeddingEnabled = settings.embedMetadata;
Track trackToDownload = item.track;
final needsEnrichment =
@@ -2785,6 +2956,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
(data['album_name'] as String?) ??
trackToDownload.albumName,
albumArtist: data['album_artist'] as String?,
artistId:
(data['artist_id'] ?? data['artistId'])?.toString() ??
trackToDownload.artistId,
albumId:
data['album_id']?.toString() ?? trackToDownload.albumId,
coverUrl: data['images'] as String?,
duration:
((data['duration_ms'] as int?) ??
@@ -2991,6 +3167,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
artistName: trackToDownload.artistName,
albumName: trackToDownload.albumName,
albumArtist: trackToDownload.albumArtist,
artistId: trackToDownload.artistId,
albumId: trackToDownload.albumId,
coverUrl: trackToDownload.coverUrl,
duration: trackToDownload.duration,
isrc: (deezerIsrc != null && _isValidISRC(deezerIsrc))
@@ -3101,12 +3279,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
artistName: trackToDownload.artistName,
albumName: trackToDownload.albumName,
albumArtist: resolvedAlbumArtist,
coverUrl: trackToDownload.coverUrl ?? '',
coverUrl: metadataEmbeddingEnabled
? (trackToDownload.coverUrl ?? '')
: '',
outputDir: outputDir,
filenameFormat: state.filenameFormat,
quality: quality,
embedLyrics: settings.embedLyrics,
embedMaxQualityCover: settings.maxQualityCover,
embedMetadata: metadataEmbeddingEnabled,
embedLyrics: metadataEmbeddingEnabled && settings.embedLyrics,
embedMaxQualityCover:
metadataEmbeddingEnabled && settings.maxQualityCover,
trackNumber: normalizedTrackNumber,
discNumber: normalizedDiscNumber,
releaseDate: trackToDownload.releaseDate ?? '',
@@ -3501,6 +3683,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
writeExternalLrc: false,
);
final newFileName = '${safBaseName ?? 'track'}.flac';
@@ -3692,7 +3875,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
}
} else if (isContentUriPath &&
} else if (metadataEmbeddingEnabled &&
isContentUriPath &&
effectiveSafMode &&
isFlacFile &&
!wasExisting) {
@@ -3724,6 +3908,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
writeExternalLrc: false,
);
final newFileName = '${safBaseName ?? 'track'}.flac';
@@ -3753,7 +3938,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} catch (_) {}
}
}
} else if (!isContentUriPath &&
} else if (metadataEmbeddingEnabled &&
!isContentUriPath &&
!effectiveSafMode &&
isFlacFile &&
!wasExisting &&
@@ -3792,7 +3978,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
// YouTube downloads: embed metadata to raw Opus/MP3 files from Cobalt
if (!wasExisting && item.service == 'youtube' && filePath != null) {
if (metadataEmbeddingEnabled &&
!wasExisting &&
item.service == 'youtube' &&
filePath != null) {
final isOpusFile = filePath.endsWith('.opus');
final isMp3File = filePath.endsWith('.mp3');
@@ -3957,6 +4146,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final lyricsMode = settings.lyricsMode;
final shouldSaveExternalLrc =
metadataEmbeddingEnabled &&
settings.embedLyrics &&
(lyricsMode == 'external' || lyricsMode == 'both');
if (shouldSaveExternalLrc &&
+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();
}
}
+166 -41
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 {
@@ -257,12 +267,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();
@@ -499,49 +516,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 +597,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 {
File diff suppressed because it is too large Load Diff
+29 -1
View File
@@ -3,12 +3,13 @@ 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');
@@ -93,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();
}
@@ -266,11 +279,26 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setAutoSkipUnavailableTracks(bool enabled) {
state = state.copyWith(autoSkipUnavailableTracks: enabled);
_saveSettings();
}
void setSmartQueueEnabled(bool enabled) {
state = state.copyWith(smartQueueEnabled: enabled);
_saveSettings();
}
void setEmbedLyrics(bool enabled) {
state = state.copyWith(embedLyrics: enabled);
_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);
+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? ?? '',
+36 -72
View File
@@ -14,9 +14,7 @@ import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
import 'package:spotiflac_android/screens/artist_screen.dart';
import 'package:spotiflac_android/screens/home_tab.dart'
show ExtensionArtistScreen;
import 'package:spotiflac_android/utils/clickable_metadata.dart';
class _AlbumCache {
static final Map<String, _CacheEntry> _cache = {};
@@ -187,7 +185,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);
@@ -215,6 +214,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(),
@@ -368,19 +370,19 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
),
if (artistName != null && artistName.isNotEmpty) ...[
const SizedBox(height: 6),
GestureDetector(
onTap: () => _navigateToArtist(context, artistName),
child: Text(
artistName,
style: TextStyle(
color: colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
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) ...[
@@ -459,7 +461,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
const SizedBox(width: 12),
FilledButton.icon(
onPressed: () => _downloadAll(context),
icon: const Icon(Icons.download, size: 18),
icon: Icon(Icons.download, size: 18),
label: Text(
context.l10n.downloadAllCount(tracks.length),
),
@@ -608,8 +610,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
),
),
child: IconButton(
onPressed:
tracks == null || tracks.isEmpty ? null : () => _loveAll(tracks),
onPressed: tracks == null || tracks.isEmpty
? null
: () => _loveAll(tracks),
icon: Icon(
allLoved ? Icons.favorite : Icons.favorite_border,
size: 22,
@@ -634,10 +637,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
),
),
child: IconButton(
onPressed:
_tracks == null || _tracks!.isEmpty
? null
: () => showAddTracksToPlaylistSheet(context, ref, _tracks!),
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,
@@ -657,9 +659,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Removed ${tracks.length} tracks from Loved'),
),
SnackBar(content: Text('Removed ${tracks.length} tracks from Loved')),
);
}
} else {
@@ -672,55 +672,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Added $addedCount tracks to Loved'),
),
SnackBar(content: Text('Added $addedCount tracks to Loved')),
);
}
}
}
void _navigateToArtist(BuildContext context, String artistName) {
final artistId =
_artistId ??
(widget.albumId.startsWith('deezer:') ? 'deezer:unknown' : 'unknown');
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,
),
),
);
}
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
final isRateLimit =
error.contains('429') ||
@@ -860,8 +817,10 @@ 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),
@@ -909,6 +868,11 @@ class _AlbumTrackItem extends ConsumerWidget {
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
),
onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet(
context,
ref,
track,
),
),
),
);
+43 -25
View File
@@ -18,6 +18,7 @@ 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 +310,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 +680,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 +778,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
List<ArtistAlbum> albums,
) async {
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
@@ -990,6 +995,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(),
@@ -1110,17 +1117,18 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
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),
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,
),
@@ -1128,16 +1136,19 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
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),
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,
),
),
],
),
],
),
),
],
],
@@ -1263,6 +1274,11 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
),
onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet(
context,
ref,
track,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
@@ -1329,8 +1345,12 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
overflow: TextOverflow.ellipsis,
),
if (track.albumName.isNotEmpty)
Text(
track.albumName,
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,
@@ -1339,9 +1359,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
],
),
),
TrackCollectionQuickActions(
track: track,
),
TrackCollectionQuickActions(track: track),
],
),
),
+13 -3
View File
@@ -13,6 +13,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
@@ -267,9 +268,17 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}
}
Future<void> _openFile(String filePath) async {
Future<void> _openFile(DownloadHistoryItem track) async {
try {
await openFile(filePath);
await ref
.read(playbackProvider.notifier)
.playLocalPath(
path: track.filePath,
title: track.trackName,
artist: track.artistName,
album: track.albumName,
coverUrl: track.coverUrl ?? '',
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -849,7 +858,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
trailing: _isSelectionMode
? null
: IconButton(
onPressed: () => _openFile(track.filePath),
onPressed: () => _openFile(track),
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer.withValues(
@@ -915,6 +924,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
showModalBottomSheet(
context: context,
useRootNavigator: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
+593 -396
View File
File diff suppressed because it is too large Load Diff
+24 -6
View File
@@ -149,6 +149,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
@@ -264,6 +265,9 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
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;
@@ -275,7 +279,14 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => _playlistIconFallback(colorScheme, size),
cacheWidth: cacheWidth,
gaplessPlayback: true,
filterQuality: FilterQuality.low,
frameBuilder: (_, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) return child;
return placeholder;
},
errorBuilder: (_, _, _) => placeholder,
),
);
}
@@ -302,7 +313,14 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => _playlistIconFallback(colorScheme, size),
cacheWidth: cacheWidth,
gaplessPlayback: true,
filterQuality: FilterQuality.low,
frameBuilder: (_, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) return child;
return placeholder;
},
errorBuilder: (_, _, _) => placeholder,
),
);
}
@@ -314,15 +332,15 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
width: size,
height: size,
fit: BoxFit.cover,
memCacheWidth: (size * 2).toInt(),
memCacheWidth: cacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => _playlistIconFallback(colorScheme, size),
errorWidget: (_, _, _) => _playlistIconFallback(colorScheme, size),
placeholder: (_, _) => placeholder,
errorWidget: (_, _, _) => placeholder,
),
);
}
return _playlistIconFallback(colorScheme, size);
return placeholder;
}
Widget _playlistIconFallback(ColorScheme colorScheme, double size) {
+276 -51
View File
@@ -6,9 +6,11 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
@@ -75,11 +77,47 @@ class _LibraryTracksFolderScreenState
};
}
String? _resolveEntryCoverUrl(
CollectionTrackEntry entry,
LocalLibraryState localState,
) {
final rawCover = entry.track.coverUrl?.trim();
if (rawCover != null &&
rawCover.isNotEmpty &&
!rawCover.startsWith('content://')) {
return rawCover;
}
final isrc = entry.track.isrc?.trim();
if (isrc != null && isrc.isNotEmpty) {
final byIsrc = localState.getByIsrc(isrc);
final localCover = byIsrc?.coverPath?.trim();
if (localCover != null && localCover.isNotEmpty) {
return localCover;
}
}
final byTrack = localState.findByTrackAndArtist(
entry.track.name,
entry.track.artistName,
);
final localCover = byTrack?.coverPath?.trim();
if (localCover != null && localCover.isNotEmpty) {
return localCover;
}
return null;
}
/// Find the first available cover URL from entries.
String? _firstCoverUrl(List<CollectionTrackEntry> entries) {
String? _firstCoverUrl(
List<CollectionTrackEntry> entries,
LocalLibraryState localState,
) {
for (final entry in entries) {
if (entry.track.coverUrl != null && entry.track.coverUrl!.isNotEmpty) {
return entry.track.coverUrl;
final cover = _resolveEntryCoverUrl(entry, localState);
if (cover != null && cover.isNotEmpty) {
return cover;
}
}
return null;
@@ -173,11 +211,7 @@ class _LibraryTracksFolderScreenState
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.selectionSelected(count),
),
),
SnackBar(content: Text(context.l10n.selectionSelected(count))),
);
}
@@ -196,11 +230,7 @@ class _LibraryTracksFolderScreenState
if (!mounted || count == 0) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.selectionSelected(count),
),
),
SnackBar(content: Text(context.l10n.selectionSelected(count))),
);
}
@@ -217,6 +247,8 @@ class _LibraryTracksFolderScreenState
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
ref.watch(localLibraryProvider.select((s) => s.items));
final localState = ref.read(localLibraryProvider);
final UserPlaylistCollection? playlist;
final List<CollectionTrackEntry> entries;
@@ -280,6 +312,9 @@ class _LibraryTracksFolderScreenState
LibraryTracksFolderMode.playlist =>
context.l10n.collectionPlaylistEmptySubtitle,
};
final folderTracks = entries
.map((entry) => entry.track)
.toList(growable: false);
final bottomPadding = MediaQuery.of(context).padding.bottom;
@@ -296,7 +331,14 @@ class _LibraryTracksFolderScreenState
CustomScrollView(
controller: _scrollController,
slivers: [
_buildAppBar(context, colorScheme, title, entries, playlist),
_buildAppBar(
context,
colorScheme,
title,
entries,
playlist,
localState,
),
if (entries.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
@@ -316,6 +358,8 @@ class _LibraryTracksFolderScreenState
entry: entry,
mode: widget.mode,
playlistId: widget.playlistId,
localLibraryState: localState,
folderTracks: folderTracks,
isSelectionMode: _isSelectionMode,
isSelected: isSelected,
onTap: _isSelectionMode
@@ -494,8 +538,8 @@ class _LibraryTracksFolderScreenState
selectedCount > 0
? '${widget.mode == LibraryTracksFolderMode.playlist ? context.l10n.collectionRemoveFromPlaylist : context.l10n.collectionRemoveFromFolder} ($selectedCount)'
: widget.mode == LibraryTracksFolderMode.playlist
? context.l10n.collectionRemoveFromPlaylist
: context.l10n.collectionRemoveFromFolder,
? context.l10n.collectionRemoveFromPlaylist
: context.l10n.collectionRemoveFromFolder,
),
style: FilledButton.styleFrom(
backgroundColor: selectedCount > 0
@@ -551,13 +595,14 @@ class _LibraryTracksFolderScreenState
String title,
List<CollectionTrackEntry> entries,
UserPlaylistCollection? playlist,
LocalLibraryState localState,
) {
final expandedHeight = _calculateExpandedHeight(context);
final customCoverPath = playlist?.coverImagePath;
final isLovedMode = widget.mode == LibraryTracksFolderMode.loved;
final isPlaylistMode = widget.mode == LibraryTracksFolderMode.playlist;
// Loved always shows the heart icon (like Spotify's Liked Songs)
final coverUrl = isLovedMode ? null : _firstCoverUrl(entries);
final coverUrl = isLovedMode ? null : _firstCoverUrl(entries, localState);
final hasCustomCover =
customCoverPath != null && customCoverPath.isNotEmpty;
final hasCoverUrl = coverUrl != null;
@@ -608,6 +653,18 @@ class _LibraryTracksFolderScreenState
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3;
final dpr = MediaQuery.devicePixelRatioOf(context);
final cacheWidth = (MediaQuery.sizeOf(context).width * dpr)
.round()
.clamp(320, 2048);
final coverFallback = Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
_modeIcon(),
size: 80,
color: colorScheme.onSurfaceVariant,
),
);
return FlexibleSpaceBar(
collapseMode: CollapseMode.pin,
@@ -619,26 +676,37 @@ class _LibraryTracksFolderScreenState
Image.file(
File(customCoverPath),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
_modeIcon(),
size: 80,
color: colorScheme.onSurfaceVariant,
),
),
cacheWidth: cacheWidth,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
frameBuilder: (_, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) return child;
return coverFallback;
},
errorBuilder: (_, _, _) => coverFallback,
)
else if (hasCoverUrl)
_isCoverLocalPath(coverUrl)
? Image.file(
File(coverUrl),
fit: BoxFit.cover,
cacheWidth: cacheWidth,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
frameBuilder:
(_, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) {
return child;
}
return Container(color: colorScheme.surface);
},
errorBuilder: (_, _, _) =>
Container(color: colorScheme.surface),
)
: CachedNetworkImage(
imageUrl: _highResCoverUrl(coverUrl) ?? coverUrl,
fit: BoxFit.cover,
memCacheWidth: cacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) =>
Container(color: colorScheme.surface),
@@ -646,14 +714,7 @@ class _LibraryTracksFolderScreenState
Container(color: colorScheme.surface),
)
else
Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
_modeIcon(),
size: 80,
color: colorScheme.onSurfaceVariant,
),
),
coverFallback,
// Bottom gradient for readability
Positioned(
left: 0,
@@ -728,6 +789,18 @@ class _LibraryTracksFolderScreenState
],
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (widget.mode !=
LibraryTracksFolderMode.wishlist) ...[
_buildShuffleButton(entries),
const SizedBox(width: 12),
],
_buildDownloadAllCenterButton(context, entries),
],
),
],
],
),
@@ -758,11 +831,127 @@ class _LibraryTracksFolderScreenState
);
}
// Shuffle / Download buttons
Widget _buildShuffleButton(List<CollectionTrackEntry> entries) {
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: entries.isEmpty ? null : () => _shufflePlay(entries),
icon: const Icon(Icons.shuffle_rounded, size: 22, color: Colors.white),
tooltip: 'Shuffle Play',
padding: EdgeInsets.zero,
),
);
}
Widget _buildDownloadAllCenterButton(
BuildContext context,
List<CollectionTrackEntry> entries,
) {
final tracks = entries.map((e) => e.track).toList(growable: false);
return FilledButton.icon(
onPressed: tracks.isEmpty ? null : () => _downloadAll(context, tracks),
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)),
),
);
}
void _shufflePlay(List<CollectionTrackEntry> entries) {
final tracks = entries.map((e) => e.track).toList(growable: false);
if (tracks.isEmpty) return;
final shuffled = [...tracks]..shuffle();
final messenger = ScaffoldMessenger.of(context);
ref.read(playbackProvider.notifier).playTrackList(shuffled).catchError((e) {
if (!mounted) return;
messenger.showSnackBar(
SnackBar(content: Text('Cannot shuffle play local tracks: $e')),
);
});
}
void _downloadAll(BuildContext context, List<Track> tracks) {
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);
_executeDownloadAll(context, tracks);
},
child: const Text('Download'),
),
],
);
},
);
}
void _executeDownloadAll(BuildContext context, List<Track> tracks) {
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
trackName: '${tracks.length} tracks',
artistName: '',
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracks, service, qualityOverride: quality);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAddedTracksToQueue(tracks.length),
),
),
);
},
);
} else {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
),
);
}
}
void _showCoverOptionsSheet(BuildContext context, bool hasCustomCover) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
@@ -840,6 +1029,8 @@ class _CollectionTrackTile extends ConsumerWidget {
final CollectionTrackEntry entry;
final LibraryTracksFolderMode mode;
final String? playlistId;
final LocalLibraryState localLibraryState;
final List<Track> folderTracks;
final bool isSelectionMode;
final bool isSelected;
final VoidCallback? onTap;
@@ -849,6 +1040,8 @@ class _CollectionTrackTile extends ConsumerWidget {
required this.entry,
required this.mode,
required this.playlistId,
required this.localLibraryState,
required this.folderTracks,
this.isSelectionMode = false,
this.isSelected = false,
this.onTap,
@@ -859,6 +1052,7 @@ class _CollectionTrackTile extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final track = entry.track;
final colorScheme = Theme.of(context).colorScheme;
final effectiveCoverUrl = _resolveCoverUrl(track);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
@@ -903,8 +1097,8 @@ class _CollectionTrackTile extends ConsumerWidget {
],
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: track.coverUrl != null && track.coverUrl!.isNotEmpty
? _buildTrackCover(context, track.coverUrl!, 52)
child: effectiveCoverUrl != null && effectiveCoverUrl.isNotEmpty
? _buildTrackCover(context, effectiveCoverUrl, 52)
: Container(
width: 52,
height: 52,
@@ -917,8 +1111,7 @@ class _CollectionTrackTile extends ConsumerWidget {
),
],
),
title:
Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
track.artistName,
maxLines: 1,
@@ -936,15 +1129,45 @@ class _CollectionTrackTile extends ConsumerWidget {
),
onTap: isSelectionMode
? onTap
: mode == LibraryTracksFolderMode.wishlist
? () => _downloadTrack(context, ref)
: () => _navigateToMetadata(context, ref),
: () {
if (mode == LibraryTracksFolderMode.wishlist) {
_downloadTrack(context, ref);
return;
}
_navigateToMetadata(context, ref);
},
onLongPress: isSelectionMode ? onTap : onLongPress,
),
),
);
}
String? _resolveCoverUrl(Track track) {
final rawCover = track.coverUrl?.trim();
if (rawCover != null &&
rawCover.isNotEmpty &&
!rawCover.startsWith('content://')) {
return rawCover;
}
final isrc = track.isrc?.trim();
if (isrc != null && isrc.isNotEmpty) {
final byIsrc = localLibraryState.getByIsrc(isrc);
final localCover = byIsrc?.coverPath?.trim();
if (localCover != null && localCover.isNotEmpty) return localCover;
}
final byTrack = localLibraryState.findByTrackAndArtist(
track.name,
track.artistName,
);
final localCover = byTrack?.coverPath?.trim();
if (localCover != null && localCover.isNotEmpty) return localCover;
return null;
}
/// Builds a cover image widget that handles both network URLs and local file paths.
Widget _buildTrackCover(BuildContext context, String coverUrl, double size) {
final isLocal =
@@ -984,9 +1207,11 @@ class _CollectionTrackTile extends ConsumerWidget {
void _showTrackOptionsSheet(BuildContext context, WidgetRef ref) {
final track = entry.track;
final effectiveCoverUrl = _resolveCoverUrl(track);
final colorScheme = Theme.of(context).colorScheme;
final historyState = ref.read(downloadHistoryProvider);
final isDownloaded = historyState.isDownloaded(track.id) ||
final isDownloaded =
historyState.isDownloaded(track.id) ||
(track.isrc != null &&
track.isrc!.isNotEmpty &&
historyState.getByIsrc(track.isrc!) != null) ||
@@ -997,6 +1222,7 @@ class _CollectionTrackTile extends ConsumerWidget {
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
@@ -1024,8 +1250,9 @@ class _CollectionTrackTile extends ConsumerWidget {
ClipRRect(
borderRadius: BorderRadius.circular(8),
child:
track.coverUrl != null && track.coverUrl!.isNotEmpty
? _buildTrackCover(context, track.coverUrl!, 56)
effectiveCoverUrl != null &&
effectiveCoverUrl.isNotEmpty
? _buildTrackCover(context, effectiveCoverUrl, 56)
: Container(
width: 56,
height: 56,
@@ -1170,15 +1397,15 @@ class _CollectionTrackTile extends ConsumerWidget {
var historyItem = historyState.getBySpotifyId(track.id);
// 2. Download history by ISRC
if (historyItem == null &&
track.isrc != null &&
track.isrc!.isNotEmpty) {
if (historyItem == null && track.isrc != null && track.isrc!.isNotEmpty) {
historyItem = historyState.getByIsrc(track.isrc!);
}
// 3. Download history by track name + artist (handles ID/ISRC mismatch)
historyItem ??=
historyState.findByTrackAndArtist(track.name, track.artistName);
historyItem ??= historyState.findByTrackAndArtist(
track.name,
track.artistName,
);
if (historyItem != null) {
await Navigator.of(context).push(
@@ -1287,9 +1514,7 @@ class _SelectionActionButton extends StatelessWidget {
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
);
}
+14 -4
View File
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
/// Screen to display tracks from a local library album
class LocalAlbumScreen extends ConsumerStatefulWidget {
@@ -204,9 +205,17 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
}
}
Future<void> _openFile(String filePath) async {
Future<void> _openFile(LocalLibraryItem track) async {
try {
await openFile(filePath);
await ref
.read(playbackProvider.notifier)
.playLocalPath(
path: track.filePath,
title: track.trackName,
artist: track.artistName,
album: track.albumName,
coverUrl: track.coverPath ?? '',
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -639,7 +648,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
),
onTap: _isSelectionMode
? () => _toggleSelection(track.id)
: () => _openFile(track.filePath),
: () => _openFile(track),
onLongPress: _isSelectionMode
? null
: () => _enterSelectionMode(track.id),
@@ -724,7 +733,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
trailing: _isSelectionMode
? null
: IconButton(
onPressed: () => _openFile(track.filePath),
onPressed: () => _openFile(track),
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer.withValues(
@@ -989,6 +998,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
showModalBottomSheet(
context: context,
useRootNavigator: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
+181 -47
View File
@@ -16,9 +16,11 @@ 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';
import 'package:spotiflac_android/widgets/mini_player_bar.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('MainShell');
@@ -36,11 +38,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 +98,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 +226,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 +263,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)) {
_log.i('Back: step 8 - double-tap exit');
SystemNavigator.pop();
} else {
_log.i('Back: step 7 - first tap, showing exit snackbar');
_lastBackPress = now;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -279,46 +389,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(),
];
@@ -378,7 +488,7 @@ class _MainShellState extends ConsumerState<MainShell> {
}
return PopScope(
canPop: canPop,
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) {
return;
@@ -387,13 +497,18 @@ class _MainShellState extends ConsumerState<MainShell> {
_handleBackPress();
},
child: Scaffold(
body: PageView(
controller: _pageController,
onPageChanged: _onPageChanged,
physics: (_currentIndex == 0 && trackIsShowingRecentAccess)
? const _NoSwipeRightPhysics()
: const ClampingScrollPhysics(),
children: tabs,
body: Column(
children: [
Expanded(
child: PageView(
controller: _pageController,
onPageChanged: _onPageChanged,
physics: const NeverScrollableScrollPhysics(),
children: tabs,
),
),
const MiniPlayerBar(),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex.clamp(0, maxIndex),
@@ -415,23 +530,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,
);
}
}
+179 -27
View File
@@ -6,9 +6,11 @@ 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/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/track_collection_quick_actions.dart';
@@ -108,6 +110,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(),
@@ -297,22 +301,15 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
),
),
const SizedBox(height: 16),
Center(
child: FilledButton.icon(
onPressed: () => _downloadAll(context),
icon: const 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),
),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildLoveAllButton(),
const SizedBox(width: 12),
_buildDownloadAllCenterButton(context),
const SizedBox(width: 12),
_buildShufflePlayButton(),
],
),
],
],
@@ -410,6 +407,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
void _downloadTrack(BuildContext context, Track track) {
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
@@ -437,22 +435,175 @@ 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 _buildShufflePlayButton() {
return _buildCircleButton(
icon: Icons.shuffle_rounded,
tooltip: 'Shuffle Play',
onPressed: _tracks.isEmpty ? null : _shufflePlayLocal,
);
}
void _shufflePlayLocal() {
if (_tracks.isEmpty) return;
final shuffled = [..._tracks]..shuffle();
final messenger = ScaffoldMessenger.of(context);
ref.read(playbackProvider.notifier).playTrackList(shuffled).catchError((e) {
if (!mounted) return;
messenger.showSnackBar(
SnackBar(content: Text('Cannot shuffle play local tracks: $e')),
);
});
}
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),
),
),
);
@@ -461,12 +612,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)),
),
);
}
@@ -602,9 +751,7 @@ class _PlaylistTrackItem extends ConsumerWidget {
],
],
),
trailing: TrackCollectionQuickActions(
track: track,
),
trailing: TrackCollectionQuickActions(track: track),
onTap: () => _handleTap(
context,
ref,
@@ -612,6 +759,11 @@ class _PlaylistTrackItem extends ConsumerWidget {
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
),
onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet(
context,
ref,
track,
),
),
),
);
+656 -433
View File
File diff suppressed because it is too large Load Diff
+32 -14
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,7 +179,21 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
),
],
),
trailing: null,
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),
);
}
@@ -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)),
+103 -2
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
@@ -166,6 +167,9 @@ class _RecentDonorsCard extends StatelessWidget {
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
const donorNames = [
'NinoBrown',
'@nino_sandzak',
'IMJ',
'J',
'Julian',
'matt_3050',
@@ -282,6 +286,19 @@ class _DonateLinksCard extends StatelessWidget {
url: AppInfo.githubSponsorsUrl,
colorScheme: colorScheme,
),
Divider(
height: 1,
thickness: 1,
indent: 74,
endIndent: 16,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
_CryptoWalletItem(
title: 'USDT (TRC20)',
walletAddress: 'TL7iAqjq9M8BwVMi9AtHvuAGHtdwEvsDta',
color: const Color(0xFF26A17B),
colorScheme: colorScheme,
),
],
),
);
@@ -357,13 +374,97 @@ class _DonateCardItem extends StatelessWidget {
}
}
class _CryptoWalletItem extends StatelessWidget {
final String title;
final String walletAddress;
final Color color;
final ColorScheme colorScheme;
const _CryptoWalletItem({
required this.title,
required this.walletAddress,
required this.color,
required this.colorScheme,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () {
Clipboard.setData(ClipboardData(text: walletAddress));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$title address copied to clipboard'),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
),
);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(12),
),
child: const Center(
child: Text(
'\$',
style: TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 2),
Text(
walletAddress,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
Icon(
Icons.copy_rounded,
size: 18,
color: colorScheme.onSurfaceVariant,
),
],
),
),
);
}
}
int _cr(String v) {
int r = 0x1F;
for (final c in v.codeUnits) { r = (r * 31 + c) & 0x7FFFFFFF; }
return r;
}
// Highlighted supporters (hashes of names): Julian, J.
const _cv = {1825257268, 1035};
// Highlighted supporters (hashes of names): Julian, J, NinoBrown, @nino_sandzak, IMJ.
const _cv = {1825257268, 1035, 1497948283, 398058782, 996135};
class _SupporterChip extends StatelessWidget {
final String name;
@@ -501,14 +501,17 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
SettingsSwitchItem(
icon: Icons.subtitles_outlined,
title: context.l10n.optionsEmbedLyrics,
subtitle: context.l10n.optionsEmbedLyricsSubtitle,
subtitle: settings.embedMetadata
? context.l10n.optionsEmbedLyricsSubtitle
: 'Disabled while Embed Metadata is turned off',
value: settings.embedLyrics,
enabled: settings.embedMetadata,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setEmbedLyrics(value),
showDivider: settings.embedLyrics,
showDivider: settings.embedMetadata && settings.embedLyrics,
),
if (settings.embedLyrics) ...[
if (settings.embedMetadata && settings.embedLyrics) ...[
SettingsItem(
icon: Icons.lyrics_outlined,
title: context.l10n.lyricsMode,
@@ -858,6 +861,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
) {
showModalBottomSheet(
context: context,
useRootNavigator: true,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -992,6 +996,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
showModalBottomSheet(
context: context,
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: colorScheme.surface,
shape: const RoundedRectangleBorder(
@@ -1209,6 +1214,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
settings.storageMode == 'saf' && settings.downloadTreeUri.isNotEmpty;
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
@@ -1288,6 +1294,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
@@ -1451,6 +1458,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
@@ -1516,6 +1524,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
}
static const _providerDisplayNames = <String, String>{
'spotify_api': 'Spotify Lyrics API',
'lrclib': 'LRCLIB',
'netease': 'Netease',
'musixmatch': 'Musixmatch',
@@ -1544,6 +1553,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
@@ -1604,6 +1614,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
@@ -1702,6 +1713,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
@@ -1786,6 +1798,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
@@ -1857,6 +1870,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
final normalizedCurrent = current.trim().toUpperCase();
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
@@ -1924,6 +1938,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
@@ -2024,16 +2039,13 @@ class _ServiceSelector extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final extState = ref.watch(extensionProvider);
final builtInServiceIds = ['tidal', 'qobuz', 'amazon', 'youtube'];
final extensionProviders = extState.extensions
.where((e) => e.enabled && e.hasDownloadProvider)
.toList();
final isExtensionService = ![
'tidal',
'qobuz',
'amazon',
].contains(currentService);
final isExtensionService = !builtInServiceIds.contains(currentService);
final isCurrentExtensionEnabled = isExtensionService
? extensionProviders.any((e) => e.id == currentService)
: true;
@@ -2046,47 +2058,56 @@ class _ServiceSelector extends ConsumerWidget {
children: [
Row(
children: [
_ServiceChip(
icon: Icons.music_note,
label: 'Tidal',
isSelected: effectiveService == 'tidal',
onTap: () => onChanged('tidal'),
Expanded(
child: _ServiceChip(
icon: Icons.music_note,
label: 'Tidal',
isSelected: effectiveService == 'tidal',
onTap: () => onChanged('tidal'),
),
),
const SizedBox(width: 8),
_ServiceChip(
icon: Icons.album,
label: 'Qobuz',
isSelected: effectiveService == 'qobuz',
onTap: () => onChanged('qobuz'),
Expanded(
child: _ServiceChip(
icon: Icons.album,
label: 'Qobuz',
isSelected: effectiveService == 'qobuz',
onTap: () => onChanged('qobuz'),
),
),
const SizedBox(width: 8),
_ServiceChip(
icon: Icons.shopping_bag_outlined,
label: 'Amazon',
isSelected: effectiveService == 'amazon',
onTap: () => onChanged('amazon'),
Expanded(
child: _ServiceChip(
icon: Icons.shopping_bag_outlined,
label: 'Amazon',
isSelected: effectiveService == 'amazon',
onTap: () => onChanged('amazon'),
),
),
const SizedBox(width: 8),
Expanded(
child: _ServiceChip(
icon: Icons.smart_display,
label: 'YouTube',
isSelected: effectiveService == 'youtube',
onTap: () => onChanged('youtube'),
),
),
],
),
if (extensionProviders.isNotEmpty) ...[
const SizedBox(height: 8),
Row(
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (int i = 0; i < extensionProviders.length; i++) ...[
if (i > 0) const SizedBox(width: 8),
Expanded(
child: _ServiceChip(
icon: Icons.extension,
label: extensionProviders[i].displayName,
isSelected: effectiveService == extensionProviders[i].id,
onTap: () => onChanged(extensionProviders[i].id),
),
for (final extension in extensionProviders)
_ServiceChip(
icon: Icons.extension,
label: extension.displayName,
isSelected: effectiveService == extension.id,
onTap: () => onChanged(extension.id),
),
],
for (int i = extensionProviders.length; i < 3; i++) ...[
const SizedBox(width: 8),
const Expanded(child: SizedBox()),
],
],
),
],
@@ -2120,38 +2141,35 @@ class _ServiceChip extends StatelessWidget {
)
: colorScheme.surfaceContainerHigh;
return Expanded(
child: Material(
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
return Material(
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(
children: [
Icon(
icon,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12),
child: Column(
children: [
Icon(
icon,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
],
),
overflow: TextOverflow.ellipsis,
),
],
),
),
),
+286 -252
View File
@@ -20,12 +20,15 @@ class ExtensionsPage extends ConsumerStatefulWidget {
}
class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
static final RegExp _platformExceptionPattern =
RegExp(r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),');
static final RegExp _platformExceptionSimplePattern =
RegExp(r'PlatformException\([^,]+,\s*(.+?),\s*null');
static final RegExp _trailingNullsPattern =
RegExp(r',\s*null\s*,\s*null\)?$');
static final RegExp _platformExceptionPattern = RegExp(
r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),',
);
static final RegExp _platformExceptionSimplePattern = RegExp(
r'PlatformException\([^,]+,\s*(.+?),\s*null',
);
static final RegExp _trailingNullsPattern = RegExp(
r',\s*null\s*,\s*null\)?$',
);
static final RegExp _leadingCommaPattern = RegExp(r'^\s*,\s*');
@override
@@ -40,11 +43,13 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
final appDir = await getApplicationDocumentsDirectory();
final extensionsDir = '${appDir.path}/extensions';
final dataDir = '${appDir.path}/extension_data';
await Directory(extensionsDir).create(recursive: true);
await Directory(dataDir).create(recursive: true);
await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir);
await ref
.read(extensionProvider.notifier)
.initialize(extensionsDir, dataDir);
}
}
@@ -59,67 +64,205 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
child: 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.extensionsTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
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,
),
),
);
},
),
),
if (extState.isLoading)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
title: Text(
context.l10n.extensionsTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
if (extState.error != null)
if (extState.isLoading)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
),
),
if (extState.error != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 12),
Expanded(
child: Text(
extState.error!,
style: TextStyle(
color: colorScheme.onErrorContainer,
),
),
),
],
),
),
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.extensionsProviderPrioritySection,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_DownloadPriorityItem(),
_MetadataPriorityItem(),
_SearchProviderSelector(),
],
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.extensionsInstalledSection,
),
),
if (extState.extensions.isEmpty && !extState.isLoading)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(
alpha: 0.3,
),
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Icon(
Icons.extension_outlined,
size: 48,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 12),
Text(
context.l10n.extensionsNoExtensions,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(color: colorScheme.onSurfaceVariant),
),
const SizedBox(height: 4),
Text(
context.l10n.extensionsNoExtensionsSubtitle,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
],
),
),
),
),
if (extState.extensions.isNotEmpty)
SliverToBoxAdapter(
child: SettingsGroup(
children: extState.extensions.asMap().entries.map((entry) {
final index = entry.key;
final ext = entry.value;
return _ExtensionItem(
extension: ext,
showDivider: index < extState.extensions.length - 1,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) =>
ExtensionDetailPage(extensionId: ext.id),
),
),
onToggle: (enabled) => ref
.read(extensionProvider.notifier)
.setExtensionEnabled(ext.id, enabled),
);
}).toList(),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: FilledButton.icon(
onPressed: _installExtension,
icon: const Icon(Icons.add),
label: Text(context.l10n.extensionsInstallButton),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.error_outline, color: colorScheme.error),
Icon(
Icons.info_outline,
size: 20,
color: colorScheme.tertiary,
),
const SizedBox(width: 12),
Expanded(
child: Text(
extState.error!,
style: TextStyle(color: colorScheme.onErrorContainer),
context.l10n.extensionsInfoTip,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onTertiaryContainer,
),
),
),
],
@@ -127,131 +270,9 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
),
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionsProviderPrioritySection),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_DownloadPriorityItem(),
_MetadataPriorityItem(),
_SearchProviderSelector(),
],
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.extensionsInstalledSection),
),
if (extState.extensions.isEmpty && !extState.isLoading)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Icon(
Icons.extension_outlined,
size: 48,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 12),
Text(
context.l10n.extensionsNoExtensions,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
context.l10n.extensionsNoExtensionsSubtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
),
),
),
if (extState.extensions.isNotEmpty)
SliverToBoxAdapter(
child: SettingsGroup(
children: extState.extensions.asMap().entries.map((entry) {
final index = entry.key;
final ext = entry.value;
return _ExtensionItem(
extension: ext,
showDivider: index < extState.extensions.length - 1,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ExtensionDetailPage(extensionId: ext.id),
),
),
onToggle: (enabled) => ref
.read(extensionProvider.notifier)
.setExtensionEnabled(ext.id, enabled),
);
}).toList(),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: FilledButton.icon(
onPressed: _installExtension,
icon: const Icon(Icons.add),
label: Text(context.l10n.extensionsInstallButton),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary),
const SizedBox(width: 12),
Expanded(
child: Text(
context.l10n.extensionsInfoTip,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onTertiaryContainer,
),
),
),
],
),
),
),
),
],
],
),
),
),
);
}
@@ -267,9 +288,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
if (!file.path!.endsWith('.spotiflac-ext')) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarSelectExtFile),
),
SnackBar(content: Text(context.l10n.snackbarSelectExtFile)),
);
}
return;
@@ -287,12 +306,12 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
} else {
message = _getFriendlyErrorMessage(extState.error);
}
ref.read(extensionProvider.notifier).clearError();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
}
}
@@ -301,9 +320,9 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
/// Parse error message to be more user-friendly
String _getFriendlyErrorMessage(String? error) {
if (error == null) return 'Failed to install extension';
String message = error;
if (message.contains('PlatformException')) {
final match = _platformExceptionPattern.firstMatch(message);
if (match != null) {
@@ -315,10 +334,10 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
}
}
}
message = message.replaceAll(_trailingNullsPattern, '');
message = message.replaceAll(_leadingCommaPattern, '');
return message;
}
}
@@ -359,7 +378,9 @@ class _ExtensionItem extends StatelessWidget {
: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: extension.iconPath != null && extension.iconPath!.isNotEmpty
child:
extension.iconPath != null &&
extension.iconPath!.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(
@@ -396,7 +417,8 @@ class _ExtensionItem extends StatelessWidget {
const SizedBox(height: 2),
Text(
hasError
? extension.errorMessage ?? context.l10n.extensionsErrorLoading
? extension.errorMessage ??
context.l10n.extensionsErrorLoading
: 'v${extension.version} ${context.l10n.extensionsAuthor(extension.author)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: hasError
@@ -435,17 +457,16 @@ class _DownloadPriorityItem extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme;
final hasDownloadExtensions = extState.extensions
.any((e) => e.enabled && e.hasDownloadProvider);
final hasDownloadExtensions = extState.extensions.any(
(e) => e.enabled && e.hasDownloadProvider,
);
return InkWell(
onTap: hasDownloadExtensions
onTap: hasDownloadExtensions
? () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const ProviderPriorityPage(),
),
MaterialPageRoute(builder: (_) => const ProviderPriorityPage()),
)
: null,
child: Padding(
@@ -454,8 +475,8 @@ class _DownloadPriorityItem extends ConsumerWidget {
children: [
Icon(
Icons.download,
color: hasDownloadExtensions
? colorScheme.onSurfaceVariant
color: hasDownloadExtensions
? colorScheme.onSurfaceVariant
: colorScheme.outline,
),
const SizedBox(width: 16),
@@ -466,14 +487,12 @@ class _DownloadPriorityItem extends ConsumerWidget {
Text(
context.l10n.extensionsDownloadPriority,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: hasDownloadExtensions
? null
: colorScheme.outline,
color: hasDownloadExtensions ? null : colorScheme.outline,
),
),
const SizedBox(height: 2),
Text(
hasDownloadExtensions
hasDownloadExtensions
? context.l10n.extensionsDownloadPrioritySubtitle
: context.l10n.extensionsNoDownloadProvider,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
@@ -485,8 +504,8 @@ class _DownloadPriorityItem extends ConsumerWidget {
),
Icon(
Icons.chevron_right,
color: hasDownloadExtensions
? colorScheme.onSurfaceVariant
color: hasDownloadExtensions
? colorScheme.onSurfaceVariant
: colorScheme.outline,
),
],
@@ -503,12 +522,13 @@ class _MetadataPriorityItem extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme;
final hasMetadataExtensions = extState.extensions
.any((e) => e.enabled && e.hasMetadataProvider);
final hasMetadataExtensions = extState.extensions.any(
(e) => e.enabled && e.hasMetadataProvider,
);
return InkWell(
onTap: hasMetadataExtensions
onTap: hasMetadataExtensions
? () => Navigator.push(
context,
MaterialPageRoute(
@@ -522,8 +542,8 @@ class _MetadataPriorityItem extends ConsumerWidget {
children: [
Icon(
Icons.search,
color: hasMetadataExtensions
? colorScheme.onSurfaceVariant
color: hasMetadataExtensions
? colorScheme.onSurfaceVariant
: colorScheme.outline,
),
const SizedBox(width: 16),
@@ -534,14 +554,12 @@ class _MetadataPriorityItem extends ConsumerWidget {
Text(
context.l10n.extensionsMetadataPriority,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: hasMetadataExtensions
? null
: colorScheme.outline,
color: hasMetadataExtensions ? null : colorScheme.outline,
),
),
const SizedBox(height: 2),
Text(
hasMetadataExtensions
hasMetadataExtensions
? context.l10n.extensionsMetadataPrioritySubtitle
: context.l10n.extensionsNoMetadataProvider,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
@@ -553,8 +571,8 @@ class _MetadataPriorityItem extends ConsumerWidget {
),
Icon(
Icons.chevron_right,
color: hasMetadataExtensions
? colorScheme.onSurfaceVariant
color: hasMetadataExtensions
? colorScheme.onSurfaceVariant
: colorScheme.outline,
),
],
@@ -572,32 +590,40 @@ class _SearchProviderSelector extends ConsumerWidget {
final settings = ref.watch(settingsProvider);
final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme;
final searchProviders = extState.extensions
.where((e) => e.enabled && e.hasCustomSearch)
.toList();
String currentProviderName = context.l10n.extensionDefaultProvider;
if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) {
final ext = searchProviders.where((e) => e.id == settings.searchProvider).firstOrNull;
if (settings.searchProvider != null &&
settings.searchProvider!.isNotEmpty) {
final ext = searchProviders
.where((e) => e.id == settings.searchProvider)
.firstOrNull;
currentProviderName = ext?.displayName ?? settings.searchProvider!;
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: searchProviders.isEmpty
? null
: () => _showSearchProviderPicker(context, ref, settings, searchProviders),
onTap: searchProviders.isEmpty
? null
: () => _showSearchProviderPicker(
context,
ref,
settings,
searchProviders,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(
Icons.manage_search,
color: searchProviders.isEmpty
? colorScheme.outline
color: searchProviders.isEmpty
? colorScheme.outline
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 16),
@@ -608,14 +634,14 @@ class _SearchProviderSelector extends ConsumerWidget {
Text(
context.l10n.extensionsSearchProvider,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: searchProviders.isEmpty
? colorScheme.outline
color: searchProviders.isEmpty
? colorScheme.outline
: null,
),
),
const SizedBox(height: 2),
Text(
searchProviders.isEmpty
searchProviders.isEmpty
? context.l10n.extensionsNoCustomSearch
: currentProviderName,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
@@ -627,8 +653,8 @@ class _SearchProviderSelector extends ConsumerWidget {
),
Icon(
Icons.chevron_right,
color: searchProviders.isEmpty
? colorScheme.outline
color: searchProviders.isEmpty
? colorScheme.outline
: colorScheme.onSurfaceVariant,
),
],
@@ -646,9 +672,10 @@ class _SearchProviderSelector extends ConsumerWidget {
List<Extension> searchProviders,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
@@ -662,9 +689,9 @@ class _SearchProviderSelector extends ConsumerWidget {
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
ctx.l10n.extensionsSearchProvider,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
@@ -680,7 +707,9 @@ class _SearchProviderSelector extends ConsumerWidget {
leading: Icon(Icons.music_note, color: colorScheme.primary),
title: Text(ctx.l10n.extensionDefaultProvider),
subtitle: Text(ctx.l10n.extensionDefaultProviderSubtitle),
trailing: (settings.searchProvider == null || settings.searchProvider!.isEmpty)
trailing:
(settings.searchProvider == null ||
settings.searchProvider!.isEmpty)
? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline),
onTap: () {
@@ -688,18 +717,23 @@ class _SearchProviderSelector extends ConsumerWidget {
Navigator.pop(ctx);
},
),
...searchProviders.map((ext) => ListTile(
leading: Icon(Icons.extension, color: colorScheme.secondary),
title: Text(ext.displayName),
subtitle: Text(ext.searchBehavior?.placeholder ?? ctx.l10n.extensionsCustomSearch),
trailing: settings.searchProvider == ext.id
? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline),
onTap: () {
ref.read(settingsProvider.notifier).setSearchProvider(ext.id);
Navigator.pop(ctx);
},
)),
...searchProviders.map(
(ext) => ListTile(
leading: Icon(Icons.extension, color: colorScheme.secondary),
title: Text(ext.displayName),
subtitle: Text(
ext.searchBehavior?.placeholder ??
ctx.l10n.extensionsCustomSearch,
),
trailing: settings.searchProvider == ext.id
? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline),
onTap: () {
ref.read(settingsProvider.notifier).setSearchProvider(ext.id);
Navigator.pop(ctx);
},
),
),
const SizedBox(height: 16),
],
),
@@ -16,6 +16,7 @@ class _LyricsProviderPriorityPageState
extends ConsumerState<LyricsProviderPriorityPage> {
static const _allProviderIds = [
'lrclib',
'spotify_api',
'netease',
'musixmatch',
'apple_music',
@@ -183,6 +184,12 @@ class _LyricsProviderPriorityPageState
static _LyricsProviderInfo _getLyricsProviderInfo(String id) {
switch (id) {
case 'spotify_api':
return _LyricsProviderInfo(
name: 'Spotify Lyrics API',
description: 'Spotify-sourced synced lyrics via community API',
icon: Icons.music_note_outlined,
);
case 'lrclib':
return _LyricsProviderInfo(
name: 'LRCLIB',
@@ -152,6 +152,30 @@ class OptionsSettingsPage extends ConsumerWidget {
onChanged: (v) =>
ref.read(settingsProvider.notifier).setAutoFallback(v),
),
SettingsSwitchItem(
icon: Icons.skip_next_rounded,
title: context.l10n.optionsAutoSkipUnavailableTracks,
subtitle: settings.autoSkipUnavailableTracks
? context
.l10n
.optionsAutoSkipUnavailableTracksSubtitleOn
: context
.l10n
.optionsAutoSkipUnavailableTracksSubtitleOff,
value: settings.autoSkipUnavailableTracks,
onChanged: (v) => ref
.read(settingsProvider.notifier)
.setAutoSkipUnavailableTracks(v),
),
SettingsSwitchItem(
icon: Icons.queue_music_rounded,
title: context.l10n.settingsSmartQueueTitle,
subtitle: context.l10n.settingsSmartQueueSubtitle,
value: settings.smartQueueEnabled,
onChanged: (v) => ref
.read(settingsProvider.notifier)
.setSmartQueueEnabled(v),
),
if (hasExtensions)
SettingsSwitchItem(
icon: Icons.extension,
@@ -164,11 +188,24 @@ class OptionsSettingsPage extends ConsumerWidget {
.read(settingsProvider.notifier)
.setUseExtensionProviders(v),
),
SettingsSwitchItem(
icon: Icons.sell_outlined,
title: 'Embed Metadata',
subtitle: settings.embedMetadata
? 'Write metadata, cover art, and embedded lyrics to files'
: 'Disabled (advanced): skip all metadata embedding',
value: settings.embedMetadata,
onChanged: (v) =>
ref.read(settingsProvider.notifier).setEmbedMetadata(v),
),
SettingsSwitchItem(
icon: Icons.image,
title: context.l10n.optionsMaxQualityCover,
subtitle: context.l10n.optionsMaxQualityCoverSubtitle,
subtitle: settings.embedMetadata
? context.l10n.optionsMaxQualityCoverSubtitle
: 'Disabled when metadata embedding is off',
value: settings.maxQualityCover,
enabled: settings.embedMetadata,
onChanged: (v) => ref
.read(settingsProvider.notifier)
.setMaxQualityCover(v),
@@ -375,6 +412,7 @@ class OptionsSettingsPage extends ConsumerWidget {
showModalBottomSheet(
context: context,
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: colorScheme.surface,
shape: const RoundedRectangleBorder(
@@ -972,9 +1010,9 @@ class _MetadataSourceSelector extends ConsumerWidget {
Expanded(
child: Text(
context.l10n.optionsSpotifyDeprecationWarning,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.error,
),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: colorScheme.error),
),
),
],
@@ -8,7 +8,8 @@ class ProviderPriorityPage extends ConsumerStatefulWidget {
const ProviderPriorityPage({super.key});
@override
ConsumerState<ProviderPriorityPage> createState() => _ProviderPriorityPageState();
ConsumerState<ProviderPriorityPage> createState() =>
_ProviderPriorityPageState();
}
class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
@@ -23,8 +24,10 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
void _loadProviders() {
final extState = ref.read(extensionProvider);
final allProviders = ref.read(extensionProvider.notifier).getAllDownloadProviders();
final allProviders = ref
.read(extensionProvider.notifier)
.getAllDownloadProviders();
if (extState.providerPriority.isNotEmpty) {
_providers = List.from(extState.providerPriority);
for (final provider in allProviders) {
@@ -86,13 +89,17 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
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),
titlePadding: EdgeInsets.only(
left: leftPadding,
bottom: 16,
),
title: Text(
context.l10n.providerPriorityTitle,
style: TextStyle(
@@ -156,14 +163,19 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
),
child: Row(
children: [
Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary),
Icon(
Icons.info_outline,
size: 20,
color: colorScheme.tertiary,
),
const SizedBox(width: 12),
Expanded(
child: Text(
context.l10n.providerPriorityInfo,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onTertiaryContainer,
),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onTertiaryContainer,
),
),
),
],
@@ -292,7 +304,9 @@ class _ProviderItem extends StatelessWidget {
),
),
Text(
info.isBuiltIn ? context.l10n.providerBuiltIn : context.l10n.providerExtension,
info.isBuiltIn
? context.l10n.providerBuiltIn
: context.l10n.providerExtension,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -300,10 +314,7 @@ class _ProviderItem extends StatelessWidget {
],
),
),
Icon(
Icons.drag_handle,
color: colorScheme.onSurfaceVariant,
),
Icon(Icons.drag_handle, color: colorScheme.onSurfaceVariant),
],
),
),
@@ -321,17 +332,19 @@ class _ProviderItem extends StatelessWidget {
isBuiltIn: true,
);
case 'qobuz':
return _ProviderInfo(
name: 'Qobuz',
icon: Icons.album,
isBuiltIn: true,
);
return _ProviderInfo(name: 'Qobuz', icon: Icons.album, isBuiltIn: true);
case 'amazon':
return _ProviderInfo(
name: 'Amazon Music',
icon: Icons.shopping_bag,
isBuiltIn: true,
);
case 'youtube':
return _ProviderInfo(
name: 'YouTube',
icon: Icons.play_circle_outline,
isBuiltIn: true,
);
default:
return _ProviderInfo(
name: provider,
+162 -126
View File
@@ -6,6 +6,7 @@ import 'package:file_picker/file_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:go_router/go_router.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
@@ -30,11 +31,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
bool _isLoading = false;
int _androidSdkVersion = 0;
// Spotify form
final _clientIdController = TextEditingController();
final _clientSecretController = TextEditingController();
bool _useSpotifyApi = false;
bool _showClientSecret = false;
// Mode selection
String _selectedMode = 'downloader';
// We add 1 for the Welcome step
int get _totalSteps => (_androidSdkVersion >= 33 ? 4 : 3) + 1;
@@ -48,8 +46,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
@override
void dispose() {
_pageController.dispose();
_clientIdController.dispose();
_clientSecretController.dispose();
super.dispose();
}
@@ -291,6 +287,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
final colorScheme = Theme.of(context).colorScheme;
await showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
@@ -339,8 +336,13 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(validation.errorReason ?? 'Invalid folder selected'),
backgroundColor: Theme.of(context).colorScheme.error,
content: Text(
validation.errorReason ??
'Invalid folder selected',
),
backgroundColor: Theme.of(
context,
).colorScheme.error,
duration: const Duration(seconds: 4),
),
);
@@ -402,20 +404,10 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
);
}
if (_useSpotifyApi &&
_clientIdController.text.trim().isNotEmpty &&
_clientSecretController.text.trim().isNotEmpty) {
ref
.read(settingsProvider.notifier)
.setSpotifyCredentials(
_clientIdController.text.trim(),
_clientSecretController.text.trim(),
);
ref.read(settingsProvider.notifier).setMetadataSource('spotify');
} else {
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
}
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
await ref
.read(extensionProvider.notifier)
.ensureSpotifyWebExtensionReady();
ref.read(settingsProvider.notifier).setFirstLaunchComplete();
if (mounted) context.go('/tutorial');
@@ -475,7 +467,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
case 2:
return _selectedDirectory != null;
case 3:
return false; // Spotify is last/submit
return true; // Mode selection always has a default
}
} else {
switch (logicStep) {
@@ -484,7 +476,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
case 1:
return _selectedDirectory != null;
case 2:
return false; // Spotify
return true; // Mode selection always has a default
}
}
return false;
@@ -561,7 +553,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
if (_androidSdkVersion >= 33)
_buildNotificationStep(colorScheme),
_buildDirectoryStep(colorScheme),
_buildSpotifyStep(colorScheme),
_buildModeSelectionStep(colorScheme),
],
),
),
@@ -581,12 +573,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
icon: const SizedBox.shrink(), // Custom layout
)
: FloatingActionButton.extended(
onPressed:
(!_useSpotifyApi ||
(_clientIdController.text.isNotEmpty &&
_clientSecretController.text.isNotEmpty))
? _completeSetup
: null,
onPressed: _isLoading ? null : _completeSetup,
label: _isLoading
? SizedBox(
width: 20,
@@ -761,106 +748,32 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
);
}
Widget _buildSpotifyStep(ColorScheme colorScheme) {
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
Widget _buildModeSelectionStep(ColorScheme colorScheme) {
return _StepLayout(
title: context.l10n.setupModeSelectionTitle,
description: context.l10n.setupModeSelectionDescription,
icon: Icons.tune,
child: Column(
children: [
Icon(Icons.api, size: 48, color: colorScheme.primary),
const SizedBox(height: 24),
Text(
context.l10n.setupSpotifyApiOptional,
style: Theme.of(
context,
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
_ModeCard(
icon: Icons.download,
title: context.l10n.setupModeDownloaderTitle,
features: [
context.l10n.setupModeDownloaderFeature1,
context.l10n.setupModeDownloaderFeature2,
context.l10n.setupModeDownloaderFeature3,
],
isSelected: _selectedMode == 'downloader',
onTap: () => setState(() => _selectedMode = 'downloader'),
colorScheme: colorScheme,
),
const SizedBox(height: 8),
const SizedBox(height: 16),
Text(
context.l10n.setupSpotifyApiDescription,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
context.l10n.setupModeChangeableLater,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 32),
Card(
elevation: 0,
color: colorScheme.surfaceContainerHighest,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
SwitchListTile(
value: _useSpotifyApi,
onChanged: (v) => setState(() => _useSpotifyApi = v),
title: Text(context.l10n.setupUseSpotifyApi),
subtitle: Text(
_useSpotifyApi
? context.l10n.setupEnterCredentialsBelow
: "Using bundled metadata",
),
),
if (_useSpotifyApi) ...[
const Divider(),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextField(
controller: _clientIdController,
decoration: InputDecoration(
labelText: context.l10n.credentialsClientId,
prefixIcon: const Icon(Icons.key),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colorScheme.outline,
width: 0.5,
),
),
),
),
const SizedBox(height: 16),
TextField(
controller: _clientSecretController,
obscureText: !_showClientSecret,
decoration: InputDecoration(
labelText: context.l10n.credentialsClientSecret,
prefixIcon: const Icon(Icons.lock),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colorScheme.outline,
width: 0.5,
),
),
suffixIcon: IconButton(
icon: Icon(
_showClientSecret
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () => setState(
() => _showClientSecret = !_showClientSecret,
),
),
),
),
],
),
),
],
],
),
textAlign: TextAlign.center,
),
],
),
@@ -975,3 +888,126 @@ class _SuccessCard extends StatelessWidget {
);
}
}
class _ModeCard extends StatelessWidget {
final IconData icon;
final String title;
final List<String> features;
final bool isSelected;
final VoidCallback onTap;
final ColorScheme colorScheme;
const _ModeCard({
required this.icon,
required this.title,
required this.features,
required this.isSelected,
required this.onTap,
required this.colorScheme,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isSelected
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outlineVariant,
width: isSelected ? 2 : 1,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: Icon(
isSelected
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
size: 22,
color: isSelected
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
icon,
size: 22,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(
fontWeight: FontWeight.bold,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurface,
),
),
),
],
),
const SizedBox(height: 8),
...features.map(
(feature) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'\u2022 ',
style: TextStyle(
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
Expanded(
child: Text(
feature,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
height: 1.4,
),
),
),
],
),
),
),
],
),
),
],
),
),
);
}
}
+103 -72
View File
@@ -43,7 +43,30 @@ class _StoreTabState extends ConsumerState<StoreTab> {
@override
Widget build(BuildContext context) {
final state = ref.watch(storeProvider);
final storeFilterState = ref.watch(
storeProvider.select(
(s) => (s.extensions, s.selectedCategory, s.searchQuery),
),
);
final extensions = storeFilterState.$1;
final selectedCategory = storeFilterState.$2;
final searchQuery = storeFilterState.$3;
final isLoading = ref.watch(storeProvider.select((s) => s.isLoading));
final error = ref.watch(storeProvider.select((s) => s.error));
final downloadingId = ref.watch(
storeProvider.select((s) => s.downloadingId),
);
final filteredExtensions = StoreState(
extensions: extensions,
selectedCategory: selectedCategory,
searchQuery: searchQuery,
).filteredExtensions;
if (_searchController.text != searchQuery) {
_searchController.value = TextEditingValue(
text: searchQuery,
selection: TextSelection.collapsed(offset: searchQuery.length),
);
}
final colorScheme = Theme.of(context).colorScheme;
final topPadding = normalizedHeaderTopPadding(context);
@@ -89,41 +112,46 @@ class _StoreTabState extends ConsumerState<StoreTab> {
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: context.l10n.storeSearch,
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
ref
.read(storeProvider.notifier)
.setSearchQuery('');
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide.none,
),
filled: true,
fillColor: Theme.of(context).brightness == Brightness.dark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: colorScheme.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
onChanged: (value) {
ref.read(storeProvider.notifier).setSearchQuery(value);
setState(() {});
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _searchController,
builder: (context, value, _) {
return TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: context.l10n.storeSearch,
prefixIcon: const Icon(Icons.search),
suffixIcon: value.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
ref
.read(storeProvider.notifier)
.setSearchQuery('');
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide.none,
),
filled: true,
fillColor:
Theme.of(context).brightness == Brightness.dark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: colorScheme.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
onChanged: (value) {
ref.read(storeProvider.notifier).setSearchQuery(value);
},
);
},
),
),
@@ -141,7 +169,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
_CategoryChip(
label: context.l10n.storeFilterAll,
icon: Icons.apps,
isSelected: state.selectedCategory == null,
isSelected: selectedCategory == null,
onTap: () =>
ref.read(storeProvider.notifier).setCategory(null),
),
@@ -149,8 +177,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
_CategoryChip(
label: context.l10n.storeFilterMetadata,
icon: Icons.label_outline,
isSelected:
state.selectedCategory == StoreCategory.metadata,
isSelected: selectedCategory == StoreCategory.metadata,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.metadata),
@@ -159,8 +186,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
_CategoryChip(
label: context.l10n.storeFilterDownload,
icon: Icons.download_outlined,
isSelected:
state.selectedCategory == StoreCategory.download,
isSelected: selectedCategory == StoreCategory.download,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.download),
@@ -169,8 +195,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
_CategoryChip(
label: context.l10n.storeFilterUtility,
icon: Icons.build_outlined,
isSelected:
state.selectedCategory == StoreCategory.utility,
isSelected: selectedCategory == StoreCategory.utility,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.utility),
@@ -179,8 +204,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
_CategoryChip(
label: context.l10n.storeFilterLyrics,
icon: Icons.lyrics_outlined,
isSelected:
state.selectedCategory == StoreCategory.lyrics,
isSelected: selectedCategory == StoreCategory.lyrics,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.lyrics),
@@ -189,8 +213,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
_CategoryChip(
label: context.l10n.storeFilterIntegration,
icon: Icons.link,
isSelected:
state.selectedCategory == StoreCategory.integration,
isSelected: selectedCategory == StoreCategory.integration,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.integration),
@@ -200,22 +223,26 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
),
if (state.isLoading && state.extensions.isEmpty)
if (isLoading && extensions.isEmpty)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
)
else if (state.error != null && state.extensions.isEmpty)
else if (error != null && extensions.isEmpty)
SliverFillRemaining(child: _buildErrorState(error, colorScheme))
else if (filteredExtensions.isEmpty)
SliverFillRemaining(
child: _buildErrorState(state.error!, colorScheme),
child: _buildEmptyState(
hasFilters:
searchQuery.isNotEmpty || selectedCategory != null,
colorScheme: colorScheme,
),
)
else if (state.filteredExtensions.isEmpty)
SliverFillRemaining(child: _buildEmptyState(state, colorScheme))
else ...[
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
'${state.filteredExtensions.length} ${state.filteredExtensions.length == 1 ? 'extension' : 'extensions'}',
'${filteredExtensions.length} ${filteredExtensions.length == 1 ? 'extension' : 'extensions'}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -227,16 +254,13 @@ class _StoreTabState extends ConsumerState<StoreTab> {
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SettingsGroup(
children: state.filteredExtensions.asMap().entries.map((
entry,
) {
children: filteredExtensions.asMap().entries.map((entry) {
final index = entry.key;
final ext = entry.value;
return _ExtensionItem(
extension: ext,
showDivider:
index < state.filteredExtensions.length - 1,
isDownloading: state.downloadingId == ext.id,
showDivider: index < filteredExtensions.length - 1,
isDownloading: downloadingId == ext.id,
onInstall: () => _installExtension(ext),
onUpdate: () => _updateExtension(ext),
onTap: () => _showExtensionDetails(ext),
@@ -288,10 +312,10 @@ class _StoreTabState extends ConsumerState<StoreTab> {
);
}
Widget _buildEmptyState(StoreState state, ColorScheme colorScheme) {
final hasFilters =
state.searchQuery.isNotEmpty || state.selectedCategory != null;
Widget _buildEmptyState({
required bool hasFilters,
required ColorScheme colorScheme,
}) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -541,7 +565,10 @@ class _ExtensionItem extends StatelessWidget {
if (extension.requiresNewerApp) ...[
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(4),
@@ -549,14 +576,19 @@ class _ExtensionItem extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.warning_amber_rounded, size: 12, color: colorScheme.onErrorContainer),
Icon(
Icons.warning_amber_rounded,
size: 12,
color: colorScheme.onErrorContainer,
),
const SizedBox(width: 4),
Text(
'Requires v${extension.minAppVersion}+',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.w500,
),
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.w500,
),
),
],
),
@@ -565,9 +597,8 @@ class _ExtensionItem extends StatelessWidget {
const SizedBox(height: 4),
Text(
extension.description,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.onSurfaceVariant),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
+14 -1
View File
@@ -13,6 +13,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:share_plus/share_plus.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
@@ -2336,6 +2337,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
) {
showModalBottomSheet(
context: context,
useRootNavigator: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
@@ -2566,6 +2568,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
showModalBottomSheet(
context: context,
useRootNavigator: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
@@ -3003,6 +3006,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final saved = await showModalBottomSheet<bool>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: colorScheme.surface,
shape: const RoundedRectangleBorder(
@@ -3039,6 +3043,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
) {
showDialog(
context: context,
useRootNavigator: false,
builder: (context) => AlertDialog(
title: Text(context.l10n.trackDeleteConfirmTitle),
content: Text(context.l10n.trackDeleteConfirmMessage),
@@ -3088,7 +3093,15 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
Future<void> _openFile(BuildContext context, String filePath) async {
try {
await openFile(filePath);
await ref
.read(playbackProvider.notifier)
.playLocalPath(
path: filePath,
title: trackName,
artist: artistName,
album: albumName,
coverUrl: _coverUrl ?? '',
);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
+2
View File
@@ -106,6 +106,8 @@ class CsvImportService {
artistName: trackData['artists'] as String? ?? track.artistName,
albumName: trackData['album_name'] as String? ?? track.albumName,
albumArtist: trackData['album_artist'] as String?,
artistId: trackData['artist_id']?.toString(),
albumId: trackData['album_id']?.toString(),
coverUrl: coverUrl ?? track.coverUrl,
isrc: trackData['isrc'] as String? ?? track.isrc,
duration: durationMs > 0 ? durationMs ~/ 1000 : track.duration,
@@ -10,6 +10,7 @@ class DownloadRequestPayload {
final String outputDir;
final String filenameFormat;
final String quality;
final bool embedMetadata;
final bool embedLyrics;
final bool embedMaxQualityCover;
final int trackNumber;
@@ -47,6 +48,7 @@ class DownloadRequestPayload {
required this.outputDir,
required this.filenameFormat,
this.quality = 'LOSSLESS',
this.embedMetadata = true,
this.embedLyrics = true,
this.embedMaxQualityCover = true,
this.trackNumber = 1,
@@ -86,6 +88,7 @@ class DownloadRequestPayload {
'output_dir': outputDir,
'filename_format': filenameFormat,
'quality': quality,
'embed_metadata': embedMetadata,
'embed_lyrics': embedLyrics,
'embed_max_quality_cover': embedMaxQualityCover,
'track_number': trackNumber,
@@ -129,6 +132,7 @@ class DownloadRequestPayload {
outputDir: outputDir,
filenameFormat: filenameFormat,
quality: quality,
embedMetadata: embedMetadata,
embedLyrics: embedLyrics,
embedMaxQualityCover: embedMaxQualityCover,
trackNumber: trackNumber,
@@ -25,6 +25,7 @@ class DownloadedEmbeddedCoverResolver {
LinkedHashMap<String, _EmbeddedCoverCacheEntry>();
static final Set<String> _pendingExtract = <String>{};
static final Set<String> _pendingRefresh = <String>{};
static final Set<String> _pendingPreviewValidation = <String>{};
static final Set<String> _failedExtract = <String>{};
static String cleanFilePath(String? filePath) {
@@ -66,12 +67,9 @@ class DownloadedEmbeddedCoverResolver {
final cached = _cache[cleanPath];
if (cached != null) {
if (File(cached.previewPath).existsSync()) {
_touch(cleanPath, cached);
return cached.previewPath;
}
_cache.remove(cleanPath);
_cleanupTempCoverPathSync(cached.previewPath);
_touch(cleanPath, cached);
_validateCachedPreviewAsync(cleanPath, cached, onChanged: onChanged);
return cached.previewPath;
}
return null;
@@ -106,6 +104,7 @@ class DownloadedEmbeddedCoverResolver {
final cached = _cache.remove(cleanPath);
_pendingExtract.remove(cleanPath);
_pendingRefresh.remove(cleanPath);
_pendingPreviewValidation.remove(cleanPath);
_failedExtract.remove(cleanPath);
if (cached != null) {
_cleanupTempCoverPathSync(cached.previewPath);
@@ -144,10 +143,36 @@ class DownloadedEmbeddedCoverResolver {
}
_pendingExtract.remove(oldestKey);
_pendingRefresh.remove(oldestKey);
_pendingPreviewValidation.remove(oldestKey);
_failedExtract.remove(oldestKey);
}
}
static void _validateCachedPreviewAsync(
String cleanPath,
_EmbeddedCoverCacheEntry entry, {
VoidCallback? onChanged,
}) {
if (_pendingPreviewValidation.contains(cleanPath)) return;
_pendingPreviewValidation.add(cleanPath);
Future.microtask(() async {
try {
final exists = await fileExists(entry.previewPath);
if (!exists) {
final latest = _cache[cleanPath];
if (latest != null && latest.previewPath == entry.previewPath) {
_cache.remove(cleanPath);
_failedExtract.remove(cleanPath);
onChanged?.call();
}
_cleanupTempCoverPathSync(entry.previewPath);
}
} finally {
_pendingPreviewValidation.remove(cleanPath);
}
});
}
static void _ensureCover(
String cleanPath, {
bool forceRefresh = false,
+463 -4
View File
@@ -1,9 +1,11 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit_config.dart';
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit_config.dart';
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_session.dart';
import 'package:ffmpeg_kit_flutter_new_full/return_code.dart';
import 'package:ffmpeg_kit_flutter_new_full/session_state.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/utils/logger.dart';
@@ -11,7 +13,20 @@ final _log = AppLogger('FFmpeg');
class FFmpegService {
static const int _commandLogPreviewLength = 300;
static const Duration _liveTunnelStartupTimeout = Duration(seconds: 8);
static const Duration _liveTunnelStartupPollInterval = Duration(
milliseconds: 200,
);
static const Duration _liveTunnelStabilizationDelay = Duration(
milliseconds: 900,
);
static int _tempEmbedCounter = 0;
static FFmpegSession? _activeLiveDecryptSession;
static String? _activeLiveDecryptUrl;
static String? _activeLiveTempInputPath;
static String? _activeNativeDashManifestPath;
static String? _activeNativeDashManifestUrl;
static final Set<String> _preparedNativeDashManifestPaths = <String>{};
static String _buildOutputPath(String inputPath, String extension) {
final normalizedExt = extension.startsWith('.') ? extension : '.$extension';
@@ -305,6 +320,433 @@ class FFmpegService {
return null;
}
static bool isActiveLiveDecryptedUrl(String url) {
final active = _activeLiveDecryptUrl;
if (active == null || active.isEmpty) return false;
return active == url.trim();
}
static bool isActiveNativeDashManifestUrl(String url) {
final activeUrl = _activeNativeDashManifestUrl;
if (activeUrl == null || activeUrl.isEmpty) return false;
final normalized = url.trim();
if (activeUrl == normalized) return true;
try {
final activePath = Uri.parse(activeUrl).toFilePath();
final incomingPath = Uri.parse(normalized).toFilePath();
return activePath == incomingPath;
} catch (_) {
return false;
}
}
static Future<String?> prepareTidalDashManifestForNativePlayback({
required String manifestPayload,
bool registerAsActive = true,
}) async {
final rawPayload = manifestPayload.trim();
if (rawPayload.isEmpty) return null;
final payload = rawPayload.startsWith('MANIFEST:')
? rawPayload.substring('MANIFEST:'.length)
: rawPayload;
final manifestPath = await _writeTempManifestFile(payload);
if (manifestPath == null) {
_log.e('Failed to prepare Tidal DASH manifest for native playback');
return null;
}
final manifestUrl = Uri.file(manifestPath).toString();
_preparedNativeDashManifestPaths.add(manifestPath);
if (registerAsActive) {
await activatePreparedNativeDashManifest(manifestUrl);
}
return manifestUrl;
}
static Future<void> activatePreparedNativeDashManifest(String url) async {
final normalized = url.trim();
if (normalized.isEmpty) return;
final manifestPath = _nativeDashManifestPathFromUrl(normalized);
if (manifestPath == null ||
!_preparedNativeDashManifestPaths.contains(manifestPath)) {
return;
}
final previousPath = _activeNativeDashManifestPath;
_activeNativeDashManifestPath = manifestPath;
_activeNativeDashManifestUrl = Uri.file(manifestPath).toString();
if (previousPath != null &&
previousPath.isNotEmpty &&
previousPath != manifestPath) {
_preparedNativeDashManifestPaths.remove(previousPath);
await _deleteNativeDashManifestFile(previousPath);
}
}
static Future<void> stopNativeDashManifestPlayback() async {
final manifestPath = _activeNativeDashManifestPath;
_activeNativeDashManifestPath = null;
_activeNativeDashManifestUrl = null;
if (manifestPath == null || manifestPath.isEmpty) return;
_preparedNativeDashManifestPaths.remove(manifestPath);
await _deleteNativeDashManifestFile(manifestPath);
}
static Future<void> cleanupInactivePreparedNativeDashManifests() async {
final activePath = _activeNativeDashManifestPath;
final stalePaths = _preparedNativeDashManifestPaths
.where((path) => path != activePath)
.toList(growable: false);
for (final path in stalePaths) {
_preparedNativeDashManifestPaths.remove(path);
await _deleteNativeDashManifestFile(path);
}
}
static String? _nativeDashManifestPathFromUrl(String url) {
try {
final uri = Uri.parse(url);
if (uri.scheme.toLowerCase() != 'file') {
return null;
}
final path = uri.toFilePath();
return path.trim().isEmpty ? null : path;
} catch (_) {
return null;
}
}
static Future<void> _deleteNativeDashManifestFile(String path) async {
try {
final file = File(path);
if (await file.exists()) {
await file.delete();
}
} catch (_) {}
}
static Future<void> stopLiveDecryptedStream() async {
final session = _activeLiveDecryptSession;
final tempInputPath = _activeLiveTempInputPath;
_activeLiveDecryptSession = null;
_activeLiveDecryptUrl = null;
_activeLiveTempInputPath = null;
if (session != null) {
try {
await session.cancel();
} catch (e) {
final sessionId = session.getSessionId();
if (sessionId != null) {
try {
await FFmpegKit.cancel(sessionId);
} catch (_) {}
}
_log.w('Failed to stop live decrypt session cleanly: $e');
}
}
if (tempInputPath != null && tempInputPath.isNotEmpty) {
try {
final file = File(tempInputPath);
if (await file.exists()) {
await file.delete();
}
} catch (_) {}
}
}
static Future<LiveDecryptedStreamResult?> startTidalDashLiveStream({
required String manifestPayload,
String preferredFormat = 'm4a',
}) async {
final rawPayload = manifestPayload.trim();
if (rawPayload.isEmpty) return null;
final payload = rawPayload.startsWith('MANIFEST:')
? rawPayload.substring('MANIFEST:'.length)
: rawPayload;
final manifestPath = await _writeTempManifestFile(payload);
if (manifestPath == null) {
_log.e('Failed to prepare Tidal DASH manifest for live stream');
return null;
}
await stopLiveDecryptedStream();
await stopNativeDashManifestPlayback();
final attempts = _buildLiveDashFormatAttempts(preferredFormat);
for (final format in attempts) {
final stream = await _tryStartLiveDashAttempt(
manifestPath: manifestPath,
format: format,
);
if (stream != null) {
_activeLiveDecryptSession = stream.session;
_activeLiveDecryptUrl = stream.localUrl;
_activeLiveTempInputPath = manifestPath;
return stream;
}
}
try {
final file = File(manifestPath);
if (await file.exists()) {
await file.delete();
}
} catch (_) {}
return null;
}
static Future<String?> _writeTempManifestFile(String payload) async {
if (payload.trim().isEmpty) return null;
Uint8List bytes;
try {
bytes = base64Decode(payload);
} catch (_) {
bytes = Uint8List.fromList(utf8.encode(payload));
}
final manifestText = utf8.decode(bytes, allowMalformed: true).trim();
if (manifestText.isEmpty) return null;
final tempDir = await getTemporaryDirectory();
final manifestPath =
'${tempDir.path}${Platform.pathSeparator}tidal_dash_${DateTime.now().microsecondsSinceEpoch}.mpd';
await File(manifestPath).writeAsString(manifestText, flush: true);
return manifestPath;
}
static List<_LiveDecryptFormat> _buildLiveDashFormatAttempts(
String preferredFormat,
) {
final normalized = preferredFormat.trim().toLowerCase();
if (normalized == 'flac') {
return const [_LiveDecryptFormat.flac, _LiveDecryptFormat.m4a];
}
return const [_LiveDecryptFormat.m4a, _LiveDecryptFormat.flac];
}
static Future<bool> _awaitLiveTunnelReady(FFmpegSession session) async {
final deadline = DateTime.now().add(_liveTunnelStartupTimeout);
var seenRunning = false;
while (DateTime.now().isBefore(deadline)) {
final state = await session.getState();
if (state == SessionState.running) {
seenRunning = true;
break;
}
if (state != SessionState.created) {
return false;
}
await Future<void>.delayed(_liveTunnelStartupPollInterval);
}
if (!seenRunning) {
return false;
}
await Future<void>.delayed(_liveTunnelStabilizationDelay);
return (await session.getState()) == SessionState.running;
}
static Future<LiveDecryptedStreamResult?> _tryStartLiveDashAttempt({
required String manifestPath,
required _LiveDecryptFormat format,
}) async {
final port = await _allocateLoopbackPort();
final ext = format == _LiveDecryptFormat.flac ? 'flac' : 'm4a';
final mimeType = format == _LiveDecryptFormat.flac
? 'audio/flac'
: 'audio/mp4';
final localUrl = 'http://localhost:$port/stream.$ext';
final commandArguments = <String>[
'-nostdin',
'-hide_banner',
'-loglevel',
'error',
'-protocol_whitelist',
'file,http,https,tcp,tls,crypto,data',
'-i',
manifestPath,
'-map',
'0:a:0',
'-c:a',
'copy',
if (format == _LiveDecryptFormat.flac) ...['-f', 'flac'],
if (format == _LiveDecryptFormat.m4a) ...[
'-movflags',
'+frag_keyframe+empty_moov+default_base_moof',
'-f',
'mp4',
],
'-content_type',
mimeType,
'-listen',
'1',
localUrl,
];
_log.d(
'Starting Tidal DASH tunnel: ${_previewCommandForLog(commandArguments.join(' '))}',
);
final session = await FFmpegKit.executeWithArgumentsAsync(commandArguments);
final isReady = await _awaitLiveTunnelReady(session);
if (isReady) {
return LiveDecryptedStreamResult(
localUrl: localUrl,
format: ext,
session: session,
);
}
final state = await session.getState();
final output = (await session.getOutput() ?? '').trim();
if (output.isNotEmpty) {
_log.w('Tidal DASH tunnel failed ($ext): $output');
} else {
_log.w('Tidal DASH tunnel failed ($ext) with session state: $state');
}
try {
await session.cancel();
} catch (_) {}
return null;
}
static Future<LiveDecryptedStreamResult?> startAmazonLiveDecryptedStream({
required String encryptedStreamUrl,
required String decryptionKey,
String preferredFormat = 'flac',
}) async {
final inputUrl = encryptedStreamUrl.trim();
if (inputUrl.isEmpty) return null;
final keyCandidates = _buildDecryptionKeyCandidates(decryptionKey);
if (keyCandidates.isEmpty) {
_log.e('No usable decryption key candidates for live stream');
return null;
}
await stopLiveDecryptedStream();
final attempts = _buildLiveDecryptFormatAttempts(preferredFormat);
for (final format in attempts) {
for (final keyCandidate in keyCandidates) {
final stream = await _tryStartLiveDecryptAttempt(
inputUrl: inputUrl,
decryptionKey: keyCandidate,
format: format,
);
if (stream != null) {
_activeLiveDecryptSession = stream.session;
_activeLiveDecryptUrl = stream.localUrl;
_activeLiveTempInputPath = null;
return stream;
}
}
}
return null;
}
static List<_LiveDecryptFormat> _buildLiveDecryptFormatAttempts(
String preferredFormat,
) {
final normalized = preferredFormat.trim().toLowerCase();
if (normalized == 'm4a' || normalized == 'mp4' || normalized == 'aac') {
return const [_LiveDecryptFormat.m4a, _LiveDecryptFormat.flac];
}
return const [_LiveDecryptFormat.flac, _LiveDecryptFormat.m4a];
}
static Future<LiveDecryptedStreamResult?> _tryStartLiveDecryptAttempt({
required String inputUrl,
required String decryptionKey,
required _LiveDecryptFormat format,
}) async {
final port = await _allocateLoopbackPort();
final ext = format == _LiveDecryptFormat.flac ? 'flac' : 'm4a';
final mimeType = format == _LiveDecryptFormat.flac
? 'audio/flac'
: 'audio/mp4';
final localUrl = 'http://localhost:$port/stream.$ext';
final commandArguments = <String>[
'-nostdin',
'-hide_banner',
'-loglevel',
'error',
'-decryption_key',
decryptionKey,
'-i',
inputUrl,
'-map',
'0:a:0',
'-c:a',
'copy',
if (format == _LiveDecryptFormat.flac) ...['-f', 'flac'],
if (format == _LiveDecryptFormat.m4a) ...[
'-movflags',
'+frag_keyframe+empty_moov+default_base_moof',
'-f',
'mp4',
],
'-content_type',
mimeType,
'-listen',
'1',
localUrl,
];
_log.d(
'Starting live decrypt tunnel: ${_previewCommandForLog(commandArguments.join(' '))}',
);
final session = await FFmpegKit.executeWithArgumentsAsync(commandArguments);
final isReady = await _awaitLiveTunnelReady(session);
if (isReady) {
return LiveDecryptedStreamResult(
localUrl: localUrl,
format: ext,
session: session,
);
}
final state = await session.getState();
final output = (await session.getOutput() ?? '').trim();
if (output.isNotEmpty) {
_log.w('Live decrypt attempt failed ($ext): $output');
} else {
_log.w('Live decrypt attempt failed ($ext) with session state: $state');
}
try {
await session.cancel();
} catch (_) {}
return null;
}
static Future<int> _allocateLoopbackPort() async {
final socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
final port = socket.port;
await socket.close();
return port;
}
static Future<String?> convertFlacToOpus(
String inputPath, {
String bitrate = '128k',
@@ -861,9 +1303,10 @@ class FFmpegService {
for (final entry in vorbisMetadata.entries) {
final key = entry.key.toUpperCase();
final normalizedKey = key.replaceAll(RegExp(r'[^A-Z0-9]'), '');
final value = entry.value;
switch (key) {
switch (normalizedKey) {
case 'TITLE':
id3Map['title'] = value;
break;
@@ -878,10 +1321,12 @@ class FFmpegService {
break;
case 'TRACKNUMBER':
case 'TRACK':
case 'TRCK':
id3Map['track'] = value;
break;
case 'DISCNUMBER':
case 'DISC':
case 'TPOS':
id3Map['disc'] = value;
break;
case 'DATE':
@@ -921,3 +1366,17 @@ class FFmpegResult {
required this.output,
});
}
enum _LiveDecryptFormat { flac, m4a }
class LiveDecryptedStreamResult {
final String localUrl;
final String format;
final FFmpegSession session;
LiveDecryptedStreamResult({
required this.localUrl,
required this.format,
required this.session,
});
}

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