mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 03:15:51 +02:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7fa040e3c | |||
| 9baa1e2088 | |||
| 482457205a | |||
| 3b2ec319e2 | |||
| a0f7e75a9a | |||
| c725e53e4c | |||
| 1d7c43a302 | |||
| df7c1c5bb7 | |||
| bb05353b7e | |||
| 7ac92d77e5 | |||
| cf00ecb756 |
@@ -85,7 +85,19 @@ jobs:
|
||||
restore-keys: gradle-${{ runner.os }}-
|
||||
|
||||
- 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
|
||||
run: |
|
||||
@@ -229,6 +241,23 @@ jobs:
|
||||
channel: 'stable'
|
||||
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
|
||||
run: flutter pub get
|
||||
|
||||
|
||||
+2
-4
@@ -13,9 +13,6 @@ Thumbs.db
|
||||
# Reference folder (development only)
|
||||
referensi/
|
||||
|
||||
# Development notes
|
||||
COMPARISON_PC_vs_ANDROID.md
|
||||
|
||||
# Old spotiflac_android folder (moved to root)
|
||||
spotiflac_android/
|
||||
|
||||
@@ -38,7 +35,7 @@ go_backend/*.xcframework/
|
||||
|
||||
# Android
|
||||
android/.gradle/
|
||||
android/app/libs/
|
||||
android/app/libs/gobackend.aar
|
||||
android/local.properties
|
||||
android/*.iml
|
||||
android/key.properties
|
||||
@@ -52,3 +49,4 @@ ios/Pods/
|
||||
ios/.symlinks/
|
||||
ios/Flutter/Flutter.framework/
|
||||
ios/Flutter/Flutter.podspec
|
||||
android/app/libs/gobackend-sources.jar
|
||||
|
||||
+138
@@ -1,5 +1,143 @@
|
||||
# Changelog
|
||||
|
||||
## [2.1.5-preview] - 2026-01-07
|
||||
|
||||
### 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)
|
||||
- **Debug Logging for Search Source**: Console logs now show which metadata source is being used
|
||||
- `[Search] Using metadata source: deezer/spotify for query: "..."`
|
||||
- `[FetchURL] Fetching track with Deezer fallback enabled...`
|
||||
|
||||
### Technical
|
||||
- New settings field: `metadataSource` in `lib/models/settings.dart`
|
||||
- New UI: Search Source selector in Options Settings page
|
||||
|
||||
## [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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://www.virustotal.com/gui/file/9092dd9300289ceadd8e70cd71706a3ba32225d9cb2ae8b12648611d31814708)
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -22,6 +23,34 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
||||
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||
</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 Deezer (Recommended)
|
||||
Works out of the box. No setup required.
|
||||
|
||||
### 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
|
||||
|
||||
> **Note**: Spotify URLs (track, album) are always supported regardless of your search source setting. The app will automatically fall back to Deezer if Spotify API is rate limited.
|
||||
|
||||
## Support
|
||||
|
||||
If you find this app useful, consider supporting the development:
|
||||
|
||||
[](https://ko-fi.com/zarzet)
|
||||
|
||||
## Other project
|
||||
|
||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||
|
||||
@@ -95,6 +95,7 @@ repositories {
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
implementation(files("libs/gobackend.aar"))
|
||||
implementation(files("libs/ffmpeg-kit-with-lame.aar"))
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
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.plugin.common.MethodChannel
|
||||
import gobackend.Gobackend
|
||||
import com.arthenica.ffmpegkit.FFmpegKit
|
||||
import com.arthenica.ffmpegkit.ReturnCode
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -13,6 +15,7 @@ import kotlinx.coroutines.withContext
|
||||
|
||||
class MainActivity: FlutterActivity() {
|
||||
private val CHANNEL = "com.zarz.spotiflac/backend"
|
||||
private val FFMPEG_CHANNEL = "com.zarz.spotiflac/ffmpeg"
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
@@ -208,6 +211,72 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
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()
|
||||
}
|
||||
} 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});
|
||||
}
|
||||
+54
-35
@@ -1,6 +1,7 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -19,6 +21,12 @@ type AmazonDownloader struct {
|
||||
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
|
||||
type DoubleDoubleSubmitResponse struct {
|
||||
Success bool `json:"success"`
|
||||
@@ -93,12 +101,15 @@ func amazonIsASCIIString(s string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// NewAmazonDownloader creates a new Amazon downloader using DoubleDouble service
|
||||
// NewAmazonDownloader creates a new Amazon downloader (returns singleton for connection reuse)
|
||||
func NewAmazonDownloader() *AmazonDownloader {
|
||||
return &AmazonDownloader{
|
||||
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
|
||||
regions: []string{"us", "eu"}, // Same regions as PC
|
||||
}
|
||||
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
|
||||
@@ -294,14 +305,18 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Use item progress writer
|
||||
// Use buffered writer for better performance (256KB buffer)
|
||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||
defer bufWriter.Flush()
|
||||
|
||||
// Use item progress writer with buffered output
|
||||
var bytesWritten int64
|
||||
if itemID != "" {
|
||||
pw := NewItemProgressWriter(out, itemID)
|
||||
pw := NewItemProgressWriter(bufWriter, itemID)
|
||||
bytesWritten, err = io.Copy(pw, resp.Body)
|
||||
} else {
|
||||
// Fallback: direct copy without progress tracking
|
||||
bytesWritten, err = io.Copy(out, resp.Body)
|
||||
bytesWritten, err = io.Copy(bufWriter, resp.Body)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
@@ -378,11 +393,29 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
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 {
|
||||
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)
|
||||
// This makes the UI show "Finalizing..." while embedding happens
|
||||
if req.ItemID != "" {
|
||||
@@ -408,41 +441,27 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
ISRC: req.ISRC,
|
||||
}
|
||||
|
||||
// Download cover to memory (avoids file permission issues on Android)
|
||||
// Use cover data from parallel fetch
|
||||
var coverData []byte
|
||||
if req.CoverURL != "" {
|
||||
fmt.Println("[Amazon] Downloading cover to memory...")
|
||||
data, err := downloadCoverToMemory(req.CoverURL, req.EmbedMaxQualityCover)
|
||||
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 parallelResult != nil && parallelResult.CoverData != nil {
|
||||
coverData = parallelResult.CoverData
|
||||
fmt.Printf("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||
}
|
||||
|
||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
|
||||
// Embed lyrics if enabled
|
||||
if req.EmbedLyrics {
|
||||
fmt.Println("[Amazon] Fetching lyrics...")
|
||||
lyricsClient := NewLyricsClient()
|
||||
lyrics, lyricsErr := lyricsClient.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName)
|
||||
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")
|
||||
// Embed lyrics from parallel fetch
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
fmt.Printf("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
fmt.Printf("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
} else {
|
||||
fmt.Printf("[Amazon] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
|
||||
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")
|
||||
}
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
deezerSearchURL = "https://api.deezer.com/search"
|
||||
deezerTrackURL = "https://api.deezer.com/track/%s"
|
||||
deezerAlbumURL = "https://api.deezer.com/album/%s"
|
||||
deezerArtistURL = "https://api.deezer.com/artist/%s"
|
||||
deezerPlaylistURL = "https://api.deezer.com/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 {
|
||||
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
|
||||
func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMetadata, error) {
|
||||
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)
|
||||
}
|
||||
|
||||
track := c.convertTrack(resp.Data[0])
|
||||
return &track, 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)
|
||||
}
|
||||
}
|
||||
@@ -516,6 +516,253 @@ func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
|
||||
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) {
|
||||
resp := DownloadResponse{
|
||||
Success: false,
|
||||
|
||||
@@ -43,6 +43,7 @@ const (
|
||||
)
|
||||
|
||||
// Shared transport with connection pooling to prevent TCP exhaustion
|
||||
// Optimized for large file downloads (FLAC ~30-50MB)
|
||||
var sharedTransport = &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
@@ -56,6 +57,9 @@ var sharedTransport = &http.Transport{
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
DisableKeepAlives: false, // Enable keep-alives for connection reuse
|
||||
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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
+13
-2
@@ -195,28 +195,39 @@ func getDownloadDir() string {
|
||||
}
|
||||
|
||||
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
||||
// Uses buffered writing for better performance
|
||||
type ItemProgressWriter struct {
|
||||
writer interface{ Write([]byte) (int, error) }
|
||||
itemID string
|
||||
current int64
|
||||
buffer []byte
|
||||
bufPos int
|
||||
}
|
||||
|
||||
const progressWriterBufferSize = 256 * 1024 // 256KB buffer for faster writes
|
||||
|
||||
// NewItemProgressWriter creates a new progress writer for a specific item
|
||||
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
|
||||
return &ItemProgressWriter{
|
||||
writer: w,
|
||||
itemID: itemID,
|
||||
current: 0,
|
||||
buffer: make([]byte, progressWriterBufferSize),
|
||||
bufPos: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Write implements io.Writer
|
||||
// Write implements io.Writer with buffering
|
||||
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
||||
n, err := pw.writer.Write(p)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
pw.current += int64(n)
|
||||
SetItemBytesReceived(pw.itemID, pw.current)
|
||||
|
||||
// Update progress less frequently (every 64KB) to reduce lock contention
|
||||
if pw.current%(64*1024) == 0 || pw.current == 0 {
|
||||
SetItemBytesReceived(pw.itemID, pw.current)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
+73
-38
@@ -1,6 +1,7 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// QobuzDownloader handles Qobuz downloads
|
||||
@@ -19,6 +21,12 @@ type QobuzDownloader struct {
|
||||
apiURL string
|
||||
}
|
||||
|
||||
var (
|
||||
// Global Qobuz downloader instance for connection reuse
|
||||
globalQobuzDownloader *QobuzDownloader
|
||||
qobuzDownloaderOnce sync.Once
|
||||
)
|
||||
|
||||
// QobuzTrack represents a Qobuz track
|
||||
type QobuzTrack struct {
|
||||
ID int64 `json:"id"`
|
||||
@@ -97,12 +105,15 @@ func qobuzIsASCIIString(s string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// NewQobuzDownloader creates a new Qobuz downloader
|
||||
// NewQobuzDownloader creates a new Qobuz downloader (returns singleton for connection reuse)
|
||||
func NewQobuzDownloader() *QobuzDownloader {
|
||||
return &QobuzDownloader{
|
||||
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
|
||||
appID: "798273057",
|
||||
}
|
||||
qobuzDownloaderOnce.Do(func() {
|
||||
globalQobuzDownloader = &QobuzDownloader{
|
||||
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
|
||||
appID: "798273057",
|
||||
}
|
||||
})
|
||||
return globalQobuzDownloader
|
||||
}
|
||||
|
||||
// GetAvailableAPIs returns list of available Qobuz APIs
|
||||
@@ -473,13 +484,17 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Use item progress writer
|
||||
// Use buffered writer for better performance (256KB buffer)
|
||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||
defer bufWriter.Flush()
|
||||
|
||||
// Use item progress writer with buffered output
|
||||
if itemID != "" {
|
||||
progressWriter := NewItemProgressWriter(out, itemID)
|
||||
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
||||
_, err = io.Copy(progressWriter, resp.Body)
|
||||
} else {
|
||||
// Fallback: direct copy without progress tracking
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
_, err = io.Copy(bufWriter, resp.Body)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -506,8 +521,21 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
var track *QobuzTrack
|
||||
var err error
|
||||
|
||||
// Strategy 1: Search by ISRC with duration verification
|
||||
// OPTIMIZATION: Check cache first for track ID
|
||||
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)
|
||||
// Verify artist
|
||||
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||
@@ -536,8 +564,11 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
|
||||
}
|
||||
|
||||
// Log match found
|
||||
// Log match found and cache the track ID
|
||||
fmt.Printf("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
|
||||
if req.ISRC != "" {
|
||||
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
||||
}
|
||||
|
||||
// Build filename
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||
@@ -581,11 +612,29 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
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 {
|
||||
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)
|
||||
// This makes the UI show "Finalizing..." while embedding happens
|
||||
if req.ItemID != "" {
|
||||
@@ -593,7 +642,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
SetItemFinalizing(req.ItemID)
|
||||
}
|
||||
|
||||
// Embed metadata
|
||||
// Embed metadata using parallel-fetched cover data
|
||||
metadata := Metadata{
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
@@ -606,41 +655,27 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
ISRC: req.ISRC,
|
||||
}
|
||||
|
||||
// Download cover to memory (avoids file permission issues on Android)
|
||||
// Use cover data from parallel fetch
|
||||
var coverData []byte
|
||||
if req.CoverURL != "" {
|
||||
fmt.Println("[Qobuz] Downloading cover to memory...")
|
||||
data, err := downloadCoverToMemory(req.CoverURL, req.EmbedMaxQualityCover)
|
||||
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 parallelResult != nil && parallelResult.CoverData != nil {
|
||||
coverData = parallelResult.CoverData
|
||||
fmt.Printf("[Qobuz] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||
}
|
||||
|
||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
|
||||
// Embed lyrics if enabled
|
||||
if req.EmbedLyrics {
|
||||
fmt.Println("[Qobuz] Fetching lyrics...")
|
||||
lyricsClient := NewLyricsClient()
|
||||
lyrics, lyricsErr := lyricsClient.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName)
|
||||
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")
|
||||
// Embed lyrics from parallel fetch
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
fmt.Printf("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
fmt.Printf("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
} else {
|
||||
fmt.Printf("[Qobuz] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
|
||||
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")
|
||||
}
|
||||
fmt.Println("[Qobuz] Lyrics embedded successfully")
|
||||
}
|
||||
} else if req.EmbedLyrics {
|
||||
fmt.Println("[Qobuz] No lyrics available from parallel fetch")
|
||||
}
|
||||
|
||||
return QobuzDownloadResult{
|
||||
|
||||
+135
-4
@@ -6,6 +6,8 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -20,16 +22,28 @@ type TrackAvailability struct {
|
||||
Tidal bool `json:"tidal"`
|
||||
Amazon bool `json:"amazon"`
|
||||
Qobuz bool `json:"qobuz"`
|
||||
Deezer bool `json:"deezer"`
|
||||
TidalURL string `json:"tidal_url,omitempty"`
|
||||
AmazonURL string `json:"amazon_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 {
|
||||
return &SongLinkClient{
|
||||
client: NewHTTPClientWithTimeout(SongLinkTimeout), // 30s timeout
|
||||
}
|
||||
songLinkClientOnce.Do(func() {
|
||||
globalSongLinkClient = &SongLinkClient{
|
||||
client: NewHTTPClientWithTimeout(SongLinkTimeout), // 30s timeout
|
||||
}
|
||||
})
|
||||
return globalSongLinkClient
|
||||
}
|
||||
|
||||
// CheckTrackAvailability checks track availability on streaming platforms
|
||||
@@ -92,6 +106,14 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
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
|
||||
if isrc != "" {
|
||||
availability.Qobuz = checkQobuzAvailability(isrc)
|
||||
@@ -151,3 +173,112 @@ func checkQobuzAvailability(isrc string) bool {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
+133
-75
@@ -1,6 +1,7 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
@@ -12,17 +13,27 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TidalDownloader handles Tidal downloads
|
||||
type TidalDownloader struct {
|
||||
client *http.Client
|
||||
clientID string
|
||||
clientSecret string
|
||||
apiURL string
|
||||
client *http.Client
|
||||
clientID string
|
||||
clientSecret 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
|
||||
type TidalTrack struct {
|
||||
ID int64 `json:"id"`
|
||||
@@ -93,24 +104,25 @@ type MPD struct {
|
||||
} `xml:"Period"`
|
||||
}
|
||||
|
||||
// NewTidalDownloader creates a new Tidal downloader
|
||||
// NewTidalDownloader creates a new Tidal downloader (returns singleton for token reuse)
|
||||
func NewTidalDownloader() *TidalDownloader {
|
||||
clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==")
|
||||
clientSecret, _ := base64.StdEncoding.DecodeString("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=")
|
||||
tidalDownloaderOnce.Do(func() {
|
||||
clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==")
|
||||
clientSecret, _ := base64.StdEncoding.DecodeString("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=")
|
||||
|
||||
downloader := &TidalDownloader{
|
||||
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
|
||||
clientID: string(clientID),
|
||||
clientSecret: string(clientSecret),
|
||||
}
|
||||
globalTidalDownloader = &TidalDownloader{
|
||||
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
|
||||
clientID: string(clientID),
|
||||
clientSecret: string(clientSecret),
|
||||
}
|
||||
|
||||
// Get first available API
|
||||
apis := downloader.GetAvailableAPIs()
|
||||
if len(apis) > 0 {
|
||||
downloader.apiURL = apis[0]
|
||||
}
|
||||
|
||||
return downloader
|
||||
// Get first available API
|
||||
apis := globalTidalDownloader.GetAvailableAPIs()
|
||||
if len(apis) > 0 {
|
||||
globalTidalDownloader.apiURL = apis[0]
|
||||
}
|
||||
})
|
||||
return globalTidalDownloader
|
||||
}
|
||||
|
||||
// GetAvailableAPIs returns list of available Tidal APIs
|
||||
@@ -138,8 +150,16 @@ func (t *TidalDownloader) GetAvailableAPIs() []string {
|
||||
return apis
|
||||
}
|
||||
|
||||
// GetAccessToken gets Tidal access token
|
||||
// GetAccessToken gets Tidal access token (with caching)
|
||||
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)
|
||||
|
||||
authURL, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hdXRoLnRpZGFsLmNvbS92MS9vYXV0aDIvdG9rZW4=")
|
||||
@@ -163,12 +183,21 @@ func (t *TidalDownloader) GetAccessToken() (string, error) {
|
||||
|
||||
var result struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -728,13 +757,17 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Use item progress writer
|
||||
// Use buffered writer for better performance (256KB buffer)
|
||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||
defer bufWriter.Flush()
|
||||
|
||||
// Use item progress writer with buffered output
|
||||
if itemID != "" {
|
||||
progressWriter := NewItemProgressWriter(out, itemID)
|
||||
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
||||
_, err = io.Copy(progressWriter, resp.Body)
|
||||
} else {
|
||||
// Fallback: direct copy without progress tracking
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
_, err = io.Copy(bufWriter, resp.Body)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -942,8 +975,44 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
var track *TidalTrack
|
||||
var err error
|
||||
|
||||
// Strategy 1: Try to get Tidal URL from SongLink (using Spotify ID)
|
||||
if req.SpotifyID != "" {
|
||||
// OPTIMIZATION: Check cache first for track ID
|
||||
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)
|
||||
if slErr == nil && tidalURL != "" {
|
||||
// Extract track ID and get track info
|
||||
@@ -986,29 +1055,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Search by ISRC with duration verification
|
||||
if track == nil && req.ISRC != "" {
|
||||
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec)
|
||||
// Verify artist for ISRC match too
|
||||
if track != nil {
|
||||
tidalArtist := track.Artist.Name
|
||||
if len(track.Artists) > 0 {
|
||||
var artistNames []string
|
||||
for _, a := range track.Artists {
|
||||
artistNames = append(artistNames, a.Name)
|
||||
}
|
||||
tidalArtist = strings.Join(artistNames, ", ")
|
||||
}
|
||||
if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||
fmt.Printf("[Tidal] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||
req.ArtistName, tidalArtist)
|
||||
track = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Search by metadata only (no ISRC requirement)
|
||||
// Strategy 3: Search by metadata only (no ISRC requirement) - last resort
|
||||
if track == nil {
|
||||
fmt.Printf("[Tidal] Trying metadata search as last resort...\n")
|
||||
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec)
|
||||
// Verify artist for metadata search too
|
||||
if track != nil {
|
||||
@@ -1047,6 +1096,11 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}
|
||||
fmt.Printf("[Tidal] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, tidalArtist, track.Duration)
|
||||
|
||||
// Cache the track ID for future use
|
||||
if req.ISRC != "" {
|
||||
GetTrackIDCache().SetTidal(req.ISRC, track.ID)
|
||||
}
|
||||
|
||||
// Build filename
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||
"title": req.TrackName,
|
||||
@@ -1080,11 +1134,29 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
// Log actual quality received
|
||||
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 {
|
||||
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)
|
||||
// This makes the UI show "Finalizing..." while embedding happens
|
||||
if req.ItemID != "" {
|
||||
@@ -1105,7 +1177,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
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{
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
@@ -1118,17 +1190,11 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
ISRC: req.ISRC,
|
||||
}
|
||||
|
||||
// Download cover to memory (avoids file permission issues on Android)
|
||||
// Use cover data from parallel fetch
|
||||
var coverData []byte
|
||||
if req.CoverURL != "" {
|
||||
fmt.Println("[Tidal] Downloading cover to memory...")
|
||||
data, err := downloadCoverToMemory(req.CoverURL, req.EmbedMaxQualityCover)
|
||||
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)
|
||||
}
|
||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||
coverData = parallelResult.CoverData
|
||||
fmt.Printf("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||
}
|
||||
|
||||
// Only embed metadata to FLAC files (M4A will be converted by Flutter)
|
||||
@@ -1137,24 +1203,16 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
|
||||
// Embed lyrics if enabled
|
||||
if req.EmbedLyrics {
|
||||
fmt.Println("[Tidal] Fetching lyrics...")
|
||||
lyricsClient := NewLyricsClient()
|
||||
lyrics, lyricsErr := lyricsClient.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName)
|
||||
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")
|
||||
// Embed lyrics from parallel fetch
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
fmt.Printf("[Tidal] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
fmt.Printf("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
} else {
|
||||
fmt.Printf("[Tidal] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
|
||||
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")
|
||||
}
|
||||
fmt.Println("[Tidal] Lyrics embedded successfully")
|
||||
}
|
||||
} else if req.EmbedLyrics {
|
||||
fmt.Println("[Tidal] No lyrics available from parallel fetch")
|
||||
}
|
||||
} else {
|
||||
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
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '2.0.6';
|
||||
static const String buildNumber = '36';
|
||||
static const String version = '2.1.5-preview';
|
||||
static const String buildNumber = '42';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
|
||||
@@ -15,4 +15,6 @@ class AppInfo {
|
||||
static const String githubRepo = 'zarzet/SpotiFLAC-Mobile';
|
||||
static const String githubUrl = 'https://github.com/$githubRepo';
|
||||
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 int concurrentDownloads; // 1 = sequential (default), max 3
|
||||
final bool checkForUpdates; // Check for updates on app start
|
||||
final String updateChannel; // stable, preview
|
||||
final bool hasSearchedBefore; // Hide helper text after first search
|
||||
final String folderOrganization; // none, artist, album, artist_album
|
||||
final String historyViewMode; // list, grid
|
||||
@@ -21,6 +22,7 @@ class AppSettings {
|
||||
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
|
||||
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
|
||||
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
|
||||
final String metadataSource; // spotify, deezer - source for search and metadata
|
||||
|
||||
const AppSettings({
|
||||
this.defaultService = 'tidal',
|
||||
@@ -33,6 +35,7 @@ class AppSettings {
|
||||
this.isFirstLaunch = true,
|
||||
this.concurrentDownloads = 1, // Default: sequential (off)
|
||||
this.checkForUpdates = true, // Default: enabled
|
||||
this.updateChannel = 'stable', // Default: stable releases only
|
||||
this.hasSearchedBefore = false, // Default: show helper text
|
||||
this.folderOrganization = 'none', // Default: no folder organization
|
||||
this.historyViewMode = 'grid', // Default: grid view
|
||||
@@ -40,6 +43,7 @@ class AppSettings {
|
||||
this.spotifyClientId = '', // Default: use built-in credentials
|
||||
this.spotifyClientSecret = '', // Default: use built-in credentials
|
||||
this.useCustomSpotifyCredentials = true, // Default: use custom if set
|
||||
this.metadataSource = 'deezer', // Default: Deezer (no rate limit)
|
||||
});
|
||||
|
||||
AppSettings copyWith({
|
||||
@@ -53,6 +57,7 @@ class AppSettings {
|
||||
bool? isFirstLaunch,
|
||||
int? concurrentDownloads,
|
||||
bool? checkForUpdates,
|
||||
String? updateChannel,
|
||||
bool? hasSearchedBefore,
|
||||
String? folderOrganization,
|
||||
String? historyViewMode,
|
||||
@@ -60,6 +65,7 @@ class AppSettings {
|
||||
String? spotifyClientId,
|
||||
String? spotifyClientSecret,
|
||||
bool? useCustomSpotifyCredentials,
|
||||
String? metadataSource,
|
||||
}) {
|
||||
return AppSettings(
|
||||
defaultService: defaultService ?? this.defaultService,
|
||||
@@ -72,6 +78,7 @@ class AppSettings {
|
||||
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
||||
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
|
||||
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
|
||||
updateChannel: updateChannel ?? this.updateChannel,
|
||||
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
||||
folderOrganization: folderOrganization ?? this.folderOrganization,
|
||||
historyViewMode: historyViewMode ?? this.historyViewMode,
|
||||
@@ -79,6 +86,7 @@ class AppSettings {
|
||||
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
|
||||
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
||||
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,
|
||||
concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1,
|
||||
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
|
||||
updateChannel: json['updateChannel'] as String? ?? 'stable',
|
||||
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
||||
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
||||
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
||||
@@ -25,6 +26,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
|
||||
useCustomSpotifyCredentials:
|
||||
json['useCustomSpotifyCredentials'] as bool? ?? true,
|
||||
metadataSource: json['metadataSource'] as String? ?? 'deezer',
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
@@ -39,6 +41,7 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
'isFirstLaunch': instance.isFirstLaunch,
|
||||
'concurrentDownloads': instance.concurrentDownloads,
|
||||
'checkForUpdates': instance.checkForUpdates,
|
||||
'updateChannel': instance.updateChannel,
|
||||
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||
'folderOrganization': instance.folderOrganization,
|
||||
'historyViewMode': instance.historyViewMode,
|
||||
@@ -46,4 +49,5 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
'spotifyClientId': instance.spotifyClientId,
|
||||
'spotifyClientSecret': instance.spotifyClientSecret,
|
||||
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
|
||||
'metadataSource': instance.metadataSource,
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
const String kThemeModeKey = 'theme_mode';
|
||||
const String kUseDynamicColorKey = 'use_dynamic_color';
|
||||
const String kSeedColorKey = 'seed_color';
|
||||
const String kUseAmoledKey = 'use_amoled';
|
||||
|
||||
/// Default Spotify green color for fallback
|
||||
const int kDefaultSeedColor = 0xFF1DB954;
|
||||
@@ -13,11 +14,13 @@ class ThemeSettings {
|
||||
final ThemeMode themeMode;
|
||||
final bool useDynamicColor;
|
||||
final int seedColorValue;
|
||||
final bool useAmoled; // Pure black background for OLED screens
|
||||
|
||||
const ThemeSettings({
|
||||
this.themeMode = ThemeMode.system,
|
||||
this.useDynamicColor = true,
|
||||
this.seedColorValue = kDefaultSeedColor,
|
||||
this.useAmoled = false,
|
||||
});
|
||||
|
||||
/// Get seed color as Color object
|
||||
@@ -28,11 +31,13 @@ class ThemeSettings {
|
||||
ThemeMode? themeMode,
|
||||
bool? useDynamicColor,
|
||||
int? seedColorValue,
|
||||
bool? useAmoled,
|
||||
}) {
|
||||
return ThemeSettings(
|
||||
themeMode: themeMode ?? this.themeMode,
|
||||
useDynamicColor: useDynamicColor ?? this.useDynamicColor,
|
||||
seedColorValue: seedColorValue ?? this.seedColorValue,
|
||||
useAmoled: useAmoled ?? this.useAmoled,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,6 +46,7 @@ class ThemeSettings {
|
||||
kThemeModeKey: themeMode.name,
|
||||
kUseDynamicColorKey: useDynamicColor,
|
||||
kSeedColorKey: seedColorValue,
|
||||
kUseAmoledKey: useAmoled,
|
||||
};
|
||||
|
||||
/// Create from JSON map
|
||||
@@ -49,6 +55,7 @@ class ThemeSettings {
|
||||
themeMode: _themeModeFromString(json[kThemeModeKey] as String?),
|
||||
useDynamicColor: json[kUseDynamicColorKey] as bool? ?? true,
|
||||
seedColorValue: json[kSeedColorKey] as int? ?? kDefaultSeedColor,
|
||||
useAmoled: json[kUseAmoledKey] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,12 +65,13 @@ class ThemeSettings {
|
||||
return other is ThemeSettings &&
|
||||
other.themeMode == themeMode &&
|
||||
other.useDynamicColor == useDynamicColor &&
|
||||
other.seedColorValue == seedColorValue;
|
||||
other.seedColorValue == seedColorValue &&
|
||||
other.useAmoled == useAmoled;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode;
|
||||
themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode ^ useAmoled.hashCode;
|
||||
}
|
||||
|
||||
/// Helper to convert string to ThemeMode
|
||||
|
||||
@@ -4,8 +4,6 @@ import 'dart:io';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.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/settings.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) {
|
||||
final items = state.items.map((item) {
|
||||
if (item.id == id && item.status == DownloadStatus.failed) {
|
||||
return item.copyWith(status: DownloadStatus.queued, progress: 0, error: null);
|
||||
final item = state.items.where((i) => i.id == id).firstOrNull;
|
||||
if (item == 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();
|
||||
state = state.copyWith(items: items);
|
||||
_saveQueueToStorage(); // Persist queue
|
||||
|
||||
// Start processing if not already
|
||||
// Start processing if not already running
|
||||
if (!state.isProcessing) {
|
||||
_log.d('Starting queue processing for retry');
|
||||
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
|
||||
// FFmpeg can embed cover art to FLAC
|
||||
if (coverPath != null && await File(coverPath).exists()) {
|
||||
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 FFmpegService.embedCover(flacPath, coverPath);
|
||||
|
||||
final session = await FFmpegKit.execute(command);
|
||||
final returnCode = await session.getReturnCode();
|
||||
|
||||
if (ReturnCode.isSuccess(returnCode)) {
|
||||
// Replace original with temp
|
||||
await File(flacPath).delete();
|
||||
await File(tempOutput).rename(flacPath);
|
||||
if (result != null) {
|
||||
_log.d('Cover embedded via FFmpeg');
|
||||
} else {
|
||||
// Try alternative method using metaflac-style embedding
|
||||
_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();
|
||||
}
|
||||
_log.w('FFmpeg cover embed failed');
|
||||
}
|
||||
|
||||
// Clean up cover file
|
||||
@@ -866,6 +868,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
_log.i('Queue processing finished');
|
||||
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)
|
||||
@@ -881,7 +890,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
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,
|
||||
orElse: () => DownloadItem(
|
||||
id: '',
|
||||
@@ -892,10 +903,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
);
|
||||
|
||||
if (nextItem.id.isEmpty) {
|
||||
_log.d('No more items to process');
|
||||
_log.d('No more items to process (checked ${currentItems.length} items)');
|
||||
break;
|
||||
}
|
||||
|
||||
_log.d('Processing next item: ${nextItem.track.name} (id: ${nextItem.id})');
|
||||
await _downloadSingleItem(nextItem);
|
||||
|
||||
// Clear item progress after download completes
|
||||
|
||||
@@ -96,6 +96,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setUpdateChannel(String channel) {
|
||||
state = state.copyWith(updateChannel: channel);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setHasSearchedBefore() {
|
||||
if (!state.hasSearchedBefore) {
|
||||
state = state.copyWith(hasSearchedBefore: true);
|
||||
@@ -151,6 +156,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
_applySpotifyCredentials();
|
||||
}
|
||||
|
||||
void setMetadataSource(String source) {
|
||||
state = state.copyWith(metadataSource: source);
|
||||
_saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||
|
||||
@@ -24,11 +24,13 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||
final modeString = prefs.getString(kThemeModeKey);
|
||||
final useDynamic = prefs.getBool(kUseDynamicColorKey);
|
||||
final seedColor = prefs.getInt(kSeedColorKey);
|
||||
final useAmoled = prefs.getBool(kUseAmoledKey);
|
||||
|
||||
state = ThemeSettings(
|
||||
themeMode: _themeModeFromString(modeString),
|
||||
useDynamicColor: useDynamic ?? true,
|
||||
seedColorValue: seedColor ?? kDefaultSeedColor,
|
||||
useAmoled: useAmoled ?? false,
|
||||
);
|
||||
} catch (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.setBool(kUseDynamicColorKey, state.useDynamicColor);
|
||||
await prefs.setInt(kSeedColorKey, state.seedColorValue);
|
||||
await prefs.setBool(kUseAmoledKey, state.useAmoled);
|
||||
} catch (e) {
|
||||
debugPrint('Error saving theme settings: $e');
|
||||
}
|
||||
@@ -72,6 +75,12 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||
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
|
||||
ThemeMode _themeModeFromString(String? value) {
|
||||
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)
|
||||
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
|
||||
final requestId = ++_currentRequestId;
|
||||
|
||||
@@ -127,7 +127,22 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
|
||||
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 (type == 'track') {
|
||||
@@ -149,6 +164,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
albumName: albumInfo['name'] as String?,
|
||||
coverUrl: albumInfo['images'] as String?,
|
||||
);
|
||||
// Pre-warm cache for album tracks in background
|
||||
_preWarmCacheForTracks(tracks);
|
||||
} else if (type == 'playlist') {
|
||||
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
||||
final trackList = metadata['track_list'] as List<dynamic>;
|
||||
@@ -160,6 +177,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
playlistName: owner?['name'] as String?,
|
||||
coverUrl: owner?['images'] as String?,
|
||||
);
|
||||
// Pre-warm cache for playlist tracks in background
|
||||
_preWarmCacheForTracks(tracks);
|
||||
} else if (type == 'artist') {
|
||||
final artistInfo = metadata['artist_info'] as Map<String, 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
|
||||
final requestId = ++_currentRequestId;
|
||||
|
||||
@@ -188,7 +207,24 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||
|
||||
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
|
||||
|
||||
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
||||
@@ -310,6 +346,28 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
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>(
|
||||
|
||||
+110
-37
@@ -71,8 +71,22 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
Future<void> _fetchTracks() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final url = 'https://open.spotify.com/album/${widget.albumId}';
|
||||
final metadata = await PlatformBridge.getSpotifyMetadata(url);
|
||||
Map<String, dynamic> metadata;
|
||||
|
||||
// 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 tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
|
||||
@@ -302,8 +316,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
void _downloadTrack(BuildContext context, Track track) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
_showQualityPicker(context, (quality) {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality);
|
||||
_showQualityPicker(context, (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
||||
} else {
|
||||
@@ -317,8 +331,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
if (tracks == null || tracks.isEmpty) return;
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
_showQualityPicker(context, (quality) {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService, qualityOverride: quality);
|
||||
_showQualityPicker(context, (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||
}, trackName: '${tracks.length} tracks', artistName: widget.albumName);
|
||||
} 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 settings = ref.read(settingsProvider);
|
||||
String selectedService = settings.defaultService;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (trackName != null) ...[
|
||||
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
||||
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
] else ...[
|
||||
const SizedBox(height: 8),
|
||||
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, 16, 24, 8),
|
||||
child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.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,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setModalState) => SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (trackName != null) ...[
|
||||
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
||||
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
] else ...[
|
||||
const SizedBox(height: 8),
|
||||
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
||||
],
|
||||
// Service selector
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
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 {
|
||||
final String trackName;
|
||||
final String? artistName;
|
||||
|
||||
@@ -69,10 +69,25 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
Future<void> _fetchDiscography() async {
|
||||
setState(() => _isLoadingDiscography = true);
|
||||
try {
|
||||
final url = 'https://open.spotify.com/artist/${widget.artistId}';
|
||||
final metadata = await PlatformBridge.getSpotifyMetadata(url);
|
||||
final albumsList = metadata['albums'] as List<dynamic>;
|
||||
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||
List<ArtistAlbum> albums;
|
||||
|
||||
// Check if this is a Deezer artist ID (format: "deezer:123456")
|
||||
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
|
||||
_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),
|
||||
const Spacer(),
|
||||
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),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
|
||||
@@ -38,7 +38,8 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
||||
if (url.startsWith('http') || url.startsWith('spotify:')) {
|
||||
await ref.read(trackProvider.notifier).fetchFromUrl(url);
|
||||
} 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;
|
||||
_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();
|
||||
}
|
||||
|
||||
@@ -112,7 +113,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
await ref.read(trackProvider.notifier).fetchFromUrl(url);
|
||||
_navigateToDetailIfNeeded();
|
||||
} 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();
|
||||
}
|
||||
@@ -170,8 +172,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
final settings = ref.read(settingsProvider);
|
||||
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
_showQualityPicker(context, (quality) {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality);
|
||||
_showQualityPicker(context, (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
||||
} 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 settings = ref.read(settingsProvider);
|
||||
String selectedService = settings.defaultService;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (trackName != null) ...[
|
||||
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
||||
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
] else ...[
|
||||
const SizedBox(height: 8),
|
||||
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, 16, 24, 8),
|
||||
child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.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,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setModalState) => SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (trackName != null) ...[
|
||||
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
||||
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
] else ...[
|
||||
const SizedBox(height: 8),
|
||||
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
||||
],
|
||||
// Service selector
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
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 {
|
||||
final String trackName;
|
||||
final String? artistName;
|
||||
|
||||
@@ -85,7 +85,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (!settings.checkForUpdates) return;
|
||||
|
||||
final updateInfo = await UpdateChecker.checkForUpdate();
|
||||
final updateInfo = await UpdateChecker.checkForUpdate(channel: settings.updateChannel);
|
||||
if (updateInfo != null && mounted) {
|
||||
showUpdateDialog(
|
||||
context,
|
||||
|
||||
@@ -168,8 +168,8 @@ class PlaylistScreen extends ConsumerWidget {
|
||||
void _downloadTrack(BuildContext context, WidgetRef ref, Track track) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
_showQualityPicker(context, (quality) {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality);
|
||||
_showQualityPicker(context, ref, (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
||||
} else {
|
||||
@@ -182,8 +182,8 @@ class PlaylistScreen extends ConsumerWidget {
|
||||
if (tracks.isEmpty) return;
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
_showQualityPicker(context, (quality) {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService, qualityOverride: quality);
|
||||
_showQualityPicker(context, ref, (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||
}, trackName: '${tracks.length} tracks', artistName: playlistName);
|
||||
} 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 settings = ref.read(settingsProvider);
|
||||
String selectedService = settings.defaultService;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (trackName != null) ...[
|
||||
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
||||
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
] else ...[
|
||||
const SizedBox(height: 8),
|
||||
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, 16, 24, 8), child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.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,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setModalState) => SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (trackName != null) ...[
|
||||
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
||||
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
] else ...[
|
||||
const SizedBox(height: 8),
|
||||
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
||||
],
|
||||
// Service selector
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
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 {
|
||||
final String trackName;
|
||||
final String? artistName;
|
||||
|
||||
@@ -23,7 +23,8 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
_searchController = TextEditingController(text: widget.query);
|
||||
if (widget.query.isNotEmpty) {
|
||||
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() {
|
||||
final query = _searchController.text.trim();
|
||||
if (query.isNotEmpty) {
|
||||
ref.read(trackProvider.notifier).search(query);
|
||||
final settings = ref.read(settingsProvider);
|
||||
ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -123,6 +123,24 @@ class AboutPage extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// Support section
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Support'),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.coffee_outlined,
|
||||
title: 'Buy me a coffee',
|
||||
subtitle: 'Support development on Ko-fi',
|
||||
onTap: () => _launchUrl(AppInfo.kofiUrl),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// App info section
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'App'),
|
||||
|
||||
@@ -40,6 +40,14 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
||||
currentMode: themeSettings.themeMode,
|
||||
onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.brightness_2,
|
||||
title: 'AMOLED Dark',
|
||||
subtitle: 'Pure black background for OLED screens',
|
||||
value: themeSettings.useAmoled,
|
||||
onChanged: (value) => ref.read(themeProvider.notifier).setUseAmoled(value),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.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/widgets/settings_group.dart';
|
||||
|
||||
@@ -113,8 +115,10 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
SettingsItem(
|
||||
icon: Icons.folder_outlined,
|
||||
title: 'Download Directory',
|
||||
subtitle: settings.downloadDirectory.isEmpty ? 'Music/SpotiFLAC' : settings.downloadDirectory,
|
||||
onTap: () => _pickDirectory(ref),
|
||||
subtitle: settings.downloadDirectory.isEmpty
|
||||
? (Platform.isIOS ? 'App Documents Folder' : 'Music/SpotiFLAC')
|
||||
: settings.downloadDirectory,
|
||||
onTap: () => _pickDirectory(context, ref),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.create_new_folder_outlined,
|
||||
@@ -161,9 +165,90 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickDirectory(WidgetRef ref) async {
|
||||
final result = await FilePicker.platform.getDirectoryPath();
|
||||
if (result != null) ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
||||
Future<void> _pickDirectory(BuildContext context, WidgetRef ref) async {
|
||||
if (Platform.isIOS) {
|
||||
// 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) {
|
||||
|
||||
@@ -105,7 +105,10 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
subtitle: 'Notify when new version is available',
|
||||
value: settings.checkForUpdates,
|
||||
onChanged: (v) => ref.read(settingsProvider.notifier).setCheckForUpdates(v),
|
||||
showDivider: false,
|
||||
),
|
||||
_UpdateChannelSelector(
|
||||
currentChannel: settings.updateChannel,
|
||||
onChanged: (v) => ref.read(settingsProvider.notifier).setUpdateChannel(v),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -116,29 +119,35 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.key,
|
||||
title: 'Custom Credentials',
|
||||
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,
|
||||
_MetadataSourceSelector(
|
||||
currentSource: settings.metadataSource,
|
||||
onChanged: (v) => ref.read(settingsProvider.notifier).setMetadataSource(v),
|
||||
),
|
||||
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,
|
||||
if (settings.metadataSource == 'spotify') ...[
|
||||
SettingsItem(
|
||||
icon: Icons.key,
|
||||
title: 'Custom Credentials',
|
||||
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)
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.toggle_on,
|
||||
title: 'Use Custom Credentials',
|
||||
subtitle: settings.useCustomSpotifyCredentials
|
||||
? 'Using your credentials'
|
||||
: 'Using default credentials',
|
||||
value: settings.useCustomSpotifyCredentials,
|
||||
onChanged: (v) => ref.read(settingsProvider.notifier).setUseCustomSpotifyCredentials(v),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -393,3 +402,149 @@ class _ConcurrentChip extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UpdateChannelSelector extends StatelessWidget {
|
||||
final String currentChannel;
|
||||
final ValueChanged<String> onChanged;
|
||||
const _UpdateChannelSelector({required this.currentChannel, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Icon(Icons.new_releases, color: colorScheme.onSurfaceVariant, size: 24),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('Update Channel', style: Theme.of(context).textTheme.bodyLarge),
|
||||
const SizedBox(height: 2),
|
||||
Text(currentChannel == 'preview' ? 'Get preview releases' : 'Stable releases only',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
])),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
Row(children: [
|
||||
_ChannelChip(label: 'Stable', isSelected: currentChannel == 'stable', onTap: () => onChanged('stable')),
|
||||
const SizedBox(width: 8),
|
||||
_ChannelChip(label: 'Preview', isSelected: currentChannel == 'preview', onTap: () => onChanged('preview')),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
Row(children: [
|
||||
Icon(Icons.info_outline, size: 16, color: colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text('Preview may contain bugs or incomplete features',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant))),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
const _ChannelChip({required this.label, required this.isSelected, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
final unselectedColor = isDark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
||||
: colorScheme.surfaceContainerHigh;
|
||||
|
||||
return Expanded(
|
||||
child: Material(
|
||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Center(child: Text(label, style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant))),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetadataSourceSelector extends StatelessWidget {
|
||||
final String currentSource;
|
||||
final ValueChanged<String> onChanged;
|
||||
const _MetadataSourceSelector({required this.currentSource, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Icon(Icons.search, color: colorScheme.onSurfaceVariant, size: 24),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('Search Source', style: Theme.of(context).textTheme.bodyLarge),
|
||||
const SizedBox(height: 2),
|
||||
Text(currentSource == 'deezer' ? 'Deezer (no need developer account)' : 'Spotify (may hit rate limit)',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
])),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
Row(children: [
|
||||
_SourceChip(label: 'Deezer', isSelected: currentSource == 'deezer', onTap: () => onChanged('deezer')),
|
||||
const SizedBox(width: 8),
|
||||
_SourceChip(label: 'Spotify', isSelected: currentSource == 'spotify', onTap: () => onChanged('spotify')),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
Row(children: [
|
||||
Icon(Icons.info_outline, size: 16, color: colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text('Spotify URLs are always supported regardless of this setting',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant))),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SourceChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
const _SourceChip({required this.label, required this.isSelected, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
final unselectedColor = isDark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
||||
: colorScheme.surfaceContainerHigh;
|
||||
|
||||
return Expanded(
|
||||
child: Material(
|
||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Center(child: Text(label, style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant))),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+103
-21
@@ -205,29 +205,35 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
String? selectedDirectory = await FilePicker.platform.getDirectoryPath(
|
||||
dialogTitle: 'Select Download Folder',
|
||||
);
|
||||
|
||||
if (selectedDirectory != null) {
|
||||
setState(() => _selectedDirectory = selectedDirectory);
|
||||
if (Platform.isIOS) {
|
||||
// iOS: Show options dialog
|
||||
await _showIOSDirectoryOptions();
|
||||
} 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')),
|
||||
],
|
||||
),
|
||||
);
|
||||
// Android: Use file picker
|
||||
String? selectedDirectory = await FilePicker.platform.getDirectoryPath(
|
||||
dialogTitle: 'Select Download Folder',
|
||||
);
|
||||
|
||||
if (useDefault == true) {
|
||||
setState(() => _selectedDirectory = defaultDir);
|
||||
if (selectedDirectory != null) {
|
||||
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 {
|
||||
if (Platform.isIOS) {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
|
||||
@@ -759,17 +759,21 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
});
|
||||
|
||||
try {
|
||||
// Add timeout to prevent infinite loading
|
||||
final result = await PlatformBridge.getLyricsLRC(
|
||||
item.spotifyId ?? '',
|
||||
item.trackName,
|
||||
item.artistName,
|
||||
filePath: _fileExists ? item.filePath : null, // Try embedded lyrics first
|
||||
).timeout(
|
||||
const Duration(seconds: 20),
|
||||
onTimeout: () => '', // Return empty string on timeout
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
if (result.isEmpty) {
|
||||
setState(() {
|
||||
_lyricsError = 'Lyrics not found';
|
||||
_lyricsError = 'Lyrics not available for this track';
|
||||
_lyricsLoading = false;
|
||||
});
|
||||
} else {
|
||||
@@ -783,8 +787,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
final errorMsg = e.toString().contains('TimeoutException')
|
||||
? 'Request timed out. Try again later.'
|
||||
: 'Failed to load lyrics';
|
||||
setState(() {
|
||||
_lyricsError = 'Failed to load lyrics';
|
||||
_lyricsError = errorMsg;
|
||||
_lyricsLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
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:flutter/services.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('FFmpeg');
|
||||
|
||||
/// FFmpeg service for audio conversion and remuxing
|
||||
/// Uses native MethodChannel to call FFmpegKit from local AAR
|
||||
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
|
||||
/// Returns the output file path on success, null on failure
|
||||
static Future<String?> convertM4aToFlac(String inputPath) async {
|
||||
@@ -16,10 +34,9 @@ class FFmpegService {
|
||||
final command =
|
||||
'-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
|
||||
|
||||
final session = await FFmpegKit.execute(command);
|
||||
final returnCode = await session.getReturnCode();
|
||||
final result = await _execute(command);
|
||||
|
||||
if (ReturnCode.isSuccess(returnCode)) {
|
||||
if (result.success) {
|
||||
// Delete original M4A file
|
||||
try {
|
||||
await File(inputPath).delete();
|
||||
@@ -27,12 +44,7 @@ class FFmpegService {
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
// Log error for debugging
|
||||
final logs = await session.getLogs();
|
||||
for (final log in logs) {
|
||||
_log.d(log.getMessage());
|
||||
}
|
||||
|
||||
_log.e('M4A to FLAC conversion failed: ${result.output}');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -54,13 +66,13 @@ class FFmpegService {
|
||||
final command =
|
||||
'-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 returnCode = await session.getReturnCode();
|
||||
final result = await _execute(command);
|
||||
|
||||
if (ReturnCode.isSuccess(returnCode)) {
|
||||
if (result.success) {
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
_log.e('FLAC to MP3 conversion failed: ${result.output}');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -91,22 +103,21 @@ class FFmpegService {
|
||||
'-i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -map_metadata 0 "$outputPath" -y';
|
||||
}
|
||||
|
||||
final session = await FFmpegKit.execute(command);
|
||||
final returnCode = await session.getReturnCode();
|
||||
final result = await _execute(command);
|
||||
|
||||
if (ReturnCode.isSuccess(returnCode)) {
|
||||
if (result.success) {
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
_log.e('FLAC to M4A conversion 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);
|
||||
final version = await _channel.invokeMethod('getVersion');
|
||||
return version != null && version.toString().isNotEmpty;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
@@ -115,11 +126,55 @@ class FFmpegService {
|
||||
/// Get FFmpeg version info
|
||||
static Future<String?> getVersion() async {
|
||||
try {
|
||||
final session = await FFmpegKit.execute('-version');
|
||||
final output = await session.getOutput();
|
||||
return output;
|
||||
final version = await _channel.invokeMethod('getVersion');
|
||||
return version as String?;
|
||||
} catch (e) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -297,4 +297,71 @@ class PlatformBridge {
|
||||
'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? apkDownloadUrl;
|
||||
final DateTime publishedAt;
|
||||
final bool isPrerelease;
|
||||
|
||||
const UpdateInfo({
|
||||
required this.version,
|
||||
@@ -19,11 +20,13 @@ class UpdateInfo {
|
||||
required this.downloadUrl,
|
||||
this.apkDownloadUrl,
|
||||
required this.publishedAt,
|
||||
this.isPrerelease = false,
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
final response = await http.get(
|
||||
Uri.parse(_apiUrl),
|
||||
headers: {'Accept': 'application/vnd.github.v3+json'},
|
||||
).timeout(const Duration(seconds: 10));
|
||||
Map<String, dynamic>? releaseData;
|
||||
|
||||
if (channel == 'preview') {
|
||||
// For preview channel, get all releases and find the latest (including prereleases)
|
||||
final response = await http.get(
|
||||
Uri.parse('$_allReleasesApiUrl?per_page=10'),
|
||||
headers: {'Accept': 'application/vnd.github.v3+json'},
|
||||
).timeout(const Duration(seconds: 10));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
_log.w('GitHub API returned ${response.statusCode}');
|
||||
return null;
|
||||
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 = data['tag_name'] as String? ?? '';
|
||||
final tagName = releaseData['tag_name'] as String? ?? '';
|
||||
final latestVersion = tagName.replaceFirst('v', '');
|
||||
final isPrerelease = releaseData['prerelease'] as bool? ?? false;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
final body = data['body'] as String? ?? 'No changelog available';
|
||||
final htmlUrl = data['html_url'] as String? ?? '${AppInfo.githubUrl}/releases';
|
||||
final publishedAt = DateTime.tryParse(data['published_at'] as String? ?? '') ?? DateTime.now();
|
||||
final body = releaseData['body'] as String? ?? 'No changelog available';
|
||||
final htmlUrl = releaseData['html_url'] as String? ?? '${AppInfo.githubUrl}/releases';
|
||||
final publishedAt = DateTime.tryParse(releaseData['published_at'] as String? ?? '') ?? DateTime.now();
|
||||
|
||||
final deviceArch = await _getDeviceArch();
|
||||
_log.d('Device architecture: $deviceArch');
|
||||
@@ -87,7 +119,7 @@ class UpdateChecker {
|
||||
String? arm32Url;
|
||||
String? universalUrl;
|
||||
|
||||
final assets = data['assets'] as List<dynamic>? ?? [];
|
||||
final assets = releaseData['assets'] as List<dynamic>? ?? [];
|
||||
for (final asset in assets) {
|
||||
final name = (asset['name'] as String? ?? '').toLowerCase();
|
||||
if (name.endsWith('.apk')) {
|
||||
@@ -117,7 +149,7 @@ class UpdateChecker {
|
||||
apkUrl = universalUrl ?? arm64Url ?? arm32Url;
|
||||
}
|
||||
|
||||
_log.i('Update available: $latestVersion, APK URL: $apkUrl');
|
||||
_log.i('Update available: $latestVersion (prerelease: $isPrerelease), APK URL: $apkUrl');
|
||||
|
||||
return UpdateInfo(
|
||||
version: latestVersion,
|
||||
@@ -125,6 +157,7 @@ class UpdateChecker {
|
||||
downloadUrl: htmlUrl,
|
||||
apkDownloadUrl: apkUrl,
|
||||
publishedAt: publishedAt,
|
||||
isPrerelease: isPrerelease,
|
||||
);
|
||||
} catch (e) {
|
||||
_log.e('Error checking for updates: $e');
|
||||
|
||||
@@ -43,6 +43,7 @@ class AppTheme {
|
||||
static ThemeData dark({
|
||||
ColorScheme? dynamicScheme,
|
||||
Color? seedColor,
|
||||
bool isAmoled = false,
|
||||
}) {
|
||||
final scheme = dynamicScheme ??
|
||||
ColorScheme.fromSeed(
|
||||
@@ -53,7 +54,8 @@ class AppTheme {
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
appBarTheme: _appBarTheme(scheme),
|
||||
scaffoldBackgroundColor: isAmoled ? Colors.black : null,
|
||||
appBarTheme: _appBarTheme(scheme, isAmoled: isAmoled),
|
||||
cardTheme: _cardTheme(scheme),
|
||||
elevatedButtonTheme: _elevatedButtonTheme(scheme),
|
||||
filledButtonTheme: _filledButtonTheme(scheme),
|
||||
@@ -63,7 +65,7 @@ class AppTheme {
|
||||
inputDecorationTheme: _inputDecorationTheme(scheme),
|
||||
listTileTheme: _listTileTheme(scheme),
|
||||
dialogTheme: _dialogTheme(scheme),
|
||||
navigationBarTheme: _navigationBarTheme(scheme),
|
||||
navigationBarTheme: _navigationBarTheme(scheme, isAmoled: isAmoled),
|
||||
snackBarTheme: _snackBarTheme(scheme),
|
||||
progressIndicatorTheme: _progressIndicatorTheme(scheme),
|
||||
switchTheme: _switchTheme(scheme),
|
||||
@@ -73,12 +75,12 @@ class AppTheme {
|
||||
}
|
||||
|
||||
/// AppBar theme
|
||||
static AppBarTheme _appBarTheme(ColorScheme scheme) => AppBarTheme(
|
||||
static AppBarTheme _appBarTheme(ColorScheme scheme, {bool isAmoled = false}) => AppBarTheme(
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 3,
|
||||
backgroundColor: scheme.surface,
|
||||
scrolledUnderElevation: isAmoled ? 0 : 3,
|
||||
backgroundColor: isAmoled ? Colors.black : scheme.surface,
|
||||
foregroundColor: scheme.onSurface,
|
||||
surfaceTintColor: scheme.surfaceTint,
|
||||
surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint,
|
||||
centerTitle: true,
|
||||
titleTextStyle: TextStyle(
|
||||
color: scheme.onSurface,
|
||||
@@ -180,12 +182,12 @@ class AppTheme {
|
||||
);
|
||||
|
||||
/// Navigation bar theme
|
||||
static NavigationBarThemeData _navigationBarTheme(ColorScheme scheme) =>
|
||||
static NavigationBarThemeData _navigationBarTheme(ColorScheme scheme, {bool isAmoled = false}) =>
|
||||
NavigationBarThemeData(
|
||||
elevation: 0,
|
||||
backgroundColor: scheme.surfaceContainer,
|
||||
backgroundColor: isAmoled ? Colors.black : scheme.surfaceContainer,
|
||||
indicatorColor: scheme.secondaryContainer,
|
||||
surfaceTintColor: scheme.surfaceTint,
|
||||
surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint,
|
||||
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
|
||||
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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 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"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||
publish_to: 'none'
|
||||
version: 2.0.6+36
|
||||
version: 2.1.5-preview+42
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
@@ -50,8 +50,8 @@ dependencies:
|
||||
receive_sharing_intent: ^1.8.1
|
||||
logger: ^2.5.0
|
||||
|
||||
# FFmpeg for audio conversion (audio-only version - much smaller)
|
||||
ffmpeg_kit_flutter_new_audio: ^2.0.0
|
||||
# FFmpeg - using local custom AAR (arm64-v8a + armeabi-v7a only)
|
||||
# ffmpeg_kit_flutter_new_audio: ^2.0.0 # Replaced with local AAR
|
||||
open_filex: ^4.7.0
|
||||
|
||||
# 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