mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 11:18:04 +02:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e9c7bf830e | |||
| 8bc97d5bd3 | |||
| f2c241c323 | |||
| 9c512ffe28 | |||
| 53a1da6249 | |||
| d4274e8ca8 | |||
| 49a9f12841 | |||
| d7fa040e3c | |||
| 9baa1e2088 | |||
| 482457205a | |||
| 3b2ec319e2 | |||
| a0f7e75a9a | |||
| c725e53e4c | |||
| 1d7c43a302 | |||
| df7c1c5bb7 | |||
| bb05353b7e | |||
| 7ac92d77e5 | |||
| cf00ecb756 | |||
| 525f2fd0cd | |||
| 3e841cef06 | |||
| a8527df80a |
@@ -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
|
||||||
|
|||||||
+175
@@ -1,5 +1,180 @@
|
|||||||
# 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
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Duration Display Bug**: Fixed duration showing incorrect values like "4135:53" instead of "4:14"
|
||||||
|
- `duration_ms` (milliseconds) was being stored directly without conversion to seconds
|
||||||
|
- Now properly converts milliseconds to seconds before display
|
||||||
|
- **Audio Quality from File**: Quality info (bit depth/sample rate) now read from actual FLAC file instead of trusting API
|
||||||
|
- More accurate quality display for all services (Tidal, Qobuz, Amazon)
|
||||||
|
- Also reads quality from existing files when skipping duplicates
|
||||||
|
- **Artist Verification for Downloads**: Added artist name verification to prevent downloading wrong tracks
|
||||||
|
- Verifies artist matches between Spotify metadata and streaming service
|
||||||
|
- Handles different scripts (Japanese/Chinese vs Latin) as same artist with different transliteration
|
||||||
|
- Applied to Tidal, Qobuz, and Amazon downloads
|
||||||
|
- **Metadata Case-Sensitivity**: Fixed FLAC metadata not being properly overwritten when downloaded file has lowercase tags
|
||||||
|
- Now uses case-insensitive comparison when replacing existing Vorbis comments
|
||||||
|
- Fixes issue where Amazon downloads could have duplicate metadata tags
|
||||||
|
- **Settings Navigation Freeze**: Fixed app freezing when navigating back from settings sub-menus on some devices
|
||||||
|
- Added proper PopScope handling for predictive back gesture on Android 14+
|
||||||
|
|
||||||
## [2.0.5] - 2026-01-05
|
## [2.0.5] - 2026-01-05
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|
||||||
@@ -11,8 +12,6 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> **Active Development Notice**: This app is under heavy development. New builds may be pushed multiple times daily. If frequent update notifications are annoying, tap "Don't remind" when the update dialog appears, or disable update checks in Settings.
|
|
||||||
|
|
||||||
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
@@ -24,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});
|
||||||
|
}
|
||||||
+149
-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"`
|
||||||
@@ -36,12 +44,72 @@ type DoubleDoubleStatusResponse struct {
|
|||||||
} `json:"current"`
|
} `json:"current"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAmazonDownloader creates a new Amazon downloader using DoubleDouble service
|
// amazonArtistsMatch checks if the artist names are similar enough
|
||||||
func NewAmazonDownloader() *AmazonDownloader {
|
func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||||
return &AmazonDownloader{
|
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||||
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
|
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||||
regions: []string{"us", "eu"}, // Same regions as PC
|
|
||||||
|
// Exact match
|
||||||
|
if normExpected == normFound {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if one contains the other
|
||||||
|
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check first artist (before comma or feat)
|
||||||
|
expectedFirst := strings.Split(normExpected, ",")[0]
|
||||||
|
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
||||||
|
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
||||||
|
expectedFirst = strings.TrimSpace(expectedFirst)
|
||||||
|
|
||||||
|
foundFirst := strings.Split(normFound, ",")[0]
|
||||||
|
foundFirst = strings.Split(foundFirst, " feat")[0]
|
||||||
|
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
||||||
|
foundFirst = strings.TrimSpace(foundFirst)
|
||||||
|
|
||||||
|
if expectedFirst == foundFirst {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if first artist is contained in the other
|
||||||
|
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
||||||
|
// assume they're the same artist with different transliteration
|
||||||
|
expectedASCII := amazonIsASCIIString(expectedArtist)
|
||||||
|
foundASCII := amazonIsASCIIString(foundArtist)
|
||||||
|
if expectedASCII != foundASCII {
|
||||||
|
fmt.Printf("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// amazonIsASCIIString checks if a string contains only ASCII characters
|
||||||
|
func amazonIsASCIIString(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if r > 127 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAmazonDownloader creates a new Amazon downloader (returns singleton for connection reuse)
|
||||||
|
func NewAmazonDownloader() *AmazonDownloader {
|
||||||
|
amazonDownloaderOnce.Do(func() {
|
||||||
|
globalAmazonDownloader = &AmazonDownloader{
|
||||||
|
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
|
||||||
@@ -226,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,6 +387,15 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify artist matches
|
||||||
|
if artistName != "" && !amazonArtistsMatch(req.ArtistName, artistName) {
|
||||||
|
fmt.Printf("[Amazon] Artist mismatch: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, artistName)
|
||||||
|
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log match found
|
||||||
|
fmt.Printf("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
|
||||||
|
|
||||||
// Build filename using Spotify metadata (more accurate)
|
// Build filename using Spotify metadata (more accurate)
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
@@ -312,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 != "" {
|
||||||
@@ -342,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+303
-12
@@ -5,6 +5,7 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -216,17 +217,36 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
|
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||||
|
actualPath := result.FilePath[7:]
|
||||||
|
// Read actual quality from existing file
|
||||||
|
quality, qErr := GetAudioQuality(actualPath)
|
||||||
|
if qErr == nil {
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
}
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "File already exists",
|
Message: "File already exists",
|
||||||
FilePath: result.FilePath[7:],
|
FilePath: actualPath,
|
||||||
AlreadyExists: true,
|
AlreadyExists: true,
|
||||||
Service: req.Service,
|
ActualBitDepth: result.BitDepth,
|
||||||
|
ActualSampleRate: result.SampleRate,
|
||||||
|
Service: req.Service,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read actual quality from downloaded file (more accurate than API)
|
||||||
|
quality, qErr := GetAudioQuality(result.FilePath)
|
||||||
|
if qErr == nil {
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[Download] Could not read quality from file: %v\n", qErr)
|
||||||
|
}
|
||||||
|
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "Download complete",
|
Message: "Download complete",
|
||||||
@@ -256,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 {
|
||||||
@@ -270,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
|
||||||
@@ -314,17 +339,36 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||||
|
actualPath := result.FilePath[7:]
|
||||||
|
// Read actual quality from existing file
|
||||||
|
quality, qErr := GetAudioQuality(actualPath)
|
||||||
|
if qErr == nil {
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
}
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "File already exists",
|
Message: "File already exists",
|
||||||
FilePath: result.FilePath[7:],
|
FilePath: actualPath,
|
||||||
AlreadyExists: true,
|
AlreadyExists: true,
|
||||||
Service: service,
|
ActualBitDepth: result.BitDepth,
|
||||||
|
ActualSampleRate: result.SampleRate,
|
||||||
|
Service: service,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read actual quality from downloaded file (more accurate than API)
|
||||||
|
quality, qErr := GetAudioQuality(result.FilePath)
|
||||||
|
if qErr == nil {
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[Download] Could not read quality from file: %v\n", qErr)
|
||||||
|
}
|
||||||
|
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "Downloaded from " + service,
|
Message: "Downloaded from " + service,
|
||||||
@@ -477,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)
|
||||||
|
|||||||
+10
-3
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/go-flac/flacpicture"
|
"github.com/go-flac/flacpicture"
|
||||||
"github.com/go-flac/flacvorbis"
|
"github.com/go-flac/flacvorbis"
|
||||||
@@ -273,10 +274,16 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
|
|||||||
if value == "" {
|
if value == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Remove existing
|
// Remove existing (case-insensitive comparison for Vorbis comments)
|
||||||
|
keyUpper := strings.ToUpper(key)
|
||||||
for i := len(cmt.Comments) - 1; i >= 0; i-- {
|
for i := len(cmt.Comments) - 1; i >= 0; i-- {
|
||||||
if len(cmt.Comments[i]) > len(key)+1 && cmt.Comments[i][:len(key)+1] == key+"=" {
|
comment := cmt.Comments[i]
|
||||||
cmt.Comments = append(cmt.Comments[:i], cmt.Comments[i+1:]...)
|
eqIdx := strings.Index(comment, "=")
|
||||||
|
if eqIdx > 0 {
|
||||||
|
existingKey := strings.ToUpper(comment[:eqIdx])
|
||||||
|
if existingKey == keyUpper {
|
||||||
|
cmt.Comments = append(cmt.Comments[:i], cmt.Comments[i+1:]...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Add new
|
// Add new
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+174
-55
@@ -1,6 +1,7 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -9,6 +10,8 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// QobuzDownloader handles Qobuz downloads
|
// QobuzDownloader handles Qobuz downloads
|
||||||
@@ -18,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"`
|
||||||
@@ -39,12 +48,72 @@ type QobuzTrack struct {
|
|||||||
} `json:"performer"`
|
} `json:"performer"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewQobuzDownloader creates a new Qobuz downloader
|
// qobuzArtistsMatch checks if the artist names are similar enough
|
||||||
func NewQobuzDownloader() *QobuzDownloader {
|
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||||
return &QobuzDownloader{
|
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||||
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
|
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||||
appID: "798273057",
|
|
||||||
|
// Exact match
|
||||||
|
if normExpected == normFound {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if one contains the other
|
||||||
|
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check first artist (before comma or feat)
|
||||||
|
expectedFirst := strings.Split(normExpected, ",")[0]
|
||||||
|
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
||||||
|
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
||||||
|
expectedFirst = strings.TrimSpace(expectedFirst)
|
||||||
|
|
||||||
|
foundFirst := strings.Split(normFound, ",")[0]
|
||||||
|
foundFirst = strings.Split(foundFirst, " feat")[0]
|
||||||
|
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
||||||
|
foundFirst = strings.TrimSpace(foundFirst)
|
||||||
|
|
||||||
|
if expectedFirst == foundFirst {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if first artist is contained in the other
|
||||||
|
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
||||||
|
// assume they're the same artist with different transliteration
|
||||||
|
expectedASCII := qobuzIsASCIIString(expectedArtist)
|
||||||
|
foundASCII := qobuzIsASCIIString(foundArtist)
|
||||||
|
if expectedASCII != foundASCII {
|
||||||
|
fmt.Printf("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// qobuzIsASCIIString checks if a string contains only ASCII characters
|
||||||
|
func qobuzIsASCIIString(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if r > 127 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewQobuzDownloader creates a new Qobuz downloader (returns singleton for connection reuse)
|
||||||
|
func NewQobuzDownloader() *QobuzDownloader {
|
||||||
|
qobuzDownloaderOnce.Do(func() {
|
||||||
|
globalQobuzDownloader = &QobuzDownloader{
|
||||||
|
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
|
||||||
|
appID: "798273057",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return globalQobuzDownloader
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAvailableAPIs returns list of available Qobuz APIs
|
// GetAvailableAPIs returns list of available Qobuz APIs
|
||||||
@@ -404,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
|
||||||
@@ -448,36 +546,53 @@ 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
|
||||||
|
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||||
|
fmt.Printf("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
|
req.ArtistName, track.Performer.Name)
|
||||||
|
track = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: Search by metadata with duration verification
|
// Strategy 2: Search by metadata with duration verification
|
||||||
if track == nil {
|
if track == nil {
|
||||||
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
|
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
|
||||||
|
// Verify artist
|
||||||
|
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||||
|
fmt.Printf("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
|
req.ArtistName, track.Performer.Name)
|
||||||
|
track = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if track == nil {
|
if track == nil {
|
||||||
errMsg := "could not find track on Qobuz"
|
errMsg := "could not find matching track on Qobuz (artist/duration mismatch)"
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errMsg = err.Error()
|
errMsg = err.Error()
|
||||||
}
|
}
|
||||||
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
|
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final duration verification
|
// Log match found and cache the track ID
|
||||||
if expectedDurationSec > 0 {
|
fmt.Printf("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
|
||||||
durationDiff := track.Duration - expectedDurationSec
|
if req.ISRC != "" {
|
||||||
if durationDiff < 0 {
|
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
||||||
durationDiff = -durationDiff
|
|
||||||
}
|
|
||||||
if durationDiff > 30 {
|
|
||||||
return QobuzDownloadResult{}, fmt.Errorf("duration mismatch: expected %ds, found %ds (diff: %ds). Track may be wrong version",
|
|
||||||
expectedDurationSec, track.Duration, durationDiff)
|
|
||||||
}
|
|
||||||
fmt.Printf("[Qobuz] Duration verified: expected %ds, found %ds (diff: %ds)\n",
|
|
||||||
expectedDurationSec, track.Duration, durationDiff)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build filename
|
// Build filename
|
||||||
@@ -522,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 != "" {
|
||||||
@@ -534,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,
|
||||||
@@ -547,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
|
||||||
|
}
|
||||||
|
|||||||
+300
-93
@@ -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
|
||||||
@@ -869,6 +945,64 @@ type TidalDownloadResult struct {
|
|||||||
SampleRate int
|
SampleRate int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// artistsMatch checks if the artist names are similar enough
|
||||||
|
func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
||||||
|
normSpotify := strings.ToLower(strings.TrimSpace(spotifyArtist))
|
||||||
|
normTidal := strings.ToLower(strings.TrimSpace(tidalArtist))
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
if normSpotify == normTidal {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if one contains the other (for cases like "Artist" vs "Artist feat. Someone")
|
||||||
|
if strings.Contains(normSpotify, normTidal) || strings.Contains(normTidal, normSpotify) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check first artist (before comma or feat)
|
||||||
|
spotifyFirst := strings.Split(normSpotify, ",")[0]
|
||||||
|
spotifyFirst = strings.Split(spotifyFirst, " feat")[0]
|
||||||
|
spotifyFirst = strings.Split(spotifyFirst, " ft.")[0]
|
||||||
|
spotifyFirst = strings.TrimSpace(spotifyFirst)
|
||||||
|
|
||||||
|
tidalFirst := strings.Split(normTidal, ",")[0]
|
||||||
|
tidalFirst = strings.Split(tidalFirst, " feat")[0]
|
||||||
|
tidalFirst = strings.Split(tidalFirst, " ft.")[0]
|
||||||
|
tidalFirst = strings.TrimSpace(tidalFirst)
|
||||||
|
|
||||||
|
if spotifyFirst == tidalFirst {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if first artist is contained in the other
|
||||||
|
if strings.Contains(spotifyFirst, tidalFirst) || strings.Contains(tidalFirst, spotifyFirst) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
||||||
|
// assume they're the same artist with different transliteration
|
||||||
|
// This handles cases like "鈴木雅之" vs "Masayuki Suzuki"
|
||||||
|
spotifyASCII := isASCIIString(spotifyArtist)
|
||||||
|
tidalASCII := isASCIIString(tidalArtist)
|
||||||
|
if spotifyASCII != tidalASCII {
|
||||||
|
fmt.Printf("[Tidal] Artist names in different scripts, assuming match: '%s' vs '%s'\n", spotifyArtist, tidalArtist)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isASCIIString checks if a string contains only ASCII characters
|
||||||
|
func isASCIIString(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if r > 127 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// downloadFromTidal downloads a track using the request parameters
|
// downloadFromTidal downloads a track using the request parameters
|
||||||
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||||
downloader := NewTidalDownloader()
|
downloader := NewTidalDownloader()
|
||||||
@@ -884,61 +1018,130 @@ 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
|
||||||
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
|
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
|
||||||
if idErr == nil {
|
if idErr == nil {
|
||||||
track, err = downloader.GetTrackInfoByID(trackID)
|
track, err = downloader.GetTrackInfoByID(trackID)
|
||||||
// Verify duration if we have expected duration
|
if track != nil {
|
||||||
if track != nil && expectedDurationSec > 0 {
|
// Get artist name from track
|
||||||
durationDiff := track.Duration - expectedDurationSec
|
tidalArtist := track.Artist.Name
|
||||||
if durationDiff < 0 {
|
if len(track.Artists) > 0 {
|
||||||
durationDiff = -durationDiff
|
var artistNames []string
|
||||||
|
for _, a := range track.Artists {
|
||||||
|
artistNames = append(artistNames, a.Name)
|
||||||
|
}
|
||||||
|
tidalArtist = strings.Join(artistNames, ", ")
|
||||||
}
|
}
|
||||||
// Allow 30 seconds tolerance
|
|
||||||
if durationDiff > 30 {
|
// Verify artist matches
|
||||||
fmt.Printf("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
|
if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||||
expectedDurationSec, track.Duration)
|
fmt.Printf("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
|
||||||
track = nil // Reject this match
|
req.ArtistName, tidalArtist)
|
||||||
|
track = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify duration if we have expected duration
|
||||||
|
if track != nil && expectedDurationSec > 0 {
|
||||||
|
durationDiff := track.Duration - expectedDurationSec
|
||||||
|
if durationDiff < 0 {
|
||||||
|
durationDiff = -durationDiff
|
||||||
|
}
|
||||||
|
// Allow 30 seconds tolerance
|
||||||
|
if durationDiff > 30 {
|
||||||
|
fmt.Printf("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
|
||||||
|
expectedDurationSec, track.Duration)
|
||||||
|
track = nil // Reject this match
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
||||||
|
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 metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
|
req.ArtistName, tidalArtist)
|
||||||
|
track = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if track == nil {
|
if track == nil {
|
||||||
errMsg := "could not find track on Tidal"
|
errMsg := "could not find matching track on Tidal (artist/duration mismatch)"
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errMsg = err.Error()
|
errMsg = err.Error()
|
||||||
}
|
}
|
||||||
return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg)
|
return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final duration verification
|
// Final verification logging
|
||||||
if expectedDurationSec > 0 {
|
tidalArtist := track.Artist.Name
|
||||||
durationDiff := track.Duration - expectedDurationSec
|
if len(track.Artists) > 0 {
|
||||||
if durationDiff < 0 {
|
var artistNames []string
|
||||||
durationDiff = -durationDiff
|
for _, a := range track.Artists {
|
||||||
|
artistNames = append(artistNames, a.Name)
|
||||||
}
|
}
|
||||||
if durationDiff > 30 {
|
tidalArtist = strings.Join(artistNames, ", ")
|
||||||
return TidalDownloadResult{}, fmt.Errorf("duration mismatch: expected %ds, found %ds (diff: %ds). Track may be wrong version",
|
}
|
||||||
expectedDurationSec, track.Duration, durationDiff)
|
fmt.Printf("[Tidal] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, tidalArtist, track.Duration)
|
||||||
}
|
|
||||||
fmt.Printf("[Tidal] Duration verified: expected %ds, found %ds (diff: %ds)\n",
|
// Cache the track ID for future use
|
||||||
expectedDurationSec, track.Duration, durationDiff)
|
if req.ISRC != "" {
|
||||||
|
GetTrackIDCache().SetTidal(req.ISRC, track.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build filename
|
// Build filename
|
||||||
@@ -974,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 != "" {
|
||||||
@@ -999,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,
|
||||||
@@ -1012,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)
|
||||||
@@ -1031,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.5';
|
static const String version = '2.1.5';
|
||||||
static const String buildNumber = '35';
|
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>? ?? [];
|
||||||
@@ -266,7 +302,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
albumArtist: data['album_artist'] as String?,
|
albumArtist: data['album_artist'] as String?,
|
||||||
coverUrl: data['images'] as String?,
|
coverUrl: data['images'] as String?,
|
||||||
isrc: data['isrc'] as String?,
|
isrc: data['isrc'] as String?,
|
||||||
duration: data['duration_ms'] as int? ?? 0,
|
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
|
||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
releaseDate: data['release_date'] as String?,
|
releaseDate: data['release_date'] as String?,
|
||||||
@@ -282,7 +318,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
albumArtist: data['album_artist'] as String?,
|
albumArtist: data['album_artist'] as String?,
|
||||||
coverUrl: data['images'] as String?,
|
coverUrl: data['images'] as String?,
|
||||||
isrc: data['isrc'] as String?,
|
isrc: data['isrc'] as String?,
|
||||||
duration: data['duration_ms'] as int? ?? 0,
|
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
|
||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
releaseDate: data['release_date'] as String?,
|
releaseDate: data['release_date'] as String?,
|
||||||
@@ -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>(
|
||||||
|
|||||||
+111
-38
@@ -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();
|
||||||
|
|
||||||
@@ -104,7 +118,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
albumArtist: data['album_artist'] as String?,
|
albumArtist: data['album_artist'] as String?,
|
||||||
coverUrl: data['images'] as String?,
|
coverUrl: data['images'] as String?,
|
||||||
isrc: data['isrc'] as String?,
|
isrc: data['isrc'] as String?,
|
||||||
duration: data['duration_ms'] as int? ?? 0,
|
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
|
||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
releaseDate: data['release_date'] as String?,
|
releaseDate: data['release_date'] as String?,
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,53 +12,46 @@ class AboutPage extends StatelessWidget {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final topPadding = MediaQuery.of(context).padding.top;
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
return Scaffold(
|
return PopScope(
|
||||||
body: CustomScrollView(
|
canPop: true,
|
||||||
slivers: [
|
child: Scaffold(
|
||||||
// Collapsing App Bar with back button
|
body: CustomScrollView(
|
||||||
SliverAppBar(
|
slivers: [
|
||||||
expandedHeight: 120 + topPadding,
|
// Collapsing App Bar with back button
|
||||||
collapsedHeight: kToolbarHeight,
|
SliverAppBar(
|
||||||
floating: false,
|
expandedHeight: 120 + topPadding,
|
||||||
pinned: true,
|
collapsedHeight: kToolbarHeight,
|
||||||
backgroundColor: colorScheme.surface,
|
floating: false,
|
||||||
surfaceTintColor: Colors.transparent,
|
pinned: true,
|
||||||
leading: IconButton(
|
backgroundColor: colorScheme.surface,
|
||||||
icon: const Icon(Icons.arrow_back),
|
surfaceTintColor: Colors.transparent,
|
||||||
onPressed: () => Navigator.pop(context),
|
leading: IconButton(
|
||||||
),
|
icon: const Icon(Icons.arrow_back),
|
||||||
flexibleSpace: LayoutBuilder(
|
onPressed: () => Navigator.pop(context),
|
||||||
builder: (context, constraints) {
|
),
|
||||||
final maxHeight = 120 + topPadding;
|
flexibleSpace: LayoutBuilder(
|
||||||
final minHeight = kToolbarHeight + topPadding;
|
builder: (context, constraints) {
|
||||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
final maxHeight = 120 + topPadding;
|
||||||
final animation = AlwaysStoppedAnimation(expandRatio);
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
return FlexibleSpaceBar(
|
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||||
expandedTitleScale: 1.0,
|
// When collapsed (expandRatio=0): left=56 to avoid back button
|
||||||
titlePadding: EdgeInsets.zero,
|
// When expanded (expandRatio=1): left=24 for normal padding
|
||||||
title: SafeArea(
|
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||||
child: Container(
|
return FlexibleSpaceBar(
|
||||||
alignment: Alignment.bottomLeft,
|
expandedTitleScale: 1.0,
|
||||||
padding: EdgeInsets.only(
|
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||||
// When collapsed (expandRatio=0): left=56 to align with back button
|
title: Text(
|
||||||
// When expanded (expandRatio=1): left=24 for normal padding
|
'About',
|
||||||
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
|
style: TextStyle(
|
||||||
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation),
|
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||||
),
|
fontWeight: FontWeight.bold,
|
||||||
child: Text(
|
color: colorScheme.onSurface,
|
||||||
'About',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// App header card with logo and description
|
// App header card with logo and description
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
@@ -130,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'),
|
||||||
@@ -166,6 +177,7 @@ class AboutPage extends StatelessWidget {
|
|||||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,104 +14,121 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final topPadding = MediaQuery.of(context).padding.top;
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
return Scaffold(
|
return PopScope(
|
||||||
body: CustomScrollView(
|
canPop: true,
|
||||||
slivers: [
|
child: Scaffold(
|
||||||
// Collapsing App Bar with back button
|
body: CustomScrollView(
|
||||||
SliverAppBar(
|
slivers: [
|
||||||
expandedHeight: 120 + topPadding,
|
// Collapsing App Bar with back button
|
||||||
collapsedHeight: kToolbarHeight,
|
SliverAppBar(
|
||||||
floating: false,
|
expandedHeight: 120 + topPadding,
|
||||||
pinned: true,
|
collapsedHeight: kToolbarHeight,
|
||||||
backgroundColor: colorScheme.surface,
|
floating: false,
|
||||||
surfaceTintColor: Colors.transparent,
|
pinned: true,
|
||||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
backgroundColor: colorScheme.surface,
|
||||||
flexibleSpace: LayoutBuilder(
|
surfaceTintColor: Colors.transparent,
|
||||||
builder: (context, constraints) {
|
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
||||||
final maxHeight = 120 + topPadding;
|
flexibleSpace: _AppBarTitle(title: 'Appearance', topPadding: topPadding),
|
||||||
final minHeight = kToolbarHeight + topPadding;
|
),
|
||||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
|
||||||
final animation = AlwaysStoppedAnimation(expandRatio);
|
// Theme section
|
||||||
return FlexibleSpaceBar(
|
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Theme')),
|
||||||
expandedTitleScale: 1.0,
|
SliverToBoxAdapter(
|
||||||
titlePadding: EdgeInsets.zero,
|
child: SettingsGroup(
|
||||||
title: SafeArea(
|
children: [
|
||||||
child: Container(
|
_ThemeModeSelector(
|
||||||
alignment: Alignment.bottomLeft,
|
currentMode: themeSettings.themeMode,
|
||||||
padding: EdgeInsets.only(
|
onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
|
||||||
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
|
),
|
||||||
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation),
|
SettingsSwitchItem(
|
||||||
),
|
icon: Icons.brightness_2,
|
||||||
child: Text('Appearance',
|
title: 'AMOLED Dark',
|
||||||
style: TextStyle(
|
subtitle: 'Pure black background for OLED screens',
|
||||||
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
|
value: themeSettings.useAmoled,
|
||||||
fontWeight: FontWeight.bold,
|
onChanged: (value) => ref.read(themeProvider.notifier).setUseAmoled(value),
|
||||||
color: colorScheme.onSurface,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Color section
|
||||||
|
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Color')),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon: Icons.auto_awesome,
|
||||||
|
title: 'Dynamic Color',
|
||||||
|
subtitle: 'Use colors from your wallpaper',
|
||||||
|
value: themeSettings.useDynamicColor,
|
||||||
|
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
|
||||||
|
showDivider: !themeSettings.useDynamicColor,
|
||||||
|
),
|
||||||
|
if (!themeSettings.useDynamicColor)
|
||||||
|
_ColorPicker(
|
||||||
|
currentColor: themeSettings.seedColorValue,
|
||||||
|
onColorSelected: (color) => ref.read(themeProvider.notifier).setSeedColor(color),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Layout section
|
||||||
|
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Layout')),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
_HistoryViewSelector(
|
||||||
|
currentMode: settings.historyViewMode,
|
||||||
|
onChanged: (mode) => ref.read(settingsProvider.notifier).setHistoryViewMode(mode),
|
||||||
),
|
),
|
||||||
);
|
],
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// Theme section
|
// Fill remaining for scroll
|
||||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Theme')),
|
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||||
SliverToBoxAdapter(
|
],
|
||||||
child: SettingsGroup(
|
),
|
||||||
children: [
|
|
||||||
_ThemeModeSelector(
|
|
||||||
currentMode: themeSettings.themeMode,
|
|
||||||
onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Color section
|
|
||||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Color')),
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: SettingsGroup(
|
|
||||||
children: [
|
|
||||||
SettingsSwitchItem(
|
|
||||||
icon: Icons.auto_awesome,
|
|
||||||
title: 'Dynamic Color',
|
|
||||||
subtitle: 'Use colors from your wallpaper',
|
|
||||||
value: themeSettings.useDynamicColor,
|
|
||||||
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
|
|
||||||
showDivider: !themeSettings.useDynamicColor,
|
|
||||||
),
|
|
||||||
if (!themeSettings.useDynamicColor)
|
|
||||||
_ColorPicker(
|
|
||||||
currentColor: themeSettings.seedColorValue,
|
|
||||||
onColorSelected: (color) => ref.read(themeProvider.notifier).setSeedColor(color),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Layout section
|
|
||||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Layout')),
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: SettingsGroup(
|
|
||||||
children: [
|
|
||||||
_HistoryViewSelector(
|
|
||||||
currentMode: settings.historyViewMode,
|
|
||||||
onChanged: (mode) => ref.read(settingsProvider.notifier).setHistoryViewMode(mode),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Fill remaining for scroll
|
|
||||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Optimized app bar title with animation
|
||||||
|
class _AppBarTitle extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final double topPadding;
|
||||||
|
|
||||||
|
const _AppBarTitle({required this.title, required this.topPadding});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final maxHeight = 120 + topPadding;
|
||||||
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
|
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||||
|
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||||
|
return FlexibleSpaceBar(
|
||||||
|
expandedTitleScale: 1.0,
|
||||||
|
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||||
|
title: Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _ThemeModeSelector extends StatelessWidget {
|
class _ThemeModeSelector extends StatelessWidget {
|
||||||
final ThemeMode currentMode;
|
final ThemeMode currentMode;
|
||||||
final ValueChanged<ThemeMode> onChanged;
|
final ValueChanged<ThemeMode> onChanged;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -13,47 +15,41 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final topPadding = MediaQuery.of(context).padding.top;
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
return Scaffold(
|
return PopScope(
|
||||||
body: CustomScrollView(
|
canPop: true,
|
||||||
slivers: [
|
child: Scaffold(
|
||||||
// Collapsing App Bar with back button
|
body: CustomScrollView(
|
||||||
SliverAppBar(
|
slivers: [
|
||||||
expandedHeight: 120 + topPadding,
|
// Collapsing App Bar with back button
|
||||||
collapsedHeight: kToolbarHeight,
|
SliverAppBar(
|
||||||
floating: false,
|
expandedHeight: 120 + topPadding,
|
||||||
pinned: true,
|
collapsedHeight: kToolbarHeight,
|
||||||
backgroundColor: colorScheme.surface,
|
floating: false,
|
||||||
surfaceTintColor: Colors.transparent,
|
pinned: true,
|
||||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
backgroundColor: colorScheme.surface,
|
||||||
flexibleSpace: LayoutBuilder(
|
surfaceTintColor: Colors.transparent,
|
||||||
builder: (context, constraints) {
|
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
||||||
final maxHeight = 120 + topPadding;
|
flexibleSpace: LayoutBuilder(
|
||||||
final minHeight = kToolbarHeight + topPadding;
|
builder: (context, constraints) {
|
||||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
final maxHeight = 120 + topPadding;
|
||||||
final animation = AlwaysStoppedAnimation(expandRatio);
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
return FlexibleSpaceBar(
|
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||||
expandedTitleScale: 1.0,
|
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||||
titlePadding: EdgeInsets.zero,
|
return FlexibleSpaceBar(
|
||||||
title: SafeArea(
|
expandedTitleScale: 1.0,
|
||||||
child: Container(
|
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||||
alignment: Alignment.bottomLeft,
|
title: Text(
|
||||||
padding: EdgeInsets.only(
|
'Download',
|
||||||
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
|
style: TextStyle(
|
||||||
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation),
|
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||||
),
|
fontWeight: FontWeight.bold,
|
||||||
child: Text('Download',
|
color: colorScheme.onSurface,
|
||||||
style: TextStyle(
|
|
||||||
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// Service section
|
// Service section
|
||||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Service')),
|
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Service')),
|
||||||
@@ -119,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,
|
||||||
@@ -136,6 +134,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,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) {
|
||||||
|
|||||||
@@ -14,47 +14,41 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final topPadding = MediaQuery.of(context).padding.top;
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
return Scaffold(
|
return PopScope(
|
||||||
body: CustomScrollView(
|
canPop: true,
|
||||||
slivers: [
|
child: Scaffold(
|
||||||
// Collapsing App Bar with back button
|
body: CustomScrollView(
|
||||||
SliverAppBar(
|
slivers: [
|
||||||
expandedHeight: 120 + topPadding,
|
// Collapsing App Bar with back button
|
||||||
collapsedHeight: kToolbarHeight,
|
SliverAppBar(
|
||||||
floating: false,
|
expandedHeight: 120 + topPadding,
|
||||||
pinned: true,
|
collapsedHeight: kToolbarHeight,
|
||||||
backgroundColor: colorScheme.surface,
|
floating: false,
|
||||||
surfaceTintColor: Colors.transparent,
|
pinned: true,
|
||||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
backgroundColor: colorScheme.surface,
|
||||||
flexibleSpace: LayoutBuilder(
|
surfaceTintColor: Colors.transparent,
|
||||||
builder: (context, constraints) {
|
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
||||||
final maxHeight = 120 + topPadding;
|
flexibleSpace: LayoutBuilder(
|
||||||
final minHeight = kToolbarHeight + topPadding;
|
builder: (context, constraints) {
|
||||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
final maxHeight = 120 + topPadding;
|
||||||
final animation = AlwaysStoppedAnimation(expandRatio);
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
return FlexibleSpaceBar(
|
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||||
expandedTitleScale: 1.0,
|
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||||
titlePadding: EdgeInsets.zero,
|
return FlexibleSpaceBar(
|
||||||
title: SafeArea(
|
expandedTitleScale: 1.0,
|
||||||
child: Container(
|
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||||
alignment: Alignment.bottomLeft,
|
title: Text(
|
||||||
padding: EdgeInsets.only(
|
'Options',
|
||||||
left: Tween<double>(begin: 56, end: 24).evaluate(animation),
|
style: TextStyle(
|
||||||
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation),
|
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||||
),
|
fontWeight: FontWeight.bold,
|
||||||
child: Text('Options',
|
color: colorScheme.onSurface,
|
||||||
style: TextStyle(
|
|
||||||
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// Download options section
|
// Download options section
|
||||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Download')),
|
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Download')),
|
||||||
@@ -111,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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -122,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,
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -168,6 +171,7 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,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'},
|
|
||||||
).timeout(const Duration(seconds: 10));
|
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (channel == 'preview') {
|
||||||
_log.w('GitHub API returned ${response.statusCode}');
|
// For preview channel, get all releases and find the latest (including prereleases)
|
||||||
return null;
|
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) {
|
||||||
|
_log.w('GitHub API returned ${response.statusCode}');
|
||||||
|
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.5+35
|
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