diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 90f4dee..585a90b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -249,23 +249,6 @@ 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/CHANGELOG.md b/CHANGELOG.md index 83d5ffb..5c55041 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ - New "Enable Lossy Option" toggle in Settings > Download > Audio Quality - Choose between MP3 (320kbps) or Opus (256kbps) format - Downloads FLAC first, then converts using FFmpeg -- **New Languages**: Turkish and Portuguese Portugal translations +- **New Languages**: Turkish and Japanese translations ### Changed diff --git a/build_assets/ffmpeg_service_ios.dart b/build_assets/ffmpeg_service_ios.dart deleted file mode 100644 index da0543c..0000000 --- a/build_assets/ffmpeg_service_ios.dart +++ /dev/null @@ -1,335 +0,0 @@ -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 - /// If deleteOriginal is true, deletes the FLAC file after conversion - static Future convertFlacToMp3( - String inputPath, { - String bitrate = '320k', - bool deleteOriginal = true, - }) async { - // Convert in same folder, just change extension - final outputPath = inputPath.replaceAll('.flac', '.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) { - // Delete original FLAC if requested - if (deleteOriginal) { - try { - await File(inputPath).delete(); - } catch (_) {} - } - 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; - } - - /// Embed metadata and cover art to FLAC file - /// Returns the file path on success, null on failure - static Future embedMetadata({ - required String flacPath, - String? coverPath, - Map? metadata, - }) async { - final tempOutput = '$flacPath.tmp'; - - // Construct command - final StringBuffer cmdBuffer = StringBuffer(); - cmdBuffer.write('-i "$flacPath" '); - - // Add cover input if available - if (coverPath != null) { - cmdBuffer.write('-i "$coverPath" '); - } - - // Map audio stream - cmdBuffer.write('-map 0:a '); - - // Map cover stream if available - if (coverPath != null) { - cmdBuffer.write('-map 1:0 '); - cmdBuffer.write('-c:v copy '); - cmdBuffer.write('-disposition:v attached_pic '); - cmdBuffer.write('-metadata:s:v title="Album cover" '); - cmdBuffer.write('-metadata:s:v comment="Cover (front)" '); - } - - // Copy audio codec (don't re-encode) - cmdBuffer.write('-c:a copy '); - - // Add text metadata - if (metadata != null) { - metadata.forEach((key, value) { - // Sanitize value: escape double quotes - final sanitizedValue = value.replaceAll('"', '\\"'); - cmdBuffer.write('-metadata $key="$sanitizedValue" '); - }); - } - - cmdBuffer.write('"$tempOutput" -y'); - - final command = cmdBuffer.toString(); - _log.d('Executing FFmpeg command: $command'); - - 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 metadata 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('Metadata/Cover embed failed: ${result.output}'); - return null; - } - - /// Embed metadata and cover art to MP3 file using ID3v2 tags - /// Returns the file path on success, null on failure - static Future embedMetadataToMp3({ - required String mp3Path, - String? coverPath, - Map? metadata, - }) async { - final tempOutput = '$mp3Path.tmp'; - - final StringBuffer cmdBuffer = StringBuffer(); - cmdBuffer.write('-i "$mp3Path" '); - - if (coverPath != null) { - cmdBuffer.write('-i "$coverPath" '); - } - - cmdBuffer.write('-map 0:a '); - - if (coverPath != null) { - cmdBuffer.write('-map 1:0 '); - cmdBuffer.write('-c:v:0 copy '); - cmdBuffer.write('-id3v2_version 3 '); - cmdBuffer.write('-metadata:s:v title="Album cover" '); - cmdBuffer.write('-metadata:s:v comment="Cover (front)" '); - } - - cmdBuffer.write('-c:a copy '); - - if (metadata != null) { - // Convert FLAC/Vorbis tags to ID3v2 tags for MP3 - final id3Metadata = _convertToId3Tags(metadata); - id3Metadata.forEach((key, value) { - final sanitizedValue = value.replaceAll('"', '\\"'); - cmdBuffer.write('-metadata $key="$sanitizedValue" '); - }); - } - - cmdBuffer.write('-id3v2_version 3 "$tempOutput" -y'); - - final command = cmdBuffer.toString(); - _log.d('Executing FFmpeg MP3 embed command: $command'); - - final result = await _execute(command); - - if (result.success) { - try { - await File(mp3Path).delete(); - await File(tempOutput).rename(mp3Path); - _log.d('MP3 metadata embedded successfully'); - return mp3Path; - } catch (e) { - _log.e('Failed to replace MP3 file after metadata embed: $e'); - return null; - } - } - - try { - final tempFile = File(tempOutput); - if (await tempFile.exists()) { - await tempFile.delete(); - } - } catch (_) {} - - _log.e('MP3 Metadata/Cover embed failed: ${result.output}'); - return null; - } - - /// Convert FLAC/Vorbis comment tags to ID3v2 compatible tags - static Map _convertToId3Tags(Map vorbisMetadata) { - final id3Map = {}; - - for (final entry in vorbisMetadata.entries) { - final key = entry.key.toUpperCase(); - final value = entry.value; - - // Map Vorbis comments to ID3v2 frame names - switch (key) { - case 'TITLE': - id3Map['title'] = value; - break; - case 'ARTIST': - id3Map['artist'] = value; - break; - case 'ALBUM': - id3Map['album'] = value; - break; - case 'ALBUMARTIST': - id3Map['album_artist'] = value; - break; - case 'TRACKNUMBER': - case 'TRACK': - id3Map['track'] = value; - break; - case 'DISCNUMBER': - case 'DISC': - id3Map['disc'] = value; - break; - case 'DATE': - case 'YEAR': - id3Map['date'] = value; - break; - case 'ISRC': - id3Map['TSRC'] = value; // ID3v2 ISRC frame - break; - case 'LYRICS': - case 'UNSYNCEDLYRICS': - id3Map['lyrics'] = value; - break; - default: - // Pass through other tags as-is - id3Map[key.toLowerCase()] = value; - } - } - - return id3Map; - } - - /// 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/pubspec_ios.yaml b/pubspec_ios.yaml deleted file mode 100644 index c4a49d9..0000000 --- a/pubspec_ios.yaml +++ /dev/null @@ -1,92 +0,0 @@ -name: spotiflac_android -description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music -publish_to: "none" -version: 3.2.2+66 - -environment: - sdk: ^3.10.0 - -dependencies: - flutter: - sdk: flutter - - # Localization - flutter_localizations: - sdk: flutter - intl: any - - # 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 - path: ^1.9.0 - sqflite: ^2.4.1 - - # HTTP & Network - http: ^1.6.0 - dio: ^5.8.0 - -# UI Components - cupertino_icons: ^1.0.8 - cached_network_image: ^3.4.1 - flutter_cache_manager: ^3.4.1 - flutter_svg: ^2.1.0 - - # Material Expressive 3 / Dynamic Color - dynamic_color: ^1.7.0 - material_color_utilities: ^0.11.1 - palette_generator: ^0.3.3+4 - - # Permissions - permission_handler: ^12.0.1 - - # File Picker - file_picker: ^10.3.8 - - # 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 - generate: true - - assets: - - assets/images/