Compare commits

...

5 Commits

Author SHA1 Message Date
zarzet cf00ecb756 feat: use custom FFmpeg AAR for Android, reduce APK size
- Replace ffmpeg_kit_flutter plugin with custom AAR (arm64 + arm7a only)
- Add MethodChannel bridge for FFmpeg in MainActivity
- Create separate pubspec_ios.yaml for iOS builds with ffmpeg_kit plugin
- Update GitHub workflow to swap pubspec for iOS builds
- Reduces Android APK size by ~50MB
2026-01-05 14:09:32 +07:00
zarzet 525f2fd0cd chore: bump version to 2.0.6+36 2026-01-05 12:21:05 +07:00
zarzet 3e841cef06 fix: duration display, audio quality from file, artist verification, metadata case-sensitivity, settings navigation freeze
- Fix duration showing incorrect values (ms to seconds conversion)
- Read audio quality from FLAC file instead of trusting API
- Add artist verification for Tidal/Qobuz/Amazon downloads
- Fix FLAC metadata case-insensitive replacement
- Fix settings navigation freeze on Android 14+ (PopScope handling)
2026-01-05 10:30:57 +07:00
zarzet a8527df80a docs: application stabilized, remove dev notice 2026-01-05 03:12:30 +07:00
zarzet 51b2ad5c77 v2.0.5: Large playlist support + duration verification fix
- Add pagination for playlists (up to 1000 tracks)
- Add duration verification to prevent wrong track downloads
- When Tidal returns wrong version, fallback to Qobuz/Amazon
2026-01-05 03:08:15 +07:00
25 changed files with 1186 additions and 309 deletions
+17
View File
@@ -229,6 +229,23 @@ jobs:
channel: 'stable' channel: 'stable'
cache: true cache: true
# Swap pubspec for iOS build (includes ffmpeg_kit_flutter)
- name: Use iOS pubspec with FFmpeg plugin
run: |
cp pubspec.yaml pubspec_android_backup.yaml
cp pubspec_ios.yaml pubspec.yaml
echo "Swapped to iOS pubspec with ffmpeg_kit_flutter"
# Swap FFmpeg service for iOS
- name: Use iOS FFmpeg service
run: |
cp lib/services/ffmpeg_service.dart lib/services/ffmpeg_service_android.dart
cp build_assets/ffmpeg_service_ios.dart lib/services/ffmpeg_service.dart
# Update class name in the swapped file
sed -i '' 's/FFmpegServiceIOS/FFmpegService/g' lib/services/ffmpeg_service.dart
sed -i '' 's/FFmpegResultIOS/FFmpegResult/g' lib/services/ffmpeg_service.dart
echo "Swapped to iOS FFmpeg service"
- name: Get Flutter dependencies - name: Get Flutter dependencies
run: flutter pub get run: flutter pub get
+27
View File
@@ -1,5 +1,32 @@
# Changelog # Changelog
## [2.0.6] - 2026-01-05
### Fixed
- **Duration Display Bug**: Fixed duration showing incorrect values like "4135:53" instead of "4:14"
- `duration_ms` (milliseconds) was being stored directly without conversion to seconds
- Now properly converts milliseconds to seconds before display
- **Audio Quality from File**: Quality info (bit depth/sample rate) now read from actual FLAC file instead of trusting API
- More accurate quality display for all services (Tidal, Qobuz, Amazon)
- Also reads quality from existing files when skipping duplicates
- **Artist Verification for Downloads**: Added artist name verification to prevent downloading wrong tracks
- Verifies artist matches between Spotify metadata and streaming service
- Handles different scripts (Japanese/Chinese vs Latin) as same artist with different transliteration
- Applied to Tidal, Qobuz, and Amazon downloads
- **Metadata Case-Sensitivity**: Fixed FLAC metadata not being properly overwritten when downloaded file has lowercase tags
- Now uses case-insensitive comparison when replacing existing Vorbis comments
- Fixes issue where Amazon downloads could have duplicate metadata tags
- **Settings Navigation Freeze**: Fixed app freezing when navigating back from settings sub-menus on some devices
- Added proper PopScope handling for predictive back gesture on Android 14+
## [2.0.5] - 2026-01-05
### Added
- **Large Playlist Support**: Playlists with up to 1000 tracks are now fully fetched (was limited to 100)
### Fixed
- **Wrong Track Download**: Fixed issue where tracks with same ISRC but different versions (e.g., short/instrumental vs full version) would download the wrong track. Now verifies duration matches before downloading (30 second tolerance).
## [2.0.4] - 2026-01-04 ## [2.0.4] - 2026-01-04
### Fixed ### Fixed
-2
View File
@@ -11,8 +11,6 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
</div> </div>
> **Active Development Notice**: This app is under heavy development. New builds may be pushed multiple times daily. If frequent update notifications are annoying, tap "Don't remind" when the update dialog appears, or disable update checks in Settings.
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases) ### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
## Screenshots ## Screenshots
+1
View File
@@ -95,6 +95,7 @@ repositories {
dependencies { dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
implementation(files("libs/gobackend.aar")) implementation(files("libs/gobackend.aar"))
implementation(files("libs/ffmpeg-kit-with-lame.aar"))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
} }
@@ -5,6 +5,8 @@ import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import gobackend.Gobackend import gobackend.Gobackend
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.ReturnCode
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -13,6 +15,7 @@ import kotlinx.coroutines.withContext
class MainActivity: FlutterActivity() { class MainActivity: FlutterActivity() {
private val CHANNEL = "com.zarz.spotiflac/backend" private val CHANNEL = "com.zarz.spotiflac/backend"
private val FFMPEG_CHANNEL = "com.zarz.spotiflac/ffmpeg"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
@@ -215,5 +218,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)
}
}
}
} }
} }
+136
View File
@@ -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});
}
+66
View File
@@ -36,6 +36,63 @@ type DoubleDoubleStatusResponse struct {
} `json:"current"` } `json:"current"`
} }
// amazonArtistsMatch checks if the artist names are similar enough
func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
// Exact match
if normExpected == normFound {
return true
}
// Check if one contains the other
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
return true
}
// Check first artist (before comma or feat)
expectedFirst := strings.Split(normExpected, ",")[0]
expectedFirst = strings.Split(expectedFirst, " feat")[0]
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
expectedFirst = strings.TrimSpace(expectedFirst)
foundFirst := strings.Split(normFound, ",")[0]
foundFirst = strings.Split(foundFirst, " feat")[0]
foundFirst = strings.Split(foundFirst, " ft.")[0]
foundFirst = strings.TrimSpace(foundFirst)
if expectedFirst == foundFirst {
return true
}
// Check if first artist is contained in the other
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
return true
}
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
// assume they're the same artist with different transliteration
expectedASCII := amazonIsASCIIString(expectedArtist)
foundASCII := amazonIsASCIIString(foundArtist)
if expectedASCII != foundASCII {
fmt.Printf("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
return true
}
return false
}
// amazonIsASCIIString checks if a string contains only ASCII characters
func amazonIsASCIIString(s string) bool {
for _, r := range s {
if r > 127 {
return false
}
}
return true
}
// NewAmazonDownloader creates a new Amazon downloader using DoubleDouble service // NewAmazonDownloader creates a new Amazon downloader using DoubleDouble service
func NewAmazonDownloader() *AmazonDownloader { func NewAmazonDownloader() *AmazonDownloader {
return &AmazonDownloader{ return &AmazonDownloader{
@@ -295,6 +352,15 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err) return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
} }
// Verify artist matches
if artistName != "" && !amazonArtistsMatch(req.ArtistName, artistName) {
fmt.Printf("[Amazon] Artist mismatch: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, artistName)
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
}
// Log match found
fmt.Printf("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
// Build filename using Spotify metadata (more accurate) // Build filename using Spotify metadata (more accurate)
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName, "title": req.TrackName,
+51 -11
View File
@@ -5,6 +5,7 @@ package gobackend
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"strings" "strings"
"time" "time"
) )
@@ -132,7 +133,8 @@ type DownloadRequest struct {
DiscNumber int `json:"disc_number"` DiscNumber int `json:"disc_number"`
TotalTracks int `json:"total_tracks"` TotalTracks int `json:"total_tracks"`
ReleaseDate string `json:"release_date"` ReleaseDate string `json:"release_date"`
ItemID string `json:"item_id"` // Unique ID for progress tracking ItemID string `json:"item_id"` // Unique ID for progress tracking
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
} }
// DownloadResponse represents the result of a download // DownloadResponse represents the result of a download
@@ -215,17 +217,36 @@ func DownloadTrack(requestJSON string) (string, error) {
// Check if file already exists // Check if file already exists
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" { if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
actualPath := result.FilePath[7:]
// Read actual quality from existing file
quality, qErr := GetAudioQuality(actualPath)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
}
resp := DownloadResponse{ resp := DownloadResponse{
Success: true, Success: true,
Message: "File already exists", Message: "File already exists",
FilePath: result.FilePath[7:], FilePath: actualPath,
AlreadyExists: true, AlreadyExists: true,
Service: req.Service, ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate,
Service: req.Service,
} }
jsonBytes, _ := json.Marshal(resp) jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// Read actual quality from downloaded file (more accurate than API)
quality, qErr := GetAudioQuality(result.FilePath)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
} else {
fmt.Printf("[Download] Could not read quality from file: %v\n", qErr)
}
resp := DownloadResponse{ resp := DownloadResponse{
Success: true, Success: true,
Message: "Download complete", Message: "Download complete",
@@ -313,17 +334,36 @@ func DownloadWithFallback(requestJSON string) (string, error) {
if err == nil { if err == nil {
// Check if file already exists // Check if file already exists
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" { if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
actualPath := result.FilePath[7:]
// Read actual quality from existing file
quality, qErr := GetAudioQuality(actualPath)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
}
resp := DownloadResponse{ resp := DownloadResponse{
Success: true, Success: true,
Message: "File already exists", Message: "File already exists",
FilePath: result.FilePath[7:], FilePath: actualPath,
AlreadyExists: true, AlreadyExists: true,
Service: service, ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate,
Service: service,
} }
jsonBytes, _ := json.Marshal(resp) jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// Read actual quality from downloaded file (more accurate than API)
quality, qErr := GetAudioQuality(result.FilePath)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
} else {
fmt.Printf("[Download] Could not read quality from file: %v\n", qErr)
}
resp := DownloadResponse{ resp := DownloadResponse{
Success: true, Success: true,
Message: "Downloaded from " + service, Message: "Downloaded from " + service,
+10 -3
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"strconv" "strconv"
"strings"
"github.com/go-flac/flacpicture" "github.com/go-flac/flacpicture"
"github.com/go-flac/flacvorbis" "github.com/go-flac/flacvorbis"
@@ -273,10 +274,16 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
if value == "" { if value == "" {
return return
} }
// Remove existing // Remove existing (case-insensitive comparison for Vorbis comments)
keyUpper := strings.ToUpper(key)
for i := len(cmt.Comments) - 1; i >= 0; i-- { for i := len(cmt.Comments) - 1; i >= 0; i-- {
if len(cmt.Comments[i]) > len(key)+1 && cmt.Comments[i][:len(key)+1] == key+"=" { comment := cmt.Comments[i]
cmt.Comments = append(cmt.Comments[:i], cmt.Comments[i+1:]...) eqIdx := strings.Index(comment, "=")
if eqIdx > 0 {
existingKey := strings.ToUpper(comment[:eqIdx])
if existingKey == keyUpper {
cmt.Comments = append(cmt.Comments[:i], cmt.Comments[i+1:]...)
}
} }
} }
// Add new // Add new
+208 -11
View File
@@ -9,6 +9,7 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings"
) )
// QobuzDownloader handles Qobuz downloads // QobuzDownloader handles Qobuz downloads
@@ -39,6 +40,63 @@ type QobuzTrack struct {
} `json:"performer"` } `json:"performer"`
} }
// qobuzArtistsMatch checks if the artist names are similar enough
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
// Exact match
if normExpected == normFound {
return true
}
// Check if one contains the other
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
return true
}
// Check first artist (before comma or feat)
expectedFirst := strings.Split(normExpected, ",")[0]
expectedFirst = strings.Split(expectedFirst, " feat")[0]
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
expectedFirst = strings.TrimSpace(expectedFirst)
foundFirst := strings.Split(normFound, ",")[0]
foundFirst = strings.Split(foundFirst, " feat")[0]
foundFirst = strings.Split(foundFirst, " ft.")[0]
foundFirst = strings.TrimSpace(foundFirst)
if expectedFirst == foundFirst {
return true
}
// Check if first artist is contained in the other
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
return true
}
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
// assume they're the same artist with different transliteration
expectedASCII := qobuzIsASCIIString(expectedArtist)
foundASCII := qobuzIsASCIIString(foundArtist)
if expectedASCII != foundASCII {
fmt.Printf("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
return true
}
return false
}
// qobuzIsASCIIString checks if a string contains only ASCII characters
func qobuzIsASCIIString(s string) bool {
for _, r := range s {
if r > 127 {
return false
}
}
return true
}
// NewQobuzDownloader creates a new Qobuz downloader // NewQobuzDownloader creates a new Qobuz downloader
func NewQobuzDownloader() *QobuzDownloader { func NewQobuzDownloader() *QobuzDownloader {
return &QobuzDownloader{ return &QobuzDownloader{
@@ -112,8 +170,96 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc) return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
} }
// SearchTrackByISRCWithTitle searches for a track by ISRC with duration verification
// expectedDurationSec is the expected duration in seconds (0 to skip verification)
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil {
return nil, err
}
resp, err := DoRequestWithUserAgent(q.client, req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode)
}
var result struct {
Tracks struct {
Items []QobuzTrack `json:"items"`
} `json:"tracks"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
// Find ISRC matches
var isrcMatches []*QobuzTrack
for i := range result.Tracks.Items {
if result.Tracks.Items[i].ISRC == isrc {
isrcMatches = append(isrcMatches, &result.Tracks.Items[i])
}
}
if len(isrcMatches) > 0 {
// Verify duration if provided
if expectedDurationSec > 0 {
var durationVerifiedMatches []*QobuzTrack
for _, track := range isrcMatches {
durationDiff := track.Duration - expectedDurationSec
if durationDiff < 0 {
durationDiff = -durationDiff
}
// Allow 30 seconds tolerance
if durationDiff <= 30 {
durationVerifiedMatches = append(durationVerifiedMatches, track)
}
}
if len(durationVerifiedMatches) > 0 {
fmt.Printf("[Qobuz] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
durationVerifiedMatches[0].Title, expectedDurationSec, durationVerifiedMatches[0].Duration)
return durationVerifiedMatches[0], nil
}
// ISRC matches but duration doesn't
fmt.Printf("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
isrc, expectedDurationSec, isrcMatches[0].Duration)
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)",
expectedDurationSec, isrcMatches[0].Duration)
}
// No duration to verify, return first match
fmt.Printf("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
return isrcMatches[0], nil
}
if len(result.Tracks.Items) == 0 {
return nil, fmt.Errorf("no tracks found for ISRC: %s", isrc)
}
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
}
// SearchTrackByISRCWithTitle is deprecated, use SearchTrackByISRCWithDuration instead
func (q *QobuzDownloader) SearchTrackByISRCWithTitle(isrc, expectedTitle string) (*QobuzTrack, error) {
return q.SearchTrackByISRCWithDuration(isrc, 0)
}
// SearchTrackByMetadata searches for a track using artist name and track name // SearchTrackByMetadata searches for a track using artist name and track name
func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) { func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) {
return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0)
}
// SearchTrackByMetadataWithDuration searches for a track with duration verification
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
// Try multiple search strategies // Try multiple search strategies
@@ -129,6 +275,8 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
queries = append(queries, trackName) queries = append(queries, trackName)
} }
var allTracks []QobuzTrack
for _, query := range queries { for _, query := range queries {
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(query), q.appID) searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(query), q.appID)
@@ -159,19 +307,50 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
resp.Body.Close() resp.Body.Close()
if len(result.Tracks.Items) > 0 { if len(result.Tracks.Items) > 0 {
// Return first result with best quality allTracks = append(allTracks, result.Tracks.Items...)
for i := range result.Tracks.Items { }
track := &result.Tracks.Items[i] }
if len(allTracks) == 0 {
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
}
// If duration verification is requested
if expectedDurationSec > 0 {
var durationMatches []*QobuzTrack
for i := range allTracks {
track := &allTracks[i]
durationDiff := track.Duration - expectedDurationSec
if durationDiff < 0 {
durationDiff = -durationDiff
}
if durationDiff <= 30 {
durationMatches = append(durationMatches, track)
}
}
if len(durationMatches) > 0 {
// Return best quality among duration matches
for _, track := range durationMatches {
if track.MaximumBitDepth >= 24 { if track.MaximumBitDepth >= 24 {
return track, nil return track, nil
} }
} }
// Return first result if no hi-res found return durationMatches[0], nil
return &result.Tracks.Items[0], nil
} }
// No duration match found
return nil, fmt.Errorf("no tracks found with matching duration (expected %ds)", expectedDurationSec)
} }
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName) // No duration verification, return best quality
for i := range allTracks {
track := &allTracks[i]
if track.MaximumBitDepth >= 24 {
return track, nil
}
}
return &allTracks[0], nil
} }
// getQobuzDownloadURLSequential requests download URL from APIs sequentially // getQobuzDownloadURLSequential requests download URL from APIs sequentially
@@ -321,27 +500,45 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
} }
// Convert expected duration from ms to seconds
expectedDurationSec := req.DurationMS / 1000
var track *QobuzTrack var track *QobuzTrack
var err error var err error
// Strategy 1: Search by ISRC // Strategy 1: Search by ISRC with duration verification
if req.ISRC != "" { if req.ISRC != "" {
track, err = downloader.SearchTrackByISRC(req.ISRC) track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
// Verify artist
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
fmt.Printf("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, track.Performer.Name)
track = nil
}
} }
// Strategy 2: Search by metadata // Strategy 2: Search by metadata with duration verification
if track == nil { if track == nil {
track, err = downloader.SearchTrackByMetadata(req.TrackName, req.ArtistName) track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
// Verify artist
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
fmt.Printf("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, track.Performer.Name)
track = nil
}
} }
if track == nil { if track == nil {
errMsg := "could not find track on Qobuz" errMsg := "could not find matching track on Qobuz (artist/duration mismatch)"
if err != nil { if err != nil {
errMsg = err.Error() errMsg = err.Error()
} }
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg) return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
} }
// Log match found
fmt.Printf("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
// Build filename // Build filename
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName, "title": req.TrackName,
+56 -2
View File
@@ -567,6 +567,7 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
} }
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) { func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
// First request to get playlist info and first batch of tracks
var data struct { var data struct {
Name string `json:"name"` Name string `json:"name"`
Images []image `json:"images"` Images []image `json:"images"`
@@ -577,7 +578,8 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
Items []struct { Items []struct {
Track *trackFull `json:"track"` Track *trackFull `json:"track"`
} `json:"items"` } `json:"items"`
Total int `json:"total"` Total int `json:"total"`
Next string `json:"next"`
} `json:"tracks"` } `json:"tracks"`
} }
@@ -591,7 +593,10 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
info.Owner.Name = data.Name info.Owner.Name = data.Name
info.Owner.Images = firstImageURL(data.Images) info.Owner.Images = firstImageURL(data.Images)
tracks := make([]AlbumTrackMetadata, 0, len(data.Tracks.Items)) // Pre-allocate with expected capacity
tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total)
// Add first batch of tracks
for _, item := range data.Tracks.Items { for _, item := range data.Tracks.Items {
if item.Track == nil { if item.Track == nil {
continue continue
@@ -615,6 +620,55 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
}) })
} }
// Fetch remaining tracks using pagination (up to 1000 tracks max)
nextURL := data.Tracks.Next
maxTracks := 1000
for nextURL != "" && len(tracks) < maxTracks {
var pageData struct {
Items []struct {
Track *trackFull `json:"track"`
} `json:"items"`
Next string `json:"next"`
}
if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil {
// Log error but return what we have so far
fmt.Printf("[Spotify] Warning: failed to fetch page, returning %d tracks: %v\n", len(tracks), err)
break
}
for _, item := range pageData.Items {
if item.Track == nil {
continue
}
if len(tracks) >= maxTracks {
break
}
tracks = append(tracks, AlbumTrackMetadata{
SpotifyID: item.Track.ID,
Artists: joinArtists(item.Track.Artists),
Name: item.Track.Name,
AlbumName: item.Track.Album.Name,
AlbumArtist: joinArtists(item.Track.Album.Artists),
DurationMS: item.Track.DurationMS,
Images: firstImageURL(item.Track.Album.Images),
ReleaseDate: item.Track.Album.ReleaseDate,
TrackNumber: item.Track.TrackNumber,
TotalTracks: item.Track.Album.TotalTracks,
DiscNumber: item.Track.DiscNumber,
ExternalURL: item.Track.ExternalURL.Spotify,
ISRC: item.Track.ExternalID.ISRC,
AlbumID: item.Track.Album.ID,
AlbumURL: item.Track.Album.ExternalURL.Spotify,
})
}
nextURL = pageData.Next
}
fmt.Printf("[Spotify] Fetched %d tracks from playlist (total: %d)\n", len(tracks), data.Tracks.Total)
return &PlaylistResponsePayload{ return &PlaylistResponsePayload{
PlaylistInfo: info, PlaylistInfo: info,
TrackList: tracks, TrackList: tracks,
+200 -6
View File
@@ -315,6 +315,28 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc) return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
} }
// normalizeTitle normalizes a track title for comparison (kept for potential future use)
func normalizeTitle(title string) string {
normalized := strings.ToLower(strings.TrimSpace(title))
// Remove common suffixes in parentheses or brackets
suffixPatterns := []string{
" (remaster)", " (remastered)", " (deluxe)", " (deluxe edition)",
" (bonus track)", " (single)", " (album version)", " (radio edit)",
" [remaster]", " [remastered]", " [deluxe]", " [bonus track]",
}
for _, suffix := range suffixPatterns {
normalized = strings.TrimSuffix(normalized, suffix)
}
// Remove multiple spaces
for strings.Contains(normalized, " ") {
normalized = strings.ReplaceAll(normalized, " ", " ")
}
return normalized
}
// SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority // SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) { func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
token, err := t.GetAccessToken() token, err := t.GetAccessToken()
@@ -390,14 +412,50 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
return nil, fmt.Errorf("no tracks found for any search query") return nil, fmt.Errorf("no tracks found for any search query")
} }
// Priority 1: Match by ISRC (exact match) // Priority 1: Match by ISRC (exact match) WITH title verification
if spotifyISRC != "" { if spotifyISRC != "" {
var isrcMatches []*TidalTrack
for i := range allTracks { for i := range allTracks {
track := &allTracks[i] track := &allTracks[i]
if track.ISRC == spotifyISRC { if track.ISRC == spotifyISRC {
return track, nil isrcMatches = append(isrcMatches, track)
} }
} }
if len(isrcMatches) > 0 {
// Verify duration first (most important check)
if expectedDuration > 0 {
var durationVerifiedMatches []*TidalTrack
for _, track := range isrcMatches {
durationDiff := track.Duration - expectedDuration
if durationDiff < 0 {
durationDiff = -durationDiff
}
// Allow 30 seconds tolerance for duration
if durationDiff <= 30 {
durationVerifiedMatches = append(durationVerifiedMatches, track)
}
}
if len(durationVerifiedMatches) > 0 {
// Return first duration-verified match
fmt.Printf("[Tidal] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration)
return durationVerifiedMatches[0], nil
}
// ISRC matches but duration doesn't - this is likely wrong version
fmt.Printf("[Tidal] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
spotifyISRC, expectedDuration, isrcMatches[0].Duration)
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version/edit)",
expectedDuration, isrcMatches[0].Duration)
}
// No duration to verify, just return first ISRC match
fmt.Printf("[Tidal] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
return isrcMatches[0], nil
}
// If ISRC was provided but no match found, return error // If ISRC was provided but no match found, return error
return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC) return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC)
} }
@@ -811,6 +869,64 @@ type TidalDownloadResult struct {
SampleRate int SampleRate int
} }
// artistsMatch checks if the artist names are similar enough
func artistsMatch(spotifyArtist, tidalArtist string) bool {
normSpotify := strings.ToLower(strings.TrimSpace(spotifyArtist))
normTidal := strings.ToLower(strings.TrimSpace(tidalArtist))
// Exact match
if normSpotify == normTidal {
return true
}
// Check if one contains the other (for cases like "Artist" vs "Artist feat. Someone")
if strings.Contains(normSpotify, normTidal) || strings.Contains(normTidal, normSpotify) {
return true
}
// Check first artist (before comma or feat)
spotifyFirst := strings.Split(normSpotify, ",")[0]
spotifyFirst = strings.Split(spotifyFirst, " feat")[0]
spotifyFirst = strings.Split(spotifyFirst, " ft.")[0]
spotifyFirst = strings.TrimSpace(spotifyFirst)
tidalFirst := strings.Split(normTidal, ",")[0]
tidalFirst = strings.Split(tidalFirst, " feat")[0]
tidalFirst = strings.Split(tidalFirst, " ft.")[0]
tidalFirst = strings.TrimSpace(tidalFirst)
if spotifyFirst == tidalFirst {
return true
}
// Check if first artist is contained in the other
if strings.Contains(spotifyFirst, tidalFirst) || strings.Contains(tidalFirst, spotifyFirst) {
return true
}
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
// assume they're the same artist with different transliteration
// This handles cases like "鈴木雅之" vs "Masayuki Suzuki"
spotifyASCII := isASCIIString(spotifyArtist)
tidalASCII := isASCIIString(tidalArtist)
if spotifyASCII != tidalASCII {
fmt.Printf("[Tidal] Artist names in different scripts, assuming match: '%s' vs '%s'\n", spotifyArtist, tidalArtist)
return true
}
return false
}
// isASCIIString checks if a string contains only ASCII characters
func isASCIIString(s string) bool {
for _, r := range s {
if r > 127 {
return false
}
}
return true
}
// downloadFromTidal downloads a track using the request parameters // downloadFromTidal downloads a track using the request parameters
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
downloader := NewTidalDownloader() downloader := NewTidalDownloader()
@@ -820,6 +936,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
} }
// Convert expected duration from ms to seconds
expectedDurationSec := req.DurationMS / 1000
var track *TidalTrack var track *TidalTrack
var err error var err error
@@ -831,28 +950,103 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL) trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
if idErr == nil { if idErr == nil {
track, err = downloader.GetTrackInfoByID(trackID) track, err = downloader.GetTrackInfoByID(trackID)
if track != nil {
// Get artist name from track
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, ", ")
}
// Verify artist matches
if !artistsMatch(req.ArtistName, tidalArtist) {
fmt.Printf("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, tidalArtist)
track = nil
}
// Verify duration if we have expected duration
if track != nil && expectedDurationSec > 0 {
durationDiff := track.Duration - expectedDurationSec
if durationDiff < 0 {
durationDiff = -durationDiff
}
// Allow 30 seconds tolerance
if durationDiff > 30 {
fmt.Printf("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
expectedDurationSec, track.Duration)
track = nil // Reject this match
}
}
}
} }
} }
} }
// Strategy 2: Search by ISRC with multi-strategy fallback // Strategy 2: Search by ISRC with duration verification
if track == nil && req.ISRC != "" { if track == nil && req.ISRC != "" {
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, 0) 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)
if track == nil { if track == nil {
track, err = downloader.SearchTrackByMetadata(req.TrackName, req.ArtistName) track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec)
// Verify artist for metadata search too
if track != nil {
tidalArtist := track.Artist.Name
if len(track.Artists) > 0 {
var artistNames []string
for _, a := range track.Artists {
artistNames = append(artistNames, a.Name)
}
tidalArtist = strings.Join(artistNames, ", ")
}
if !artistsMatch(req.ArtistName, tidalArtist) {
fmt.Printf("[Tidal] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, tidalArtist)
track = nil
}
}
} }
if track == nil { if track == nil {
errMsg := "could not find track on Tidal" errMsg := "could not find matching track on Tidal (artist/duration mismatch)"
if err != nil { if err != nil {
errMsg = err.Error() errMsg = err.Error()
} }
return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg) return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg)
} }
// Final verification logging
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, ", ")
}
fmt.Printf("[Tidal] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, tidalArtist, track.Duration)
// Build filename // Build filename
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName, "title": req.TrackName,
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants /// App version and info constants
/// Update version here only - all other files will reference this /// Update version here only - all other files will reference this
class AppInfo { class AppInfo {
static const String version = '2.0.4'; static const String version = '2.0.7-preview';
static const String buildNumber = '34'; static const String buildNumber = '37';
static const String fullVersion = '$version+$buildNumber'; static const String fullVersion = '$version+$buildNumber';
+5 -18
View File
@@ -4,8 +4,6 @@ import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/models/track.dart';
@@ -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 // For now, we'll use FFmpeg to embed cover since Go backend expects to download the file
// FFmpeg can embed cover art to FLAC // FFmpeg can embed cover art to FLAC
if (coverPath != null && await File(coverPath).exists()) { if (coverPath != null && await File(coverPath).exists()) {
final tempOutput = '$flacPath.tmp'; final result = await FFmpegService.embedCover(flacPath, coverPath);
final command = '-i "$flacPath" -i "$coverPath" -map 0:a -map 1:0 -c copy -metadata:s:v title="Album cover" -metadata:s:v comment="Cover (front)" -disposition:v attached_pic "$tempOutput" -y';
final session = await FFmpegKit.execute(command); if (result != null) {
final returnCode = await session.getReturnCode();
if (ReturnCode.isSuccess(returnCode)) {
// Replace original with temp
await File(flacPath).delete();
await File(tempOutput).rename(flacPath);
_log.d('Cover embedded via FFmpeg'); _log.d('Cover embedded via FFmpeg');
} else { } else {
// Try alternative method using metaflac-style embedding _log.w('FFmpeg cover embed failed');
_log.w('FFmpeg cover embed failed, trying alternative...');
// Clean up temp file if exists
final tempFile = File(tempOutput);
if (await tempFile.exists()) {
await tempFile.delete();
}
} }
// Clean up cover file // Clean up cover file
@@ -1007,6 +992,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
releaseDate: item.track.releaseDate, releaseDate: item.track.releaseDate,
preferredService: item.service, preferredService: item.service,
itemId: item.id, // Pass item ID for progress tracking itemId: item.id, // Pass item ID for progress tracking
durationMs: item.track.duration, // Duration in ms for verification
); );
} else { } else {
result = await PlatformBridge.downloadTrack( result = await PlatformBridge.downloadTrack(
@@ -1025,6 +1011,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
discNumber: item.track.discNumber ?? 1, discNumber: item.track.discNumber ?? 1,
releaseDate: item.track.releaseDate, releaseDate: item.track.releaseDate,
itemId: item.id, // Pass item ID for progress tracking itemId: item.id, // Pass item ID for progress tracking
durationMs: item.track.duration, // Duration in ms for verification
); );
} }
+2 -2
View File
@@ -266,7 +266,7 @@ class TrackNotifier extends Notifier<TrackState> {
albumArtist: data['album_artist'] as String?, albumArtist: data['album_artist'] as String?,
coverUrl: data['images'] as String?, coverUrl: data['images'] as String?,
isrc: data['isrc'] as String?, isrc: data['isrc'] as String?,
duration: data['duration_ms'] as int? ?? 0, duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
trackNumber: data['track_number'] as int?, trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?, discNumber: data['disc_number'] as int?,
releaseDate: data['release_date'] as String?, releaseDate: data['release_date'] as String?,
@@ -282,7 +282,7 @@ class TrackNotifier extends Notifier<TrackState> {
albumArtist: data['album_artist'] as String?, albumArtist: data['album_artist'] as String?,
coverUrl: data['images'] as String?, coverUrl: data['images'] as String?,
isrc: data['isrc'] as String?, isrc: data['isrc'] as String?,
duration: data['duration_ms'] as int? ?? 0, duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
trackNumber: data['track_number'] as int?, trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?, discNumber: data['disc_number'] as int?,
releaseDate: data['release_date'] as String?, releaseDate: data['release_date'] as String?,
+1 -1
View File
@@ -104,7 +104,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
albumArtist: data['album_artist'] as String?, albumArtist: data['album_artist'] as String?,
coverUrl: data['images'] as String?, coverUrl: data['images'] as String?,
isrc: data['isrc'] as String?, isrc: data['isrc'] as String?,
duration: data['duration_ms'] as int? ?? 0, duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
trackNumber: data['track_number'] as int?, trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?, discNumber: data['disc_number'] as int?,
releaseDate: data['release_date'] as String?, releaseDate: data['release_date'] as String?,
+38 -44
View File
@@ -12,53 +12,46 @@ class AboutPage extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = MediaQuery.of(context).padding.top;
return Scaffold( return PopScope(
body: CustomScrollView( canPop: true,
slivers: [ child: Scaffold(
// Collapsing App Bar with back button body: CustomScrollView(
SliverAppBar( slivers: [
expandedHeight: 120 + topPadding, // Collapsing App Bar with back button
collapsedHeight: kToolbarHeight, SliverAppBar(
floating: false, expandedHeight: 120 + topPadding,
pinned: true, collapsedHeight: kToolbarHeight,
backgroundColor: colorScheme.surface, floating: false,
surfaceTintColor: Colors.transparent, pinned: true,
leading: IconButton( backgroundColor: colorScheme.surface,
icon: const Icon(Icons.arrow_back), surfaceTintColor: Colors.transparent,
onPressed: () => Navigator.pop(context), leading: IconButton(
), icon: const Icon(Icons.arrow_back),
flexibleSpace: LayoutBuilder( onPressed: () => Navigator.pop(context),
builder: (context, constraints) { ),
final maxHeight = 120 + topPadding; flexibleSpace: LayoutBuilder(
final minHeight = kToolbarHeight + topPadding; builder: (context, constraints) {
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0); final maxHeight = 120 + topPadding;
final animation = AlwaysStoppedAnimation(expandRatio); final minHeight = kToolbarHeight + topPadding;
return FlexibleSpaceBar( final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
expandedTitleScale: 1.0, // When collapsed (expandRatio=0): left=56 to avoid back button
titlePadding: EdgeInsets.zero, // When expanded (expandRatio=1): left=24 for normal padding
title: SafeArea( final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
child: Container( return FlexibleSpaceBar(
alignment: Alignment.bottomLeft, expandedTitleScale: 1.0,
padding: EdgeInsets.only( titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
// When collapsed (expandRatio=0): left=56 to align with back button title: Text(
// When expanded (expandRatio=1): left=24 for normal padding 'About',
left: Tween<double>(begin: 56, end: 24).evaluate(animation), style: TextStyle(
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation), fontSize: 20 + (8 * expandRatio), // 20 -> 28
), fontWeight: FontWeight.bold,
child: Text( color: colorScheme.onSurface,
'About',
style: TextStyle(
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
), ),
), ),
), );
); },
}, ),
), ),
),
// App header card with logo and description // App header card with logo and description
SliverToBoxAdapter( SliverToBoxAdapter(
@@ -166,6 +159,7 @@ class AboutPage extends StatelessWidget {
const SliverToBoxAdapter(child: SizedBox(height: 16)), const SliverToBoxAdapter(child: SizedBox(height: 16)),
], ],
), ),
),
); );
} }
@@ -14,104 +14,113 @@ class AppearanceSettingsPage extends ConsumerWidget {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = MediaQuery.of(context).padding.top;
return Scaffold( return PopScope(
body: CustomScrollView( canPop: true,
slivers: [ child: Scaffold(
// Collapsing App Bar with back button body: CustomScrollView(
SliverAppBar( slivers: [
expandedHeight: 120 + topPadding, // Collapsing App Bar with back button
collapsedHeight: kToolbarHeight, SliverAppBar(
floating: false, expandedHeight: 120 + topPadding,
pinned: true, collapsedHeight: kToolbarHeight,
backgroundColor: colorScheme.surface, floating: false,
surfaceTintColor: Colors.transparent, pinned: true,
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)), backgroundColor: colorScheme.surface,
flexibleSpace: LayoutBuilder( surfaceTintColor: Colors.transparent,
builder: (context, constraints) { leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
final maxHeight = 120 + topPadding; flexibleSpace: _AppBarTitle(title: 'Appearance', topPadding: topPadding),
final minHeight = kToolbarHeight + topPadding; ),
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
final animation = AlwaysStoppedAnimation(expandRatio); // Theme section
return FlexibleSpaceBar( const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Theme')),
expandedTitleScale: 1.0, SliverToBoxAdapter(
titlePadding: EdgeInsets.zero, child: SettingsGroup(
title: SafeArea( children: [
child: Container( _ThemeModeSelector(
alignment: Alignment.bottomLeft, currentMode: themeSettings.themeMode,
padding: EdgeInsets.only( onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
left: Tween<double>(begin: 56, end: 24).evaluate(animation), ),
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation), ],
), ),
child: Text('Appearance', ),
style: TextStyle(
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation), // Color section
fontWeight: FontWeight.bold, const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Color')),
color: colorScheme.onSurface, SliverToBoxAdapter(
), child: SettingsGroup(
), children: [
SettingsSwitchItem(
icon: Icons.auto_awesome,
title: 'Dynamic Color',
subtitle: 'Use colors from your wallpaper',
value: themeSettings.useDynamicColor,
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
showDivider: !themeSettings.useDynamicColor,
),
if (!themeSettings.useDynamicColor)
_ColorPicker(
currentColor: themeSettings.seedColorValue,
onColorSelected: (color) => ref.read(themeProvider.notifier).setSeedColor(color),
), ),
],
),
),
// Layout section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Layout')),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_HistoryViewSelector(
currentMode: settings.historyViewMode,
onChanged: (mode) => ref.read(settingsProvider.notifier).setHistoryViewMode(mode),
), ),
); ],
}, ),
), ),
),
// Theme section // Fill remaining for scroll
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Theme')), const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
SliverToBoxAdapter( ],
child: SettingsGroup( ),
children: [
_ThemeModeSelector(
currentMode: themeSettings.themeMode,
onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
),
],
),
),
// Color section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Color')),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.auto_awesome,
title: 'Dynamic Color',
subtitle: 'Use colors from your wallpaper',
value: themeSettings.useDynamicColor,
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
showDivider: !themeSettings.useDynamicColor,
),
if (!themeSettings.useDynamicColor)
_ColorPicker(
currentColor: themeSettings.seedColorValue,
onColorSelected: (color) => ref.read(themeProvider.notifier).setSeedColor(color),
),
],
),
),
// Layout section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Layout')),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_HistoryViewSelector(
currentMode: settings.historyViewMode,
onChanged: (mode) => ref.read(settingsProvider.notifier).setHistoryViewMode(mode),
),
],
),
),
// Fill remaining for scroll
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
],
), ),
); );
} }
} }
/// Optimized app bar title with animation
class _AppBarTitle extends StatelessWidget {
final String title;
final double topPadding;
const _AppBarTitle({required this.title, required this.topPadding});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
title,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
);
}
}
class _ThemeModeSelector extends StatelessWidget { class _ThemeModeSelector extends StatelessWidget {
final ThemeMode currentMode; final ThemeMode currentMode;
final ValueChanged<ThemeMode> onChanged; final ValueChanged<ThemeMode> onChanged;
@@ -13,47 +13,41 @@ class DownloadSettingsPage extends ConsumerWidget {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = MediaQuery.of(context).padding.top;
return Scaffold( return PopScope(
body: CustomScrollView( canPop: true,
slivers: [ child: Scaffold(
// Collapsing App Bar with back button body: CustomScrollView(
SliverAppBar( slivers: [
expandedHeight: 120 + topPadding, // Collapsing App Bar with back button
collapsedHeight: kToolbarHeight, SliverAppBar(
floating: false, expandedHeight: 120 + topPadding,
pinned: true, collapsedHeight: kToolbarHeight,
backgroundColor: colorScheme.surface, floating: false,
surfaceTintColor: Colors.transparent, pinned: true,
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)), backgroundColor: colorScheme.surface,
flexibleSpace: LayoutBuilder( surfaceTintColor: Colors.transparent,
builder: (context, constraints) { leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
final maxHeight = 120 + topPadding; flexibleSpace: LayoutBuilder(
final minHeight = kToolbarHeight + topPadding; builder: (context, constraints) {
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0); final maxHeight = 120 + topPadding;
final animation = AlwaysStoppedAnimation(expandRatio); final minHeight = kToolbarHeight + topPadding;
return FlexibleSpaceBar( final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
expandedTitleScale: 1.0, final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
titlePadding: EdgeInsets.zero, return FlexibleSpaceBar(
title: SafeArea( expandedTitleScale: 1.0,
child: Container( titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
alignment: Alignment.bottomLeft, title: Text(
padding: EdgeInsets.only( 'Download',
left: Tween<double>(begin: 56, end: 24).evaluate(animation), style: TextStyle(
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation), fontSize: 20 + (8 * expandRatio), // 20 -> 28
), fontWeight: FontWeight.bold,
child: Text('Download', color: colorScheme.onSurface,
style: TextStyle(
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
), ),
), ),
), );
); },
}, ),
), ),
),
// Service section // Service section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Service')), const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Service')),
@@ -136,6 +130,7 @@ class DownloadSettingsPage extends ConsumerWidget {
const SliverToBoxAdapter(child: SizedBox(height: 32)), const SliverToBoxAdapter(child: SizedBox(height: 32)),
], ],
), ),
),
); );
} }
+33 -38
View File
@@ -14,47 +14,41 @@ class OptionsSettingsPage extends ConsumerWidget {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = MediaQuery.of(context).padding.top;
return Scaffold( return PopScope(
body: CustomScrollView( canPop: true,
slivers: [ child: Scaffold(
// Collapsing App Bar with back button body: CustomScrollView(
SliverAppBar( slivers: [
expandedHeight: 120 + topPadding, // Collapsing App Bar with back button
collapsedHeight: kToolbarHeight, SliverAppBar(
floating: false, expandedHeight: 120 + topPadding,
pinned: true, collapsedHeight: kToolbarHeight,
backgroundColor: colorScheme.surface, floating: false,
surfaceTintColor: Colors.transparent, pinned: true,
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)), backgroundColor: colorScheme.surface,
flexibleSpace: LayoutBuilder( surfaceTintColor: Colors.transparent,
builder: (context, constraints) { leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
final maxHeight = 120 + topPadding; flexibleSpace: LayoutBuilder(
final minHeight = kToolbarHeight + topPadding; builder: (context, constraints) {
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0); final maxHeight = 120 + topPadding;
final animation = AlwaysStoppedAnimation(expandRatio); final minHeight = kToolbarHeight + topPadding;
return FlexibleSpaceBar( final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
expandedTitleScale: 1.0, final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
titlePadding: EdgeInsets.zero, return FlexibleSpaceBar(
title: SafeArea( expandedTitleScale: 1.0,
child: Container( titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
alignment: Alignment.bottomLeft, title: Text(
padding: EdgeInsets.only( 'Options',
left: Tween<double>(begin: 56, end: 24).evaluate(animation), style: TextStyle(
bottom: Tween<double>(begin: 16, end: 16).evaluate(animation), fontSize: 20 + (8 * expandRatio), // 20 -> 28
), fontWeight: FontWeight.bold,
child: Text('Options', color: colorScheme.onSurface,
style: TextStyle(
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
), ),
), ),
), );
); },
}, ),
), ),
),
// Download options section // Download options section
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Download')), const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Download')),
@@ -168,6 +162,7 @@ class OptionsSettingsPage extends ConsumerWidget {
const SliverToBoxAdapter(child: SizedBox(height: 32)), const SliverToBoxAdapter(child: SizedBox(height: 32)),
], ],
), ),
),
); );
} }
+78 -23
View File
@@ -1,12 +1,30 @@
import 'dart:io'; import 'dart:io';
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart'; import 'package:flutter/services.dart';
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('FFmpeg'); final _log = AppLogger('FFmpeg');
/// FFmpeg service for audio conversion and remuxing /// FFmpeg service for audio conversion and remuxing
/// Uses native MethodChannel to call FFmpegKit from local AAR
class FFmpegService { class FFmpegService {
static const _channel = MethodChannel('com.zarz.spotiflac/ffmpeg');
/// Execute FFmpeg command and return result
static Future<FFmpegResult> _execute(String command) async {
try {
final result = await _channel.invokeMethod('execute', {'command': command});
final map = Map<String, dynamic>.from(result);
return FFmpegResult(
success: map['success'] as bool,
returnCode: map['returnCode'] as int,
output: map['output'] as String,
);
} catch (e) {
_log.e('FFmpeg execute error: $e');
return FFmpegResult(success: false, returnCode: -1, output: e.toString());
}
}
/// Convert M4A (DASH segments) to FLAC /// Convert M4A (DASH segments) to FLAC
/// Returns the output file path on success, null on failure /// Returns the output file path on success, null on failure
static Future<String?> convertM4aToFlac(String inputPath) async { static Future<String?> convertM4aToFlac(String inputPath) async {
@@ -16,10 +34,9 @@ class FFmpegService {
final command = final command =
'-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y'; '-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
final session = await FFmpegKit.execute(command); final result = await _execute(command);
final returnCode = await session.getReturnCode();
if (ReturnCode.isSuccess(returnCode)) { if (result.success) {
// Delete original M4A file // Delete original M4A file
try { try {
await File(inputPath).delete(); await File(inputPath).delete();
@@ -27,12 +44,7 @@ class FFmpegService {
return outputPath; return outputPath;
} }
// Log error for debugging _log.e('M4A to FLAC conversion failed: ${result.output}');
final logs = await session.getLogs();
for (final log in logs) {
_log.d(log.getMessage());
}
return null; return null;
} }
@@ -54,13 +66,13 @@ class FFmpegService {
final command = final command =
'-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y'; '-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
final session = await FFmpegKit.execute(command); final result = await _execute(command);
final returnCode = await session.getReturnCode();
if (ReturnCode.isSuccess(returnCode)) { if (result.success) {
return outputPath; return outputPath;
} }
_log.e('FLAC to MP3 conversion failed: ${result.output}');
return null; return null;
} }
@@ -91,22 +103,21 @@ class FFmpegService {
'-i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -map_metadata 0 "$outputPath" -y'; '-i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -map_metadata 0 "$outputPath" -y';
} }
final session = await FFmpegKit.execute(command); final result = await _execute(command);
final returnCode = await session.getReturnCode();
if (ReturnCode.isSuccess(returnCode)) { if (result.success) {
return outputPath; return outputPath;
} }
_log.e('FLAC to M4A conversion failed: ${result.output}');
return null; return null;
} }
/// Check if FFmpeg is available /// Check if FFmpeg is available
static Future<bool> isAvailable() async { static Future<bool> isAvailable() async {
try { try {
final session = await FFmpegKit.execute('-version'); final version = await _channel.invokeMethod('getVersion');
final returnCode = await session.getReturnCode(); return version != null && version.toString().isNotEmpty;
return ReturnCode.isSuccess(returnCode);
} catch (e) { } catch (e) {
return false; return false;
} }
@@ -115,11 +126,55 @@ class FFmpegService {
/// Get FFmpeg version info /// Get FFmpeg version info
static Future<String?> getVersion() async { static Future<String?> getVersion() async {
try { try {
final session = await FFmpegKit.execute('-version'); final version = await _channel.invokeMethod('getVersion');
final output = await session.getOutput(); return version as String?;
return output;
} catch (e) { } catch (e) {
return null; return null;
} }
} }
/// Embed cover art to FLAC file
/// Returns the file path on success, null on failure
static Future<String?> embedCover(String flacPath, String coverPath) async {
final tempOutput = '$flacPath.tmp';
final command = '-i "$flacPath" -i "$coverPath" -map 0:a -map 1:0 -c copy -metadata:s:v title="Album cover" -metadata:s:v comment="Cover (front)" -disposition:v attached_pic "$tempOutput" -y';
final result = await _execute(command);
if (result.success) {
try {
// Replace original with temp
await File(flacPath).delete();
await File(tempOutput).rename(flacPath);
return flacPath;
} catch (e) {
_log.e('Failed to replace file after cover embed: $e');
return null;
}
}
// Clean up temp file if exists
try {
final tempFile = File(tempOutput);
if (await tempFile.exists()) {
await tempFile.delete();
}
} catch (_) {}
_log.e('Cover embed failed: ${result.output}');
return null;
}
}
/// Result of FFmpeg command execution
class FFmpegResult {
final bool success;
final int returnCode;
final String output;
FFmpegResult({
required this.success,
required this.returnCode,
required this.output,
});
} }
+4
View File
@@ -65,6 +65,7 @@ class PlatformBridge {
int totalTracks = 1, int totalTracks = 1,
String? releaseDate, String? releaseDate,
String? itemId, String? itemId,
int durationMs = 0,
}) async { }) async {
final request = jsonEncode({ final request = jsonEncode({
'isrc': isrc, 'isrc': isrc,
@@ -85,6 +86,7 @@ class PlatformBridge {
'total_tracks': totalTracks, 'total_tracks': totalTracks,
'release_date': releaseDate ?? '', 'release_date': releaseDate ?? '',
'item_id': itemId ?? '', 'item_id': itemId ?? '',
'duration_ms': durationMs,
}); });
final result = await _channel.invokeMethod('downloadTrack', request); final result = await _channel.invokeMethod('downloadTrack', request);
@@ -111,6 +113,7 @@ class PlatformBridge {
String? releaseDate, String? releaseDate,
String preferredService = 'tidal', String preferredService = 'tidal',
String? itemId, String? itemId,
int durationMs = 0,
}) async { }) async {
final request = jsonEncode({ final request = jsonEncode({
'isrc': isrc, 'isrc': isrc,
@@ -131,6 +134,7 @@ class PlatformBridge {
'total_tracks': totalTracks, 'total_tracks': totalTracks,
'release_date': releaseDate ?? '', 'release_date': releaseDate ?? '',
'item_id': itemId ?? '', 'item_id': itemId ?? '',
'duration_ms': durationMs,
}); });
final result = await _channel.invokeMethod('downloadWithFallback', request); final result = await _channel.invokeMethod('downloadWithFallback', request);
-16
View File
@@ -297,22 +297,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" version: "2.1.4"
ffmpeg_kit_flutter_new_audio:
dependency: "direct main"
description:
name: ffmpeg_kit_flutter_new_audio
sha256: "0a698b46cd163c8e9917af75325c84d27871a2a8b2c37de3b40486cd0ab662ae"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
ffmpeg_kit_flutter_platform_interface:
dependency: transitive
description:
name: ffmpeg_kit_flutter_platform_interface
sha256: addf046ae44e190ad0101b2fde2ad909a3cd08a2a109f6106d2f7048b7abedee
url: "https://pub.dev"
source: hosted
version: "0.2.1"
file: file:
dependency: transitive dependency: transitive
description: description:
+3 -3
View File
@@ -1,7 +1,7 @@
name: spotiflac_android name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: 'none' publish_to: 'none'
version: 2.0.4+34 version: 2.0.7+37
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0
@@ -50,8 +50,8 @@ dependencies:
receive_sharing_intent: ^1.8.1 receive_sharing_intent: ^1.8.1
logger: ^2.5.0 logger: ^2.5.0
# FFmpeg for audio conversion (audio-only version - much smaller) # FFmpeg - using local custom AAR (arm64-v8a + armeabi-v7a only)
ffmpeg_kit_flutter_new_audio: ^2.0.0 # ffmpeg_kit_flutter_new_audio: ^2.0.0 # Replaced with local AAR
open_filex: ^4.7.0 open_filex: ^4.7.0
# Notifications # Notifications
+82
View File
@@ -0,0 +1,82 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: 'none'
version: 2.0.7+37
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/