mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-04 03:37:56 +02:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e9c7bf830e | |||
| 8bc97d5bd3 | |||
| f2c241c323 | |||
| 9c512ffe28 | |||
| 53a1da6249 | |||
| d4274e8ca8 | |||
| 49a9f12841 | |||
| d7fa040e3c | |||
| 9baa1e2088 | |||
| 482457205a | |||
| 3b2ec319e2 | |||
| a0f7e75a9a | |||
| c725e53e4c | |||
| 1d7c43a302 | |||
| df7c1c5bb7 | |||
| bb05353b7e | |||
| 7ac92d77e5 | |||
| cf00ecb756 |
@@ -85,7 +85,19 @@ jobs:
|
|||||||
restore-keys: gradle-${{ runner.os }}-
|
restore-keys: gradle-${{ runner.os }}-
|
||||||
|
|
||||||
- name: Install Android SDK & NDK
|
- name: Install Android SDK & NDK
|
||||||
uses: android-actions/setup-android@v3
|
run: |
|
||||||
|
# Use pre-installed Android SDK on GitHub runners
|
||||||
|
echo "ANDROID_HOME=$ANDROID_HOME"
|
||||||
|
echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT"
|
||||||
|
|
||||||
|
# Accept licenses
|
||||||
|
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
||||||
|
|
||||||
|
# Install NDK (required for gomobile)
|
||||||
|
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;25.2.9519653" "platforms;android-34" "build-tools;34.0.0"
|
||||||
|
|
||||||
|
# Set NDK path
|
||||||
|
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/25.2.9519653" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Install gomobile
|
- name: Install gomobile
|
||||||
run: |
|
run: |
|
||||||
@@ -229,6 +241,23 @@ jobs:
|
|||||||
channel: 'stable'
|
channel: 'stable'
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
|
# Swap pubspec for iOS build (includes ffmpeg_kit_flutter)
|
||||||
|
- name: Use iOS pubspec with FFmpeg plugin
|
||||||
|
run: |
|
||||||
|
cp pubspec.yaml pubspec_android_backup.yaml
|
||||||
|
cp pubspec_ios.yaml pubspec.yaml
|
||||||
|
echo "Swapped to iOS pubspec with ffmpeg_kit_flutter"
|
||||||
|
|
||||||
|
# Swap FFmpeg service for iOS
|
||||||
|
- name: Use iOS FFmpeg service
|
||||||
|
run: |
|
||||||
|
cp lib/services/ffmpeg_service.dart lib/services/ffmpeg_service_android.dart
|
||||||
|
cp build_assets/ffmpeg_service_ios.dart lib/services/ffmpeg_service.dart
|
||||||
|
# Update class name in the swapped file
|
||||||
|
sed -i '' 's/FFmpegServiceIOS/FFmpegService/g' lib/services/ffmpeg_service.dart
|
||||||
|
sed -i '' 's/FFmpegResultIOS/FFmpegResult/g' lib/services/ffmpeg_service.dart
|
||||||
|
echo "Swapped to iOS FFmpeg service"
|
||||||
|
|
||||||
- name: Get Flutter dependencies
|
- name: Get Flutter dependencies
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|
||||||
|
|||||||
+2
-4
@@ -13,9 +13,6 @@ Thumbs.db
|
|||||||
# Reference folder (development only)
|
# Reference folder (development only)
|
||||||
referensi/
|
referensi/
|
||||||
|
|
||||||
# Development notes
|
|
||||||
COMPARISON_PC_vs_ANDROID.md
|
|
||||||
|
|
||||||
# Old spotiflac_android folder (moved to root)
|
# Old spotiflac_android folder (moved to root)
|
||||||
spotiflac_android/
|
spotiflac_android/
|
||||||
|
|
||||||
@@ -38,7 +35,7 @@ go_backend/*.xcframework/
|
|||||||
|
|
||||||
# Android
|
# Android
|
||||||
android/.gradle/
|
android/.gradle/
|
||||||
android/app/libs/
|
android/app/libs/gobackend.aar
|
||||||
android/local.properties
|
android/local.properties
|
||||||
android/*.iml
|
android/*.iml
|
||||||
android/key.properties
|
android/key.properties
|
||||||
@@ -52,3 +49,4 @@ ios/Pods/
|
|||||||
ios/.symlinks/
|
ios/.symlinks/
|
||||||
ios/Flutter/Flutter.framework/
|
ios/Flutter/Flutter.framework/
|
||||||
ios/Flutter/Flutter.podspec
|
ios/Flutter/Flutter.podspec
|
||||||
|
android/app/libs/gobackend-sources.jar
|
||||||
|
|||||||
+156
@@ -1,5 +1,161 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [2.1.5] - 2026-01-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Deezer as Alternative Metadata Source**: Choose between Deezer or Spotify for search
|
||||||
|
- Configure in Settings > Options > Spotify API > Search Source
|
||||||
|
- Default is Deezer for better reliability
|
||||||
|
- Spotify URLs are always supported regardless of this setting
|
||||||
|
- **Automatic Deezer Fallback for Spotify URLs**: When Spotify API is rate limited (429), automatically falls back to Deezer
|
||||||
|
- Uses SongLink/Odesli API to convert Spotify track/album ID to Deezer ID
|
||||||
|
- Fetches metadata from Deezer instead
|
||||||
|
- Works for tracks and albums (playlists are user-specific, artists require Spotify API)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Default Download Service**: Changed from Tidal to Qobuz
|
||||||
|
- Fallback order is now: Qobuz → Tidal → Amazon
|
||||||
|
- **Deezer API Updated to v2.0**: More reliable and complete metadata
|
||||||
|
- Direct ISRC lookup via `/track/isrc:{ISRC}` endpoint
|
||||||
|
- Search results now fetch full track info to include ISRC
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Progress Bar Not Updating**: Fixed bug where download progress jumped from 1% directly to 100%
|
||||||
|
- Progress now updates smoothly every 64KB of data received
|
||||||
|
- First progress update happens immediately when download starts
|
||||||
|
- **Incomplete Downloads**: Fixed bug where interrupted downloads could result in corrupted/incomplete files
|
||||||
|
- File size is validated against server's Content-Length header
|
||||||
|
- Incomplete files are automatically deleted and error is reported
|
||||||
|
- Applies to all services: Tidal, Qobuz, and Amazon
|
||||||
|
- **ISRC Not Available from Deezer Search**: Search results now fetch full track details to get ISRC
|
||||||
|
- Improves track matching accuracy when downloading
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
- New settings field: `metadataSource` in `lib/models/settings.dart`
|
||||||
|
- New UI: Search Source selector in Options Settings page
|
||||||
|
- Improved `ItemProgressWriter` with threshold-based progress updates
|
||||||
|
- Download functions now properly handle network interruptions
|
||||||
|
- Deezer API base URL changed to `https://api.deezer.com/2.0`
|
||||||
|
|
||||||
|
## [2.1.0] - 2026-01-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Service Switcher in Quality Picker**: Choose download service (Tidal/Qobuz/Amazon) directly when selecting quality
|
||||||
|
- Service selector chips appear above quality options
|
||||||
|
- Defaults to your preferred service from settings
|
||||||
|
- Change service on-the-fly without going to settings
|
||||||
|
- Available in Home, Album, and Playlist screens
|
||||||
|
- **AMOLED Dark Theme**: Pure black background for OLED screens
|
||||||
|
- Toggle in Settings > Appearance > Theme
|
||||||
|
- Saves battery on OLED/AMOLED displays
|
||||||
|
- All surface colors adjusted for true black background
|
||||||
|
- **Update Channel Setting**: Choose between Stable and Preview release channels
|
||||||
|
- Stable: Only receive stable release notifications
|
||||||
|
- Preview: Get notified about preview/beta releases too
|
||||||
|
- Configure in Settings > Options > App
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Reduced APK Size**: Replaced FFmpeg plugin with custom AAR containing only required codecs
|
||||||
|
- arm64 APK: 46.6 MB (previously 51 MB)
|
||||||
|
- arm32 APK: 59 MB (previously 64 MB)
|
||||||
|
- Only includes FLAC, MP3 (LAME), and AAC codecs
|
||||||
|
- Custom FFmpeg AAR with arm64-v8a and armeabi-v7a only
|
||||||
|
- Native MethodChannel bridge for FFmpeg operations
|
||||||
|
- Separate iOS build configuration with ffmpeg_kit_flutter plugin
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Retry Failed Downloads**: Fixed issue where retrying failed downloads sometimes did nothing
|
||||||
|
- Now properly handles retry when queue processing has finished
|
||||||
|
- Also allows retrying skipped (cancelled) downloads
|
||||||
|
- **Lyrics Loading Timeout**: Added 20 second timeout for lyrics fetching
|
||||||
|
- Shows "Lyrics not available" instead of loading forever
|
||||||
|
- **iOS Directory Picker**: Fixed unable to select download folder on iOS
|
||||||
|
- iOS limitation: Empty folders cannot be selected via document picker
|
||||||
|
- Added "App Documents Folder" option as recommended default
|
||||||
|
- Files saved to app Documents folder are accessible via iOS Files app
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- **Download Speed Optimizations**: Significant improvements to download initialization and throughput
|
||||||
|
- Token caching for Tidal (eliminates redundant auth requests)
|
||||||
|
- Singleton pattern for all downloaders (HTTP connection reuse)
|
||||||
|
- ISRC search first strategy (faster than SongLink API)
|
||||||
|
- Track ID cache with 30 minute TTL for album/playlist downloads
|
||||||
|
- Pre-warm cache when viewing album/playlist
|
||||||
|
- Parallel cover art and lyrics fetching during audio download
|
||||||
|
- 64KB HTTP read/write buffers
|
||||||
|
- 256KB buffered file writer for all downloaders
|
||||||
|
- Progress updates every 64KB (reduced lock contention)
|
||||||
|
- **Amazon Music Optimizations**: Same optimizations now applied to Amazon downloader
|
||||||
|
|
||||||
|
## [2.1.0-preview2] - 2026-01-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Service Switcher in Quality Picker**: Choose download service (Tidal/Qobuz/Amazon) directly when selecting quality
|
||||||
|
- Service selector chips appear above quality options
|
||||||
|
- Defaults to your preferred service from settings
|
||||||
|
- Change service on-the-fly without going to settings
|
||||||
|
- Available in Home, Album, and Playlist screens
|
||||||
|
- **AMOLED Dark Theme**: Pure black background for OLED screens
|
||||||
|
- Toggle in Settings > Appearance > Theme
|
||||||
|
- Saves battery on OLED/AMOLED displays
|
||||||
|
- All surface colors adjusted for true black background
|
||||||
|
- **Update Channel Setting**: Choose between Stable and Preview release channels
|
||||||
|
- Stable: Only receive stable release notifications
|
||||||
|
- Preview: Get notified about preview/beta releases too
|
||||||
|
- Configure in Settings > Options > App
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Retry Failed Downloads**: Fixed issue where retrying failed downloads sometimes did nothing
|
||||||
|
- Now properly handles retry when queue processing has finished
|
||||||
|
- Also allows retrying skipped (cancelled) downloads
|
||||||
|
- Added logging for better debugging
|
||||||
|
- **Lyrics Loading Timeout**: Added 20 second timeout for lyrics fetching
|
||||||
|
- Shows "Lyrics not available" instead of loading forever
|
||||||
|
- Better error messages for timeout and not found cases
|
||||||
|
|
||||||
|
## [2.1.0-preview] - 2026-01-06
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- **Download Speed Optimizations**: Significant improvements to download initialization and throughput
|
||||||
|
- Token caching for Tidal (eliminates redundant auth requests)
|
||||||
|
- Singleton pattern for all downloaders (HTTP connection reuse)
|
||||||
|
- ISRC search first strategy (faster than SongLink API)
|
||||||
|
- Track ID cache with 30 minute TTL for album/playlist downloads
|
||||||
|
- Pre-warm cache when viewing album/playlist
|
||||||
|
- Parallel cover art and lyrics fetching during audio download
|
||||||
|
- 64KB HTTP read/write buffers
|
||||||
|
- 256KB buffered file writer for all downloaders
|
||||||
|
- Progress updates every 64KB (reduced lock contention)
|
||||||
|
- **Amazon Music Optimizations**: Same optimizations now applied to Amazon downloader
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
- New `go_backend/parallel.go` with `TrackIDCache`, `FetchCoverAndLyricsParallel()`, `PreWarmTrackCache()`
|
||||||
|
- Flutter: `_preWarmCacheForTracks()` in `track_provider.dart`
|
||||||
|
- New method channels: `preWarmTrackCache`, `getTrackCacheSize`, `clearTrackCache`
|
||||||
|
|
||||||
|
## [2.0.7-preview2] - 2026-01-06
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **iOS Directory Picker**: Fixed unable to select download folder on iOS
|
||||||
|
- iOS limitation: Empty folders cannot be selected via document picker
|
||||||
|
- Added "App Documents Folder" option as recommended default
|
||||||
|
- Shows info message explaining iOS limitation
|
||||||
|
- Files saved to app Documents folder are accessible via iOS Files app
|
||||||
|
|
||||||
|
## [2.0.7-preview] - 2026-01-05
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Reduced APK Size**: Replaced FFmpeg plugin with custom AAR containing only required codecs
|
||||||
|
- arm64 APK: 46.6 MB (previously 51 MB)
|
||||||
|
- arm32 APK: 59 MB (previously 64 MB)
|
||||||
|
- Only includes FLAC, MP3 (LAME), and AAC codecs
|
||||||
|
- Removed x86/x86_64 architectures (emulator only)
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
- Custom FFmpeg AAR with arm64-v8a and armeabi-v7a only
|
||||||
|
- Native MethodChannel bridge for FFmpeg operations
|
||||||
|
- Separate iOS build configuration with ffmpeg_kit_flutter plugin
|
||||||
|
|
||||||
## [2.0.6] - 2026-01-05
|
## [2.0.6] - 2026-01-05
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
|
[](https://www.virustotal.com/gui/file/9092dd9300289ceadd8e70cd71706a3ba32225d9cb2ae8b12648611d31814708)
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
@@ -22,11 +23,30 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
|||||||
<img src="assets/images/4.jpg?v=2" width="200" />
|
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
## Metadata Source
|
||||||
|
|
||||||
|
SpotiFLAC supports two metadata sources for searching tracks:
|
||||||
|
|
||||||
|
| Source | Pros | Cons |
|
||||||
|
|--------|------|------|
|
||||||
|
| **Deezer** (Default) | No developer account needed, rate limit per user IP | Slightly less comprehensive catalog |
|
||||||
|
| **Spotify** | More comprehensive catalog, better search results | Requires developer API credentials to avoid rate limiting |
|
||||||
|
|
||||||
|
### Using Spotify
|
||||||
|
To use Spotify as your search source without hitting rate limits:
|
||||||
|
1. Create a Spotify Developer account at [developer.spotify.com](https://developer.spotify.com)
|
||||||
|
2. Create an app to get your Client ID and Client Secret
|
||||||
|
3. Go to **Settings > Options > Spotify API > Custom Credentials**
|
||||||
|
4. Enter your Client ID and Secret
|
||||||
|
5. Change **Search Source** to Spotify
|
||||||
|
|
||||||
## Other project
|
## Other project
|
||||||
|
|
||||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
||||||
|
|
||||||
|
[](https://ko-fi.com/zarzet)
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
> **iOS Support**: This app is primarily tested on Android. iOS support is experimental and may have bugs — the developer is too poor to afford an iPhone for proper testing. If you encounter issues on iOS, please report them!
|
> **iOS Support**: This app is primarily tested on Android. iOS support is experimental and may have bugs — the developer is too poor to afford an iPhone for proper testing. If you encounter issues on iOS, please report them!
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ repositories {
|
|||||||
dependencies {
|
dependencies {
|
||||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||||
implementation(files("libs/gobackend.aar"))
|
implementation(files("libs/gobackend.aar"))
|
||||||
|
implementation(files("libs/ffmpeg-kit-with-lame.aar"))
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -5,6 +5,8 @@ import io.flutter.embedding.android.FlutterActivity
|
|||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import gobackend.Gobackend
|
import gobackend.Gobackend
|
||||||
|
import com.arthenica.ffmpegkit.FFmpegKit
|
||||||
|
import com.arthenica.ffmpegkit.ReturnCode
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -13,6 +15,7 @@ import kotlinx.coroutines.withContext
|
|||||||
|
|
||||||
class MainActivity: FlutterActivity() {
|
class MainActivity: FlutterActivity() {
|
||||||
private val CHANNEL = "com.zarz.spotiflac/backend"
|
private val CHANNEL = "com.zarz.spotiflac/backend"
|
||||||
|
private val FFMPEG_CHANNEL = "com.zarz.spotiflac/ffmpeg"
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
@@ -208,6 +211,72 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
"preWarmTrackCache" -> {
|
||||||
|
val tracksJson = call.argument<String>("tracks") ?: "[]"
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.preWarmTrackCacheJSON(tracksJson)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"getTrackCacheSize" -> {
|
||||||
|
val size = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getTrackCacheSize()
|
||||||
|
}
|
||||||
|
result.success(size.toInt())
|
||||||
|
}
|
||||||
|
"clearTrackCache" -> {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.clearTrackIDCache()
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
// Deezer API methods
|
||||||
|
"searchDeezerAll" -> {
|
||||||
|
val query = call.argument<String>("query") ?: ""
|
||||||
|
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||||
|
val artistLimit = call.argument<Int>("artist_limit") ?: 3
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.searchDeezerAll(query, trackLimit.toLong(), artistLimit.toLong())
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"getDeezerMetadata" -> {
|
||||||
|
val resourceType = call.argument<String>("resource_type") ?: ""
|
||||||
|
val resourceId = call.argument<String>("resource_id") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getDeezerMetadata(resourceType, resourceId)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"parseDeezerUrl" -> {
|
||||||
|
val url = call.argument<String>("url") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.parseDeezerURLExport(url)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"searchDeezerByISRC" -> {
|
||||||
|
val isrc = call.argument<String>("isrc") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.searchDeezerByISRC(isrc)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"convertSpotifyToDeezer" -> {
|
||||||
|
val resourceType = call.argument<String>("resource_type") ?: ""
|
||||||
|
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.convertSpotifyToDeezer(resourceType, spotifyId)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"getSpotifyMetadataWithFallback" -> {
|
||||||
|
val url = call.argument<String>("url") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getSpotifyMetadataWithDeezerFallback(url)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -215,5 +284,37 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FFmpeg method channel
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, FFMPEG_CHANNEL).setMethodCallHandler { call, result ->
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
when (call.method) {
|
||||||
|
"execute" -> {
|
||||||
|
val command = call.argument<String>("command") ?: ""
|
||||||
|
val session = withContext(Dispatchers.IO) {
|
||||||
|
FFmpegKit.execute(command)
|
||||||
|
}
|
||||||
|
val returnCode = session.returnCode
|
||||||
|
val output = session.output ?: ""
|
||||||
|
result.success(mapOf(
|
||||||
|
"success" to ReturnCode.isSuccess(returnCode),
|
||||||
|
"returnCode" to (returnCode?.value ?: -1),
|
||||||
|
"output" to output
|
||||||
|
))
|
||||||
|
}
|
||||||
|
"getVersion" -> {
|
||||||
|
val session = withContext(Dispatchers.IO) {
|
||||||
|
FFmpegKit.execute("-version")
|
||||||
|
}
|
||||||
|
result.success(session.output ?: "unknown")
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("FFMPEG_ERROR", e.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
|
||||||
|
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
|
||||||
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
|
final _log = AppLogger('FFmpeg');
|
||||||
|
|
||||||
|
/// FFmpeg service for iOS using ffmpeg_kit_flutter plugin
|
||||||
|
class FFmpegServiceIOS {
|
||||||
|
/// Execute FFmpeg command and return result
|
||||||
|
static Future<FFmpegResultIOS> _execute(String command) async {
|
||||||
|
try {
|
||||||
|
final session = await FFmpegKit.execute(command);
|
||||||
|
final returnCode = await session.getReturnCode();
|
||||||
|
final output = await session.getOutput() ?? '';
|
||||||
|
return FFmpegResultIOS(
|
||||||
|
success: ReturnCode.isSuccess(returnCode),
|
||||||
|
returnCode: returnCode?.getValue() ?? -1,
|
||||||
|
output: output,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('FFmpeg execute error: $e');
|
||||||
|
return FFmpegResultIOS(success: false, returnCode: -1, output: e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert M4A (DASH segments) to FLAC
|
||||||
|
static Future<String?> convertM4aToFlac(String inputPath) async {
|
||||||
|
final outputPath = inputPath.replaceAll('.m4a', '.flac');
|
||||||
|
final command = '-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
|
||||||
|
final result = await _execute(command);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
try {
|
||||||
|
await File(inputPath).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
return outputPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.e('M4A to FLAC conversion failed: ${result.output}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert FLAC to MP3
|
||||||
|
static Future<String?> convertFlacToMp3(String inputPath, {String bitrate = '320k'}) async {
|
||||||
|
final dir = File(inputPath).parent.path;
|
||||||
|
final baseName = inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
|
||||||
|
final outputDir = '$dir${Platform.pathSeparator}MP3';
|
||||||
|
await Directory(outputDir).create(recursive: true);
|
||||||
|
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.mp3';
|
||||||
|
|
||||||
|
final command = '-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
|
||||||
|
final result = await _execute(command);
|
||||||
|
|
||||||
|
if (result.success) return outputPath;
|
||||||
|
_log.e('FLAC to MP3 conversion failed: ${result.output}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert FLAC to M4A
|
||||||
|
static Future<String?> convertFlacToM4a(String inputPath, {String codec = 'aac', String bitrate = '256k'}) async {
|
||||||
|
final dir = File(inputPath).parent.path;
|
||||||
|
final baseName = inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
|
||||||
|
final outputDir = '$dir${Platform.pathSeparator}M4A';
|
||||||
|
await Directory(outputDir).create(recursive: true);
|
||||||
|
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.m4a';
|
||||||
|
|
||||||
|
String command;
|
||||||
|
if (codec == 'alac') {
|
||||||
|
command = '-i "$inputPath" -codec:a alac -map 0:a -map_metadata 0 "$outputPath" -y';
|
||||||
|
} else {
|
||||||
|
command = '-i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -map_metadata 0 "$outputPath" -y';
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await _execute(command);
|
||||||
|
if (result.success) return outputPath;
|
||||||
|
_log.e('FLAC to M4A conversion failed: ${result.output}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Embed cover art to FLAC file
|
||||||
|
static Future<String?> embedCover(String flacPath, String coverPath) async {
|
||||||
|
final tempOutput = '$flacPath.tmp';
|
||||||
|
final command = '-i "$flacPath" -i "$coverPath" -map 0:a -map 1:0 -c copy -metadata:s:v title="Album cover" -metadata:s:v comment="Cover (front)" -disposition:v attached_pic "$tempOutput" -y';
|
||||||
|
|
||||||
|
final result = await _execute(command);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
try {
|
||||||
|
await File(flacPath).delete();
|
||||||
|
await File(tempOutput).rename(flacPath);
|
||||||
|
return flacPath;
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Failed to replace file after cover embed: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final tempFile = File(tempOutput);
|
||||||
|
if (await tempFile.exists()) await tempFile.delete();
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
_log.e('Cover embed failed: ${result.output}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if FFmpeg is available
|
||||||
|
static Future<bool> isAvailable() async {
|
||||||
|
try {
|
||||||
|
final session = await FFmpegKit.execute('-version');
|
||||||
|
final returnCode = await session.getReturnCode();
|
||||||
|
return ReturnCode.isSuccess(returnCode);
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get FFmpeg version info
|
||||||
|
static Future<String?> getVersion() async {
|
||||||
|
try {
|
||||||
|
final session = await FFmpegKit.execute('-version');
|
||||||
|
return await session.getOutput();
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FFmpegResultIOS {
|
||||||
|
final bool success;
|
||||||
|
final int returnCode;
|
||||||
|
final String output;
|
||||||
|
|
||||||
|
FFmpegResultIOS({required this.success, required this.returnCode, required this.output});
|
||||||
|
}
|
||||||
+83
-44
@@ -1,6 +1,7 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,6 +21,12 @@ type AmazonDownloader struct {
|
|||||||
regions []string // us, eu regions for DoubleDouble service
|
regions []string // us, eu regions for DoubleDouble service
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Global Amazon downloader instance for connection reuse
|
||||||
|
globalAmazonDownloader *AmazonDownloader
|
||||||
|
amazonDownloaderOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
|
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
|
||||||
type DoubleDoubleSubmitResponse struct {
|
type DoubleDoubleSubmitResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
@@ -93,12 +101,15 @@ func amazonIsASCIIString(s string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAmazonDownloader creates a new Amazon downloader using DoubleDouble service
|
// NewAmazonDownloader creates a new Amazon downloader (returns singleton for connection reuse)
|
||||||
func NewAmazonDownloader() *AmazonDownloader {
|
func NewAmazonDownloader() *AmazonDownloader {
|
||||||
return &AmazonDownloader{
|
amazonDownloaderOnce.Do(func() {
|
||||||
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
|
globalAmazonDownloader = &AmazonDownloader{
|
||||||
regions: []string{"us", "eu"}, // Same regions as PC
|
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
|
||||||
}
|
regions: []string{"us", "eu"}, // Same regions as PC
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return globalAmazonDownloader
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAvailableAPIs returns list of available DoubleDouble regions
|
// GetAvailableAPIs returns list of available DoubleDouble regions
|
||||||
@@ -283,31 +294,55 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||||||
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expectedSize := resp.ContentLength
|
||||||
// Set total bytes if available
|
// Set total bytes if available
|
||||||
if resp.ContentLength > 0 && itemID != "" {
|
if expectedSize > 0 && itemID != "" {
|
||||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
SetItemBytesTotal(itemID, expectedSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := os.Create(outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer out.Close()
|
|
||||||
|
|
||||||
// Use item progress writer
|
// Use buffered writer for better performance (256KB buffer)
|
||||||
var bytesWritten int64
|
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||||
|
|
||||||
|
// Use item progress writer with buffered output
|
||||||
|
var written int64
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
pw := NewItemProgressWriter(out, itemID)
|
pw := NewItemProgressWriter(bufWriter, itemID)
|
||||||
bytesWritten, err = io.Copy(pw, resp.Body)
|
written, err = io.Copy(pw, resp.Body)
|
||||||
} else {
|
} else {
|
||||||
// Fallback: direct copy without progress tracking
|
// Fallback: direct copy without progress tracking
|
||||||
bytesWritten, err = io.Copy(out, resp.Body)
|
written, err = io.Copy(bufWriter, resp.Body)
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write file: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(bytesWritten)/(1024*1024))
|
// Flush buffer before checking for errors
|
||||||
|
flushErr := bufWriter.Flush()
|
||||||
|
closeErr := out.Close()
|
||||||
|
|
||||||
|
// Check for any errors
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("download interrupted: %w", err)
|
||||||
|
}
|
||||||
|
if flushErr != nil {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file size if Content-Length was provided
|
||||||
|
if expectedSize > 0 && written != expectedSize {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(written)/(1024*1024))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,11 +413,29 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download file with item ID for progress tracking
|
// START PARALLEL: Fetch cover and lyrics while downloading audio
|
||||||
|
var parallelResult *ParallelDownloadResult
|
||||||
|
parallelDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(parallelDone)
|
||||||
|
parallelResult = FetchCoverAndLyricsParallel(
|
||||||
|
req.CoverURL,
|
||||||
|
req.EmbedMaxQualityCover,
|
||||||
|
req.SpotifyID,
|
||||||
|
req.TrackName,
|
||||||
|
req.ArtistName,
|
||||||
|
req.EmbedLyrics,
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Download audio file with item ID for progress tracking
|
||||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait for parallel operations to complete
|
||||||
|
<-parallelDone
|
||||||
|
|
||||||
// Set progress to 100% and status to finalizing (before embedding)
|
// Set progress to 100% and status to finalizing (before embedding)
|
||||||
// This makes the UI show "Finalizing..." while embedding happens
|
// This makes the UI show "Finalizing..." while embedding happens
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
@@ -408,41 +461,27 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
ISRC: req.ISRC,
|
ISRC: req.ISRC,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download cover to memory (avoids file permission issues on Android)
|
// Use cover data from parallel fetch
|
||||||
var coverData []byte
|
var coverData []byte
|
||||||
if req.CoverURL != "" {
|
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||||
fmt.Println("[Amazon] Downloading cover to memory...")
|
coverData = parallelResult.CoverData
|
||||||
data, err := downloadCoverToMemory(req.CoverURL, req.EmbedMaxQualityCover)
|
fmt.Printf("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||||
if err == nil {
|
|
||||||
coverData = data
|
|
||||||
fmt.Printf("[Amazon] Cover downloaded successfully (%d bytes)\n", len(coverData))
|
|
||||||
} else {
|
|
||||||
fmt.Printf("[Amazon] Warning: failed to download cover: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed lyrics if enabled
|
// Embed lyrics from parallel fetch
|
||||||
if req.EmbedLyrics {
|
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
fmt.Println("[Amazon] Fetching lyrics...")
|
fmt.Printf("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||||
lyricsClient := NewLyricsClient()
|
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||||
lyrics, lyricsErr := lyricsClient.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName)
|
fmt.Printf("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||||
if lyricsErr != nil {
|
|
||||||
fmt.Printf("[Amazon] Warning: lyrics fetch error: %v\n", lyricsErr)
|
|
||||||
} else if lyrics == nil || len(lyrics.Lines) == 0 {
|
|
||||||
fmt.Println("[Amazon] No lyrics found for this track")
|
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[Amazon] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
|
fmt.Println("[Amazon] Lyrics embedded successfully")
|
||||||
lrcContent := convertToLRC(lyrics)
|
|
||||||
if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil {
|
|
||||||
fmt.Printf("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
|
||||||
} else {
|
|
||||||
fmt.Println("[Amazon] Lyrics embedded successfully")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else if req.EmbedLyrics {
|
||||||
|
fmt.Println("[Amazon] No lyrics available from parallel fetch")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
|
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
|
||||||
|
|||||||
@@ -0,0 +1,612 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
deezerCacheTTL = 10 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeezerClient handles Deezer API interactions (no auth required)
|
||||||
|
type DeezerClient struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
searchCache map[string]*cacheEntry
|
||||||
|
albumCache map[string]*cacheEntry
|
||||||
|
artistCache map[string]*cacheEntry
|
||||||
|
cacheMu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
var (
|
||||||
|
deezerClient *DeezerClient
|
||||||
|
deezerClientOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetDeezerClient returns singleton Deezer client
|
||||||
|
func GetDeezerClient() *DeezerClient {
|
||||||
|
deezerClientOnce.Do(func() {
|
||||||
|
deezerClient = &DeezerClient{
|
||||||
|
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
|
||||||
|
searchCache: make(map[string]*cacheEntry),
|
||||||
|
albumCache: make(map[string]*cacheEntry),
|
||||||
|
artistCache: make(map[string]*cacheEntry),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return deezerClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deezer API response types
|
||||||
|
type deezerTrack struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Duration int `json:"duration"` // in seconds
|
||||||
|
TrackPosition int `json:"track_position"`
|
||||||
|
DiskNumber int `json:"disk_number"`
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
Artist deezerArtist `json:"artist"`
|
||||||
|
Album deezerAlbumSimple `json:"album"`
|
||||||
|
Contributors []deezerArtist `json:"contributors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deezerArtist 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deezerAlbumSimple struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Cover string `json:"cover"`
|
||||||
|
CoverMedium string `json:"cover_medium"`
|
||||||
|
CoverBig string `json:"cover_big"`
|
||||||
|
CoverXL string `json:"cover_xl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deezerAlbumFull struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Cover string `json:"cover"`
|
||||||
|
CoverMedium string `json:"cover_medium"`
|
||||||
|
CoverBig string `json:"cover_big"`
|
||||||
|
CoverXL string `json:"cover_xl"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
NbTracks int `json:"nb_tracks"`
|
||||||
|
Artist deezerArtist `json:"artist"`
|
||||||
|
Contributors []deezerArtist `json:"contributors"`
|
||||||
|
Tracks struct {
|
||||||
|
Data []deezerTrack `json:"data"`
|
||||||
|
} `json:"tracks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deezerArtistFull 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"`
|
||||||
|
NbAlbum int `json:"nb_album"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deezerPlaylistFull struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Picture string `json:"picture"`
|
||||||
|
PictureMedium string `json:"picture_medium"`
|
||||||
|
PictureBig string `json:"picture_big"`
|
||||||
|
PictureXL string `json:"picture_xl"`
|
||||||
|
NbTracks int `json:"nb_tracks"`
|
||||||
|
Creator struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"creator"`
|
||||||
|
Tracks struct {
|
||||||
|
Data []deezerTrack `json:"data"`
|
||||||
|
} `json:"tracks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchAll searches for tracks and artists on Deezer
|
||||||
|
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
||||||
|
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d", query, trackLimit, artistLimit)
|
||||||
|
|
||||||
|
c.cacheMu.RLock()
|
||||||
|
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
return entry.data.(*SearchAllResult), nil
|
||||||
|
}
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
result := &SearchAllResult{
|
||||||
|
Tracks: make([]TrackMetadata, 0),
|
||||||
|
Artists: make([]SearchArtistResult, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search tracks
|
||||||
|
trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit)
|
||||||
|
var trackResp struct {
|
||||||
|
Data []deezerTrack `json:"data"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, trackURL, &trackResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("deezer track search failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, track := range trackResp.Data {
|
||||||
|
// Fetch full track info to get ISRC (search results don't include ISRC)
|
||||||
|
fullTrack, err := c.fetchFullTrack(ctx, fmt.Sprintf("%d", track.ID))
|
||||||
|
if err == nil && fullTrack != nil {
|
||||||
|
result.Tracks = append(result.Tracks, c.convertTrack(*fullTrack))
|
||||||
|
} else {
|
||||||
|
// Fallback to search result without ISRC
|
||||||
|
result.Tracks = append(result.Tracks, c.convertTrack(track))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search artists
|
||||||
|
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
|
||||||
|
var artistResp struct {
|
||||||
|
Data []deezerArtist `json:"data"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, artistURL, &artistResp); err == nil {
|
||||||
|
for _, artist := range artistResp.Data {
|
||||||
|
result.Artists = append(result.Artists, SearchArtistResult{
|
||||||
|
ID: fmt.Sprintf("deezer:%d", artist.ID),
|
||||||
|
Name: artist.Name,
|
||||||
|
Images: c.getBestArtistImage(artist),
|
||||||
|
Followers: artist.NbFan,
|
||||||
|
Popularity: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache result
|
||||||
|
c.cacheMu.Lock()
|
||||||
|
c.searchCache[cacheKey] = &cacheEntry{
|
||||||
|
data: result,
|
||||||
|
expiresAt: time.Now().Add(deezerCacheTTL),
|
||||||
|
}
|
||||||
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTrack fetches a single track by Deezer ID
|
||||||
|
func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) {
|
||||||
|
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
|
||||||
|
|
||||||
|
var track deezerTrack
|
||||||
|
if err := c.getJSON(ctx, trackURL, &track); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TrackResponse{
|
||||||
|
Track: c.convertTrack(track),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAlbum fetches album with tracks
|
||||||
|
func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) {
|
||||||
|
c.cacheMu.RLock()
|
||||||
|
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
return entry.data.(*AlbumResponsePayload), nil
|
||||||
|
}
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
albumURL := fmt.Sprintf(deezerAlbumURL, albumID)
|
||||||
|
|
||||||
|
var album deezerAlbumFull
|
||||||
|
if err := c.getJSON(ctx, albumURL, &album); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
albumImage := c.getBestAlbumImage(album)
|
||||||
|
artistName := album.Artist.Name
|
||||||
|
if len(album.Contributors) > 0 {
|
||||||
|
names := make([]string, len(album.Contributors))
|
||||||
|
for i, a := range album.Contributors {
|
||||||
|
names[i] = a.Name
|
||||||
|
}
|
||||||
|
artistName = strings.Join(names, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
info := AlbumInfoMetadata{
|
||||||
|
TotalTracks: album.NbTracks,
|
||||||
|
Name: album.Title,
|
||||||
|
ReleaseDate: album.ReleaseDate,
|
||||||
|
Artists: artistName,
|
||||||
|
Images: albumImage,
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data))
|
||||||
|
for _, track := range album.Tracks.Data {
|
||||||
|
// Need to fetch full track info for ISRC
|
||||||
|
fullTrack, _ := c.fetchFullTrack(ctx, fmt.Sprintf("%d", track.ID))
|
||||||
|
isrc := ""
|
||||||
|
if fullTrack != nil {
|
||||||
|
isrc = fullTrack.ISRC
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
|
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
||||||
|
Artists: track.Artist.Name,
|
||||||
|
Name: track.Title,
|
||||||
|
AlbumName: album.Title,
|
||||||
|
AlbumArtist: artistName,
|
||||||
|
DurationMS: track.Duration * 1000,
|
||||||
|
Images: albumImage,
|
||||||
|
ReleaseDate: album.ReleaseDate,
|
||||||
|
TrackNumber: track.TrackPosition,
|
||||||
|
TotalTracks: album.NbTracks,
|
||||||
|
DiscNumber: track.DiskNumber,
|
||||||
|
ExternalURL: track.Link,
|
||||||
|
ISRC: isrc,
|
||||||
|
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &AlbumResponsePayload{
|
||||||
|
AlbumInfo: info,
|
||||||
|
TrackList: tracks,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.cacheMu.Lock()
|
||||||
|
c.albumCache[albumID] = &cacheEntry{
|
||||||
|
data: result,
|
||||||
|
expiresAt: time.Now().Add(deezerCacheTTL),
|
||||||
|
}
|
||||||
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetArtist fetches artist with albums
|
||||||
|
func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistResponsePayload, error) {
|
||||||
|
c.cacheMu.RLock()
|
||||||
|
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
return entry.data.(*ArtistResponsePayload), nil
|
||||||
|
}
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
// Fetch artist info
|
||||||
|
artistURL := fmt.Sprintf(deezerArtistURL, artistID)
|
||||||
|
var artist deezerArtistFull
|
||||||
|
if err := c.getJSON(ctx, artistURL, &artist); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
artistInfo := ArtistInfoMetadata{
|
||||||
|
ID: fmt.Sprintf("deezer:%d", artist.ID),
|
||||||
|
Name: artist.Name,
|
||||||
|
Images: c.getBestArtistImageFull(artist),
|
||||||
|
Followers: artist.NbFan,
|
||||||
|
Popularity: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch artist albums
|
||||||
|
albumsURL := fmt.Sprintf("%s/albums?limit=100", fmt.Sprintf(deezerArtistURL, artistID))
|
||||||
|
var albumsResp struct {
|
||||||
|
Data []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
NbTracks int `json:"nb_tracks"`
|
||||||
|
Cover string `json:"cover"`
|
||||||
|
CoverMedium string `json:"cover_medium"`
|
||||||
|
CoverBig string `json:"cover_big"`
|
||||||
|
CoverXL string `json:"cover_xl"`
|
||||||
|
RecordType string `json:"record_type"` // album, single, ep, compile
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
albums := make([]ArtistAlbumMetadata, 0)
|
||||||
|
if err := c.getJSON(ctx, albumsURL, &albumsResp); err == nil {
|
||||||
|
for _, album := range albumsResp.Data {
|
||||||
|
albumType := album.RecordType
|
||||||
|
if albumType == "compile" {
|
||||||
|
albumType = "compilation"
|
||||||
|
}
|
||||||
|
|
||||||
|
coverURL := album.CoverXL
|
||||||
|
if coverURL == "" {
|
||||||
|
coverURL = album.CoverBig
|
||||||
|
}
|
||||||
|
if coverURL == "" {
|
||||||
|
coverURL = album.CoverMedium
|
||||||
|
}
|
||||||
|
if coverURL == "" {
|
||||||
|
coverURL = album.Cover
|
||||||
|
}
|
||||||
|
|
||||||
|
albums = append(albums, ArtistAlbumMetadata{
|
||||||
|
ID: fmt.Sprintf("deezer:%d", album.ID),
|
||||||
|
Name: album.Title,
|
||||||
|
ReleaseDate: album.ReleaseDate,
|
||||||
|
TotalTracks: album.NbTracks,
|
||||||
|
Images: coverURL,
|
||||||
|
AlbumType: albumType,
|
||||||
|
Artists: artist.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &ArtistResponsePayload{
|
||||||
|
ArtistInfo: artistInfo,
|
||||||
|
Albums: albums,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.cacheMu.Lock()
|
||||||
|
c.artistCache[artistID] = &cacheEntry{
|
||||||
|
data: result,
|
||||||
|
expiresAt: time.Now().Add(deezerCacheTTL),
|
||||||
|
}
|
||||||
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlaylist fetches playlist with tracks
|
||||||
|
func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) {
|
||||||
|
playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID)
|
||||||
|
|
||||||
|
var playlist deezerPlaylistFull
|
||||||
|
if err := c.getJSON(ctx, playlistURL, &playlist); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
playlistImage := playlist.PictureXL
|
||||||
|
if playlistImage == "" {
|
||||||
|
playlistImage = playlist.PictureBig
|
||||||
|
}
|
||||||
|
if playlistImage == "" {
|
||||||
|
playlistImage = playlist.PictureMedium
|
||||||
|
}
|
||||||
|
|
||||||
|
var info PlaylistInfoMetadata
|
||||||
|
info.Tracks.Total = playlist.NbTracks
|
||||||
|
info.Owner.DisplayName = playlist.Creator.Name
|
||||||
|
info.Owner.Name = playlist.Title
|
||||||
|
info.Owner.Images = playlistImage
|
||||||
|
|
||||||
|
tracks := make([]AlbumTrackMetadata, 0, len(playlist.Tracks.Data))
|
||||||
|
for _, track := range playlist.Tracks.Data {
|
||||||
|
albumImage := track.Album.CoverXL
|
||||||
|
if albumImage == "" {
|
||||||
|
albumImage = track.Album.CoverBig
|
||||||
|
}
|
||||||
|
if albumImage == "" {
|
||||||
|
albumImage = track.Album.CoverMedium
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch full track for ISRC
|
||||||
|
fullTrack, _ := c.fetchFullTrack(ctx, fmt.Sprintf("%d", track.ID))
|
||||||
|
isrc := ""
|
||||||
|
releaseDate := ""
|
||||||
|
if fullTrack != nil {
|
||||||
|
isrc = fullTrack.ISRC
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
|
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
||||||
|
Artists: track.Artist.Name,
|
||||||
|
Name: track.Title,
|
||||||
|
AlbumName: track.Album.Title,
|
||||||
|
AlbumArtist: track.Artist.Name,
|
||||||
|
DurationMS: track.Duration * 1000,
|
||||||
|
Images: albumImage,
|
||||||
|
ReleaseDate: releaseDate,
|
||||||
|
TrackNumber: track.TrackPosition,
|
||||||
|
DiscNumber: track.DiskNumber,
|
||||||
|
ExternalURL: track.Link,
|
||||||
|
ISRC: isrc,
|
||||||
|
AlbumID: fmt.Sprintf("deezer:%d", track.Album.ID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &PlaylistResponsePayload{
|
||||||
|
PlaylistInfo: info,
|
||||||
|
TrackList: tracks,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchByISRC searches for a track by ISRC using direct endpoint
|
||||||
|
func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMetadata, error) {
|
||||||
|
// Use direct ISRC endpoint (API 2.0)
|
||||||
|
// https://api.deezer.com/2.0/track/isrc:{ISRC}
|
||||||
|
directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc)
|
||||||
|
|
||||||
|
var track deezerTrack
|
||||||
|
if err := c.getJSON(ctx, directURL, &track); err != nil {
|
||||||
|
// Fallback to search if direct endpoint fails
|
||||||
|
searchURL := fmt.Sprintf("%s/track?q=isrc:%s&limit=1", deezerSearchURL, isrc)
|
||||||
|
var resp struct {
|
||||||
|
Data []deezerTrack `json:"data"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, searchURL, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(resp.Data) == 0 {
|
||||||
|
return nil, fmt.Errorf("no track found for ISRC: %s", isrc)
|
||||||
|
}
|
||||||
|
result := c.convertTrack(resp.Data[0])
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we got a valid response (ID > 0)
|
||||||
|
if track.ID == 0 {
|
||||||
|
return nil, fmt.Errorf("no track found for ISRC: %s", isrc)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := c.convertTrack(track)
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) fetchFullTrack(ctx context.Context, trackID string) (*deezerTrack, error) {
|
||||||
|
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
|
||||||
|
var track deezerTrack
|
||||||
|
if err := c.getJSON(ctx, trackURL, &track); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &track, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
||||||
|
artistName := track.Artist.Name
|
||||||
|
if len(track.Contributors) > 0 {
|
||||||
|
names := make([]string, len(track.Contributors))
|
||||||
|
for i, a := range track.Contributors {
|
||||||
|
names[i] = a.Name
|
||||||
|
}
|
||||||
|
artistName = strings.Join(names, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
albumImage := track.Album.CoverXL
|
||||||
|
if albumImage == "" {
|
||||||
|
albumImage = track.Album.CoverBig
|
||||||
|
}
|
||||||
|
if albumImage == "" {
|
||||||
|
albumImage = track.Album.CoverMedium
|
||||||
|
}
|
||||||
|
if albumImage == "" {
|
||||||
|
albumImage = track.Album.Cover
|
||||||
|
}
|
||||||
|
|
||||||
|
return TrackMetadata{
|
||||||
|
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
||||||
|
Artists: artistName,
|
||||||
|
Name: track.Title,
|
||||||
|
AlbumName: track.Album.Title,
|
||||||
|
AlbumArtist: track.Artist.Name,
|
||||||
|
DurationMS: track.Duration * 1000,
|
||||||
|
Images: albumImage,
|
||||||
|
TrackNumber: track.TrackPosition,
|
||||||
|
DiscNumber: track.DiskNumber,
|
||||||
|
ExternalURL: track.Link,
|
||||||
|
ISRC: track.ISRC,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) getBestArtistImage(artist deezerArtist) string {
|
||||||
|
if artist.PictureXL != "" {
|
||||||
|
return artist.PictureXL
|
||||||
|
}
|
||||||
|
if artist.PictureBig != "" {
|
||||||
|
return artist.PictureBig
|
||||||
|
}
|
||||||
|
if artist.PictureMedium != "" {
|
||||||
|
return artist.PictureMedium
|
||||||
|
}
|
||||||
|
return artist.Picture
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) getBestArtistImageFull(artist deezerArtistFull) string {
|
||||||
|
if artist.PictureXL != "" {
|
||||||
|
return artist.PictureXL
|
||||||
|
}
|
||||||
|
if artist.PictureBig != "" {
|
||||||
|
return artist.PictureBig
|
||||||
|
}
|
||||||
|
if artist.PictureMedium != "" {
|
||||||
|
return artist.PictureMedium
|
||||||
|
}
|
||||||
|
return artist.Picture
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
|
||||||
|
if album.CoverXL != "" {
|
||||||
|
return album.CoverXL
|
||||||
|
}
|
||||||
|
if album.CoverBig != "" {
|
||||||
|
return album.CoverBig
|
||||||
|
}
|
||||||
|
if album.CoverMedium != "" {
|
||||||
|
return album.CoverMedium
|
||||||
|
}
|
||||||
|
return album.Cover
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("deezer API returned status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Unmarshal(body, dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDeezerURL is internal function, returns type and ID
|
||||||
|
func parseDeezerURL(input string) (string, string, error) {
|
||||||
|
trimmed := strings.TrimSpace(input)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "", "", fmt.Errorf("empty URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(trimmed)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.Host != "www.deezer.com" && parsed.Host != "deezer.com" && parsed.Host != "deezer.page.link" {
|
||||||
|
return "", "", fmt.Errorf("not a Deezer URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||||
|
|
||||||
|
// Skip language prefix if present (e.g., /en/, /fr/)
|
||||||
|
if len(parts) > 0 && len(parts[0]) == 2 {
|
||||||
|
parts = parts[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return "", "", fmt.Errorf("invalid Deezer URL format")
|
||||||
|
}
|
||||||
|
|
||||||
|
resourceType := parts[0]
|
||||||
|
resourceID := parts[1]
|
||||||
|
|
||||||
|
switch resourceType {
|
||||||
|
case "track", "album", "artist", "playlist":
|
||||||
|
return resourceType, resourceID, nil
|
||||||
|
default:
|
||||||
|
return "", "", fmt.Errorf("unsupported Deezer resource type: %s", resourceType)
|
||||||
|
}
|
||||||
|
}
|
||||||
+254
-2
@@ -276,12 +276,14 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||||
|
|
||||||
// Build service order starting with preferred service
|
// Build service order starting with preferred service
|
||||||
allServices := []string{"tidal", "qobuz", "amazon"}
|
allServices := []string{"qobuz", "tidal", "amazon"}
|
||||||
preferredService := req.Service
|
preferredService := req.Service
|
||||||
if preferredService == "" {
|
if preferredService == "" {
|
||||||
preferredService = "tidal"
|
preferredService = "qobuz"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service)
|
||||||
|
|
||||||
// Create ordered list: preferred first, then others
|
// Create ordered list: preferred first, then others
|
||||||
services := []string{preferredService}
|
services := []string{preferredService}
|
||||||
for _, s := range allServices {
|
for _, s := range allServices {
|
||||||
@@ -290,9 +292,12 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[DownloadWithFallback] Service order: %v\n", services)
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
|
|
||||||
for _, service := range services {
|
for _, service := range services {
|
||||||
|
fmt.Printf("[DownloadWithFallback] Trying service: %s\n", service)
|
||||||
req.Service = service
|
req.Service = service
|
||||||
|
|
||||||
var result DownloadResult
|
var result DownloadResult
|
||||||
@@ -516,6 +521,253 @@ func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PreWarmTrackCacheJSON pre-warms the track ID cache for album/playlist tracks
|
||||||
|
// tracksJSON is a JSON array of objects with: isrc, track_name, artist_name, spotify_id, service
|
||||||
|
// This runs in background and returns immediately
|
||||||
|
func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
|
||||||
|
var tracks []struct {
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
TrackName string `json:"track_name"`
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
|
SpotifyID string `json:"spotify_id"`
|
||||||
|
Service string `json:"service"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(tracksJSON), &tracks); err != nil {
|
||||||
|
return errorResponse("Invalid JSON: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to PreWarmCacheRequest
|
||||||
|
requests := make([]PreWarmCacheRequest, len(tracks))
|
||||||
|
for i, t := range tracks {
|
||||||
|
requests[i] = PreWarmCacheRequest{
|
||||||
|
ISRC: t.ISRC,
|
||||||
|
TrackName: t.TrackName,
|
||||||
|
ArtistName: t.ArtistName,
|
||||||
|
SpotifyID: t.SpotifyID,
|
||||||
|
Service: t.Service,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run in background
|
||||||
|
go PreWarmTrackCache(requests)
|
||||||
|
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"message": fmt.Sprintf("Pre-warming cache for %d tracks in background", len(tracks)),
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTrackCacheSize returns the current track ID cache size
|
||||||
|
func GetTrackCacheSize() int {
|
||||||
|
return GetCacheSize()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearTrackIDCache clears the track ID cache
|
||||||
|
func ClearTrackIDCache() {
|
||||||
|
ClearTrackCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== DEEZER API ====================
|
||||||
|
|
||||||
|
// SearchDeezerAll searches for tracks and artists on Deezer (no API key required)
|
||||||
|
// Returns JSON with tracks and artists arrays
|
||||||
|
func SearchDeezerAll(query string, trackLimit, artistLimit int) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
client := GetDeezerClient()
|
||||||
|
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(results)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeezerMetadata fetches metadata from Deezer URL or ID
|
||||||
|
// resourceType: track, album, artist, playlist
|
||||||
|
// resourceID: Deezer ID
|
||||||
|
func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
client := GetDeezerClient()
|
||||||
|
var data interface{}
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch resourceType {
|
||||||
|
case "track":
|
||||||
|
data, err = client.GetTrack(ctx, resourceID)
|
||||||
|
case "album":
|
||||||
|
data, err = client.GetAlbum(ctx, resourceID)
|
||||||
|
case "artist":
|
||||||
|
data, err = client.GetArtist(ctx, resourceID)
|
||||||
|
case "playlist":
|
||||||
|
data, err = client.GetPlaylist(ctx, resourceID)
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported Deezer resource type: %s", resourceType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseDeezerURLExport parses a Deezer URL and returns type and ID
|
||||||
|
func ParseDeezerURLExport(url string) (string, error) {
|
||||||
|
resourceType, resourceID, err := parseDeezerURL(url)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]string{
|
||||||
|
"type": resourceType,
|
||||||
|
"id": resourceID,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchDeezerByISRC searches for a track by ISRC on Deezer
|
||||||
|
func SearchDeezerByISRC(isrc string) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
client := GetDeezerClient()
|
||||||
|
track, err := client.SearchByISRC(ctx, isrc)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(track)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConvertSpotifyToDeezer converts a Spotify track/album ID to Deezer and fetches metadata
|
||||||
|
// This uses SongLink API to find the Deezer equivalent, then fetches from Deezer
|
||||||
|
// Useful when Spotify API is rate limited
|
||||||
|
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
songlink := NewSongLinkClient()
|
||||||
|
deezerClient := GetDeezerClient()
|
||||||
|
|
||||||
|
// For tracks, we can use SongLink to get Deezer ID
|
||||||
|
if resourceType == "track" {
|
||||||
|
deezerID, err := songlink.GetDeezerIDFromSpotify(spotifyID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("could not find Deezer equivalent: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch metadata from Deezer
|
||||||
|
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to fetch Deezer metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(trackResp)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// For albums, SongLink also provides mapping
|
||||||
|
if resourceType == "album" {
|
||||||
|
deezerID, err := songlink.GetDeezerAlbumIDFromSpotify(spotifyID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("could not find Deezer album: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch album metadata from Deezer
|
||||||
|
albumResp, err := deezerClient.GetAlbum(ctx, deezerID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to fetch Deezer album metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(albumResp)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// For artists/playlists, SongLink doesn't provide direct mapping
|
||||||
|
return "", fmt.Errorf("Spotify to Deezer conversion only supported for tracks and albums. Please search by name for %s", resourceType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSpotifyMetadataWithDeezerFallback tries Spotify first, falls back to Deezer on rate limit
|
||||||
|
func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Try Spotify first
|
||||||
|
client := NewSpotifyMetadataClient()
|
||||||
|
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
||||||
|
if err == nil {
|
||||||
|
jsonBytes, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a rate limit error
|
||||||
|
errStr := strings.ToLower(err.Error())
|
||||||
|
if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") {
|
||||||
|
// Not a rate limit error, return original error
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limited - try Deezer fallback for tracks and albums
|
||||||
|
parsed, parseErr := parseSpotifyURI(spotifyURL)
|
||||||
|
if parseErr != nil {
|
||||||
|
return "", fmt.Errorf("spotify rate limited and failed to parse URL: %w", parseErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[Fallback] Spotify rate limited for %s, trying Deezer...\n", parsed.Type)
|
||||||
|
|
||||||
|
if parsed.Type == "track" || parsed.Type == "album" {
|
||||||
|
// Convert to Deezer
|
||||||
|
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Artist and playlist not supported for fallback
|
||||||
|
if parsed.Type == "artist" {
|
||||||
|
return "", fmt.Errorf("spotify rate limited. Artist pages require Spotify API - please try again later")
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("spotify rate limited. Playlists are user-specific and require Spotify API")
|
||||||
|
}
|
||||||
|
|
||||||
func errorResponse(msg string) (string, error) {
|
func errorResponse(msg string) (string, error) {
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Shared transport with connection pooling to prevent TCP exhaustion
|
// Shared transport with connection pooling to prevent TCP exhaustion
|
||||||
|
// Optimized for large file downloads (FLAC ~30-50MB)
|
||||||
var sharedTransport = &http.Transport{
|
var sharedTransport = &http.Transport{
|
||||||
DialContext: (&net.Dialer{
|
DialContext: (&net.Dialer{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
@@ -56,6 +57,9 @@ var sharedTransport = &http.Transport{
|
|||||||
ExpectContinueTimeout: 1 * time.Second,
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
DisableKeepAlives: false, // Enable keep-alives for connection reuse
|
DisableKeepAlives: false, // Enable keep-alives for connection reuse
|
||||||
ForceAttemptHTTP2: true,
|
ForceAttemptHTTP2: true,
|
||||||
|
WriteBufferSize: 64 * 1024, // 64KB write buffer
|
||||||
|
ReadBufferSize: 64 * 1024, // 64KB read buffer
|
||||||
|
DisableCompression: true, // FLAC is already compressed
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared HTTP client for general requests (reuses connections)
|
// Shared HTTP client for general requests (reuses connections)
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// ISRC to Track ID Cache
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// TrackIDCacheEntry holds cached track ID with metadata
|
||||||
|
type TrackIDCacheEntry struct {
|
||||||
|
TidalTrackID int64
|
||||||
|
QobuzTrackID int64
|
||||||
|
AmazonTrackID string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrackIDCache caches ISRC to track ID mappings
|
||||||
|
type TrackIDCache struct {
|
||||||
|
cache map[string]*TrackIDCacheEntry
|
||||||
|
mu sync.RWMutex
|
||||||
|
ttl time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
globalTrackIDCache *TrackIDCache
|
||||||
|
trackIDCacheOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetTrackIDCache returns the global track ID cache
|
||||||
|
func GetTrackIDCache() *TrackIDCache {
|
||||||
|
trackIDCacheOnce.Do(func() {
|
||||||
|
globalTrackIDCache = &TrackIDCache{
|
||||||
|
cache: make(map[string]*TrackIDCacheEntry),
|
||||||
|
ttl: 30 * time.Minute, // Cache for 30 minutes
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return globalTrackIDCache
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a cached entry by ISRC
|
||||||
|
func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
|
entry, exists := c.cache[isrc]
|
||||||
|
if !exists || time.Now().After(entry.ExpiresAt) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTidal caches Tidal track ID for an ISRC
|
||||||
|
func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
entry, exists := c.cache[isrc]
|
||||||
|
if !exists {
|
||||||
|
entry = &TrackIDCacheEntry{}
|
||||||
|
c.cache[isrc] = entry
|
||||||
|
}
|
||||||
|
entry.TidalTrackID = trackID
|
||||||
|
entry.ExpiresAt = time.Now().Add(c.ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetQobuz caches Qobuz track ID for an ISRC
|
||||||
|
func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
entry, exists := c.cache[isrc]
|
||||||
|
if !exists {
|
||||||
|
entry = &TrackIDCacheEntry{}
|
||||||
|
c.cache[isrc] = entry
|
||||||
|
}
|
||||||
|
entry.QobuzTrackID = trackID
|
||||||
|
entry.ExpiresAt = time.Now().Add(c.ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAmazon caches Amazon track ID for an ISRC
|
||||||
|
func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
entry, exists := c.cache[isrc]
|
||||||
|
if !exists {
|
||||||
|
entry = &TrackIDCacheEntry{}
|
||||||
|
c.cache[isrc] = entry
|
||||||
|
}
|
||||||
|
entry.AmazonTrackID = trackID
|
||||||
|
entry.ExpiresAt = time.Now().Add(c.ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear removes all cached entries
|
||||||
|
func (c *TrackIDCache) Clear() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.cache = make(map[string]*TrackIDCacheEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size returns the number of cached entries
|
||||||
|
func (c *TrackIDCache) Size() int {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return len(c.cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Parallel Download Helper
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// ParallelDownloadResult holds results from parallel operations
|
||||||
|
type ParallelDownloadResult struct {
|
||||||
|
CoverData []byte
|
||||||
|
LyricsData *LyricsResponse
|
||||||
|
LyricsLRC string
|
||||||
|
CoverErr error
|
||||||
|
LyricsErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchCoverAndLyricsParallel downloads cover and fetches lyrics in parallel
|
||||||
|
// This runs while the main audio download is happening
|
||||||
|
func FetchCoverAndLyricsParallel(
|
||||||
|
coverURL string,
|
||||||
|
maxQualityCover bool,
|
||||||
|
spotifyID string,
|
||||||
|
trackName string,
|
||||||
|
artistName string,
|
||||||
|
embedLyrics bool,
|
||||||
|
) *ParallelDownloadResult {
|
||||||
|
result := &ParallelDownloadResult{}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
// Download cover in parallel
|
||||||
|
if coverURL != "" {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
fmt.Println("[Parallel] Starting cover download...")
|
||||||
|
data, err := downloadCoverToMemory(coverURL, maxQualityCover)
|
||||||
|
if err != nil {
|
||||||
|
result.CoverErr = err
|
||||||
|
fmt.Printf("[Parallel] Cover download failed: %v\n", err)
|
||||||
|
} else {
|
||||||
|
result.CoverData = data
|
||||||
|
fmt.Printf("[Parallel] Cover downloaded: %d bytes\n", len(data))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch lyrics in parallel
|
||||||
|
if embedLyrics {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
fmt.Println("[Parallel] Starting lyrics fetch...")
|
||||||
|
client := NewLyricsClient()
|
||||||
|
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
|
||||||
|
if err != nil {
|
||||||
|
result.LyricsErr = err
|
||||||
|
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
|
||||||
|
} else if lyrics != nil && len(lyrics.Lines) > 0 {
|
||||||
|
result.LyricsData = lyrics
|
||||||
|
result.LyricsLRC = convertToLRC(lyrics)
|
||||||
|
fmt.Printf("[Parallel] Lyrics fetched: %d lines\n", len(lyrics.Lines))
|
||||||
|
} else {
|
||||||
|
result.LyricsErr = fmt.Errorf("no lyrics found")
|
||||||
|
fmt.Println("[Parallel] No lyrics found")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Pre-warm Cache for Album/Playlist
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// PreWarmCacheRequest represents a track to pre-warm cache for
|
||||||
|
type PreWarmCacheRequest struct {
|
||||||
|
ISRC string
|
||||||
|
TrackName string
|
||||||
|
ArtistName string
|
||||||
|
SpotifyID string // Needed for Amazon (SongLink lookup)
|
||||||
|
Service string // "tidal", "qobuz", "amazon"
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreWarmTrackCache pre-fetches track IDs for multiple tracks (for album/playlist)
|
||||||
|
// This runs in background while user is viewing the track list
|
||||||
|
func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
||||||
|
if len(requests) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[Cache] Pre-warming cache for %d tracks...\n", len(requests))
|
||||||
|
cache := GetTrackIDCache()
|
||||||
|
|
||||||
|
// Limit concurrent pre-warm requests
|
||||||
|
semaphore := make(chan struct{}, 3) // Max 3 concurrent
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for _, req := range requests {
|
||||||
|
// Skip if already cached
|
||||||
|
if cached := cache.Get(req.ISRC); cached != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func(r PreWarmCacheRequest) {
|
||||||
|
defer wg.Done()
|
||||||
|
semaphore <- struct{}{} // Acquire
|
||||||
|
defer func() { <-semaphore }() // Release
|
||||||
|
|
||||||
|
switch r.Service {
|
||||||
|
case "tidal":
|
||||||
|
preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName)
|
||||||
|
case "qobuz":
|
||||||
|
preWarmQobuzCache(r.ISRC)
|
||||||
|
case "amazon":
|
||||||
|
preWarmAmazonCache(r.ISRC, r.SpotifyID)
|
||||||
|
}
|
||||||
|
}(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
fmt.Printf("[Cache] Pre-warm complete. Cache size: %d\n", cache.Size())
|
||||||
|
}
|
||||||
|
|
||||||
|
func preWarmTidalCache(isrc, trackName, artistName string) {
|
||||||
|
downloader := NewTidalDownloader()
|
||||||
|
track, err := downloader.SearchTrackByISRC(isrc)
|
||||||
|
if err == nil && track != nil {
|
||||||
|
GetTrackIDCache().SetTidal(isrc, track.ID)
|
||||||
|
fmt.Printf("[Cache] Cached Tidal ID for ISRC %s: %d\n", isrc, track.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func preWarmQobuzCache(isrc string) {
|
||||||
|
downloader := NewQobuzDownloader()
|
||||||
|
track, err := downloader.SearchTrackByISRC(isrc)
|
||||||
|
if err == nil && track != nil {
|
||||||
|
GetTrackIDCache().SetQobuz(isrc, track.ID)
|
||||||
|
fmt.Printf("[Cache] Cached Qobuz ID for ISRC %s: %d\n", isrc, track.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func preWarmAmazonCache(isrc, spotifyID string) {
|
||||||
|
// Amazon uses SongLink to get URL, so we pre-warm by checking availability
|
||||||
|
client := NewSongLinkClient()
|
||||||
|
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||||
|
if err == nil && availability != nil && availability.Amazon {
|
||||||
|
// Store Amazon URL in cache (using ISRC as key)
|
||||||
|
GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL)
|
||||||
|
fmt.Printf("[Cache] Cached Amazon URL for ISRC %s\n", isrc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Exported Functions for Flutter
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// PreWarmCache is called from Flutter to pre-warm cache for album/playlist tracks
|
||||||
|
// tracksJSON is a JSON array of {isrc, track_name, artist_name, service}
|
||||||
|
func PreWarmCache(tracksJSON string) error {
|
||||||
|
var requests []PreWarmCacheRequest
|
||||||
|
// Parse JSON (simplified - in production use proper JSON parsing)
|
||||||
|
// For now, this is called from exports.go with proper parsing
|
||||||
|
|
||||||
|
go PreWarmTrackCache(requests) // Run in background
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearTrackCache clears the track ID cache
|
||||||
|
func ClearTrackCache() {
|
||||||
|
GetTrackIDCache().Clear()
|
||||||
|
fmt.Println("[Cache] Track ID cache cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCacheSize returns the current cache size
|
||||||
|
func GetCacheSize() int {
|
||||||
|
return GetTrackIDCache().Size()
|
||||||
|
}
|
||||||
+18
-8
@@ -196,27 +196,37 @@ func getDownloadDir() string {
|
|||||||
|
|
||||||
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
||||||
type ItemProgressWriter struct {
|
type ItemProgressWriter struct {
|
||||||
writer interface{ Write([]byte) (int, error) }
|
writer interface{ Write([]byte) (int, error) }
|
||||||
itemID string
|
itemID string
|
||||||
current int64
|
current int64
|
||||||
|
lastReported int64 // Track last reported bytes for threshold-based updates
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const progressUpdateThreshold = 64 * 1024 // Update progress every 64KB
|
||||||
|
|
||||||
// NewItemProgressWriter creates a new progress writer for a specific item
|
// NewItemProgressWriter creates a new progress writer for a specific item
|
||||||
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
|
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
|
||||||
return &ItemProgressWriter{
|
return &ItemProgressWriter{
|
||||||
writer: w,
|
writer: w,
|
||||||
itemID: itemID,
|
itemID: itemID,
|
||||||
current: 0,
|
current: 0,
|
||||||
|
lastReported: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write implements io.Writer
|
// Write implements io.Writer with threshold-based progress updates
|
||||||
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
||||||
n, err := pw.writer.Write(p)
|
n, err := pw.writer.Write(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
pw.current += int64(n)
|
pw.current += int64(n)
|
||||||
SetItemBytesReceived(pw.itemID, pw.current)
|
|
||||||
|
// Update progress when we've received at least 64KB since last update
|
||||||
|
// Also update on first write to show download has started
|
||||||
|
if pw.lastReported == 0 || pw.current-pw.lastReported >= progressUpdateThreshold {
|
||||||
|
SetItemBytesReceived(pw.itemID, pw.current)
|
||||||
|
pw.lastReported = pw.current
|
||||||
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+103
-43
@@ -1,6 +1,7 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// QobuzDownloader handles Qobuz downloads
|
// QobuzDownloader handles Qobuz downloads
|
||||||
@@ -19,6 +21,12 @@ type QobuzDownloader struct {
|
|||||||
apiURL string
|
apiURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Global Qobuz downloader instance for connection reuse
|
||||||
|
globalQobuzDownloader *QobuzDownloader
|
||||||
|
qobuzDownloaderOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
// QobuzTrack represents a Qobuz track
|
// QobuzTrack represents a Qobuz track
|
||||||
type QobuzTrack struct {
|
type QobuzTrack struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
@@ -97,12 +105,15 @@ func qobuzIsASCIIString(s string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewQobuzDownloader creates a new Qobuz downloader
|
// NewQobuzDownloader creates a new Qobuz downloader (returns singleton for connection reuse)
|
||||||
func NewQobuzDownloader() *QobuzDownloader {
|
func NewQobuzDownloader() *QobuzDownloader {
|
||||||
return &QobuzDownloader{
|
qobuzDownloaderOnce.Do(func() {
|
||||||
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
|
globalQobuzDownloader = &QobuzDownloader{
|
||||||
appID: "798273057",
|
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
|
||||||
}
|
appID: "798273057",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return globalQobuzDownloader
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAvailableAPIs returns list of available Qobuz APIs
|
// GetAvailableAPIs returns list of available Qobuz APIs
|
||||||
@@ -462,26 +473,55 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expectedSize := resp.ContentLength
|
||||||
// Set total bytes if available
|
// Set total bytes if available
|
||||||
if resp.ContentLength > 0 && itemID != "" {
|
if expectedSize > 0 && itemID != "" {
|
||||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
SetItemBytesTotal(itemID, expectedSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := os.Create(outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer out.Close()
|
|
||||||
|
|
||||||
// Use item progress writer
|
// Use buffered writer for better performance (256KB buffer)
|
||||||
|
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||||
|
|
||||||
|
// Use item progress writer with buffered output
|
||||||
|
var written int64
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
progressWriter := NewItemProgressWriter(out, itemID)
|
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
||||||
_, err = io.Copy(progressWriter, resp.Body)
|
written, err = io.Copy(progressWriter, resp.Body)
|
||||||
} else {
|
} else {
|
||||||
// Fallback: direct copy without progress tracking
|
// Fallback: direct copy without progress tracking
|
||||||
_, err = io.Copy(out, resp.Body)
|
written, err = io.Copy(bufWriter, resp.Body)
|
||||||
}
|
}
|
||||||
return err
|
|
||||||
|
// Flush buffer before checking for errors
|
||||||
|
flushErr := bufWriter.Flush()
|
||||||
|
closeErr := out.Close()
|
||||||
|
|
||||||
|
// Check for any errors
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("download interrupted: %w", err)
|
||||||
|
}
|
||||||
|
if flushErr != nil {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file size if Content-Length was provided
|
||||||
|
if expectedSize > 0 && written != expectedSize {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// QobuzDownloadResult contains download result with quality info
|
// QobuzDownloadResult contains download result with quality info
|
||||||
@@ -506,8 +546,21 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
var track *QobuzTrack
|
var track *QobuzTrack
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
// Strategy 1: Search by ISRC with duration verification
|
// OPTIMIZATION: Check cache first for track ID
|
||||||
if req.ISRC != "" {
|
if req.ISRC != "" {
|
||||||
|
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
|
||||||
|
fmt.Printf("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
|
||||||
|
// For Qobuz we need to search again to get full track info, but we can use the ID
|
||||||
|
track, err = downloader.SearchTrackByISRC(req.ISRC)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[Qobuz] Cache hit but search failed: %v\n", err)
|
||||||
|
track = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 1: Search by ISRC with duration verification
|
||||||
|
if track == nil && req.ISRC != "" {
|
||||||
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
|
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
|
||||||
// Verify artist
|
// Verify artist
|
||||||
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||||
@@ -536,8 +589,11 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
|
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log match found
|
// Log match found and cache the track ID
|
||||||
fmt.Printf("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
|
fmt.Printf("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
|
||||||
|
if req.ISRC != "" {
|
||||||
|
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
||||||
|
}
|
||||||
|
|
||||||
// Build filename
|
// Build filename
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
@@ -581,11 +637,29 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download file with item ID for progress tracking
|
// START PARALLEL: Fetch cover and lyrics while downloading audio
|
||||||
|
var parallelResult *ParallelDownloadResult
|
||||||
|
parallelDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(parallelDone)
|
||||||
|
parallelResult = FetchCoverAndLyricsParallel(
|
||||||
|
req.CoverURL,
|
||||||
|
req.EmbedMaxQualityCover,
|
||||||
|
req.SpotifyID,
|
||||||
|
req.TrackName,
|
||||||
|
req.ArtistName,
|
||||||
|
req.EmbedLyrics,
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Download audio file with item ID for progress tracking
|
||||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||||
return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait for parallel operations to complete
|
||||||
|
<-parallelDone
|
||||||
|
|
||||||
// Set progress to 100% and status to finalizing (before embedding)
|
// Set progress to 100% and status to finalizing (before embedding)
|
||||||
// This makes the UI show "Finalizing..." while embedding happens
|
// This makes the UI show "Finalizing..." while embedding happens
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
@@ -593,7 +667,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
SetItemFinalizing(req.ItemID)
|
SetItemFinalizing(req.ItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed metadata
|
// Embed metadata using parallel-fetched cover data
|
||||||
metadata := Metadata{
|
metadata := Metadata{
|
||||||
Title: req.TrackName,
|
Title: req.TrackName,
|
||||||
Artist: req.ArtistName,
|
Artist: req.ArtistName,
|
||||||
@@ -606,41 +680,27 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
ISRC: req.ISRC,
|
ISRC: req.ISRC,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download cover to memory (avoids file permission issues on Android)
|
// Use cover data from parallel fetch
|
||||||
var coverData []byte
|
var coverData []byte
|
||||||
if req.CoverURL != "" {
|
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||||
fmt.Println("[Qobuz] Downloading cover to memory...")
|
coverData = parallelResult.CoverData
|
||||||
data, err := downloadCoverToMemory(req.CoverURL, req.EmbedMaxQualityCover)
|
fmt.Printf("[Qobuz] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||||
if err == nil {
|
|
||||||
coverData = data
|
|
||||||
fmt.Printf("[Qobuz] Cover downloaded successfully (%d bytes)\n", len(coverData))
|
|
||||||
} else {
|
|
||||||
fmt.Printf("[Qobuz] Warning: failed to download cover: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed lyrics if enabled
|
// Embed lyrics from parallel fetch
|
||||||
if req.EmbedLyrics {
|
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
fmt.Println("[Qobuz] Fetching lyrics...")
|
fmt.Printf("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||||
lyricsClient := NewLyricsClient()
|
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||||
lyrics, lyricsErr := lyricsClient.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName)
|
fmt.Printf("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||||
if lyricsErr != nil {
|
|
||||||
fmt.Printf("[Qobuz] Warning: lyrics fetch error: %v\n", lyricsErr)
|
|
||||||
} else if lyrics == nil || len(lyrics.Lines) == 0 {
|
|
||||||
fmt.Println("[Qobuz] No lyrics found for this track")
|
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[Qobuz] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
|
fmt.Println("[Qobuz] Lyrics embedded successfully")
|
||||||
lrcContent := convertToLRC(lyrics)
|
|
||||||
if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil {
|
|
||||||
fmt.Printf("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
|
|
||||||
} else {
|
|
||||||
fmt.Println("[Qobuz] Lyrics embedded successfully")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else if req.EmbedLyrics {
|
||||||
|
fmt.Println("[Qobuz] No lyrics available from parallel fetch")
|
||||||
}
|
}
|
||||||
|
|
||||||
return QobuzDownloadResult{
|
return QobuzDownloadResult{
|
||||||
|
|||||||
+135
-4
@@ -6,6 +6,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,16 +22,28 @@ type TrackAvailability struct {
|
|||||||
Tidal bool `json:"tidal"`
|
Tidal bool `json:"tidal"`
|
||||||
Amazon bool `json:"amazon"`
|
Amazon bool `json:"amazon"`
|
||||||
Qobuz bool `json:"qobuz"`
|
Qobuz bool `json:"qobuz"`
|
||||||
|
Deezer bool `json:"deezer"`
|
||||||
TidalURL string `json:"tidal_url,omitempty"`
|
TidalURL string `json:"tidal_url,omitempty"`
|
||||||
AmazonURL string `json:"amazon_url,omitempty"`
|
AmazonURL string `json:"amazon_url,omitempty"`
|
||||||
QobuzURL string `json:"qobuz_url,omitempty"`
|
QobuzURL string `json:"qobuz_url,omitempty"`
|
||||||
|
DeezerURL string `json:"deezer_url,omitempty"`
|
||||||
|
DeezerID string `json:"deezer_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSongLinkClient creates a new SongLink client
|
var (
|
||||||
|
// Global SongLink client instance for connection reuse
|
||||||
|
globalSongLinkClient *SongLinkClient
|
||||||
|
songLinkClientOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewSongLinkClient creates a new SongLink client (returns singleton for connection reuse)
|
||||||
func NewSongLinkClient() *SongLinkClient {
|
func NewSongLinkClient() *SongLinkClient {
|
||||||
return &SongLinkClient{
|
songLinkClientOnce.Do(func() {
|
||||||
client: NewHTTPClientWithTimeout(SongLinkTimeout), // 30s timeout
|
globalSongLinkClient = &SongLinkClient{
|
||||||
}
|
client: NewHTTPClientWithTimeout(SongLinkTimeout), // 30s timeout
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return globalSongLinkClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckTrackAvailability checks track availability on streaming platforms
|
// CheckTrackAvailability checks track availability on streaming platforms
|
||||||
@@ -92,6 +106,14 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
|||||||
availability.AmazonURL = amazonLink.URL
|
availability.AmazonURL = amazonLink.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check Deezer
|
||||||
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
|
availability.Deezer = true
|
||||||
|
availability.DeezerURL = deezerLink.URL
|
||||||
|
// Extract Deezer ID from URL (e.g., https://www.deezer.com/track/123456)
|
||||||
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||||
|
}
|
||||||
|
|
||||||
// Check Qobuz using ISRC
|
// Check Qobuz using ISRC
|
||||||
if isrc != "" {
|
if isrc != "" {
|
||||||
availability.Qobuz = checkQobuzAvailability(isrc)
|
availability.Qobuz = checkQobuzAvailability(isrc)
|
||||||
@@ -151,3 +173,112 @@ func checkQobuzAvailability(isrc string) bool {
|
|||||||
|
|
||||||
return searchResp.Tracks.Total > 0
|
return searchResp.Tracks.Total > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL
|
||||||
|
func extractDeezerIDFromURL(deezerURL string) string {
|
||||||
|
// URL format: https://www.deezer.com/track/123456 or https://www.deezer.com/en/track/123456
|
||||||
|
parts := strings.Split(deezerURL, "/")
|
||||||
|
if len(parts) > 0 {
|
||||||
|
// Get the last part which should be the ID
|
||||||
|
lastPart := parts[len(parts)-1]
|
||||||
|
// Remove any query parameters
|
||||||
|
if idx := strings.Index(lastPart, "?"); idx > 0 {
|
||||||
|
lastPart = lastPart[:idx]
|
||||||
|
}
|
||||||
|
return lastPart
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeezerIDFromSpotify converts a Spotify track ID to Deezer track ID using SongLink
|
||||||
|
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
|
||||||
|
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !availability.Deezer || availability.DeezerID == "" {
|
||||||
|
return "", fmt.Errorf("track not found on Deezer")
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability.DeezerID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlbumAvailability represents album availability on different platforms
|
||||||
|
type AlbumAvailability struct {
|
||||||
|
SpotifyID string `json:"spotify_id"`
|
||||||
|
Deezer bool `json:"deezer"`
|
||||||
|
DeezerURL string `json:"deezer_url,omitempty"`
|
||||||
|
DeezerID string `json:"deezer_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckAlbumAvailability checks album availability on streaming platforms using SongLink
|
||||||
|
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
|
||||||
|
// Use global rate limiter
|
||||||
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
|
// Build API URL for album
|
||||||
|
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLw==")
|
||||||
|
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyAlbumID)
|
||||||
|
|
||||||
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||||
|
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retryConfig := DefaultRetryConfig()
|
||||||
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check album availability: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := ReadResponseBody(resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var songLinkResp struct {
|
||||||
|
LinksByPlatform map[string]struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"linksByPlatform"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
availability := &AlbumAvailability{
|
||||||
|
SpotifyID: spotifyAlbumID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Deezer
|
||||||
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
|
availability.Deezer = true
|
||||||
|
availability.DeezerURL = deezerLink.URL
|
||||||
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeezerAlbumIDFromSpotify converts a Spotify album ID to Deezer album ID using SongLink
|
||||||
|
func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (string, error) {
|
||||||
|
availability, err := s.CheckAlbumAvailability(spotifyAlbumID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !availability.Deezer || availability.DeezerID == "" {
|
||||||
|
return "", fmt.Errorf("album not found on Deezer")
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability.DeezerID, nil
|
||||||
|
}
|
||||||
|
|||||||
+188
-87
@@ -1,6 +1,7 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
@@ -12,17 +13,27 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TidalDownloader handles Tidal downloads
|
// TidalDownloader handles Tidal downloads
|
||||||
type TidalDownloader struct {
|
type TidalDownloader struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
clientID string
|
clientID string
|
||||||
clientSecret string
|
clientSecret string
|
||||||
apiURL string
|
apiURL string
|
||||||
|
cachedToken string
|
||||||
|
tokenExpiresAt time.Time
|
||||||
|
tokenMu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Global Tidal downloader instance for token reuse
|
||||||
|
globalTidalDownloader *TidalDownloader
|
||||||
|
tidalDownloaderOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
// TidalTrack represents a Tidal track
|
// TidalTrack represents a Tidal track
|
||||||
type TidalTrack struct {
|
type TidalTrack struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
@@ -93,24 +104,25 @@ type MPD struct {
|
|||||||
} `xml:"Period"`
|
} `xml:"Period"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTidalDownloader creates a new Tidal downloader
|
// NewTidalDownloader creates a new Tidal downloader (returns singleton for token reuse)
|
||||||
func NewTidalDownloader() *TidalDownloader {
|
func NewTidalDownloader() *TidalDownloader {
|
||||||
clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==")
|
tidalDownloaderOnce.Do(func() {
|
||||||
clientSecret, _ := base64.StdEncoding.DecodeString("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=")
|
clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==")
|
||||||
|
clientSecret, _ := base64.StdEncoding.DecodeString("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=")
|
||||||
|
|
||||||
downloader := &TidalDownloader{
|
globalTidalDownloader = &TidalDownloader{
|
||||||
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
|
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
|
||||||
clientID: string(clientID),
|
clientID: string(clientID),
|
||||||
clientSecret: string(clientSecret),
|
clientSecret: string(clientSecret),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get first available API
|
// Get first available API
|
||||||
apis := downloader.GetAvailableAPIs()
|
apis := globalTidalDownloader.GetAvailableAPIs()
|
||||||
if len(apis) > 0 {
|
if len(apis) > 0 {
|
||||||
downloader.apiURL = apis[0]
|
globalTidalDownloader.apiURL = apis[0]
|
||||||
}
|
}
|
||||||
|
})
|
||||||
return downloader
|
return globalTidalDownloader
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAvailableAPIs returns list of available Tidal APIs
|
// GetAvailableAPIs returns list of available Tidal APIs
|
||||||
@@ -138,8 +150,16 @@ func (t *TidalDownloader) GetAvailableAPIs() []string {
|
|||||||
return apis
|
return apis
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAccessToken gets Tidal access token
|
// GetAccessToken gets Tidal access token (with caching)
|
||||||
func (t *TidalDownloader) GetAccessToken() (string, error) {
|
func (t *TidalDownloader) GetAccessToken() (string, error) {
|
||||||
|
t.tokenMu.Lock()
|
||||||
|
defer t.tokenMu.Unlock()
|
||||||
|
|
||||||
|
// Return cached token if still valid (with 60s buffer)
|
||||||
|
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)
|
data := fmt.Sprintf("client_id=%s&grant_type=client_credentials", t.clientID)
|
||||||
|
|
||||||
authURL, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hdXRoLnRpZGFsLmNvbS92MS9vYXV0aDIvdG9rZW4=")
|
authURL, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hdXRoLnRpZGFsLmNvbS92MS9vYXV0aDIvdG9rZW4=")
|
||||||
@@ -163,12 +183,21 @@ func (t *TidalDownloader) GetAccessToken() (string, error) {
|
|||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
AccessToken string `json:"access_token"`
|
AccessToken string `json:"access_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache the token
|
||||||
|
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 result.AccessToken, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -717,26 +746,55 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expectedSize := resp.ContentLength
|
||||||
// Set total bytes if available
|
// Set total bytes if available
|
||||||
if resp.ContentLength > 0 && itemID != "" {
|
if expectedSize > 0 && itemID != "" {
|
||||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
SetItemBytesTotal(itemID, expectedSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := os.Create(outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer out.Close()
|
|
||||||
|
|
||||||
// Use item progress writer
|
// Use buffered writer for better performance (256KB buffer)
|
||||||
|
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||||
|
|
||||||
|
// Use item progress writer with buffered output
|
||||||
|
var written int64
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
progressWriter := NewItemProgressWriter(out, itemID)
|
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
||||||
_, err = io.Copy(progressWriter, resp.Body)
|
written, err = io.Copy(progressWriter, resp.Body)
|
||||||
} else {
|
} else {
|
||||||
// Fallback: direct copy without progress tracking
|
// Fallback: direct copy without progress tracking
|
||||||
_, err = io.Copy(out, resp.Body)
|
written, err = io.Copy(bufWriter, resp.Body)
|
||||||
}
|
}
|
||||||
return err
|
|
||||||
|
// Flush buffer before checking for errors
|
||||||
|
flushErr := bufWriter.Flush()
|
||||||
|
closeErr := out.Close()
|
||||||
|
|
||||||
|
// Check for any errors
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("download interrupted: %w", err)
|
||||||
|
}
|
||||||
|
if flushErr != nil {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file size if Content-Length was provided
|
||||||
|
if expectedSize > 0 && written != expectedSize {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID string) error {
|
func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID string) error {
|
||||||
@@ -772,26 +830,44 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
return fmt.Errorf("download failed with status %d", resp.StatusCode)
|
return fmt.Errorf("download failed with status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expectedSize := resp.ContentLength
|
||||||
// Set total bytes for progress tracking
|
// Set total bytes for progress tracking
|
||||||
if resp.ContentLength > 0 && itemID != "" {
|
if expectedSize > 0 && itemID != "" {
|
||||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
SetItemBytesTotal(itemID, expectedSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := os.Create(outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create file: %w", err)
|
return fmt.Errorf("failed to create file: %w", err)
|
||||||
}
|
}
|
||||||
defer out.Close()
|
|
||||||
|
|
||||||
// Use item progress writer
|
// Use item progress writer
|
||||||
|
var written int64
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
progressWriter := NewItemProgressWriter(out, itemID)
|
progressWriter := NewItemProgressWriter(out, itemID)
|
||||||
_, err = io.Copy(progressWriter, resp.Body)
|
written, err = io.Copy(progressWriter, resp.Body)
|
||||||
} else {
|
} else {
|
||||||
// Fallback: direct copy without progress tracking
|
written, err = io.Copy(out, resp.Body)
|
||||||
_, err = io.Copy(out, resp.Body)
|
|
||||||
}
|
}
|
||||||
return err
|
|
||||||
|
closeErr := out.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("download interrupted: %w", err)
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file size if Content-Length was provided
|
||||||
|
if expectedSize > 0 && written != expectedSize {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DASH format - download segments to temporary file
|
// DASH format - download segments to temporary file
|
||||||
@@ -942,8 +1018,44 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
var track *TidalTrack
|
var track *TidalTrack
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
// Strategy 1: Try to get Tidal URL from SongLink (using Spotify ID)
|
// OPTIMIZATION: Check cache first for track ID
|
||||||
if req.SpotifyID != "" {
|
if req.ISRC != "" {
|
||||||
|
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 {
|
||||||
|
fmt.Printf("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID)
|
||||||
|
track, err = downloader.GetTrackInfoByID(cached.TidalTrackID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[Tidal] Cache hit but failed to get track info: %v\n", err)
|
||||||
|
track = nil // Fall through to normal search
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OPTIMIZED: Try ISRC search first (faster than SongLink API)
|
||||||
|
// Strategy 1: Search by ISRC with duration verification (FASTEST)
|
||||||
|
if track == nil && req.ISRC != "" {
|
||||||
|
fmt.Printf("[Tidal] Trying ISRC search first (faster): %s\n", req.ISRC)
|
||||||
|
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec)
|
||||||
|
// Verify artist for ISRC match
|
||||||
|
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) {
|
||||||
|
fmt.Printf("[Tidal] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
|
req.ArtistName, tidalArtist)
|
||||||
|
track = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 2: Try SongLink only if ISRC search failed (slower but more accurate)
|
||||||
|
if track == nil && req.SpotifyID != "" {
|
||||||
|
fmt.Printf("[Tidal] ISRC search failed, trying SongLink...\n")
|
||||||
tidalURL, slErr := downloader.GetTidalURLFromSpotify(req.SpotifyID)
|
tidalURL, slErr := downloader.GetTidalURLFromSpotify(req.SpotifyID)
|
||||||
if slErr == nil && tidalURL != "" {
|
if slErr == nil && tidalURL != "" {
|
||||||
// Extract track ID and get track info
|
// Extract track ID and get track info
|
||||||
@@ -986,29 +1098,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: Search by ISRC with duration verification
|
// Strategy 3: Search by metadata only (no ISRC requirement) - last resort
|
||||||
if track == nil && req.ISRC != "" {
|
|
||||||
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec)
|
|
||||||
// Verify artist for ISRC match too
|
|
||||||
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) {
|
|
||||||
fmt.Printf("[Tidal] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
|
||||||
req.ArtistName, tidalArtist)
|
|
||||||
track = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 3: Search by metadata only (no ISRC requirement)
|
|
||||||
if track == nil {
|
if track == nil {
|
||||||
|
fmt.Printf("[Tidal] Trying metadata search as last resort...\n")
|
||||||
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec)
|
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec)
|
||||||
// Verify artist for metadata search too
|
// Verify artist for metadata search too
|
||||||
if track != nil {
|
if track != nil {
|
||||||
@@ -1047,6 +1139,11 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
fmt.Printf("[Tidal] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, tidalArtist, track.Duration)
|
fmt.Printf("[Tidal] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, tidalArtist, track.Duration)
|
||||||
|
|
||||||
|
// Cache the track ID for future use
|
||||||
|
if req.ISRC != "" {
|
||||||
|
GetTrackIDCache().SetTidal(req.ISRC, track.ID)
|
||||||
|
}
|
||||||
|
|
||||||
// Build filename
|
// Build filename
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
@@ -1080,11 +1177,29 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
// Log actual quality received
|
// Log actual quality received
|
||||||
fmt.Printf("[Tidal] Actual quality: %d-bit/%dHz\n", downloadInfo.BitDepth, downloadInfo.SampleRate)
|
fmt.Printf("[Tidal] Actual quality: %d-bit/%dHz\n", downloadInfo.BitDepth, downloadInfo.SampleRate)
|
||||||
|
|
||||||
// Download file with item ID for progress tracking
|
// START PARALLEL: Fetch cover and lyrics while downloading audio
|
||||||
|
var parallelResult *ParallelDownloadResult
|
||||||
|
parallelDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(parallelDone)
|
||||||
|
parallelResult = FetchCoverAndLyricsParallel(
|
||||||
|
req.CoverURL,
|
||||||
|
req.EmbedMaxQualityCover,
|
||||||
|
req.SpotifyID,
|
||||||
|
req.TrackName,
|
||||||
|
req.ArtistName,
|
||||||
|
req.EmbedLyrics,
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Download audio file with item ID for progress tracking
|
||||||
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil {
|
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil {
|
||||||
return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait for parallel operations to complete
|
||||||
|
<-parallelDone
|
||||||
|
|
||||||
// Set progress to 100% and status to finalizing (before embedding)
|
// Set progress to 100% and status to finalizing (before embedding)
|
||||||
// This makes the UI show "Finalizing..." while embedding happens
|
// This makes the UI show "Finalizing..." while embedding happens
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
@@ -1105,7 +1220,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath)
|
return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed metadata
|
// Embed metadata using parallel-fetched cover data
|
||||||
metadata := Metadata{
|
metadata := Metadata{
|
||||||
Title: req.TrackName,
|
Title: req.TrackName,
|
||||||
Artist: req.ArtistName,
|
Artist: req.ArtistName,
|
||||||
@@ -1118,17 +1233,11 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
ISRC: req.ISRC,
|
ISRC: req.ISRC,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download cover to memory (avoids file permission issues on Android)
|
// Use cover data from parallel fetch
|
||||||
var coverData []byte
|
var coverData []byte
|
||||||
if req.CoverURL != "" {
|
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||||
fmt.Println("[Tidal] Downloading cover to memory...")
|
coverData = parallelResult.CoverData
|
||||||
data, err := downloadCoverToMemory(req.CoverURL, req.EmbedMaxQualityCover)
|
fmt.Printf("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||||
if err == nil {
|
|
||||||
coverData = data
|
|
||||||
fmt.Printf("[Tidal] Cover downloaded successfully (%d bytes)\n", len(coverData))
|
|
||||||
} else {
|
|
||||||
fmt.Printf("[Tidal] Warning: failed to download cover: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only embed metadata to FLAC files (M4A will be converted by Flutter)
|
// Only embed metadata to FLAC files (M4A will be converted by Flutter)
|
||||||
@@ -1137,24 +1246,16 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed lyrics if enabled
|
// Embed lyrics from parallel fetch
|
||||||
if req.EmbedLyrics {
|
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
fmt.Println("[Tidal] Fetching lyrics...")
|
fmt.Printf("[Tidal] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||||
lyricsClient := NewLyricsClient()
|
if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||||
lyrics, lyricsErr := lyricsClient.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName)
|
fmt.Printf("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||||
if lyricsErr != nil {
|
|
||||||
fmt.Printf("[Tidal] Warning: lyrics fetch error: %v\n", lyricsErr)
|
|
||||||
} else if lyrics == nil || len(lyrics.Lines) == 0 {
|
|
||||||
fmt.Println("[Tidal] No lyrics found for this track")
|
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[Tidal] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
|
fmt.Println("[Tidal] Lyrics embedded successfully")
|
||||||
lrcContent := convertToLRC(lyrics)
|
|
||||||
if embedErr := EmbedLyrics(actualOutputPath, lrcContent); embedErr != nil {
|
|
||||||
fmt.Printf("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr)
|
|
||||||
} else {
|
|
||||||
fmt.Println("[Tidal] Lyrics embedded successfully")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else if req.EmbedLyrics {
|
||||||
|
fmt.Println("[Tidal] No lyrics available from parallel fetch")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[Tidal] Skipping metadata embed for M4A file (will be handled after conversion): %s\n", actualOutputPath)
|
fmt.Printf("[Tidal] Skipping metadata embed for M4A file (will be handled after conversion): %s\n", actualOutputPath)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/// App version and info constants
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '2.0.6';
|
static const String version = '2.1.5';
|
||||||
static const String buildNumber = '36';
|
static const String buildNumber = '43';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
@@ -15,4 +15,6 @@ class AppInfo {
|
|||||||
static const String githubRepo = 'zarzet/SpotiFLAC-Mobile';
|
static const String githubRepo = 'zarzet/SpotiFLAC-Mobile';
|
||||||
static const String githubUrl = 'https://github.com/$githubRepo';
|
static const String githubUrl = 'https://github.com/$githubRepo';
|
||||||
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
|
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
|
||||||
|
|
||||||
|
static const String kofiUrl = 'https://ko-fi.com/zarzet';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class AppSettings {
|
|||||||
final bool isFirstLaunch;
|
final bool isFirstLaunch;
|
||||||
final int concurrentDownloads; // 1 = sequential (default), max 3
|
final int concurrentDownloads; // 1 = sequential (default), max 3
|
||||||
final bool checkForUpdates; // Check for updates on app start
|
final bool checkForUpdates; // Check for updates on app start
|
||||||
|
final String updateChannel; // stable, preview
|
||||||
final bool hasSearchedBefore; // Hide helper text after first search
|
final bool hasSearchedBefore; // Hide helper text after first search
|
||||||
final String folderOrganization; // none, artist, album, artist_album
|
final String folderOrganization; // none, artist, album, artist_album
|
||||||
final String historyViewMode; // list, grid
|
final String historyViewMode; // list, grid
|
||||||
@@ -21,9 +22,10 @@ class AppSettings {
|
|||||||
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
|
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
|
||||||
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
|
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
|
||||||
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
|
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
|
||||||
|
final String metadataSource; // spotify, deezer - source for search and metadata
|
||||||
|
|
||||||
const AppSettings({
|
const AppSettings({
|
||||||
this.defaultService = 'tidal',
|
this.defaultService = 'qobuz',
|
||||||
this.audioQuality = 'LOSSLESS',
|
this.audioQuality = 'LOSSLESS',
|
||||||
this.filenameFormat = '{title} - {artist}',
|
this.filenameFormat = '{title} - {artist}',
|
||||||
this.downloadDirectory = '',
|
this.downloadDirectory = '',
|
||||||
@@ -33,6 +35,7 @@ class AppSettings {
|
|||||||
this.isFirstLaunch = true,
|
this.isFirstLaunch = true,
|
||||||
this.concurrentDownloads = 1, // Default: sequential (off)
|
this.concurrentDownloads = 1, // Default: sequential (off)
|
||||||
this.checkForUpdates = true, // Default: enabled
|
this.checkForUpdates = true, // Default: enabled
|
||||||
|
this.updateChannel = 'stable', // Default: stable releases only
|
||||||
this.hasSearchedBefore = false, // Default: show helper text
|
this.hasSearchedBefore = false, // Default: show helper text
|
||||||
this.folderOrganization = 'none', // Default: no folder organization
|
this.folderOrganization = 'none', // Default: no folder organization
|
||||||
this.historyViewMode = 'grid', // Default: grid view
|
this.historyViewMode = 'grid', // Default: grid view
|
||||||
@@ -40,6 +43,7 @@ class AppSettings {
|
|||||||
this.spotifyClientId = '', // Default: use built-in credentials
|
this.spotifyClientId = '', // Default: use built-in credentials
|
||||||
this.spotifyClientSecret = '', // Default: use built-in credentials
|
this.spotifyClientSecret = '', // Default: use built-in credentials
|
||||||
this.useCustomSpotifyCredentials = true, // Default: use custom if set
|
this.useCustomSpotifyCredentials = true, // Default: use custom if set
|
||||||
|
this.metadataSource = 'deezer', // Default: Deezer (no rate limit)
|
||||||
});
|
});
|
||||||
|
|
||||||
AppSettings copyWith({
|
AppSettings copyWith({
|
||||||
@@ -53,6 +57,7 @@ class AppSettings {
|
|||||||
bool? isFirstLaunch,
|
bool? isFirstLaunch,
|
||||||
int? concurrentDownloads,
|
int? concurrentDownloads,
|
||||||
bool? checkForUpdates,
|
bool? checkForUpdates,
|
||||||
|
String? updateChannel,
|
||||||
bool? hasSearchedBefore,
|
bool? hasSearchedBefore,
|
||||||
String? folderOrganization,
|
String? folderOrganization,
|
||||||
String? historyViewMode,
|
String? historyViewMode,
|
||||||
@@ -60,6 +65,7 @@ class AppSettings {
|
|||||||
String? spotifyClientId,
|
String? spotifyClientId,
|
||||||
String? spotifyClientSecret,
|
String? spotifyClientSecret,
|
||||||
bool? useCustomSpotifyCredentials,
|
bool? useCustomSpotifyCredentials,
|
||||||
|
String? metadataSource,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
defaultService: defaultService ?? this.defaultService,
|
defaultService: defaultService ?? this.defaultService,
|
||||||
@@ -72,6 +78,7 @@ class AppSettings {
|
|||||||
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
||||||
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
|
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
|
||||||
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
|
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
|
||||||
|
updateChannel: updateChannel ?? this.updateChannel,
|
||||||
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
||||||
folderOrganization: folderOrganization ?? this.folderOrganization,
|
folderOrganization: folderOrganization ?? this.folderOrganization,
|
||||||
historyViewMode: historyViewMode ?? this.historyViewMode,
|
historyViewMode: historyViewMode ?? this.historyViewMode,
|
||||||
@@ -79,6 +86,7 @@ class AppSettings {
|
|||||||
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
|
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
|
||||||
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
||||||
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
|
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
|
||||||
|
metadataSource: metadataSource ?? this.metadataSource,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
||||||
concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1,
|
concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1,
|
||||||
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
|
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
|
||||||
|
updateChannel: json['updateChannel'] as String? ?? 'stable',
|
||||||
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
||||||
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
||||||
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
||||||
@@ -25,6 +26,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
|
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
|
||||||
useCustomSpotifyCredentials:
|
useCustomSpotifyCredentials:
|
||||||
json['useCustomSpotifyCredentials'] as bool? ?? true,
|
json['useCustomSpotifyCredentials'] as bool? ?? true,
|
||||||
|
metadataSource: json['metadataSource'] as String? ?? 'deezer',
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||||
@@ -39,6 +41,7 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
|||||||
'isFirstLaunch': instance.isFirstLaunch,
|
'isFirstLaunch': instance.isFirstLaunch,
|
||||||
'concurrentDownloads': instance.concurrentDownloads,
|
'concurrentDownloads': instance.concurrentDownloads,
|
||||||
'checkForUpdates': instance.checkForUpdates,
|
'checkForUpdates': instance.checkForUpdates,
|
||||||
|
'updateChannel': instance.updateChannel,
|
||||||
'hasSearchedBefore': instance.hasSearchedBefore,
|
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||||
'folderOrganization': instance.folderOrganization,
|
'folderOrganization': instance.folderOrganization,
|
||||||
'historyViewMode': instance.historyViewMode,
|
'historyViewMode': instance.historyViewMode,
|
||||||
@@ -46,4 +49,5 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
|||||||
'spotifyClientId': instance.spotifyClientId,
|
'spotifyClientId': instance.spotifyClientId,
|
||||||
'spotifyClientSecret': instance.spotifyClientSecret,
|
'spotifyClientSecret': instance.spotifyClientSecret,
|
||||||
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
|
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
|
||||||
|
'metadataSource': instance.metadataSource,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
const String kThemeModeKey = 'theme_mode';
|
const String kThemeModeKey = 'theme_mode';
|
||||||
const String kUseDynamicColorKey = 'use_dynamic_color';
|
const String kUseDynamicColorKey = 'use_dynamic_color';
|
||||||
const String kSeedColorKey = 'seed_color';
|
const String kSeedColorKey = 'seed_color';
|
||||||
|
const String kUseAmoledKey = 'use_amoled';
|
||||||
|
|
||||||
/// Default Spotify green color for fallback
|
/// Default Spotify green color for fallback
|
||||||
const int kDefaultSeedColor = 0xFF1DB954;
|
const int kDefaultSeedColor = 0xFF1DB954;
|
||||||
@@ -13,11 +14,13 @@ class ThemeSettings {
|
|||||||
final ThemeMode themeMode;
|
final ThemeMode themeMode;
|
||||||
final bool useDynamicColor;
|
final bool useDynamicColor;
|
||||||
final int seedColorValue;
|
final int seedColorValue;
|
||||||
|
final bool useAmoled; // Pure black background for OLED screens
|
||||||
|
|
||||||
const ThemeSettings({
|
const ThemeSettings({
|
||||||
this.themeMode = ThemeMode.system,
|
this.themeMode = ThemeMode.system,
|
||||||
this.useDynamicColor = true,
|
this.useDynamicColor = true,
|
||||||
this.seedColorValue = kDefaultSeedColor,
|
this.seedColorValue = kDefaultSeedColor,
|
||||||
|
this.useAmoled = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Get seed color as Color object
|
/// Get seed color as Color object
|
||||||
@@ -28,11 +31,13 @@ class ThemeSettings {
|
|||||||
ThemeMode? themeMode,
|
ThemeMode? themeMode,
|
||||||
bool? useDynamicColor,
|
bool? useDynamicColor,
|
||||||
int? seedColorValue,
|
int? seedColorValue,
|
||||||
|
bool? useAmoled,
|
||||||
}) {
|
}) {
|
||||||
return ThemeSettings(
|
return ThemeSettings(
|
||||||
themeMode: themeMode ?? this.themeMode,
|
themeMode: themeMode ?? this.themeMode,
|
||||||
useDynamicColor: useDynamicColor ?? this.useDynamicColor,
|
useDynamicColor: useDynamicColor ?? this.useDynamicColor,
|
||||||
seedColorValue: seedColorValue ?? this.seedColorValue,
|
seedColorValue: seedColorValue ?? this.seedColorValue,
|
||||||
|
useAmoled: useAmoled ?? this.useAmoled,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +46,7 @@ class ThemeSettings {
|
|||||||
kThemeModeKey: themeMode.name,
|
kThemeModeKey: themeMode.name,
|
||||||
kUseDynamicColorKey: useDynamicColor,
|
kUseDynamicColorKey: useDynamicColor,
|
||||||
kSeedColorKey: seedColorValue,
|
kSeedColorKey: seedColorValue,
|
||||||
|
kUseAmoledKey: useAmoled,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Create from JSON map
|
/// Create from JSON map
|
||||||
@@ -49,6 +55,7 @@ class ThemeSettings {
|
|||||||
themeMode: _themeModeFromString(json[kThemeModeKey] as String?),
|
themeMode: _themeModeFromString(json[kThemeModeKey] as String?),
|
||||||
useDynamicColor: json[kUseDynamicColorKey] as bool? ?? true,
|
useDynamicColor: json[kUseDynamicColorKey] as bool? ?? true,
|
||||||
seedColorValue: json[kSeedColorKey] as int? ?? kDefaultSeedColor,
|
seedColorValue: json[kSeedColorKey] as int? ?? kDefaultSeedColor,
|
||||||
|
useAmoled: json[kUseAmoledKey] as bool? ?? false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,12 +65,13 @@ class ThemeSettings {
|
|||||||
return other is ThemeSettings &&
|
return other is ThemeSettings &&
|
||||||
other.themeMode == themeMode &&
|
other.themeMode == themeMode &&
|
||||||
other.useDynamicColor == useDynamicColor &&
|
other.useDynamicColor == useDynamicColor &&
|
||||||
other.seedColorValue == seedColorValue;
|
other.seedColorValue == seedColorValue &&
|
||||||
|
other.useAmoled == useAmoled;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode;
|
themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode ^ useAmoled.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper to convert string to ThemeMode
|
/// Helper to convert string to ThemeMode
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import 'dart:io';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
|
|
||||||
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
|
|
||||||
import 'package:spotiflac_android/models/download_item.dart';
|
import 'package:spotiflac_android/models/download_item.dart';
|
||||||
import 'package:spotiflac_android/models/settings.dart';
|
import 'package:spotiflac_android/models/settings.dart';
|
||||||
import 'package:spotiflac_android/models/track.dart';
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
@@ -688,20 +686,37 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retry a failed download
|
/// Retry a failed or skipped download
|
||||||
void retryItem(String id) {
|
void retryItem(String id) {
|
||||||
final items = state.items.map((item) {
|
final item = state.items.where((i) => i.id == id).firstOrNull;
|
||||||
if (item.id == id && item.status == DownloadStatus.failed) {
|
if (item == null) {
|
||||||
return item.copyWith(status: DownloadStatus.queued, progress: 0, error: null);
|
_log.w('retryItem: Item not found: $id');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only retry if status is failed or skipped
|
||||||
|
if (item.status != DownloadStatus.failed && item.status != DownloadStatus.skipped) {
|
||||||
|
_log.w('retryItem: Item status is ${item.status}, not retrying');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.i('Retrying item: ${item.track.name} (id: $id)');
|
||||||
|
|
||||||
|
final items = state.items.map((i) {
|
||||||
|
if (i.id == id) {
|
||||||
|
return i.copyWith(status: DownloadStatus.queued, progress: 0, error: null);
|
||||||
}
|
}
|
||||||
return item;
|
return i;
|
||||||
}).toList();
|
}).toList();
|
||||||
state = state.copyWith(items: items);
|
state = state.copyWith(items: items);
|
||||||
_saveQueueToStorage(); // Persist queue
|
_saveQueueToStorage(); // Persist queue
|
||||||
|
|
||||||
// Start processing if not already
|
// Start processing if not already running
|
||||||
if (!state.isProcessing) {
|
if (!state.isProcessing) {
|
||||||
|
_log.d('Starting queue processing for retry');
|
||||||
Future.microtask(() => _processQueue());
|
Future.microtask(() => _processQueue());
|
||||||
|
} else {
|
||||||
|
_log.d('Queue already processing, item will be picked up');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -745,25 +760,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
// For now, we'll use FFmpeg to embed cover since Go backend expects to download the file
|
// For now, we'll use FFmpeg to embed cover since Go backend expects to download the file
|
||||||
// FFmpeg can embed cover art to FLAC
|
// FFmpeg can embed cover art to FLAC
|
||||||
if (coverPath != null && await File(coverPath).exists()) {
|
if (coverPath != null && await File(coverPath).exists()) {
|
||||||
final tempOutput = '$flacPath.tmp';
|
final result = await FFmpegService.embedCover(flacPath, coverPath);
|
||||||
final command = '-i "$flacPath" -i "$coverPath" -map 0:a -map 1:0 -c copy -metadata:s:v title="Album cover" -metadata:s:v comment="Cover (front)" -disposition:v attached_pic "$tempOutput" -y';
|
|
||||||
|
|
||||||
final session = await FFmpegKit.execute(command);
|
if (result != null) {
|
||||||
final returnCode = await session.getReturnCode();
|
|
||||||
|
|
||||||
if (ReturnCode.isSuccess(returnCode)) {
|
|
||||||
// Replace original with temp
|
|
||||||
await File(flacPath).delete();
|
|
||||||
await File(tempOutput).rename(flacPath);
|
|
||||||
_log.d('Cover embedded via FFmpeg');
|
_log.d('Cover embedded via FFmpeg');
|
||||||
} else {
|
} else {
|
||||||
// Try alternative method using metaflac-style embedding
|
_log.w('FFmpeg cover embed failed');
|
||||||
_log.w('FFmpeg cover embed failed, trying alternative...');
|
|
||||||
// Clean up temp file if exists
|
|
||||||
final tempFile = File(tempOutput);
|
|
||||||
if (await tempFile.exists()) {
|
|
||||||
await tempFile.delete();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up cover file
|
// Clean up cover file
|
||||||
@@ -866,6 +868,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
_log.i('Queue processing finished');
|
_log.i('Queue processing finished');
|
||||||
state = state.copyWith(isProcessing: false, currentDownload: null);
|
state = state.copyWith(isProcessing: false, currentDownload: null);
|
||||||
|
|
||||||
|
// Check if there are new queued items (e.g., from retry) and restart if needed
|
||||||
|
final hasQueuedItems = state.items.any((item) => item.status == DownloadStatus.queued);
|
||||||
|
if (hasQueuedItems) {
|
||||||
|
_log.i('Found queued items after processing finished, restarting queue...');
|
||||||
|
Future.microtask(() => _processQueue());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sequential download processing (uses multi-progress system with single item)
|
/// Sequential download processing (uses multi-progress system with single item)
|
||||||
@@ -881,7 +890,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
final nextItem = state.items.firstWhere(
|
// Re-read state to get latest items (important for retry)
|
||||||
|
final currentItems = state.items;
|
||||||
|
final nextItem = currentItems.firstWhere(
|
||||||
(item) => item.status == DownloadStatus.queued,
|
(item) => item.status == DownloadStatus.queued,
|
||||||
orElse: () => DownloadItem(
|
orElse: () => DownloadItem(
|
||||||
id: '',
|
id: '',
|
||||||
@@ -892,10 +903,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (nextItem.id.isEmpty) {
|
if (nextItem.id.isEmpty) {
|
||||||
_log.d('No more items to process');
|
_log.d('No more items to process (checked ${currentItems.length} items)');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_log.d('Processing next item: ${nextItem.track.name} (id: ${nextItem.id})');
|
||||||
await _downloadSingleItem(nextItem);
|
await _downloadSingleItem(nextItem);
|
||||||
|
|
||||||
// Clear item progress after download completes
|
// Clear item progress after download completes
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import 'package:spotiflac_android/models/settings.dart';
|
|||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
|
||||||
const _settingsKey = 'app_settings';
|
const _settingsKey = 'app_settings';
|
||||||
|
const _migrationVersionKey = 'settings_migration_version';
|
||||||
|
const _currentMigrationVersion = 1;
|
||||||
|
|
||||||
class SettingsNotifier extends Notifier<AppSettings> {
|
class SettingsNotifier extends Notifier<AppSettings> {
|
||||||
@override
|
@override
|
||||||
@@ -18,11 +20,35 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
final json = prefs.getString(_settingsKey);
|
final json = prefs.getString(_settingsKey);
|
||||||
if (json != null) {
|
if (json != null) {
|
||||||
state = AppSettings.fromJson(jsonDecode(json));
|
state = AppSettings.fromJson(jsonDecode(json));
|
||||||
|
|
||||||
|
// Run migrations if needed
|
||||||
|
await _runMigrations(prefs);
|
||||||
|
|
||||||
// Apply Spotify credentials to Go backend on load
|
// Apply Spotify credentials to Go backend on load
|
||||||
_applySpotifyCredentials();
|
_applySpotifyCredentials();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run one-time migrations for settings
|
||||||
|
Future<void> _runMigrations(SharedPreferences prefs) async {
|
||||||
|
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
|
||||||
|
|
||||||
|
if (lastMigration < 1) {
|
||||||
|
// Migration 1: Set metadataSource to 'deezer' for existing users
|
||||||
|
// Only apply if user hasn't enabled custom Spotify credentials
|
||||||
|
// (users with custom credentials likely prefer Spotify)
|
||||||
|
if (!state.useCustomSpotifyCredentials) {
|
||||||
|
state = state.copyWith(metadataSource: 'deezer');
|
||||||
|
await _saveSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save current migration version
|
||||||
|
if (lastMigration < _currentMigrationVersion) {
|
||||||
|
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _saveSettings() async {
|
Future<void> _saveSettings() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
|
await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
|
||||||
@@ -96,6 +122,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setUpdateChannel(String channel) {
|
||||||
|
state = state.copyWith(updateChannel: channel);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
void setHasSearchedBefore() {
|
void setHasSearchedBefore() {
|
||||||
if (!state.hasSearchedBefore) {
|
if (!state.hasSearchedBefore) {
|
||||||
state = state.copyWith(hasSearchedBefore: true);
|
state = state.copyWith(hasSearchedBefore: true);
|
||||||
@@ -151,6 +182,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
_applySpotifyCredentials();
|
_applySpotifyCredentials();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setMetadataSource(String source) {
|
||||||
|
state = state.copyWith(metadataSource: source);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||||
|
|||||||
@@ -24,11 +24,13 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
|||||||
final modeString = prefs.getString(kThemeModeKey);
|
final modeString = prefs.getString(kThemeModeKey);
|
||||||
final useDynamic = prefs.getBool(kUseDynamicColorKey);
|
final useDynamic = prefs.getBool(kUseDynamicColorKey);
|
||||||
final seedColor = prefs.getInt(kSeedColorKey);
|
final seedColor = prefs.getInt(kSeedColorKey);
|
||||||
|
final useAmoled = prefs.getBool(kUseAmoledKey);
|
||||||
|
|
||||||
state = ThemeSettings(
|
state = ThemeSettings(
|
||||||
themeMode: _themeModeFromString(modeString),
|
themeMode: _themeModeFromString(modeString),
|
||||||
useDynamicColor: useDynamic ?? true,
|
useDynamicColor: useDynamic ?? true,
|
||||||
seedColorValue: seedColor ?? kDefaultSeedColor,
|
seedColorValue: seedColor ?? kDefaultSeedColor,
|
||||||
|
useAmoled: useAmoled ?? false,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Error loading theme settings: $e');
|
debugPrint('Error loading theme settings: $e');
|
||||||
@@ -43,6 +45,7 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
|||||||
await prefs.setString(kThemeModeKey, state.themeMode.name);
|
await prefs.setString(kThemeModeKey, state.themeMode.name);
|
||||||
await prefs.setBool(kUseDynamicColorKey, state.useDynamicColor);
|
await prefs.setBool(kUseDynamicColorKey, state.useDynamicColor);
|
||||||
await prefs.setInt(kSeedColorKey, state.seedColorValue);
|
await prefs.setInt(kSeedColorKey, state.seedColorValue);
|
||||||
|
await prefs.setBool(kUseAmoledKey, state.useAmoled);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Error saving theme settings: $e');
|
debugPrint('Error saving theme settings: $e');
|
||||||
}
|
}
|
||||||
@@ -72,6 +75,12 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
|||||||
await _saveToStorage();
|
await _saveToStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enable or disable AMOLED mode (pure black background)
|
||||||
|
Future<void> setUseAmoled(bool value) async {
|
||||||
|
state = state.copyWith(useAmoled: value);
|
||||||
|
await _saveToStorage();
|
||||||
|
}
|
||||||
|
|
||||||
/// Helper to convert string to ThemeMode
|
/// Helper to convert string to ThemeMode
|
||||||
ThemeMode _themeModeFromString(String? value) {
|
ThemeMode _themeModeFromString(String? value) {
|
||||||
if (value == null) return ThemeMode.system;
|
if (value == null) return ThemeMode.system;
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
/// Check if request is still valid (not cancelled by newer request)
|
/// Check if request is still valid (not cancelled by newer request)
|
||||||
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
|
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
|
||||||
|
|
||||||
Future<void> fetchFromUrl(String url) async {
|
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
|
||||||
// Increment request ID to cancel any pending requests
|
// Increment request ID to cancel any pending requests
|
||||||
final requestId = ++_currentRequestId;
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
@@ -127,7 +127,22 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
|
|
||||||
final type = parsed['type'] as String;
|
final type = parsed['type'] as String;
|
||||||
|
|
||||||
final metadata = await PlatformBridge.getSpotifyMetadata(url);
|
// Use the new fallback-enabled method
|
||||||
|
Map<String, dynamic> metadata;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[FetchURL] Fetching $type with Deezer fallback enabled...');
|
||||||
|
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[FetchURL] Metadata fetch success');
|
||||||
|
} catch (e) {
|
||||||
|
// If fallback also fails, show error
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[FetchURL] Metadata fetch failed: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
|
||||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
|
|
||||||
if (type == 'track') {
|
if (type == 'track') {
|
||||||
@@ -149,6 +164,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
albumName: albumInfo['name'] as String?,
|
albumName: albumInfo['name'] as String?,
|
||||||
coverUrl: albumInfo['images'] as String?,
|
coverUrl: albumInfo['images'] as String?,
|
||||||
);
|
);
|
||||||
|
// Pre-warm cache for album tracks in background
|
||||||
|
_preWarmCacheForTracks(tracks);
|
||||||
} else if (type == 'playlist') {
|
} else if (type == 'playlist') {
|
||||||
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
||||||
final trackList = metadata['track_list'] as List<dynamic>;
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
@@ -160,6 +177,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
playlistName: owner?['name'] as String?,
|
playlistName: owner?['name'] as String?,
|
||||||
coverUrl: owner?['images'] as String?,
|
coverUrl: owner?['images'] as String?,
|
||||||
);
|
);
|
||||||
|
// Pre-warm cache for playlist tracks in background
|
||||||
|
_preWarmCacheForTracks(tracks);
|
||||||
} else if (type == 'artist') {
|
} else if (type == 'artist') {
|
||||||
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
||||||
final albumsList = metadata['albums'] as List<dynamic>;
|
final albumsList = metadata['albums'] as List<dynamic>;
|
||||||
@@ -180,7 +199,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> search(String query) async {
|
Future<void> search(String query, {String? metadataSource}) async {
|
||||||
// Increment request ID to cancel any pending requests
|
// Increment request ID to cancel any pending requests
|
||||||
final requestId = ++_currentRequestId;
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
@@ -188,7 +207,24 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
|
// Use Deezer or Spotify based on settings
|
||||||
|
final source = metadataSource ?? 'deezer';
|
||||||
|
|
||||||
|
// Debug log to show which source is being used
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[Search] Using metadata source: $source for query: "$query"');
|
||||||
|
|
||||||
|
Map<String, dynamic> results;
|
||||||
|
if (source == 'deezer') {
|
||||||
|
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[Search] Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks');
|
||||||
|
} else {
|
||||||
|
results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[Search] Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks');
|
||||||
|
}
|
||||||
|
|
||||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
|
|
||||||
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
||||||
@@ -310,6 +346,28 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
popularity: data['popularity'] as int? ?? 0,
|
popularity: data['popularity'] as int? ?? 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pre-warm track ID cache for faster downloads
|
||||||
|
/// Runs in background, doesn't block UI
|
||||||
|
void _preWarmCacheForTracks(List<Track> tracks) {
|
||||||
|
// Only pre-warm if we have tracks with ISRC
|
||||||
|
final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList();
|
||||||
|
if (tracksWithIsrc.isEmpty) return;
|
||||||
|
|
||||||
|
// Build request list for Go backend
|
||||||
|
final cacheRequests = tracksWithIsrc.map((t) => {
|
||||||
|
'isrc': t.isrc!,
|
||||||
|
'track_name': t.name,
|
||||||
|
'artist_name': t.artistName,
|
||||||
|
'spotify_id': t.id, // Include Spotify ID for Amazon lookup
|
||||||
|
'service': 'tidal', // Default to tidal for pre-warming
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
// Fire and forget - runs in background
|
||||||
|
PlatformBridge.preWarmTrackCache(cacheRequests).catchError((_) {
|
||||||
|
// Silently ignore errors - this is just an optimization
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final trackProvider = NotifierProvider<TrackNotifier, TrackState>(
|
final trackProvider = NotifierProvider<TrackNotifier, TrackState>(
|
||||||
|
|||||||
+110
-37
@@ -71,8 +71,22 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
Future<void> _fetchTracks() async {
|
Future<void> _fetchTracks() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
try {
|
try {
|
||||||
final url = 'https://open.spotify.com/album/${widget.albumId}';
|
Map<String, dynamic> metadata;
|
||||||
final metadata = await PlatformBridge.getSpotifyMetadata(url);
|
|
||||||
|
// Check if this is a Deezer album ID (format: "deezer:123456")
|
||||||
|
if (widget.albumId.startsWith('deezer:')) {
|
||||||
|
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[AlbumScreen] Fetching from Deezer: $deezerAlbumId');
|
||||||
|
metadata = await PlatformBridge.getDeezerMetadata('album', deezerAlbumId);
|
||||||
|
} else {
|
||||||
|
// Spotify album - use fallback method
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[AlbumScreen] Fetching from Spotify with fallback: ${widget.albumId}');
|
||||||
|
final url = 'https://open.spotify.com/album/${widget.albumId}';
|
||||||
|
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
||||||
|
}
|
||||||
|
|
||||||
final trackList = metadata['track_list'] as List<dynamic>;
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||||
|
|
||||||
@@ -302,8 +316,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
void _downloadTrack(BuildContext context, Track track) {
|
void _downloadTrack(BuildContext context, Track track) {
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
if (settings.askQualityBeforeDownload) {
|
if (settings.askQualityBeforeDownload) {
|
||||||
_showQualityPicker(context, (quality) {
|
_showQualityPicker(context, (quality, service) {
|
||||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality);
|
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||||
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
||||||
} else {
|
} else {
|
||||||
@@ -317,8 +331,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
if (tracks == null || tracks.isEmpty) return;
|
if (tracks == null || tracks.isEmpty) return;
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
if (settings.askQualityBeforeDownload) {
|
if (settings.askQualityBeforeDownload) {
|
||||||
_showQualityPicker(context, (quality) {
|
_showQualityPicker(context, (quality, service) {
|
||||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService, qualityOverride: quality);
|
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||||
}, trackName: '${tracks.length} tracks', artistName: widget.albumName);
|
}, trackName: '${tracks.length} tracks', artistName: widget.albumName);
|
||||||
} else {
|
} else {
|
||||||
@@ -327,44 +341,69 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
|
void _showQualityPicker(BuildContext context, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
String selectedService = settings.defaultService;
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||||
builder: (context) => SafeArea(
|
isScrollControlled: true,
|
||||||
child: Column(
|
builder: (context) => StatefulBuilder(
|
||||||
mainAxisSize: MainAxisSize.min,
|
builder: (context, setModalState) => SafeArea(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: SingleChildScrollView(
|
||||||
children: [
|
child: Column(
|
||||||
if (trackName != null) ...[
|
mainAxisSize: MainAxisSize.min,
|
||||||
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
children: [
|
||||||
] else ...[
|
if (trackName != null) ...[
|
||||||
const SizedBox(height: 8),
|
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
||||||
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||||
],
|
] else ...[
|
||||||
Padding(
|
const SizedBox(height: 8),
|
||||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
||||||
child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
],
|
||||||
),
|
// Service selector
|
||||||
// Disclaimer
|
Padding(
|
||||||
Padding(
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
child: Text(
|
|
||||||
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
fontStyle: FontStyle.italic,
|
|
||||||
),
|
),
|
||||||
),
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||||
|
child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
// Disclaimer
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
||||||
|
child: Text(
|
||||||
|
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); }),
|
||||||
|
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); }),
|
||||||
|
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); }),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); }),
|
),
|
||||||
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES'); }),
|
|
||||||
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -455,6 +494,40 @@ class _QualityOption extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ServiceChip extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
const _ServiceChip({required this.label, required this.isSelected, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Expanded(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _TrackInfoHeader extends StatefulWidget {
|
class _TrackInfoHeader extends StatefulWidget {
|
||||||
final String trackName;
|
final String trackName;
|
||||||
final String? artistName;
|
final String? artistName;
|
||||||
|
|||||||
@@ -69,10 +69,25 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
Future<void> _fetchDiscography() async {
|
Future<void> _fetchDiscography() async {
|
||||||
setState(() => _isLoadingDiscography = true);
|
setState(() => _isLoadingDiscography = true);
|
||||||
try {
|
try {
|
||||||
final url = 'https://open.spotify.com/artist/${widget.artistId}';
|
List<ArtistAlbum> albums;
|
||||||
final metadata = await PlatformBridge.getSpotifyMetadata(url);
|
|
||||||
final albumsList = metadata['albums'] as List<dynamic>;
|
// Check if this is a Deezer artist ID (format: "deezer:123456")
|
||||||
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
if (widget.artistId.startsWith('deezer:')) {
|
||||||
|
final deezerArtistId = widget.artistId.replaceFirst('deezer:', '');
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[ArtistScreen] Fetching from Deezer: $deezerArtistId');
|
||||||
|
final metadata = await PlatformBridge.getDeezerMetadata('artist', deezerArtistId);
|
||||||
|
final albumsList = metadata['albums'] as List<dynamic>;
|
||||||
|
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||||
|
} else {
|
||||||
|
// Spotify artist - use fallback method
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[ArtistScreen] Fetching from Spotify with fallback: ${widget.artistId}');
|
||||||
|
final url = 'https://open.spotify.com/artist/${widget.artistId}';
|
||||||
|
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
||||||
|
final albumsList = metadata['albums'] as List<dynamic>;
|
||||||
|
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
// Store in cache
|
// Store in cache
|
||||||
_ArtistCache.set(widget.artistId, albums);
|
_ArtistCache.set(widget.artistId, albums);
|
||||||
@@ -290,7 +305,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
Text(album.name, style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), maxLines: 2, overflow: TextOverflow.ellipsis),
|
Text(album.name, style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Text(
|
Text(
|
||||||
'${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} • ${album.totalTracks} tracks',
|
album.totalTracks > 0
|
||||||
|
? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} • ${album.totalTracks} tracks'
|
||||||
|
: album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant, fontSize: 11),
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant, fontSize: 11),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|||||||
if (url.startsWith('http') || url.startsWith('spotify:')) {
|
if (url.startsWith('http') || url.startsWith('spotify:')) {
|
||||||
await ref.read(trackProvider.notifier).fetchFromUrl(url);
|
await ref.read(trackProvider.notifier).fetchFromUrl(url);
|
||||||
} else {
|
} else {
|
||||||
await ref.read(trackProvider.notifier).search(url);
|
final settings = ref.read(settingsProvider);
|
||||||
|
await ref.read(trackProvider.notifier).search(url, metadataSource: settings.metadataSource);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+111
-50
@@ -81,7 +81,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
if (_lastSearchQuery == query) return;
|
if (_lastSearchQuery == query) return;
|
||||||
_lastSearchQuery = query;
|
_lastSearchQuery = query;
|
||||||
|
|
||||||
await ref.read(trackProvider.notifier).search(query);
|
final settings = ref.read(settingsProvider);
|
||||||
|
await ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource);
|
||||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +113,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
await ref.read(trackProvider.notifier).fetchFromUrl(url);
|
await ref.read(trackProvider.notifier).fetchFromUrl(url);
|
||||||
_navigateToDetailIfNeeded();
|
_navigateToDetailIfNeeded();
|
||||||
} else {
|
} else {
|
||||||
await ref.read(trackProvider.notifier).search(url);
|
final settings = ref.read(settingsProvider);
|
||||||
|
await ref.read(trackProvider.notifier).search(url, metadataSource: settings.metadataSource);
|
||||||
}
|
}
|
||||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||||
}
|
}
|
||||||
@@ -170,8 +172,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
|
|
||||||
if (settings.askQualityBeforeDownload) {
|
if (settings.askQualityBeforeDownload) {
|
||||||
_showQualityPicker(context, (quality) {
|
_showQualityPicker(context, (quality, service) {
|
||||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality);
|
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||||
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
||||||
} else {
|
} else {
|
||||||
@@ -181,59 +183,84 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
|
void _showQualityPicker(BuildContext context, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
String selectedService = settings.defaultService;
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||||
builder: (context) => SafeArea(
|
isScrollControlled: true,
|
||||||
child: Column(
|
builder: (context) => StatefulBuilder(
|
||||||
mainAxisSize: MainAxisSize.min,
|
builder: (context, setModalState) => SafeArea(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: SingleChildScrollView(
|
||||||
children: [
|
child: Column(
|
||||||
if (trackName != null) ...[
|
mainAxisSize: MainAxisSize.min,
|
||||||
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
children: [
|
||||||
] else ...[
|
if (trackName != null) ...[
|
||||||
const SizedBox(height: 8),
|
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
||||||
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||||
],
|
] else ...[
|
||||||
Padding(
|
const SizedBox(height: 8),
|
||||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
||||||
child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
],
|
||||||
),
|
// Service selector
|
||||||
// Disclaimer
|
Padding(
|
||||||
Padding(
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
child: Text(
|
|
||||||
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
fontStyle: FontStyle.italic,
|
|
||||||
),
|
),
|
||||||
),
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||||
|
child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
// Disclaimer
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
||||||
|
child: Text(
|
||||||
|
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_QualityPickerOption(
|
||||||
|
title: 'FLAC Lossless',
|
||||||
|
subtitle: '16-bit / 44.1kHz',
|
||||||
|
icon: Icons.music_note,
|
||||||
|
onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); },
|
||||||
|
),
|
||||||
|
_QualityPickerOption(
|
||||||
|
title: 'Hi-Res FLAC',
|
||||||
|
subtitle: '24-bit / up to 96kHz',
|
||||||
|
icon: Icons.high_quality,
|
||||||
|
onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); },
|
||||||
|
),
|
||||||
|
_QualityPickerOption(
|
||||||
|
title: 'Hi-Res FLAC Max',
|
||||||
|
subtitle: '24-bit / up to 192kHz',
|
||||||
|
icon: Icons.four_k,
|
||||||
|
onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); },
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
_QualityPickerOption(
|
),
|
||||||
title: 'FLAC Lossless',
|
|
||||||
subtitle: '16-bit / 44.1kHz',
|
|
||||||
icon: Icons.music_note,
|
|
||||||
onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); },
|
|
||||||
),
|
|
||||||
_QualityPickerOption(
|
|
||||||
title: 'Hi-Res FLAC',
|
|
||||||
subtitle: '24-bit / up to 96kHz',
|
|
||||||
icon: Icons.high_quality,
|
|
||||||
onTap: () { Navigator.pop(context); onSelect('HI_RES'); },
|
|
||||||
),
|
|
||||||
_QualityPickerOption(
|
|
||||||
title: 'Hi-Res FLAC Max',
|
|
||||||
subtitle: '24-bit / up to 192kHz',
|
|
||||||
icon: Icons.four_k,
|
|
||||||
onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); },
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -764,6 +791,40 @@ class _QualityPickerOption extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ServiceChip extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
const _ServiceChip({required this.label, required this.isSelected, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Expanded(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _TrackInfoHeader extends StatefulWidget {
|
class _TrackInfoHeader extends StatefulWidget {
|
||||||
final String trackName;
|
final String trackName;
|
||||||
final String? artistName;
|
final String? artistName;
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
if (!settings.checkForUpdates) return;
|
if (!settings.checkForUpdates) return;
|
||||||
|
|
||||||
final updateInfo = await UpdateChecker.checkForUpdate();
|
final updateInfo = await UpdateChecker.checkForUpdate(channel: settings.updateChannel);
|
||||||
if (updateInfo != null && mounted) {
|
if (updateInfo != null && mounted) {
|
||||||
showUpdateDialog(
|
showUpdateDialog(
|
||||||
context,
|
context,
|
||||||
|
|||||||
@@ -168,8 +168,8 @@ class PlaylistScreen extends ConsumerWidget {
|
|||||||
void _downloadTrack(BuildContext context, WidgetRef ref, Track track) {
|
void _downloadTrack(BuildContext context, WidgetRef ref, Track track) {
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
if (settings.askQualityBeforeDownload) {
|
if (settings.askQualityBeforeDownload) {
|
||||||
_showQualityPicker(context, (quality) {
|
_showQualityPicker(context, ref, (quality, service) {
|
||||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality);
|
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||||
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
||||||
} else {
|
} else {
|
||||||
@@ -182,8 +182,8 @@ class PlaylistScreen extends ConsumerWidget {
|
|||||||
if (tracks.isEmpty) return;
|
if (tracks.isEmpty) return;
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
if (settings.askQualityBeforeDownload) {
|
if (settings.askQualityBeforeDownload) {
|
||||||
_showQualityPicker(context, (quality) {
|
_showQualityPicker(context, ref, (quality, service) {
|
||||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService, qualityOverride: quality);
|
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||||
}, trackName: '${tracks.length} tracks', artistName: playlistName);
|
}, trackName: '${tracks.length} tracks', artistName: playlistName);
|
||||||
} else {
|
} else {
|
||||||
@@ -192,41 +192,66 @@ class PlaylistScreen extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
|
void _showQualityPicker(BuildContext context, WidgetRef ref, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
String selectedService = settings.defaultService;
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||||
builder: (context) => SafeArea(
|
isScrollControlled: true,
|
||||||
child: Column(
|
builder: (context) => StatefulBuilder(
|
||||||
mainAxisSize: MainAxisSize.min,
|
builder: (context, setModalState) => SafeArea(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: SingleChildScrollView(
|
||||||
children: [
|
child: Column(
|
||||||
if (trackName != null) ...[
|
mainAxisSize: MainAxisSize.min,
|
||||||
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
children: [
|
||||||
] else ...[
|
if (trackName != null) ...[
|
||||||
const SizedBox(height: 8),
|
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
||||||
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||||
],
|
] else ...[
|
||||||
Padding(padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold))),
|
const SizedBox(height: 8),
|
||||||
// Disclaimer
|
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
||||||
Padding(
|
],
|
||||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
// Service selector
|
||||||
child: Text(
|
Padding(
|
||||||
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
fontStyle: FontStyle.italic,
|
|
||||||
),
|
),
|
||||||
),
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold))),
|
||||||
|
// Disclaimer
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
||||||
|
child: Text(
|
||||||
|
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); }),
|
||||||
|
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); }),
|
||||||
|
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); }),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); }),
|
),
|
||||||
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES'); }),
|
|
||||||
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -254,6 +279,40 @@ class _QualityOption extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ServiceChip extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
const _ServiceChip({required this.label, required this.isSelected, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Expanded(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _TrackInfoHeader extends StatefulWidget {
|
class _TrackInfoHeader extends StatefulWidget {
|
||||||
final String trackName;
|
final String trackName;
|
||||||
final String? artistName;
|
final String? artistName;
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
_searchController = TextEditingController(text: widget.query);
|
_searchController = TextEditingController(text: widget.query);
|
||||||
if (widget.query.isNotEmpty) {
|
if (widget.query.isNotEmpty) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
ref.read(trackProvider.notifier).search(widget.query);
|
final settings = ref.read(settingsProvider);
|
||||||
|
ref.read(trackProvider.notifier).search(widget.query, metadataSource: settings.metadataSource);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,7 +38,8 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
void _search() {
|
void _search() {
|
||||||
final query = _searchController.text.trim();
|
final query = _searchController.text.trim();
|
||||||
if (query.isNotEmpty) {
|
if (query.isNotEmpty) {
|
||||||
ref.read(trackProvider.notifier).search(query);
|
final settings = ref.read(settingsProvider);
|
||||||
|
ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +123,24 @@ class AboutPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Support section
|
||||||
|
const SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(title: 'Support'),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.coffee_outlined,
|
||||||
|
title: 'Buy me a coffee',
|
||||||
|
subtitle: 'Support development on Ko-fi',
|
||||||
|
onTap: () => _launchUrl(AppInfo.kofiUrl),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// App info section
|
// App info section
|
||||||
const SliverToBoxAdapter(
|
const SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'App'),
|
child: SettingsSectionHeader(title: 'App'),
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||||||
currentMode: themeSettings.themeMode,
|
currentMode: themeSettings.themeMode,
|
||||||
onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
|
onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
|
||||||
),
|
),
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon: Icons.brightness_2,
|
||||||
|
title: 'AMOLED Dark',
|
||||||
|
subtitle: 'Pure black background for OLED screens',
|
||||||
|
value: themeSettings.useAmoled,
|
||||||
|
onChanged: (value) => ref.read(themeProvider.notifier).setUseAmoled(value),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
@@ -113,8 +115,10 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
SettingsItem(
|
SettingsItem(
|
||||||
icon: Icons.folder_outlined,
|
icon: Icons.folder_outlined,
|
||||||
title: 'Download Directory',
|
title: 'Download Directory',
|
||||||
subtitle: settings.downloadDirectory.isEmpty ? 'Music/SpotiFLAC' : settings.downloadDirectory,
|
subtitle: settings.downloadDirectory.isEmpty
|
||||||
onTap: () => _pickDirectory(ref),
|
? (Platform.isIOS ? 'App Documents Folder' : 'Music/SpotiFLAC')
|
||||||
|
: settings.downloadDirectory,
|
||||||
|
onTap: () => _pickDirectory(context, ref),
|
||||||
),
|
),
|
||||||
SettingsItem(
|
SettingsItem(
|
||||||
icon: Icons.create_new_folder_outlined,
|
icon: Icons.create_new_folder_outlined,
|
||||||
@@ -161,9 +165,90 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickDirectory(WidgetRef ref) async {
|
Future<void> _pickDirectory(BuildContext context, WidgetRef ref) async {
|
||||||
final result = await FilePicker.platform.getDirectoryPath();
|
if (Platform.isIOS) {
|
||||||
if (result != null) ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
// iOS: Show options dialog
|
||||||
|
_showIOSDirectoryOptions(context, ref);
|
||||||
|
} else {
|
||||||
|
// Android: Use file picker
|
||||||
|
final result = await FilePicker.platform.getDirectoryPath();
|
||||||
|
if (result != null) ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showIOSDirectoryOptions(BuildContext context, WidgetRef ref) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||||
|
builder: (ctx) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||||
|
child: Text('Download Location', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||||
|
child: Text(
|
||||||
|
'On iOS, downloads are saved to the app\'s Documents folder which is accessible via the Files app.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(Icons.folder_special, color: colorScheme.primary),
|
||||||
|
title: const Text('App Documents Folder'),
|
||||||
|
subtitle: const Text('Recommended - accessible via Files app'),
|
||||||
|
trailing: Icon(Icons.check_circle, color: colorScheme.primary),
|
||||||
|
onTap: () async {
|
||||||
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
|
ref.read(settingsProvider.notifier).setDownloadDirectory(dir.path);
|
||||||
|
if (ctx.mounted) Navigator.pop(ctx);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
|
||||||
|
title: const Text('Choose from Files'),
|
||||||
|
subtitle: const Text('Select iCloud or other location'),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
// Note: iOS requires folder to have at least one file to be selectable
|
||||||
|
final result = await FilePicker.platform.getDirectoryPath();
|
||||||
|
if (result != null) {
|
||||||
|
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 8, 24, 16),
|
||||||
|
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(
|
||||||
|
'iOS limitation: Empty folders cannot be selected. Create a file inside first or use App Documents.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onTertiaryContainer),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _getFolderOrganizationLabel(String value) {
|
String _getFolderOrganizationLabel(String value) {
|
||||||
|
|||||||
@@ -105,7 +105,10 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
subtitle: 'Notify when new version is available',
|
subtitle: 'Notify when new version is available',
|
||||||
value: settings.checkForUpdates,
|
value: settings.checkForUpdates,
|
||||||
onChanged: (v) => ref.read(settingsProvider.notifier).setCheckForUpdates(v),
|
onChanged: (v) => ref.read(settingsProvider.notifier).setCheckForUpdates(v),
|
||||||
showDivider: false,
|
),
|
||||||
|
_UpdateChannelSelector(
|
||||||
|
currentChannel: settings.updateChannel,
|
||||||
|
onChanged: (v) => ref.read(settingsProvider.notifier).setUpdateChannel(v),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -116,29 +119,35 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
SettingsItem(
|
_MetadataSourceSelector(
|
||||||
icon: Icons.key,
|
currentSource: settings.metadataSource,
|
||||||
title: 'Custom Credentials',
|
onChanged: (v) => ref.read(settingsProvider.notifier).setMetadataSource(v),
|
||||||
subtitle: settings.spotifyClientId.isNotEmpty
|
|
||||||
? 'Client ID: ${settings.spotifyClientId.length > 8 ? '${settings.spotifyClientId.substring(0, 8)}...' : settings.spotifyClientId}'
|
|
||||||
: 'Not configured',
|
|
||||||
onTap: () => _showSpotifyCredentialsDialog(context, ref, settings),
|
|
||||||
trailing: settings.spotifyClientId.isNotEmpty
|
|
||||||
? Icon(Icons.edit, color: Theme.of(context).colorScheme.onSurfaceVariant, size: 20)
|
|
||||||
: Icon(Icons.add, color: Theme.of(context).colorScheme.primary, size: 20),
|
|
||||||
showDivider: settings.spotifyClientId.isNotEmpty,
|
|
||||||
),
|
),
|
||||||
if (settings.spotifyClientId.isNotEmpty)
|
if (settings.metadataSource == 'spotify') ...[
|
||||||
SettingsSwitchItem(
|
SettingsItem(
|
||||||
icon: Icons.toggle_on,
|
icon: Icons.key,
|
||||||
title: 'Use Custom Credentials',
|
title: 'Custom Credentials',
|
||||||
subtitle: settings.useCustomSpotifyCredentials
|
subtitle: settings.spotifyClientId.isNotEmpty
|
||||||
? 'Using your credentials'
|
? 'Client ID: ${settings.spotifyClientId.length > 8 ? '${settings.spotifyClientId.substring(0, 8)}...' : settings.spotifyClientId}'
|
||||||
: 'Using default credentials',
|
: 'Not configured',
|
||||||
value: settings.useCustomSpotifyCredentials,
|
onTap: () => _showSpotifyCredentialsDialog(context, ref, settings),
|
||||||
onChanged: (v) => ref.read(settingsProvider.notifier).setUseCustomSpotifyCredentials(v),
|
trailing: settings.spotifyClientId.isNotEmpty
|
||||||
showDivider: false,
|
? Icon(Icons.edit, color: Theme.of(context).colorScheme.onSurfaceVariant, size: 20)
|
||||||
|
: Icon(Icons.add, color: Theme.of(context).colorScheme.primary, size: 20),
|
||||||
|
showDivider: settings.spotifyClientId.isNotEmpty,
|
||||||
),
|
),
|
||||||
|
if (settings.spotifyClientId.isNotEmpty)
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon: Icons.toggle_on,
|
||||||
|
title: 'Use Custom Credentials',
|
||||||
|
subtitle: settings.useCustomSpotifyCredentials
|
||||||
|
? 'Using your credentials'
|
||||||
|
: 'Using default credentials',
|
||||||
|
value: settings.useCustomSpotifyCredentials,
|
||||||
|
onChanged: (v) => ref.read(settingsProvider.notifier).setUseCustomSpotifyCredentials(v),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -393,3 +402,149 @@ class _ConcurrentChip extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _UpdateChannelSelector extends StatelessWidget {
|
||||||
|
final String currentChannel;
|
||||||
|
final ValueChanged<String> onChanged;
|
||||||
|
const _UpdateChannelSelector({required this.currentChannel, required this.onChanged});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Row(children: [
|
||||||
|
Icon(Icons.new_releases, color: colorScheme.onSurfaceVariant, size: 24),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Text('Update Channel', style: Theme.of(context).textTheme.bodyLarge),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(currentChannel == 'preview' ? 'Get preview releases' : 'Stable releases only',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
|
])),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(children: [
|
||||||
|
_ChannelChip(label: 'Stable', isSelected: currentChannel == 'stable', onTap: () => onChanged('stable')),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ChannelChip(label: 'Preview', isSelected: currentChannel == 'preview', onTap: () => onChanged('preview')),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(children: [
|
||||||
|
Icon(Icons.info_outline, size: 16, color: colorScheme.onSurfaceVariant),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text('Preview may contain bugs or incomplete features',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant))),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChannelChip extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
const _ChannelChip({required this.label, required this.isSelected, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
final unselectedColor = isDark
|
||||||
|
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
||||||
|
: colorScheme.surfaceContainerHigh;
|
||||||
|
|
||||||
|
return Expanded(
|
||||||
|
child: Material(
|
||||||
|
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
child: Center(child: Text(label, style: TextStyle(
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MetadataSourceSelector extends StatelessWidget {
|
||||||
|
final String currentSource;
|
||||||
|
final ValueChanged<String> onChanged;
|
||||||
|
const _MetadataSourceSelector({required this.currentSource, required this.onChanged});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Row(children: [
|
||||||
|
Icon(Icons.search, color: colorScheme.onSurfaceVariant, size: 24),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Text('Search Source', style: Theme.of(context).textTheme.bodyLarge),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(currentSource == 'deezer' ? 'Deezer (no need developer account)' : 'Spotify (may hit rate limit)',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
|
])),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(children: [
|
||||||
|
_SourceChip(label: 'Deezer', isSelected: currentSource == 'deezer', onTap: () => onChanged('deezer')),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_SourceChip(label: 'Spotify', isSelected: currentSource == 'spotify', onTap: () => onChanged('spotify')),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(children: [
|
||||||
|
Icon(Icons.info_outline, size: 16, color: colorScheme.onSurfaceVariant),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text('Spotify URLs are always supported regardless of this setting',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant))),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SourceChip extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
const _SourceChip({required this.label, required this.isSelected, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
final unselectedColor = isDark
|
||||||
|
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
||||||
|
: colorScheme.surfaceContainerHigh;
|
||||||
|
|
||||||
|
return Expanded(
|
||||||
|
child: Material(
|
||||||
|
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
child: Center(child: Text(label, style: TextStyle(
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+103
-21
@@ -205,29 +205,35 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String? selectedDirectory = await FilePicker.platform.getDirectoryPath(
|
if (Platform.isIOS) {
|
||||||
dialogTitle: 'Select Download Folder',
|
// iOS: Show options dialog
|
||||||
);
|
await _showIOSDirectoryOptions();
|
||||||
|
|
||||||
if (selectedDirectory != null) {
|
|
||||||
setState(() => _selectedDirectory = selectedDirectory);
|
|
||||||
} else {
|
} else {
|
||||||
final defaultDir = await _getDefaultDirectory();
|
// Android: Use file picker
|
||||||
if (mounted) {
|
String? selectedDirectory = await FilePicker.platform.getDirectoryPath(
|
||||||
final useDefault = await showDialog<bool>(
|
dialogTitle: 'Select Download Folder',
|
||||||
context: context,
|
);
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text('Use Default Folder?'),
|
|
||||||
content: Text('No folder selected. Would you like to use the default Music folder?\n\n$defaultDir'),
|
|
||||||
actions: [
|
|
||||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
|
||||||
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Use Default')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (useDefault == true) {
|
if (selectedDirectory != null) {
|
||||||
setState(() => _selectedDirectory = defaultDir);
|
setState(() => _selectedDirectory = selectedDirectory);
|
||||||
|
} else {
|
||||||
|
final defaultDir = await _getDefaultDirectory();
|
||||||
|
if (mounted) {
|
||||||
|
final useDefault = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Use Default Folder?'),
|
||||||
|
content: Text('No folder selected. Would you like to use the default Music folder?\n\n$defaultDir'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Use Default')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (useDefault == true) {
|
||||||
|
setState(() => _selectedDirectory = defaultDir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,6 +242,82 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _showIOSDirectoryOptions() async {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
await showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||||
|
builder: (ctx) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||||
|
child: Text('Download Location', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||||
|
child: Text(
|
||||||
|
'On iOS, downloads are saved to the app\'s Documents folder which is accessible via the Files app.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(Icons.folder_special, color: colorScheme.primary),
|
||||||
|
title: const Text('App Documents Folder'),
|
||||||
|
subtitle: const Text('Recommended - accessible via Files app'),
|
||||||
|
trailing: Icon(Icons.check_circle, color: colorScheme.primary),
|
||||||
|
onTap: () async {
|
||||||
|
final dir = await _getDefaultDirectory();
|
||||||
|
setState(() => _selectedDirectory = dir);
|
||||||
|
if (ctx.mounted) Navigator.pop(ctx);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
|
||||||
|
title: const Text('Choose from Files'),
|
||||||
|
subtitle: const Text('Select iCloud or other location'),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
// Note: iOS requires folder to have at least one file to be selectable
|
||||||
|
final result = await FilePicker.platform.getDirectoryPath();
|
||||||
|
if (result != null) {
|
||||||
|
setState(() => _selectedDirectory = result);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 8, 24, 16),
|
||||||
|
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(
|
||||||
|
'iOS limitation: Empty folders cannot be selected. Create a file inside first or use App Documents.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onTertiaryContainer),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<String> _getDefaultDirectory() async {
|
Future<String> _getDefaultDirectory() async {
|
||||||
if (Platform.isIOS) {
|
if (Platform.isIOS) {
|
||||||
final appDir = await getApplicationDocumentsDirectory();
|
final appDir = await getApplicationDocumentsDirectory();
|
||||||
|
|||||||
@@ -759,17 +759,21 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Add timeout to prevent infinite loading
|
||||||
final result = await PlatformBridge.getLyricsLRC(
|
final result = await PlatformBridge.getLyricsLRC(
|
||||||
item.spotifyId ?? '',
|
item.spotifyId ?? '',
|
||||||
item.trackName,
|
item.trackName,
|
||||||
item.artistName,
|
item.artistName,
|
||||||
filePath: _fileExists ? item.filePath : null, // Try embedded lyrics first
|
filePath: _fileExists ? item.filePath : null, // Try embedded lyrics first
|
||||||
|
).timeout(
|
||||||
|
const Duration(seconds: 20),
|
||||||
|
onTimeout: () => '', // Return empty string on timeout
|
||||||
);
|
);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
if (result.isEmpty) {
|
if (result.isEmpty) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_lyricsError = 'Lyrics not found';
|
_lyricsError = 'Lyrics not available for this track';
|
||||||
_lyricsLoading = false;
|
_lyricsLoading = false;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -783,8 +787,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
final errorMsg = e.toString().contains('TimeoutException')
|
||||||
|
? 'Request timed out. Try again later.'
|
||||||
|
: 'Failed to load lyrics';
|
||||||
setState(() {
|
setState(() {
|
||||||
_lyricsError = 'Failed to load lyrics';
|
_lyricsError = errorMsg;
|
||||||
_lyricsLoading = false;
|
_lyricsLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,30 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
|
|
||||||
import 'package:spotiflac_android/utils/logger.dart';
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
final _log = AppLogger('FFmpeg');
|
final _log = AppLogger('FFmpeg');
|
||||||
|
|
||||||
/// FFmpeg service for audio conversion and remuxing
|
/// FFmpeg service for audio conversion and remuxing
|
||||||
|
/// Uses native MethodChannel to call FFmpegKit from local AAR
|
||||||
class FFmpegService {
|
class FFmpegService {
|
||||||
|
static const _channel = MethodChannel('com.zarz.spotiflac/ffmpeg');
|
||||||
|
|
||||||
|
/// Execute FFmpeg command and return result
|
||||||
|
static Future<FFmpegResult> _execute(String command) async {
|
||||||
|
try {
|
||||||
|
final result = await _channel.invokeMethod('execute', {'command': command});
|
||||||
|
final map = Map<String, dynamic>.from(result);
|
||||||
|
return FFmpegResult(
|
||||||
|
success: map['success'] as bool,
|
||||||
|
returnCode: map['returnCode'] as int,
|
||||||
|
output: map['output'] as String,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('FFmpeg execute error: $e');
|
||||||
|
return FFmpegResult(success: false, returnCode: -1, output: e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert M4A (DASH segments) to FLAC
|
/// Convert M4A (DASH segments) to FLAC
|
||||||
/// Returns the output file path on success, null on failure
|
/// Returns the output file path on success, null on failure
|
||||||
static Future<String?> convertM4aToFlac(String inputPath) async {
|
static Future<String?> convertM4aToFlac(String inputPath) async {
|
||||||
@@ -16,10 +34,9 @@ class FFmpegService {
|
|||||||
final command =
|
final command =
|
||||||
'-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
|
'-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
|
||||||
|
|
||||||
final session = await FFmpegKit.execute(command);
|
final result = await _execute(command);
|
||||||
final returnCode = await session.getReturnCode();
|
|
||||||
|
|
||||||
if (ReturnCode.isSuccess(returnCode)) {
|
if (result.success) {
|
||||||
// Delete original M4A file
|
// Delete original M4A file
|
||||||
try {
|
try {
|
||||||
await File(inputPath).delete();
|
await File(inputPath).delete();
|
||||||
@@ -27,12 +44,7 @@ class FFmpegService {
|
|||||||
return outputPath;
|
return outputPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log error for debugging
|
_log.e('M4A to FLAC conversion failed: ${result.output}');
|
||||||
final logs = await session.getLogs();
|
|
||||||
for (final log in logs) {
|
|
||||||
_log.d(log.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,13 +66,13 @@ class FFmpegService {
|
|||||||
final command =
|
final command =
|
||||||
'-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
|
'-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
|
||||||
|
|
||||||
final session = await FFmpegKit.execute(command);
|
final result = await _execute(command);
|
||||||
final returnCode = await session.getReturnCode();
|
|
||||||
|
|
||||||
if (ReturnCode.isSuccess(returnCode)) {
|
if (result.success) {
|
||||||
return outputPath;
|
return outputPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_log.e('FLAC to MP3 conversion failed: ${result.output}');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,22 +103,21 @@ class FFmpegService {
|
|||||||
'-i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -map_metadata 0 "$outputPath" -y';
|
'-i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -map_metadata 0 "$outputPath" -y';
|
||||||
}
|
}
|
||||||
|
|
||||||
final session = await FFmpegKit.execute(command);
|
final result = await _execute(command);
|
||||||
final returnCode = await session.getReturnCode();
|
|
||||||
|
|
||||||
if (ReturnCode.isSuccess(returnCode)) {
|
if (result.success) {
|
||||||
return outputPath;
|
return outputPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_log.e('FLAC to M4A conversion failed: ${result.output}');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if FFmpeg is available
|
/// Check if FFmpeg is available
|
||||||
static Future<bool> isAvailable() async {
|
static Future<bool> isAvailable() async {
|
||||||
try {
|
try {
|
||||||
final session = await FFmpegKit.execute('-version');
|
final version = await _channel.invokeMethod('getVersion');
|
||||||
final returnCode = await session.getReturnCode();
|
return version != null && version.toString().isNotEmpty;
|
||||||
return ReturnCode.isSuccess(returnCode);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -115,11 +126,55 @@ class FFmpegService {
|
|||||||
/// Get FFmpeg version info
|
/// Get FFmpeg version info
|
||||||
static Future<String?> getVersion() async {
|
static Future<String?> getVersion() async {
|
||||||
try {
|
try {
|
||||||
final session = await FFmpegKit.execute('-version');
|
final version = await _channel.invokeMethod('getVersion');
|
||||||
final output = await session.getOutput();
|
return version as String?;
|
||||||
return output;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Embed cover art to FLAC file
|
||||||
|
/// Returns the file path on success, null on failure
|
||||||
|
static Future<String?> embedCover(String flacPath, String coverPath) async {
|
||||||
|
final tempOutput = '$flacPath.tmp';
|
||||||
|
final command = '-i "$flacPath" -i "$coverPath" -map 0:a -map 1:0 -c copy -metadata:s:v title="Album cover" -metadata:s:v comment="Cover (front)" -disposition:v attached_pic "$tempOutput" -y';
|
||||||
|
|
||||||
|
final result = await _execute(command);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
try {
|
||||||
|
// Replace original with temp
|
||||||
|
await File(flacPath).delete();
|
||||||
|
await File(tempOutput).rename(flacPath);
|
||||||
|
return flacPath;
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Failed to replace file after cover embed: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up temp file if exists
|
||||||
|
try {
|
||||||
|
final tempFile = File(tempOutput);
|
||||||
|
if (await tempFile.exists()) {
|
||||||
|
await tempFile.delete();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
_log.e('Cover embed failed: ${result.output}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of FFmpeg command execution
|
||||||
|
class FFmpegResult {
|
||||||
|
final bool success;
|
||||||
|
final int returnCode;
|
||||||
|
final String output;
|
||||||
|
|
||||||
|
FFmpegResult({
|
||||||
|
required this.success,
|
||||||
|
required this.returnCode,
|
||||||
|
required this.output,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ class PlatformBridge {
|
|||||||
int discNumber = 1,
|
int discNumber = 1,
|
||||||
int totalTracks = 1,
|
int totalTracks = 1,
|
||||||
String? releaseDate,
|
String? releaseDate,
|
||||||
String preferredService = 'tidal',
|
String preferredService = 'qobuz',
|
||||||
String? itemId,
|
String? itemId,
|
||||||
int durationMs = 0,
|
int durationMs = 0,
|
||||||
}) async {
|
}) async {
|
||||||
@@ -297,4 +297,71 @@ class PlatformBridge {
|
|||||||
'client_secret': clientSecret,
|
'client_secret': clientSecret,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pre-warm track ID cache for album/playlist tracks
|
||||||
|
/// This runs in background and returns immediately
|
||||||
|
/// Speeds up subsequent downloads by caching ISRC → Track ID mappings
|
||||||
|
static Future<void> preWarmTrackCache(List<Map<String, String>> tracks) async {
|
||||||
|
final tracksJson = jsonEncode(tracks);
|
||||||
|
await _channel.invokeMethod('preWarmTrackCache', {'tracks': tracksJson});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current track cache size
|
||||||
|
static Future<int> getTrackCacheSize() async {
|
||||||
|
final result = await _channel.invokeMethod('getTrackCacheSize');
|
||||||
|
return result as int;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear track ID cache
|
||||||
|
static Future<void> clearTrackCache() async {
|
||||||
|
await _channel.invokeMethod('clearTrackCache');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== DEEZER API ====================
|
||||||
|
|
||||||
|
/// Search Deezer for tracks and artists (no API key required)
|
||||||
|
static Future<Map<String, dynamic>> searchDeezerAll(String query, {int trackLimit = 15, int artistLimit = 3}) async {
|
||||||
|
final result = await _channel.invokeMethod('searchDeezerAll', {
|
||||||
|
'query': query,
|
||||||
|
'track_limit': trackLimit,
|
||||||
|
'artist_limit': artistLimit,
|
||||||
|
});
|
||||||
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get Deezer metadata by type and ID
|
||||||
|
static Future<Map<String, dynamic>> getDeezerMetadata(String resourceType, String resourceId) async {
|
||||||
|
final result = await _channel.invokeMethod('getDeezerMetadata', {
|
||||||
|
'resource_type': resourceType,
|
||||||
|
'resource_id': resourceId,
|
||||||
|
});
|
||||||
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse Deezer URL and return type and ID
|
||||||
|
static Future<Map<String, dynamic>> parseDeezerUrl(String url) async {
|
||||||
|
final result = await _channel.invokeMethod('parseDeezerUrl', {'url': url});
|
||||||
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search Deezer by ISRC
|
||||||
|
static Future<Map<String, dynamic>> searchDeezerByISRC(String isrc) async {
|
||||||
|
final result = await _channel.invokeMethod('searchDeezerByISRC', {'isrc': isrc});
|
||||||
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert Spotify track to Deezer and get metadata (for rate limit fallback)
|
||||||
|
static Future<Map<String, dynamic>> convertSpotifyToDeezer(String resourceType, String spotifyId) async {
|
||||||
|
final result = await _channel.invokeMethod('convertSpotifyToDeezer', {
|
||||||
|
'resource_type': resourceType,
|
||||||
|
'spotify_id': spotifyId,
|
||||||
|
});
|
||||||
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get Spotify metadata with automatic Deezer fallback on rate limit
|
||||||
|
static Future<Map<String, dynamic>> getSpotifyMetadataWithFallback(String url) async {
|
||||||
|
final result = await _channel.invokeMethod('getSpotifyMetadataWithFallback', {'url': url});
|
||||||
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class UpdateInfo {
|
|||||||
final String downloadUrl;
|
final String downloadUrl;
|
||||||
final String? apkDownloadUrl;
|
final String? apkDownloadUrl;
|
||||||
final DateTime publishedAt;
|
final DateTime publishedAt;
|
||||||
|
final bool isPrerelease;
|
||||||
|
|
||||||
const UpdateInfo({
|
const UpdateInfo({
|
||||||
required this.version,
|
required this.version,
|
||||||
@@ -19,11 +20,13 @@ class UpdateInfo {
|
|||||||
required this.downloadUrl,
|
required this.downloadUrl,
|
||||||
this.apkDownloadUrl,
|
this.apkDownloadUrl,
|
||||||
required this.publishedAt,
|
required this.publishedAt,
|
||||||
|
this.isPrerelease = false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class UpdateChecker {
|
class UpdateChecker {
|
||||||
static const String _apiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest';
|
static const String _latestApiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest';
|
||||||
|
static const String _allReleasesApiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases';
|
||||||
|
|
||||||
static Future<String> _getDeviceArch() async {
|
static Future<String> _getDeviceArch() async {
|
||||||
if (!Platform.isAndroid) return 'unknown';
|
if (!Platform.isAndroid) return 'unknown';
|
||||||
@@ -55,30 +58,59 @@ class UpdateChecker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<UpdateInfo?> checkForUpdate() async {
|
/// Check for updates based on channel preference
|
||||||
|
/// [channel] can be 'stable' or 'preview'
|
||||||
|
static Future<UpdateInfo?> checkForUpdate({String channel = 'stable'}) async {
|
||||||
try {
|
try {
|
||||||
final response = await http.get(
|
Map<String, dynamic>? releaseData;
|
||||||
Uri.parse(_apiUrl),
|
|
||||||
headers: {'Accept': 'application/vnd.github.v3+json'},
|
if (channel == 'preview') {
|
||||||
).timeout(const Duration(seconds: 10));
|
// For preview channel, get all releases and find the latest (including prereleases)
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('$_allReleasesApiUrl?per_page=10'),
|
||||||
|
headers: {'Accept': 'application/vnd.github.v3+json'},
|
||||||
|
).timeout(const Duration(seconds: 10));
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
_log.w('GitHub API returned ${response.statusCode}');
|
_log.w('GitHub API returned ${response.statusCode}');
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final releases = jsonDecode(response.body) as List<dynamic>;
|
||||||
|
if (releases.isEmpty) {
|
||||||
|
_log.i('No releases found');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First release is the latest (including prereleases)
|
||||||
|
releaseData = releases.first as Map<String, dynamic>;
|
||||||
|
} else {
|
||||||
|
// For stable channel, use /latest endpoint (excludes prereleases)
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse(_latestApiUrl),
|
||||||
|
headers: {'Accept': 'application/vnd.github.v3+json'},
|
||||||
|
).timeout(const Duration(seconds: 10));
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
_log.w('GitHub API returned ${response.statusCode}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseData = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
final tagName = releaseData['tag_name'] as String? ?? '';
|
||||||
final tagName = data['tag_name'] as String? ?? '';
|
|
||||||
final latestVersion = tagName.replaceFirst('v', '');
|
final latestVersion = tagName.replaceFirst('v', '');
|
||||||
|
final isPrerelease = releaseData['prerelease'] as bool? ?? false;
|
||||||
|
|
||||||
if (!_isNewerVersion(latestVersion, AppInfo.version)) {
|
if (!_isNewerVersion(latestVersion, AppInfo.version)) {
|
||||||
_log.i('No update available (current: ${AppInfo.version}, latest: $latestVersion)');
|
_log.i('No update available (current: ${AppInfo.version}, latest: $latestVersion, channel: $channel)');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final body = data['body'] as String? ?? 'No changelog available';
|
final body = releaseData['body'] as String? ?? 'No changelog available';
|
||||||
final htmlUrl = data['html_url'] as String? ?? '${AppInfo.githubUrl}/releases';
|
final htmlUrl = releaseData['html_url'] as String? ?? '${AppInfo.githubUrl}/releases';
|
||||||
final publishedAt = DateTime.tryParse(data['published_at'] as String? ?? '') ?? DateTime.now();
|
final publishedAt = DateTime.tryParse(releaseData['published_at'] as String? ?? '') ?? DateTime.now();
|
||||||
|
|
||||||
final deviceArch = await _getDeviceArch();
|
final deviceArch = await _getDeviceArch();
|
||||||
_log.d('Device architecture: $deviceArch');
|
_log.d('Device architecture: $deviceArch');
|
||||||
@@ -87,7 +119,7 @@ class UpdateChecker {
|
|||||||
String? arm32Url;
|
String? arm32Url;
|
||||||
String? universalUrl;
|
String? universalUrl;
|
||||||
|
|
||||||
final assets = data['assets'] as List<dynamic>? ?? [];
|
final assets = releaseData['assets'] as List<dynamic>? ?? [];
|
||||||
for (final asset in assets) {
|
for (final asset in assets) {
|
||||||
final name = (asset['name'] as String? ?? '').toLowerCase();
|
final name = (asset['name'] as String? ?? '').toLowerCase();
|
||||||
if (name.endsWith('.apk')) {
|
if (name.endsWith('.apk')) {
|
||||||
@@ -117,7 +149,7 @@ class UpdateChecker {
|
|||||||
apkUrl = universalUrl ?? arm64Url ?? arm32Url;
|
apkUrl = universalUrl ?? arm64Url ?? arm32Url;
|
||||||
}
|
}
|
||||||
|
|
||||||
_log.i('Update available: $latestVersion, APK URL: $apkUrl');
|
_log.i('Update available: $latestVersion (prerelease: $isPrerelease), APK URL: $apkUrl');
|
||||||
|
|
||||||
return UpdateInfo(
|
return UpdateInfo(
|
||||||
version: latestVersion,
|
version: latestVersion,
|
||||||
@@ -125,6 +157,7 @@ class UpdateChecker {
|
|||||||
downloadUrl: htmlUrl,
|
downloadUrl: htmlUrl,
|
||||||
apkDownloadUrl: apkUrl,
|
apkDownloadUrl: apkUrl,
|
||||||
publishedAt: publishedAt,
|
publishedAt: publishedAt,
|
||||||
|
isPrerelease: isPrerelease,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.e('Error checking for updates: $e');
|
_log.e('Error checking for updates: $e');
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ class AppTheme {
|
|||||||
static ThemeData dark({
|
static ThemeData dark({
|
||||||
ColorScheme? dynamicScheme,
|
ColorScheme? dynamicScheme,
|
||||||
Color? seedColor,
|
Color? seedColor,
|
||||||
|
bool isAmoled = false,
|
||||||
}) {
|
}) {
|
||||||
final scheme = dynamicScheme ??
|
final scheme = dynamicScheme ??
|
||||||
ColorScheme.fromSeed(
|
ColorScheme.fromSeed(
|
||||||
@@ -53,7 +54,8 @@ class AppTheme {
|
|||||||
return ThemeData(
|
return ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
colorScheme: scheme,
|
colorScheme: scheme,
|
||||||
appBarTheme: _appBarTheme(scheme),
|
scaffoldBackgroundColor: isAmoled ? Colors.black : null,
|
||||||
|
appBarTheme: _appBarTheme(scheme, isAmoled: isAmoled),
|
||||||
cardTheme: _cardTheme(scheme),
|
cardTheme: _cardTheme(scheme),
|
||||||
elevatedButtonTheme: _elevatedButtonTheme(scheme),
|
elevatedButtonTheme: _elevatedButtonTheme(scheme),
|
||||||
filledButtonTheme: _filledButtonTheme(scheme),
|
filledButtonTheme: _filledButtonTheme(scheme),
|
||||||
@@ -63,7 +65,7 @@ class AppTheme {
|
|||||||
inputDecorationTheme: _inputDecorationTheme(scheme),
|
inputDecorationTheme: _inputDecorationTheme(scheme),
|
||||||
listTileTheme: _listTileTheme(scheme),
|
listTileTheme: _listTileTheme(scheme),
|
||||||
dialogTheme: _dialogTheme(scheme),
|
dialogTheme: _dialogTheme(scheme),
|
||||||
navigationBarTheme: _navigationBarTheme(scheme),
|
navigationBarTheme: _navigationBarTheme(scheme, isAmoled: isAmoled),
|
||||||
snackBarTheme: _snackBarTheme(scheme),
|
snackBarTheme: _snackBarTheme(scheme),
|
||||||
progressIndicatorTheme: _progressIndicatorTheme(scheme),
|
progressIndicatorTheme: _progressIndicatorTheme(scheme),
|
||||||
switchTheme: _switchTheme(scheme),
|
switchTheme: _switchTheme(scheme),
|
||||||
@@ -73,12 +75,12 @@ class AppTheme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// AppBar theme
|
/// AppBar theme
|
||||||
static AppBarTheme _appBarTheme(ColorScheme scheme) => AppBarTheme(
|
static AppBarTheme _appBarTheme(ColorScheme scheme, {bool isAmoled = false}) => AppBarTheme(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
scrolledUnderElevation: 3,
|
scrolledUnderElevation: isAmoled ? 0 : 3,
|
||||||
backgroundColor: scheme.surface,
|
backgroundColor: isAmoled ? Colors.black : scheme.surface,
|
||||||
foregroundColor: scheme.onSurface,
|
foregroundColor: scheme.onSurface,
|
||||||
surfaceTintColor: scheme.surfaceTint,
|
surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint,
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
titleTextStyle: TextStyle(
|
titleTextStyle: TextStyle(
|
||||||
color: scheme.onSurface,
|
color: scheme.onSurface,
|
||||||
@@ -180,12 +182,12 @@ class AppTheme {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/// Navigation bar theme
|
/// Navigation bar theme
|
||||||
static NavigationBarThemeData _navigationBarTheme(ColorScheme scheme) =>
|
static NavigationBarThemeData _navigationBarTheme(ColorScheme scheme, {bool isAmoled = false}) =>
|
||||||
NavigationBarThemeData(
|
NavigationBarThemeData(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
backgroundColor: scheme.surfaceContainer,
|
backgroundColor: isAmoled ? Colors.black : scheme.surfaceContainer,
|
||||||
indicatorColor: scheme.secondaryContainer,
|
indicatorColor: scheme.secondaryContainer,
|
||||||
surfaceTintColor: scheme.surfaceTint,
|
surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint,
|
||||||
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
|
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -40,12 +40,32 @@ class DynamicColorWrapper extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply AMOLED mode if enabled (pure black background)
|
||||||
|
if (themeSettings.useAmoled) {
|
||||||
|
darkScheme = _applyAmoledColors(darkScheme);
|
||||||
|
}
|
||||||
|
|
||||||
// Build themes
|
// Build themes
|
||||||
final lightTheme = AppTheme.light(dynamicScheme: lightScheme);
|
final lightTheme = AppTheme.light(dynamicScheme: lightScheme);
|
||||||
final darkTheme = AppTheme.dark(dynamicScheme: darkScheme);
|
final darkTheme = AppTheme.dark(dynamicScheme: darkScheme, isAmoled: themeSettings.useAmoled);
|
||||||
|
|
||||||
return builder(lightTheme, darkTheme, themeSettings.themeMode);
|
return builder(lightTheme, darkTheme, themeSettings.themeMode);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply AMOLED colors - pure black background with adjusted surface colors
|
||||||
|
ColorScheme _applyAmoledColors(ColorScheme scheme) {
|
||||||
|
return scheme.copyWith(
|
||||||
|
surface: Colors.black,
|
||||||
|
onSurface: Colors.white,
|
||||||
|
surfaceContainerLowest: Colors.black,
|
||||||
|
surfaceContainerLow: const Color(0xFF0A0A0A),
|
||||||
|
surfaceContainer: const Color(0xFF121212),
|
||||||
|
surfaceContainerHigh: const Color(0xFF1A1A1A),
|
||||||
|
surfaceContainerHighest: const Color(0xFF222222),
|
||||||
|
inverseSurface: Colors.white,
|
||||||
|
onInverseSurface: Colors.black,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -297,22 +297,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
version: "2.1.4"
|
||||||
ffmpeg_kit_flutter_new_audio:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: ffmpeg_kit_flutter_new_audio
|
|
||||||
sha256: "0a698b46cd163c8e9917af75325c84d27871a2a8b2c37de3b40486cd0ab662ae"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.0.0"
|
|
||||||
ffmpeg_kit_flutter_platform_interface:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: ffmpeg_kit_flutter_platform_interface
|
|
||||||
sha256: addf046ae44e190ad0101b2fde2ad909a3cd08a2a109f6106d2f7048b7abedee
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.2.1"
|
|
||||||
file:
|
file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 2.0.6+36
|
version: 2.1.5+43
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
@@ -50,8 +50,8 @@ dependencies:
|
|||||||
receive_sharing_intent: ^1.8.1
|
receive_sharing_intent: ^1.8.1
|
||||||
logger: ^2.5.0
|
logger: ^2.5.0
|
||||||
|
|
||||||
# FFmpeg for audio conversion (audio-only version - much smaller)
|
# FFmpeg - using local custom AAR (arm64-v8a + armeabi-v7a only)
|
||||||
ffmpeg_kit_flutter_new_audio: ^2.0.0
|
# ffmpeg_kit_flutter_new_audio: ^2.0.0 # Replaced with local AAR
|
||||||
open_filex: ^4.7.0
|
open_filex: ^4.7.0
|
||||||
|
|
||||||
# Notifications
|
# Notifications
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
name: spotiflac_android
|
||||||
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
|
publish_to: 'none'
|
||||||
|
version: 2.1.0-preview2+40
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: ^3.10.0
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
|
# State Management
|
||||||
|
flutter_riverpod: ^3.1.0
|
||||||
|
riverpod_annotation: ^4.0.0
|
||||||
|
|
||||||
|
# Navigation
|
||||||
|
go_router: ^17.0.1
|
||||||
|
|
||||||
|
# Storage & Persistence
|
||||||
|
shared_preferences: ^2.5.3
|
||||||
|
path_provider: ^2.1.5
|
||||||
|
|
||||||
|
# HTTP & Network
|
||||||
|
http: ^1.4.0
|
||||||
|
dio: ^5.8.0
|
||||||
|
|
||||||
|
# UI Components
|
||||||
|
cupertino_icons: ^1.0.8
|
||||||
|
cached_network_image: ^3.4.1
|
||||||
|
flutter_svg: ^2.1.0
|
||||||
|
|
||||||
|
# Material Expressive 3 / Dynamic Color
|
||||||
|
dynamic_color: ^1.7.0
|
||||||
|
material_color_utilities: ^0.11.1
|
||||||
|
|
||||||
|
# Permissions
|
||||||
|
permission_handler: ^12.0.1
|
||||||
|
|
||||||
|
# File Picker
|
||||||
|
file_picker: ^10.3.0
|
||||||
|
|
||||||
|
# JSON Serialization
|
||||||
|
json_annotation: ^4.9.0
|
||||||
|
|
||||||
|
# Utils
|
||||||
|
url_launcher: ^6.3.1
|
||||||
|
device_info_plus: ^12.3.0
|
||||||
|
share_plus: ^12.0.1
|
||||||
|
receive_sharing_intent: ^1.8.1
|
||||||
|
logger: ^2.5.0
|
||||||
|
|
||||||
|
# FFmpeg for iOS (uses plugin, Android uses custom AAR)
|
||||||
|
ffmpeg_kit_flutter_new_audio: ^2.0.0
|
||||||
|
open_filex: ^4.7.0
|
||||||
|
|
||||||
|
# Notifications
|
||||||
|
flutter_local_notifications: ^19.0.0
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
flutter_lints: ^6.0.0
|
||||||
|
build_runner: ^2.10.4
|
||||||
|
riverpod_generator: ^4.0.0
|
||||||
|
json_serializable: ^6.11.2
|
||||||
|
flutter_launcher_icons: ^0.14.3
|
||||||
|
|
||||||
|
flutter_launcher_icons:
|
||||||
|
android: true
|
||||||
|
ios: true
|
||||||
|
image_path: "icon.png"
|
||||||
|
adaptive_icon_background: "#1a1a2e"
|
||||||
|
adaptive_icon_foreground: "icon.png"
|
||||||
|
ios_content_mode: scaleAspectFill
|
||||||
|
remove_alpha_ios: true
|
||||||
|
|
||||||
|
flutter:
|
||||||
|
uses-material-design: true
|
||||||
|
|
||||||
|
assets:
|
||||||
|
- assets/images/
|
||||||
Reference in New Issue
Block a user