diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index e6f828b2..a66b3423 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -9,6 +9,7 @@ import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/confirm_and_delete_tracks.dart'; +import 'package:spotiflac_android/utils/ffmpeg_reenrich.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/image_cache_utils.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; @@ -16,7 +17,6 @@ import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/batch_track_actions.dart'; import 'package:spotiflac_android/models/unified_library_item.dart'; -import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/local_track_redownload_service.dart'; import 'package:spotiflac_android/widgets/batch_progress_dialog.dart'; import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart'; @@ -425,140 +425,6 @@ class _LocalAlbumScreenState extends ConsumerState ); } - bool _hasValue(String? value) => value != null && value.trim().isNotEmpty; - - Future _safeDeleteFile(String path) async { - try { - final file = File(path); - if (await file.exists()) { - await file.delete(); - } - } catch (_) {} - } - - Future _cleanupTempFileAndParent(String path) async { - await _safeDeleteFile(path); - try { - final parent = File(path).parent; - if (await parent.exists()) { - await parent.delete(); - } - } catch (_) {} - } - - Future _applyFfmpegReEnrichResult( - LocalLibraryItem item, - Map result, - ) async { - final tempPath = result['temp_path'] as String?; - final safUri = result['saf_uri'] as String?; - final ffmpegTarget = _hasValue(tempPath) ? tempPath! : item.filePath; - final downloadedCoverPath = result['cover_path'] as String?; - String? effectiveCoverPath = downloadedCoverPath; - String? extractedCoverPath; - - if (!_hasValue(effectiveCoverPath)) { - try { - final tempDir = await Directory.systemTemp.createTemp( - 'reenrich_cover_', - ); - final coverOutput = '${tempDir.path}${Platform.pathSeparator}cover.jpg'; - final extracted = await PlatformBridge.extractCoverToFile( - ffmpegTarget, - coverOutput, - ); - if (extracted['error'] == null) { - effectiveCoverPath = coverOutput; - extractedCoverPath = coverOutput; - } else { - try { - await tempDir.delete(recursive: true); - } catch (_) {} - } - } catch (_) {} - } - - final metadata = (result['metadata'] as Map?)?.map( - (k, v) => MapEntry(k, v.toString()), - ); - - final format = item.format?.toLowerCase(); - final lowerPath = item.filePath.toLowerCase(); - final isMp3 = format == 'mp3' || lowerPath.endsWith('.mp3'); - final isM4A = - format == 'm4a' || - format == 'aac' || - lowerPath.endsWith('.m4a') || - lowerPath.endsWith('.aac'); - final isOpus = - format == 'opus' || - format == 'ogg' || - lowerPath.endsWith('.opus') || - lowerPath.endsWith('.ogg'); - - final artistTagMode = ref.read(settingsProvider).artistTagMode; - String? ffmpegResult; - if (isMp3) { - ffmpegResult = await FFmpegService.embedMetadataToMp3( - mp3Path: ffmpegTarget, - coverPath: effectiveCoverPath, - metadata: metadata, - preserveMetadata: true, - ); - } else if (isM4A) { - ffmpegResult = await FFmpegService.embedMetadataToM4a( - m4aPath: ffmpegTarget, - coverPath: effectiveCoverPath, - metadata: metadata, - preserveMetadata: true, - ); - } else if (isOpus) { - ffmpegResult = await FFmpegService.embedMetadataToOpus( - opusPath: ffmpegTarget, - coverPath: effectiveCoverPath, - metadata: metadata, - artistTagMode: artistTagMode, - preserveMetadata: true, - ); - } - - if (ffmpegResult != null && _hasValue(tempPath) && _hasValue(safUri)) { - final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri!); - if (!ok) { - if (_hasValue(downloadedCoverPath)) { - await _safeDeleteFile(downloadedCoverPath!); - } - if (_hasValue(extractedCoverPath)) { - await _cleanupTempFileAndParent(extractedCoverPath!); - } - await _safeDeleteFile(tempPath!); - return false; - } - await writeReEnrichSafSidecarLrc(safUri: safUri, reEnrichResult: result); - } - - if (_hasValue(downloadedCoverPath)) { - await _safeDeleteFile(downloadedCoverPath!); - } - if (_hasValue(extractedCoverPath)) { - await _cleanupTempFileAndParent(extractedCoverPath!); - } - if (_hasValue(tempPath)) { - await _safeDeleteFile(tempPath!); - } - - if (ffmpegResult != null) { - // Filesystem .lrc sidecar. SAF sidecar is written only after - // writeTempToSaf succeeds. - await writeReEnrichSidecarLrc( - audioFilePath: item.filePath, - reEnrichResult: result, - ); - } - - return ffmpegResult != null; - } - Future _reEnrichLocalTrack( LocalLibraryItem item, { List? updateFields, @@ -603,7 +469,11 @@ class _LocalAlbumScreenState extends ConsumerState return true; } if (method == 'ffmpeg') { - return _applyFfmpegReEnrichResult(item, result); + return applyFfmpegReEnrichResult( + item: item, + result: result, + artistTagMode: ref.read(settingsProvider).artistTagMode, + ); } return false; } diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 06134df7..245efaf7 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -5,13 +5,13 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:share_plus/share_plus.dart'; -import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; +import 'package:spotiflac_android/utils/ffmpeg_reenrich.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/models/download_item.dart'; diff --git a/lib/screens/queue_tab_batch_actions.dart b/lib/screens/queue_tab_batch_actions.dart index 2118ca99..d8b46d79 100644 --- a/lib/screens/queue_tab_batch_actions.dart +++ b/lib/screens/queue_tab_batch_actions.dart @@ -1,140 +1,6 @@ part of 'queue_tab.dart'; extension _QueueTabBatchActions on _QueueTabState { - Future _safeDeleteTempFile(String path) async { - try { - final file = File(path); - if (await file.exists()) { - await file.delete(); - } - } catch (_) {} - } - - Future _cleanupTempFileAndParentDir(String path) async { - await _safeDeleteTempFile(path); - try { - final parent = File(path).parent; - if (await parent.exists()) { - await parent.delete(); - } - } catch (_) {} - } - - Future _applyQueueFfmpegReEnrichResult( - LocalLibraryItem item, - Map result, - ) async { - final tempPath = result['temp_path'] as String?; - final safUri = result['saf_uri'] as String?; - final ffmpegTarget = _hasTextValue(tempPath) ? tempPath! : item.filePath; - final downloadedCoverPath = result['cover_path'] as String?; - String? effectiveCoverPath = downloadedCoverPath; - String? extractedCoverPath; - - if (!_hasTextValue(effectiveCoverPath)) { - try { - final tempDir = await Directory.systemTemp.createTemp( - 'reenrich_cover_', - ); - final coverOutput = '${tempDir.path}${Platform.pathSeparator}cover.jpg'; - final extracted = await PlatformBridge.extractCoverToFile( - ffmpegTarget, - coverOutput, - ); - if (extracted['error'] == null) { - effectiveCoverPath = coverOutput; - extractedCoverPath = coverOutput; - } else { - try { - await tempDir.delete(recursive: true); - } catch (_) {} - } - } catch (_) {} - } - - final metadata = (result['metadata'] as Map?)?.map( - (k, v) => MapEntry(k, v.toString()), - ); - - final format = item.format?.toLowerCase(); - final lowerPath = item.filePath.toLowerCase(); - final isMp3 = format == 'mp3' || lowerPath.endsWith('.mp3'); - final isM4A = - format == 'm4a' || - format == 'aac' || - lowerPath.endsWith('.m4a') || - lowerPath.endsWith('.aac'); - final isOpus = - format == 'opus' || - format == 'ogg' || - lowerPath.endsWith('.opus') || - lowerPath.endsWith('.ogg'); - - final artistTagMode = ref.read(settingsProvider).artistTagMode; - String? ffmpegResult; - if (isMp3) { - ffmpegResult = await FFmpegService.embedMetadataToMp3( - mp3Path: ffmpegTarget, - coverPath: effectiveCoverPath, - metadata: metadata, - preserveMetadata: true, - ); - } else if (isM4A) { - ffmpegResult = await FFmpegService.embedMetadataToM4a( - m4aPath: ffmpegTarget, - coverPath: effectiveCoverPath, - metadata: metadata, - preserveMetadata: true, - ); - } else if (isOpus) { - ffmpegResult = await FFmpegService.embedMetadataToOpus( - opusPath: ffmpegTarget, - coverPath: effectiveCoverPath, - metadata: metadata, - artistTagMode: artistTagMode, - preserveMetadata: true, - ); - } - - if (ffmpegResult != null && - _hasTextValue(tempPath) && - _hasTextValue(safUri)) { - final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri!); - if (!ok) { - if (_hasTextValue(downloadedCoverPath)) { - await _safeDeleteTempFile(downloadedCoverPath!); - } - if (_hasTextValue(extractedCoverPath)) { - await _cleanupTempFileAndParentDir(extractedCoverPath!); - } - await _safeDeleteTempFile(tempPath!); - return false; - } - await writeReEnrichSafSidecarLrc(safUri: safUri, reEnrichResult: result); - } - - if (_hasTextValue(downloadedCoverPath)) { - await _safeDeleteTempFile(downloadedCoverPath!); - } - if (_hasTextValue(extractedCoverPath)) { - await _cleanupTempFileAndParentDir(extractedCoverPath!); - } - if (_hasTextValue(tempPath)) { - await _safeDeleteTempFile(tempPath!); - } - - if (ffmpegResult != null) { - // Filesystem .lrc sidecar. SAF sidecar is written only after - // writeTempToSaf succeeds. - await writeReEnrichSidecarLrc( - audioFilePath: item.filePath, - reEnrichResult: result, - ); - } - - return ffmpegResult != null; - } - Future _reEnrichQueueLocalTrack( LocalLibraryItem item, { List? updateFields, @@ -179,7 +45,11 @@ extension _QueueTabBatchActions on _QueueTabState { return true; } if (method == 'ffmpeg') { - return _applyQueueFfmpegReEnrichResult(item, result); + return applyFfmpegReEnrichResult( + item: item, + result: result, + artistTagMode: artistTagMode, + ); } return false; } diff --git a/lib/screens/queue_tab_filter_widgets.dart b/lib/screens/queue_tab_filter_widgets.dart index 906e3a5c..3d2e544d 100644 --- a/lib/screens/queue_tab_filter_widgets.dart +++ b/lib/screens/queue_tab_filter_widgets.dart @@ -952,8 +952,6 @@ extension _QueueTabFilterWidgets on _QueueTabState { ); } - bool _hasTextValue(String? value) => value != null && value.trim().isNotEmpty; - List _selectedItemsFromAll( List allItems, ) { diff --git a/lib/utils/ffmpeg_reenrich.dart b/lib/utils/ffmpeg_reenrich.dart new file mode 100644 index 00000000..021cd521 --- /dev/null +++ b/lib/utils/ffmpeg_reenrich.dart @@ -0,0 +1,147 @@ +import 'dart:io'; + +import 'package:spotiflac_android/services/ffmpeg_service.dart'; +import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; + +bool _hasValue(String? value) => value != null && value.trim().isNotEmpty; + +Future _safeDeleteFile(String path) async { + try { + final file = File(path); + if (await file.exists()) { + await file.delete(); + } + } catch (_) {} +} + +Future _cleanupTempFileAndParent(String path) async { + await _safeDeleteFile(path); + try { + final parent = File(path).parent; + if (await parent.exists()) { + await parent.delete(); + } + } catch (_) {} +} + +/// Embeds [result]'s metadata/cover into [item] via FFmpeg while preserving +/// existing tags, writes the output back through SAF when the source was staged +/// to a temp file, drops the filesystem .lrc sidecar, and cleans up every temp +/// artifact (including on the SAF-write failure path). Returns true when the +/// FFmpeg step produced an output. +/// +/// Shared by the local-album and queue batch re-enrich flows. The track-detail +/// screen keeps its own copy because it drives snackbars and does not preserve +/// existing metadata. +Future applyFfmpegReEnrichResult({ + required LocalLibraryItem item, + required Map result, + required String artistTagMode, +}) async { + final tempPath = result['temp_path'] as String?; + final safUri = result['saf_uri'] as String?; + final ffmpegTarget = _hasValue(tempPath) ? tempPath! : item.filePath; + final downloadedCoverPath = result['cover_path'] as String?; + String? effectiveCoverPath = downloadedCoverPath; + String? extractedCoverPath; + + if (!_hasValue(effectiveCoverPath)) { + try { + final tempDir = await Directory.systemTemp.createTemp('reenrich_cover_'); + final coverOutput = '${tempDir.path}${Platform.pathSeparator}cover.jpg'; + final extracted = await PlatformBridge.extractCoverToFile( + ffmpegTarget, + coverOutput, + ); + if (extracted['error'] == null) { + effectiveCoverPath = coverOutput; + extractedCoverPath = coverOutput; + } else { + try { + await tempDir.delete(recursive: true); + } catch (_) {} + } + } catch (_) {} + } + + final metadata = (result['metadata'] as Map?)?.map( + (k, v) => MapEntry(k, v.toString()), + ); + + final format = item.format?.toLowerCase(); + final lowerPath = item.filePath.toLowerCase(); + final isMp3 = format == 'mp3' || lowerPath.endsWith('.mp3'); + final isM4A = + format == 'm4a' || + format == 'aac' || + lowerPath.endsWith('.m4a') || + lowerPath.endsWith('.aac'); + final isOpus = + format == 'opus' || + format == 'ogg' || + lowerPath.endsWith('.opus') || + lowerPath.endsWith('.ogg'); + + String? ffmpegResult; + if (isMp3) { + ffmpegResult = await FFmpegService.embedMetadataToMp3( + mp3Path: ffmpegTarget, + coverPath: effectiveCoverPath, + metadata: metadata, + preserveMetadata: true, + ); + } else if (isM4A) { + ffmpegResult = await FFmpegService.embedMetadataToM4a( + m4aPath: ffmpegTarget, + coverPath: effectiveCoverPath, + metadata: metadata, + preserveMetadata: true, + ); + } else if (isOpus) { + ffmpegResult = await FFmpegService.embedMetadataToOpus( + opusPath: ffmpegTarget, + coverPath: effectiveCoverPath, + metadata: metadata, + artistTagMode: artistTagMode, + preserveMetadata: true, + ); + } + + if (ffmpegResult != null && _hasValue(tempPath) && _hasValue(safUri)) { + final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri!); + if (!ok) { + if (_hasValue(downloadedCoverPath)) { + await _safeDeleteFile(downloadedCoverPath!); + } + if (_hasValue(extractedCoverPath)) { + await _cleanupTempFileAndParent(extractedCoverPath!); + } + await _safeDeleteFile(tempPath!); + return false; + } + await writeReEnrichSafSidecarLrc(safUri: safUri, reEnrichResult: result); + } + + if (_hasValue(downloadedCoverPath)) { + await _safeDeleteFile(downloadedCoverPath!); + } + if (_hasValue(extractedCoverPath)) { + await _cleanupTempFileAndParent(extractedCoverPath!); + } + if (_hasValue(tempPath)) { + await _safeDeleteFile(tempPath!); + } + + if (ffmpegResult != null) { + // Filesystem .lrc sidecar. SAF sidecar is written only after + // writeTempToSaf succeeds. + await writeReEnrichSidecarLrc( + audioFilePath: item.filePath, + reEnrichResult: result, + ); + } + + return ffmpegResult != null; +}