mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 11:18:04 +02:00
Compare commits
4 Commits
v2.0.6
...
v2.1.0-preview
| Author | SHA1 | Date | |
|---|---|---|---|
| a9088455c3 | |||
| bb05353b7e | |||
| 7ac92d77e5 | |||
| cf00ecb756 |
@@ -229,6 +229,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
|
||||
|
||||
@@ -1,5 +1,48 @@
|
||||
# Changelog
|
||||
|
||||
## [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
|
||||
|
||||
@@ -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,25 @@ 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)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -215,5 +237,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")
|
||||
|
||||
@@ -516,6 +516,56 @@ 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()
|
||||
}
|
||||
|
||||
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{
|
||||
|
||||
+14
-4
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -25,11 +26,20 @@ type TrackAvailability struct {
|
||||
QobuzURL string `json:"qobuz_url,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
|
||||
|
||||
+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.0-preview';
|
||||
static const String buildNumber = '39';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -745,25 +743,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
|
||||
|
||||
@@ -149,6 +149,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 +162,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>;
|
||||
@@ -310,6 +314,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>(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+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();
|
||||
|
||||
@@ -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,23 @@ 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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.0-preview+39
|
||||
|
||||
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-preview+39
|
||||
|
||||
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