diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e56c9b2..56e77a3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -229,6 +229,23 @@ jobs: channel: 'stable' cache: true + # Swap pubspec for iOS build (includes ffmpeg_kit_flutter) + - name: Use iOS pubspec with FFmpeg plugin + run: | + cp pubspec.yaml pubspec_android_backup.yaml + cp pubspec_ios.yaml pubspec.yaml + echo "Swapped to iOS pubspec with ffmpeg_kit_flutter" + + # Swap FFmpeg service for iOS + - name: Use iOS FFmpeg service + run: | + cp lib/services/ffmpeg_service.dart lib/services/ffmpeg_service_android.dart + cp build_assets/ffmpeg_service_ios.dart lib/services/ffmpeg_service.dart + # Update class name in the swapped file + sed -i '' 's/FFmpegServiceIOS/FFmpegService/g' lib/services/ffmpeg_service.dart + sed -i '' 's/FFmpegResultIOS/FFmpegResult/g' lib/services/ffmpeg_service.dart + echo "Swapped to iOS FFmpeg service" + - name: Get Flutter dependencies run: flutter pub get diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 1faffac..871c5c4 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -95,6 +95,7 @@ repositories { dependencies { coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") implementation(files("libs/gobackend.aar")) + implementation(files("libs/ffmpeg-kit-with-lame.aar")) implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") } diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index c285e53..e643c91 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -5,6 +5,8 @@ import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel import gobackend.Gobackend +import com.arthenica.ffmpegkit.FFmpegKit +import com.arthenica.ffmpegkit.ReturnCode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -13,6 +15,7 @@ import kotlinx.coroutines.withContext class MainActivity: FlutterActivity() { private val CHANNEL = "com.zarz.spotiflac/backend" + private val FFMPEG_CHANNEL = "com.zarz.spotiflac/ffmpeg" private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) override fun onNewIntent(intent: Intent) { @@ -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("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) + } + } + } } } diff --git a/build_assets/ffmpeg_service_ios.dart b/build_assets/ffmpeg_service_ios.dart new file mode 100644 index 0000000..e5172bd --- /dev/null +++ b/build_assets/ffmpeg_service_ios.dart @@ -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 _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 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 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 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 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 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 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}); +} diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 9cad5fe..a54a360 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '2.0.6'; - static const String buildNumber = '36'; + static const String version = '2.0.7-preview'; + static const String buildNumber = '37'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index dc584f8..acfa16a 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -4,8 +4,6 @@ import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart'; -import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart'; import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/models/track.dart'; @@ -745,25 +743,12 @@ class DownloadQueueNotifier extends Notifier { // For now, we'll use FFmpeg to embed cover since Go backend expects to download the file // FFmpeg can embed cover art to FLAC if (coverPath != null && await File(coverPath).exists()) { - final tempOutput = '$flacPath.tmp'; - final command = '-i "$flacPath" -i "$coverPath" -map 0:a -map 1:0 -c copy -metadata:s:v title="Album cover" -metadata:s:v comment="Cover (front)" -disposition:v attached_pic "$tempOutput" -y'; + final result = await FFmpegService.embedCover(flacPath, coverPath); - final session = await FFmpegKit.execute(command); - final returnCode = await session.getReturnCode(); - - if (ReturnCode.isSuccess(returnCode)) { - // Replace original with temp - await File(flacPath).delete(); - await File(tempOutput).rename(flacPath); + if (result != null) { _log.d('Cover embedded via FFmpeg'); } else { - // Try alternative method using metaflac-style embedding - _log.w('FFmpeg cover embed failed, trying alternative...'); - // Clean up temp file if exists - final tempFile = File(tempOutput); - if (await tempFile.exists()) { - await tempFile.delete(); - } + _log.w('FFmpeg cover embed failed'); } // Clean up cover file diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index be6016b..f5aa91a 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -1,12 +1,30 @@ import 'dart:io'; -import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart'; -import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart'; +import 'package:flutter/services.dart'; import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('FFmpeg'); /// FFmpeg service for audio conversion and remuxing +/// Uses native MethodChannel to call FFmpegKit from local AAR class FFmpegService { + static const _channel = MethodChannel('com.zarz.spotiflac/ffmpeg'); + + /// Execute FFmpeg command and return result + static Future _execute(String command) async { + try { + final result = await _channel.invokeMethod('execute', {'command': command}); + final map = Map.from(result); + return FFmpegResult( + success: map['success'] as bool, + returnCode: map['returnCode'] as int, + output: map['output'] as String, + ); + } catch (e) { + _log.e('FFmpeg execute error: $e'); + return FFmpegResult(success: false, returnCode: -1, output: e.toString()); + } + } + /// Convert M4A (DASH segments) to FLAC /// Returns the output file path on success, null on failure static Future convertM4aToFlac(String inputPath) async { @@ -16,10 +34,9 @@ class FFmpegService { final command = '-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y'; - final session = await FFmpegKit.execute(command); - final returnCode = await session.getReturnCode(); + final result = await _execute(command); - if (ReturnCode.isSuccess(returnCode)) { + if (result.success) { // Delete original M4A file try { await File(inputPath).delete(); @@ -27,12 +44,7 @@ class FFmpegService { return outputPath; } - // Log error for debugging - final logs = await session.getLogs(); - for (final log in logs) { - _log.d(log.getMessage()); - } - + _log.e('M4A to FLAC conversion failed: ${result.output}'); return null; } @@ -54,13 +66,13 @@ class FFmpegService { final command = '-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y'; - final session = await FFmpegKit.execute(command); - final returnCode = await session.getReturnCode(); + final result = await _execute(command); - if (ReturnCode.isSuccess(returnCode)) { + if (result.success) { return outputPath; } + _log.e('FLAC to MP3 conversion failed: ${result.output}'); return null; } @@ -91,22 +103,21 @@ class FFmpegService { '-i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -map_metadata 0 "$outputPath" -y'; } - final session = await FFmpegKit.execute(command); - final returnCode = await session.getReturnCode(); + final result = await _execute(command); - if (ReturnCode.isSuccess(returnCode)) { + if (result.success) { return outputPath; } + _log.e('FLAC to M4A conversion failed: ${result.output}'); return null; } /// Check if FFmpeg is available static Future isAvailable() async { try { - final session = await FFmpegKit.execute('-version'); - final returnCode = await session.getReturnCode(); - return ReturnCode.isSuccess(returnCode); + final version = await _channel.invokeMethod('getVersion'); + return version != null && version.toString().isNotEmpty; } catch (e) { return false; } @@ -115,11 +126,55 @@ class FFmpegService { /// Get FFmpeg version info static Future getVersion() async { try { - final session = await FFmpegKit.execute('-version'); - final output = await session.getOutput(); - return output; + final version = await _channel.invokeMethod('getVersion'); + return version as String?; } catch (e) { return null; } } + + /// Embed cover art to FLAC file + /// Returns the file path on success, null on failure + static Future 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, + }); } diff --git a/pubspec.lock b/pubspec.lock index ee61251..0b14aaf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -297,22 +297,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" - ffmpeg_kit_flutter_new_audio: - dependency: "direct main" - description: - name: ffmpeg_kit_flutter_new_audio - sha256: "0a698b46cd163c8e9917af75325c84d27871a2a8b2c37de3b40486cd0ab662ae" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - ffmpeg_kit_flutter_platform_interface: - dependency: transitive - description: - name: ffmpeg_kit_flutter_platform_interface - sha256: addf046ae44e190ad0101b2fde2ad909a3cd08a2a109f6106d2f7048b7abedee - url: "https://pub.dev" - source: hosted - version: "0.2.1" file: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index be1c9d9..1b2de41 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: 'none' -version: 2.0.6+36 +version: 2.0.7+37 environment: sdk: ^3.10.0 @@ -50,8 +50,8 @@ dependencies: receive_sharing_intent: ^1.8.1 logger: ^2.5.0 - # FFmpeg for audio conversion (audio-only version - much smaller) - ffmpeg_kit_flutter_new_audio: ^2.0.0 + # FFmpeg - using local custom AAR (arm64-v8a + armeabi-v7a only) + # ffmpeg_kit_flutter_new_audio: ^2.0.0 # Replaced with local AAR open_filex: ^4.7.0 # Notifications diff --git a/pubspec_ios.yaml b/pubspec_ios.yaml new file mode 100644 index 0000000..e68b3bd --- /dev/null +++ b/pubspec_ios.yaml @@ -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/