Compare commits

...

4 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
23 changed files with 907 additions and 325 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
+19
View File
@@ -1,5 +1,24 @@
# 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 ## [2.0.5] - 2026-01-05
### Added ### Added
-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,
+49 -10
View File
@@ -5,6 +5,7 @@ package gobackend
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"strings" "strings"
"time" "time"
) )
@@ -216,17 +217,36 @@ func DownloadTrack(requestJSON string) (string, error) {
// Check if file already exists // Check if file already exists
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" { if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
actualPath := result.FilePath[7:]
// Read actual quality from existing file
quality, qErr := GetAudioQuality(actualPath)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
}
resp := DownloadResponse{ resp := DownloadResponse{
Success: true, Success: true,
Message: "File already exists", Message: "File already exists",
FilePath: result.FilePath[7:], FilePath: actualPath,
AlreadyExists: true, AlreadyExists: true,
Service: req.Service, ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate,
Service: req.Service,
} }
jsonBytes, _ := json.Marshal(resp) jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// Read actual quality from downloaded file (more accurate than API)
quality, qErr := GetAudioQuality(result.FilePath)
if qErr == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
} else {
fmt.Printf("[Download] Could not read quality from file: %v\n", qErr)
}
resp := DownloadResponse{ resp := DownloadResponse{
Success: true, Success: true,
Message: "Download complete", Message: "Download complete",
@@ -314,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
+73 -14
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{
@@ -451,34 +509,35 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
// Strategy 1: Search by ISRC with duration verification // Strategy 1: Search by ISRC with duration verification
if req.ISRC != "" { if req.ISRC != "" {
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec) track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
// Verify artist
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
fmt.Printf("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, track.Performer.Name)
track = nil
}
} }
// Strategy 2: Search by metadata with duration verification // Strategy 2: Search by metadata with duration verification
if track == nil { if track == nil {
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec) track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
// Verify artist
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
fmt.Printf("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, track.Performer.Name)
track = nil
}
} }
if track == nil { if track == nil {
errMsg := "could not find track on Qobuz" errMsg := "could not find matching track on Qobuz (artist/duration mismatch)"
if err != nil { if err != nil {
errMsg = err.Error() errMsg = err.Error()
} }
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg) return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
} }
// Final duration verification // Log match found
if expectedDurationSec > 0 { fmt.Printf("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
durationDiff := track.Duration - expectedDurationSec
if durationDiff < 0 {
durationDiff = -durationDiff
}
if durationDiff > 30 {
return QobuzDownloadResult{}, fmt.Errorf("duration mismatch: expected %ds, found %ds (diff: %ds). Track may be wrong version",
expectedDurationSec, track.Duration, durationDiff)
}
fmt.Printf("[Qobuz] Duration verified: expected %ds, found %ds (diff: %ds)\n",
expectedDurationSec, track.Duration, durationDiff)
}
// Build filename // Build filename
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
+128 -22
View File
@@ -869,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()
@@ -892,17 +950,36 @@ 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)
// Verify duration if we have expected duration if track != nil {
if track != nil && expectedDurationSec > 0 { // Get artist name from track
durationDiff := track.Duration - expectedDurationSec tidalArtist := track.Artist.Name
if durationDiff < 0 { if len(track.Artists) > 0 {
durationDiff = -durationDiff var artistNames []string
for _, a := range track.Artists {
artistNames = append(artistNames, a.Name)
}
tidalArtist = strings.Join(artistNames, ", ")
} }
// Allow 30 seconds tolerance
if durationDiff > 30 { // Verify artist matches
fmt.Printf("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n", if !artistsMatch(req.ArtistName, tidalArtist) {
expectedDurationSec, track.Duration) fmt.Printf("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
track = nil // Reject this match req.ArtistName, tidalArtist)
track = nil
}
// Verify duration if we have expected duration
if track != nil && expectedDurationSec > 0 {
durationDiff := track.Duration - expectedDurationSec
if durationDiff < 0 {
durationDiff = -durationDiff
}
// Allow 30 seconds tolerance
if durationDiff > 30 {
fmt.Printf("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
expectedDurationSec, track.Duration)
track = nil // Reject this match
}
} }
} }
} }
@@ -912,34 +989,63 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
// Strategy 2: Search by ISRC with duration verification // 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, expectedDurationSec) 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.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec) track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec)
// Verify artist for metadata search too
if track != nil {
tidalArtist := track.Artist.Name
if len(track.Artists) > 0 {
var artistNames []string
for _, a := range track.Artists {
artistNames = append(artistNames, a.Name)
}
tidalArtist = strings.Join(artistNames, ", ")
}
if !artistsMatch(req.ArtistName, tidalArtist) {
fmt.Printf("[Tidal] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, tidalArtist)
track = nil
}
}
} }
if track == nil { if track == nil {
errMsg := "could not find track on Tidal" errMsg := "could not find matching track on Tidal (artist/duration mismatch)"
if err != nil { if err != nil {
errMsg = err.Error() errMsg = err.Error()
} }
return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg) return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg)
} }
// Final duration verification // Final verification logging
if expectedDurationSec > 0 { tidalArtist := track.Artist.Name
durationDiff := track.Duration - expectedDurationSec if len(track.Artists) > 0 {
if durationDiff < 0 { var artistNames []string
durationDiff = -durationDiff for _, a := range track.Artists {
artistNames = append(artistNames, a.Name)
} }
if durationDiff > 30 { tidalArtist = strings.Join(artistNames, ", ")
return TidalDownloadResult{}, fmt.Errorf("duration mismatch: expected %ds, found %ds (diff: %ds). Track may be wrong version",
expectedDurationSec, track.Duration, durationDiff)
}
fmt.Printf("[Tidal] Duration verified: expected %ds, found %ds (diff: %ds)\n",
expectedDurationSec, track.Duration, durationDiff)
} }
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{}{
+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.5'; static const String version = '2.0.7-preview';
static const String buildNumber = '35'; static const String buildNumber = '37';
static const String fullVersion = '$version+$buildNumber'; static const String fullVersion = '$version+$buildNumber';
+3 -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
+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,
});
} }
-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.5+35 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/