mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-06-13 01:47:52 +02:00
chore: rebuild dev history without streaming-era commits
This commit is contained in:
@@ -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.
@@ -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 300–560px) 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
|
||||
|
||||
@@ -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]
|
||||
|
||||
Vendored
+10
@@ -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.** { *; }
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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®ion=%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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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": "自动发现并将相似曲目添加到您的队列中"
|
||||
}
|
||||
@@ -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": "自动发现并将相似曲目添加到您的队列中"
|
||||
}
|
||||
@@ -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": "自動探索並將相似曲目新增到您的佇列中"
|
||||
}
|
||||
@@ -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://');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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? ?? '',
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,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
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user