diff --git a/lib/services/ffmpeg_models.dart b/lib/services/ffmpeg_models.dart index d3dd13df..b905d7e3 100644 --- a/lib/services/ffmpeg_models.dart +++ b/lib/services/ffmpeg_models.dart @@ -1,5 +1,3 @@ -import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_session.dart'; - /// Describes an extension-requested decryption step without coupling callers /// to the FFmpeg execution service. class DownloadDecryptionDescriptor { @@ -116,18 +114,6 @@ class FFmpegResult { }); } -class LiveDecryptedStreamResult { - final String localUrl; - final String format; - final FFmpegSession session; - - LiveDecryptedStreamResult({ - required this.localUrl, - required this.format, - required this.session, - }); -} - /// Result of an EBU R128 loudness scan, used to compute ReplayGain tags. class ReplayGainResult { /// Track gain in dB, e.g. "-6.50 dB". diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 890d3006..63e15e52 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -3,11 +3,8 @@ import 'dart:io'; import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit.dart'; -import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit_config.dart'; -import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_session.dart'; import 'package:ffmpeg_kit_flutter_new_full/ffprobe_kit.dart'; import 'package:ffmpeg_kit_flutter_new_full/return_code.dart'; -import 'package:ffmpeg_kit_flutter_new_full/session_state.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/services/audio_metadata_mapper.dart'; import 'package:spotiflac_android/services/ffmpeg_models.dart'; @@ -61,21 +58,8 @@ class _ConversionOutputPlan { class FFmpegService { static const int _commandLogPreviewLength = 300; static const int _audioProbeCacheMaxEntries = 64; - static const Duration _liveTunnelStartupTimeout = Duration(seconds: 8); - static const Duration _liveTunnelStartupPollInterval = Duration( - milliseconds: 200, - ); - static const Duration _liveTunnelStabilizationDelay = Duration( - milliseconds: 900, - ); static const String _genericMovKeyDecryptionStrategy = 'ffmpeg.mov_key'; static int _tempEmbedCounter = 0; - static FFmpegSession? _activeLiveDecryptSession; - static String? _activeLiveDecryptUrl; - static String? _activeLiveTempInputPath; - static String? _activeNativeDashManifestPath; - static String? _activeNativeDashManifestUrl; - static final Set _preparedNativeDashManifestPaths = {}; static final Map _audioProbeCache = {}; @@ -992,440 +976,6 @@ class FFmpegService { } } - static bool isActiveLiveDecryptedUrl(String url) { - final active = _activeLiveDecryptUrl; - if (active == null || active.isEmpty) return false; - return active == url.trim(); - } - - static bool isActiveNativeDashManifestUrl(String url) { - final activeUrl = _activeNativeDashManifestUrl; - if (activeUrl == null || activeUrl.isEmpty) return false; - - final normalized = url.trim(); - if (activeUrl == normalized) return true; - - try { - final activePath = Uri.parse(activeUrl).toFilePath(); - final incomingPath = Uri.parse(normalized).toFilePath(); - return activePath == incomingPath; - } catch (_) { - return false; - } - } - - static Future prepareDashManifestForNativePlayback({ - required String manifestPayload, - bool registerAsActive = true, - }) async { - final rawPayload = manifestPayload.trim(); - if (rawPayload.isEmpty) return null; - - final payload = rawPayload.startsWith('MANIFEST:') - ? rawPayload.substring('MANIFEST:'.length) - : rawPayload; - - final manifestPath = await _writeTempManifestFile(payload); - if (manifestPath == null) { - _log.e('Failed to prepare DASH manifest for native playback'); - return null; - } - - final manifestUrl = Uri.file(manifestPath).toString(); - _preparedNativeDashManifestPaths.add(manifestPath); - if (registerAsActive) { - await activatePreparedNativeDashManifest(manifestUrl); - } - return manifestUrl; - } - - static Future activatePreparedNativeDashManifest(String url) async { - final normalized = url.trim(); - if (normalized.isEmpty) return; - - final manifestPath = _nativeDashManifestPathFromUrl(normalized); - if (manifestPath == null || - !_preparedNativeDashManifestPaths.contains(manifestPath)) { - return; - } - - final previousPath = _activeNativeDashManifestPath; - _activeNativeDashManifestPath = manifestPath; - _activeNativeDashManifestUrl = Uri.file(manifestPath).toString(); - - if (previousPath != null && - previousPath.isNotEmpty && - previousPath != manifestPath) { - _preparedNativeDashManifestPaths.remove(previousPath); - await _deleteNativeDashManifestFile(previousPath); - } - } - - static Future stopNativeDashManifestPlayback() async { - final manifestPath = _activeNativeDashManifestPath; - _activeNativeDashManifestPath = null; - _activeNativeDashManifestUrl = null; - - if (manifestPath == null || manifestPath.isEmpty) return; - _preparedNativeDashManifestPaths.remove(manifestPath); - await _deleteNativeDashManifestFile(manifestPath); - } - - static Future cleanupInactivePreparedNativeDashManifests() async { - final activePath = _activeNativeDashManifestPath; - final stalePaths = _preparedNativeDashManifestPaths - .where((path) => path != activePath) - .toList(growable: false); - - for (final path in stalePaths) { - _preparedNativeDashManifestPaths.remove(path); - await _deleteNativeDashManifestFile(path); - } - } - - static String? _nativeDashManifestPathFromUrl(String url) { - try { - final uri = Uri.parse(url); - if (uri.scheme.toLowerCase() != 'file') { - return null; - } - final path = uri.toFilePath(); - return path.trim().isEmpty ? null : path; - } catch (_) { - return null; - } - } - - static Future _deleteNativeDashManifestFile(String path) async { - try { - final file = File(path); - if (await file.exists()) { - await file.delete(); - } - } catch (_) {} - } - - static Future stopLiveDecryptedStream() async { - final session = _activeLiveDecryptSession; - final tempInputPath = _activeLiveTempInputPath; - _activeLiveDecryptSession = null; - _activeLiveDecryptUrl = null; - _activeLiveTempInputPath = null; - - if (session != null) { - try { - await session.cancel(); - } catch (e) { - final sessionId = session.getSessionId(); - if (sessionId != null) { - try { - await FFmpegKit.cancel(sessionId); - } catch (_) {} - } - _log.w('Failed to stop live decrypt session cleanly: $e'); - } - } - - if (tempInputPath != null && tempInputPath.isNotEmpty) { - try { - final file = File(tempInputPath); - if (await file.exists()) { - await file.delete(); - } - } catch (_) {} - } - } - - static Future startDashLiveStream({ - required String manifestPayload, - String preferredFormat = 'm4a', - }) async { - final rawPayload = manifestPayload.trim(); - if (rawPayload.isEmpty) return null; - - final payload = rawPayload.startsWith('MANIFEST:') - ? rawPayload.substring('MANIFEST:'.length) - : rawPayload; - - final manifestPath = await _writeTempManifestFile(payload); - if (manifestPath == null) { - _log.e('Failed to prepare DASH manifest for live stream'); - return null; - } - - await stopLiveDecryptedStream(); - await stopNativeDashManifestPlayback(); - - final attempts = _buildLiveDashFormatAttempts(preferredFormat); - for (final format in attempts) { - final stream = await _tryStartLiveDashAttempt( - manifestPath: manifestPath, - format: format, - ); - if (stream != null) { - _activeLiveDecryptSession = stream.session; - _activeLiveDecryptUrl = stream.localUrl; - _activeLiveTempInputPath = manifestPath; - return stream; - } - } - - try { - final file = File(manifestPath); - if (await file.exists()) { - await file.delete(); - } - } catch (_) {} - return null; - } - - static Future _writeTempManifestFile(String payload) async { - if (payload.trim().isEmpty) return null; - - Uint8List bytes; - try { - bytes = base64Decode(payload); - } catch (_) { - bytes = Uint8List.fromList(utf8.encode(payload)); - } - - final manifestText = utf8.decode(bytes, allowMalformed: true).trim(); - if (manifestText.isEmpty) return null; - - final tempDir = await getTemporaryDirectory(); - final manifestPath = - '${tempDir.path}${Platform.pathSeparator}dash_${DateTime.now().microsecondsSinceEpoch}.mpd'; - await File(manifestPath).writeAsString(manifestText, flush: true); - return manifestPath; - } - - static List<_LiveDecryptFormat> _buildLiveDashFormatAttempts( - String preferredFormat, - ) { - final normalized = preferredFormat.trim().toLowerCase(); - if (normalized == 'flac') { - return const [_LiveDecryptFormat.flac, _LiveDecryptFormat.m4a]; - } - return const [_LiveDecryptFormat.m4a, _LiveDecryptFormat.flac]; - } - - static Future _awaitLiveTunnelReady(FFmpegSession session) async { - final deadline = DateTime.now().add(_liveTunnelStartupTimeout); - var seenRunning = false; - - while (DateTime.now().isBefore(deadline)) { - final state = await session.getState(); - if (state == SessionState.running) { - seenRunning = true; - break; - } - if (state != SessionState.created) { - return false; - } - await Future.delayed(_liveTunnelStartupPollInterval); - } - - if (!seenRunning) { - return false; - } - - await Future.delayed(_liveTunnelStabilizationDelay); - return (await session.getState()) == SessionState.running; - } - - static Future _tryStartLiveDashAttempt({ - required String manifestPath, - required _LiveDecryptFormat format, - }) async { - final port = await _allocateLoopbackPort(); - final ext = format == _LiveDecryptFormat.flac ? 'flac' : 'm4a'; - final mimeType = format == _LiveDecryptFormat.flac - ? 'audio/flac' - : 'audio/mp4'; - final localUrl = 'http://localhost:$port/stream.$ext'; - - final commandArguments = [ - '-nostdin', - '-hide_banner', - '-loglevel', - 'error', - '-protocol_whitelist', - 'file,http,https,tcp,tls,crypto,data', - '-i', - manifestPath, - '-map', - '0:a:0', - '-c:a', - 'copy', - if (format == _LiveDecryptFormat.flac) ...['-f', 'flac'], - if (format == _LiveDecryptFormat.m4a) ...[ - '-movflags', - '+frag_keyframe+empty_moov+default_base_moof', - '-f', - 'mp4', - ], - '-content_type', - mimeType, - '-listen', - '1', - localUrl, - ]; - - _log.d( - 'Starting DASH tunnel: ${_previewCommandForLog(commandArguments.join(' '))}', - ); - - final session = await FFmpegKit.executeWithArgumentsAsync(commandArguments); - final isReady = await _awaitLiveTunnelReady(session); - if (isReady) { - return LiveDecryptedStreamResult( - localUrl: localUrl, - format: ext, - session: session, - ); - } - - final state = await session.getState(); - final output = (await session.getOutput() ?? '').trim(); - if (output.isNotEmpty) { - _log.w('DASH tunnel failed ($ext): $output'); - } else { - _log.w('DASH tunnel failed ($ext) with session state: $state'); - } - - try { - await session.cancel(); - } catch (_) {} - return null; - } - - static Future startEncryptedLiveDecryptedStream({ - required String encryptedStreamUrl, - required String decryptionKey, - String preferredFormat = 'flac', - }) async { - final inputUrl = encryptedStreamUrl.trim(); - if (inputUrl.isEmpty) return null; - - final keyCandidates = _buildDecryptionKeyCandidates(decryptionKey); - if (keyCandidates.isEmpty) { - _log.e('No usable decryption key candidates for live stream'); - return null; - } - - await stopLiveDecryptedStream(); - - final attempts = _buildLiveDecryptFormatAttempts(preferredFormat); - for (final format in attempts) { - for (final keyCandidate in keyCandidates) { - final stream = await _tryStartLiveDecryptAttempt( - inputUrl: inputUrl, - decryptionKey: keyCandidate, - format: format, - ); - if (stream != null) { - _activeLiveDecryptSession = stream.session; - _activeLiveDecryptUrl = stream.localUrl; - _activeLiveTempInputPath = null; - return stream; - } - } - } - - return null; - } - - static List<_LiveDecryptFormat> _buildLiveDecryptFormatAttempts( - String preferredFormat, - ) { - final normalized = preferredFormat.trim().toLowerCase(); - if (normalized == 'm4a' || normalized == 'mp4' || normalized == 'aac') { - return const [_LiveDecryptFormat.m4a, _LiveDecryptFormat.flac]; - } - return const [_LiveDecryptFormat.flac, _LiveDecryptFormat.m4a]; - } - - static Future _tryStartLiveDecryptAttempt({ - required String inputUrl, - required String decryptionKey, - required _LiveDecryptFormat format, - }) async { - final port = await _allocateLoopbackPort(); - final ext = format == _LiveDecryptFormat.flac ? 'flac' : 'm4a'; - final mimeType = format == _LiveDecryptFormat.flac - ? 'audio/flac' - : 'audio/mp4'; - final localUrl = 'http://localhost:$port/stream.$ext'; - - final commandArguments = [ - '-nostdin', - '-hide_banner', - '-loglevel', - 'error', - '-decryption_key', - decryptionKey, - '-i', - inputUrl, - '-map', - '0:a:0', - '-c:a', - 'copy', - if (format == _LiveDecryptFormat.flac) ...['-f', 'flac'], - if (format == _LiveDecryptFormat.m4a) ...[ - '-movflags', - '+frag_keyframe+empty_moov+default_base_moof', - '-f', - 'mp4', - ], - '-content_type', - mimeType, - '-listen', - '1', - localUrl, - ]; - - _log.d('Starting live decrypt tunnel (format=$ext)'); - - final session = await FFmpegKit.executeWithArgumentsAsync(commandArguments); - final isReady = await _awaitLiveTunnelReady(session); - if (isReady) { - return LiveDecryptedStreamResult( - localUrl: localUrl, - format: ext, - session: session, - ); - } - - final state = await session.getState(); - final output = (await session.getOutput() ?? '').trim(); - if (output.isNotEmpty) { - _log.w('Live decrypt attempt failed ($ext): $output'); - } else { - _log.w('Live decrypt attempt failed ($ext) with session state: $state'); - } - - try { - await session.cancel(); - } catch (_) {} - return null; - } - - static Future _allocateLoopbackPort() async { - final socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); - final port = socket.port; - await socket.close(); - return port; - } - - static Future isAvailable() async { - try { - final version = await FFmpegKitConfig.getFFmpegVersion(); - return version?.isNotEmpty ?? false; - } catch (e) { - return false; - } - } - /// Scan an audio file for EBU R128 loudness and compute ReplayGain values. /// /// Uses the FFmpeg `ebur128` audio filter to measure integrated loudness (LUFS) @@ -2818,5 +2368,3 @@ class FFmpegService { return '${hours.toString().padLeft(2, '0')}:${mins.toInt().toString().padLeft(2, '0')}:${secs.toStringAsFixed(3).padLeft(6, '0')}'; } } - -enum _LiveDecryptFormat { flac, m4a }