mirror of
https://github.com/namidaco/namida.git
synced 2026-03-13 08:12:29 +08:00
- and some refactoring for tags to ensure consistency - add icons for all sort options ref: #424
617 lines
20 KiB
Dart
617 lines
20 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:ffmpeg_kit_flutter/ffmpeg_kit_config.dart';
|
|
|
|
import 'package:namida/class/faudiomodel.dart';
|
|
import 'package:namida/class/file_parts.dart';
|
|
import 'package:namida/class/media_info.dart';
|
|
import 'package:namida/class/track.dart';
|
|
import 'package:namida/controller/indexer_controller.dart';
|
|
import 'package:namida/controller/thumbnail_manager.dart';
|
|
import 'package:namida/core/constants.dart';
|
|
import 'package:namida/core/extensions.dart';
|
|
import 'package:namida/core/namida_converter_ext.dart';
|
|
import 'package:namida/core/utils.dart';
|
|
import 'package:namida/main.dart';
|
|
import 'package:namida/youtube/controller/youtube_info_controller.dart';
|
|
import 'package:namida/youtube/widgets/yt_thumbnail.dart';
|
|
|
|
import 'platform/ffmpeg_executer/ffmpeg_executer.dart';
|
|
|
|
class NamidaFFMPEG {
|
|
static NamidaFFMPEG get inst => _instance;
|
|
static final NamidaFFMPEG _instance = NamidaFFMPEG._internal();
|
|
NamidaFFMPEG._internal();
|
|
|
|
static Future<void> configure() async {
|
|
if (Platform.isAndroid || Platform.isIOS || Platform.isMacOS) {
|
|
await [
|
|
FFmpegKitConfig.disableLogs(),
|
|
FFmpegKitConfig.setSessionHistorySize(500),
|
|
].executeAllAndSilentReportErrors();
|
|
}
|
|
}
|
|
|
|
final _executer = FFMPEGExecuter.platform()..init();
|
|
|
|
final currentOperations = <OperationType, Rx<OperationProgress>>{
|
|
OperationType.imageCompress: OperationProgress().obs,
|
|
OperationType.ytdlpThumbnailFix: OperationProgress().obs,
|
|
};
|
|
|
|
Future<MediaInfo?> ffmpegExtractMetadata(String path) async {
|
|
return _executer.extractMetadata(path);
|
|
}
|
|
|
|
Future<bool> editMetadata({
|
|
required String path,
|
|
MIFormatTags? oldTags,
|
|
required Map<String, String?> tagsMap,
|
|
bool keepFileStats = true,
|
|
}) async {
|
|
final originalFile = File(path);
|
|
final originalStats = keepFileStats ? await originalFile.stat() : null;
|
|
String ext = 'm4a';
|
|
try {
|
|
ext = path.getExtension;
|
|
} catch (_) {}
|
|
final cacheFile = FileParts.join(AppDirs.INTERNAL_STORAGE, ".temp_${path.hashCode}.$ext");
|
|
|
|
// if (tagsMap[FFMPEGTagField.trackNumber] != null || tagsMap[FFMPEGTagField.discNumber] != null) {
|
|
// oldTags ??= await extractMetadata(path).then((value) => value?.format?.tags);
|
|
// void plsAddDT(String valInMap, (String, String?)? trackOrDisc) {
|
|
// if (trackOrDisc != null) {
|
|
// final trackN = trackOrDisc.$1;
|
|
// final trackT = trackOrDisc.$2;
|
|
// if (trackT == null && trackN != "0") {
|
|
// tagsMapToEditConverted[valInMap] = trackN;
|
|
// } else if (trackT != null) {
|
|
// tagsMapToEditConverted[valInMap] = "${trackOrDisc.$1}/${trackOrDisc.$2}";
|
|
// }
|
|
// }
|
|
// }
|
|
|
|
// final trackNT = _trackAndDiscSplitter(oldTags?.track);
|
|
// final discNT = _trackAndDiscSplitter(oldTags?.disc);
|
|
// plsAddDT("track", (tagsMapToEditConverted["track"] ?? trackNT?.$1 ?? "0", trackNT?.$2));
|
|
// plsAddDT("disc", (tagsMapToEditConverted["disc"] ?? discNT?.$1 ?? "0", trackNT?.$2));
|
|
// }
|
|
|
|
final params = [
|
|
'-i',
|
|
originalFile.path,
|
|
];
|
|
|
|
final oldTagsToApply = <String, String>{};
|
|
final oldMetadata = await ffmpegExtractMetadata(originalFile.path);
|
|
|
|
// -- not all of them but always one of them
|
|
const opusEtcFormats = {'opus', 'ogg', 'oga', 'ogx', 'flac', 'alac'};
|
|
|
|
bool isOpusEtc() {
|
|
if (oldMetadata == null) return false;
|
|
final formatName = oldMetadata.format?.formatName;
|
|
if (formatName != null && opusEtcFormats.contains(formatName.toLowerCase())) {
|
|
return true;
|
|
}
|
|
final oldMetadataStreams = oldMetadata.streams;
|
|
if (oldMetadataStreams != null) {
|
|
for (final stream in oldMetadataStreams) {
|
|
final streamName = stream.codecName;
|
|
if (streamName != null && opusEtcFormats.contains(streamName.toLowerCase())) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
if (isOpusEtc()) {
|
|
// -- overwriting tags for opus is not supported, we need to remove all first (-map_metadata -1) and write all combined
|
|
final tags = oldMetadata?.toFAudioModel(artwork: null).tags;
|
|
if (tags != null) {
|
|
final oldTags = FFMPEGTagField.createTagsMapfromFTag(tags);
|
|
for (final e in oldTags.entries) {
|
|
if (tagsMap[e.key] != null) continue;
|
|
|
|
final val = e.value;
|
|
if (val != null) {
|
|
oldTagsToApply[e.key] = val;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (oldTagsToApply.isNotEmpty) {
|
|
params.addAll(
|
|
[
|
|
'-map_metadata',
|
|
'-1',
|
|
'-disposition:v',
|
|
'attached_pic',
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
for (final e in tagsMap.entries.followedBy(oldTagsToApply.entries)) {
|
|
final val = e.value;
|
|
if (val != null) {
|
|
final valueCleaned = val.replaceAll('"', r'\"');
|
|
params.add('-metadata');
|
|
params.add('${e.key}=$valueCleaned');
|
|
}
|
|
}
|
|
|
|
params.addAll([
|
|
'-id3v2_version',
|
|
'3',
|
|
'-write_id3v2',
|
|
'1',
|
|
'-c',
|
|
'copy',
|
|
'-y',
|
|
cacheFile.path,
|
|
]);
|
|
|
|
final didSuccess = await _executer.ffmpegExecute(params);
|
|
|
|
return await _ensureFileValidBeforeMovingBack(
|
|
didSuccess,
|
|
originalFile,
|
|
cacheFile,
|
|
originalStats,
|
|
);
|
|
}
|
|
|
|
Future<File?> extractAudioThumbnail({
|
|
required String audioPath,
|
|
required String thumbnailSavePath,
|
|
bool compress = false,
|
|
bool forceReExtract = false,
|
|
}) async {
|
|
if (!forceReExtract && await File(thumbnailSavePath).exists()) {
|
|
return File(thumbnailSavePath);
|
|
}
|
|
|
|
final codecParams = compress ? ['-filter:v', 'scale=-2:250', '-an'] : ['-c', 'copy'];
|
|
|
|
bool didSuccess = await _executer.ffmpegExecute(['-i', audioPath, '-map', '0:v', '-map', '-0:V', ...codecParams, '-y', thumbnailSavePath]);
|
|
if (didSuccess) return File(thumbnailSavePath);
|
|
|
|
didSuccess = await _executer.ffmpegExecute(['-i', audioPath, '-an', '-c:v', 'copy', '-y', thumbnailSavePath]);
|
|
if (didSuccess) return File(thumbnailSavePath);
|
|
|
|
return null;
|
|
}
|
|
|
|
Future<bool> editAudioThumbnail({
|
|
required String audioPath,
|
|
required String thumbnailPath,
|
|
bool keepOriginalFileStats = true,
|
|
}) async {
|
|
final audioFile = File(audioPath);
|
|
final originalStats = keepOriginalFileStats ? await audioFile.stat() : null;
|
|
|
|
String ext = 'm4a';
|
|
try {
|
|
ext = audioPath.getExtension;
|
|
} catch (_) {}
|
|
|
|
final isVideoFile = NamidaFileExtensionsWrapper.video.isExtensionValid(ext);
|
|
final cacheFile = FileParts.join(AppDirs.APP_CACHE, "${audioPath.hashCode}.$ext");
|
|
final didSuccess = await _executer.ffmpegExecute([
|
|
'-i',
|
|
audioPath,
|
|
'-i',
|
|
thumbnailPath,
|
|
'-map',
|
|
'0:a?',
|
|
if (isVideoFile) ...[
|
|
'-map',
|
|
'0:v:0?',
|
|
],
|
|
'-map',
|
|
'1',
|
|
'-c',
|
|
'copy',
|
|
isVideoFile ? '-disposition:v:1' : '-disposition:v:0',
|
|
'attached_pic',
|
|
'-y',
|
|
cacheFile.path,
|
|
]);
|
|
|
|
return await _ensureFileValidBeforeMovingBack(
|
|
didSuccess,
|
|
audioFile,
|
|
cacheFile,
|
|
originalStats,
|
|
);
|
|
}
|
|
|
|
Future<bool> _ensureFileValidBeforeMovingBack(bool didSuccess, File originalFile, File cacheFile, FileStat? originalStats) async {
|
|
bool canSafelyMoveBack = false;
|
|
try {
|
|
int? preferredMinSize;
|
|
if (originalStats != null) {
|
|
if (originalStats.size > 0) {
|
|
preferredMinSize = (originalStats.size * 0.1).round().withMinimum(1);
|
|
}
|
|
}
|
|
preferredMinSize ??= 0;
|
|
canSafelyMoveBack = didSuccess && await cacheFile.exists() && await cacheFile.length() > preferredMinSize;
|
|
} catch (_) {}
|
|
if (canSafelyMoveBack) {
|
|
// only move output file back in case of success.
|
|
await cacheFile.copy(originalFile.path);
|
|
|
|
if (originalStats != null) {
|
|
await setFileStats(originalFile, originalStats);
|
|
}
|
|
}
|
|
|
|
cacheFile.tryDeleting();
|
|
|
|
return canSafelyMoveBack;
|
|
}
|
|
|
|
Future<bool> setFileStats(File file, FileStat stats) async {
|
|
try {
|
|
await file.setLastAccessed(stats.accessed);
|
|
await file.setLastModified(stats.modified);
|
|
return true;
|
|
} catch (e) {
|
|
printy(e, isError: true);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<bool> compressImage({
|
|
required String path,
|
|
required String saveDir,
|
|
bool keepOriginalFileStats = true,
|
|
int percentage = 50,
|
|
}) async {
|
|
assert(percentage >= 0 && percentage <= 100);
|
|
|
|
final toQSC = (percentage / 3.2).round();
|
|
|
|
final imageFile = File(path);
|
|
final originalStats = keepOriginalFileStats ? await imageFile.stat() : null;
|
|
final newFilePath = FileParts.joinPath(saveDir, "${path.getFilenameWOExt}.jpg");
|
|
final didSuccess = await _executer.ffmpegExecute(['-i', path, '-qscale:v', '$toQSC', '-y', newFilePath]);
|
|
|
|
if (originalStats != null) {
|
|
await setFileStats(File(newFilePath), originalStats);
|
|
}
|
|
|
|
return didSuccess;
|
|
}
|
|
|
|
Future<void> compressImageDirectories({
|
|
required Iterable<String> dirs,
|
|
required int compressionPerc,
|
|
required bool keepOriginalFileStats,
|
|
bool recursive = true,
|
|
}) async {
|
|
if (!await requestManageStoragePermission()) return;
|
|
|
|
final dir = await Directory(AppDirs.COMPRESSED_IMAGES).create(recursive: true);
|
|
|
|
final dirFiles = <FileSystemEntity>[];
|
|
|
|
for (final d in dirs) {
|
|
dirFiles.addAll(await Directory(d).listAllIsolate(recursive: recursive));
|
|
}
|
|
|
|
dirFiles.retainWhere((element) => element is File);
|
|
currentOperations[OperationType.imageCompress]!.value = OperationProgress(); // resetting
|
|
|
|
final totalFiles = dirFiles.length;
|
|
int currentProgress = 0;
|
|
int currentFailed = 0;
|
|
|
|
for (int i = 0; i < totalFiles; i++) {
|
|
var f = dirFiles[i];
|
|
final didUpdate = await compressImage(
|
|
path: f.path,
|
|
saveDir: dir.path,
|
|
percentage: compressionPerc,
|
|
keepOriginalFileStats: keepOriginalFileStats,
|
|
);
|
|
if (!didUpdate) currentFailed++;
|
|
currentProgress++;
|
|
currentOperations[OperationType.imageCompress]!.value = OperationProgress(
|
|
totalFiles: totalFiles,
|
|
progress: currentProgress,
|
|
currentFilePath: f.path,
|
|
totalFailed: currentFailed,
|
|
);
|
|
}
|
|
currentOperations[OperationType.imageCompress]!.value.currentFilePath = null;
|
|
}
|
|
|
|
Future<void> fixYTDLPBigThumbnailSize({required List<String> directoriesPaths, bool recursive = true}) async {
|
|
if (!await requestManageStoragePermission(ensureDirectoryCreated: true)) return;
|
|
|
|
final allFiles = <FileSystemEntity>[];
|
|
int remainingDirsLength = directoriesPaths.length;
|
|
final completer = Completer<void>();
|
|
directoriesPaths.loop((e) {
|
|
Directory(e).listAllIsolate(recursive: recursive).then(
|
|
(value) {
|
|
allFiles.addAll(value);
|
|
remainingDirsLength--;
|
|
if (remainingDirsLength == 0) completer.complete();
|
|
},
|
|
);
|
|
});
|
|
await completer.future;
|
|
final totalFilesLength = allFiles.length;
|
|
|
|
int currentProgress = 0;
|
|
int currentFailed = 0;
|
|
|
|
currentOperations[OperationType.ytdlpThumbnailFix]!.value = OperationProgress(); // resetting
|
|
|
|
for (int i = 0; i < totalFilesLength; i++) {
|
|
var filee = allFiles[i];
|
|
currentProgress++;
|
|
if (filee is File) {
|
|
final tr =
|
|
Indexer.inst.allTracksMappedByPath[filee.path] ??
|
|
await Indexer.inst.getTrackInfo(
|
|
trackPath: filee.path,
|
|
onMinDurTrigger: () => null,
|
|
onMinSizeTrigger: () => null,
|
|
isNetwork: false,
|
|
);
|
|
if (tr == null) continue;
|
|
final videoId = tr.youtubeID;
|
|
if (videoId.isEmpty) continue;
|
|
|
|
File? thumbnailFile;
|
|
bool isTempThumbnail = false;
|
|
try {
|
|
// -- try getting cropped version if required
|
|
final channelName = await YoutubeInfoController.utils.getVideoChannelName(videoId);
|
|
const topic = '- Topic';
|
|
if (channelName != null && channelName.endsWith(topic)) {
|
|
final thumbFilePath = FileParts.joinPath(Directory.systemTemp.path, '$videoId.png');
|
|
final thumbFile = await YoutubeInfoController.video.fetchMusicVideoThumbnailToFile(videoId, thumbFilePath);
|
|
if (thumbFile != null) {
|
|
thumbnailFile = thumbFile;
|
|
isTempThumbnail = true;
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
|
|
thumbnailFile ??= await ThumbnailManager.inst.getYoutubeThumbnailAndCache(
|
|
id: videoId,
|
|
isImportantInCache: true,
|
|
type: ThumbnailType.video,
|
|
);
|
|
|
|
if (thumbnailFile == null) {
|
|
currentFailed++;
|
|
} else {
|
|
final didUpdate = await editAudioThumbnail(
|
|
audioPath: filee.path,
|
|
thumbnailPath: thumbnailFile.path,
|
|
);
|
|
if (!didUpdate) currentFailed++;
|
|
|
|
if (isTempThumbnail) {
|
|
thumbnailFile.tryDeleting();
|
|
}
|
|
}
|
|
|
|
currentOperations[OperationType.ytdlpThumbnailFix]!.value = OperationProgress(
|
|
totalFiles: totalFilesLength,
|
|
progress: currentProgress,
|
|
currentFilePath: filee.path,
|
|
totalFailed: currentFailed,
|
|
);
|
|
}
|
|
}
|
|
currentOperations[OperationType.ytdlpThumbnailFix]!.value.currentFilePath = null;
|
|
}
|
|
|
|
/// * Extracts thumbnail from a given video, usually this tries to get embed thumbnail,
|
|
/// if failed then it will extract a frame at a given duration.
|
|
/// * [quality] & [atDuration] will not be used in case an embed thumbnail was found
|
|
/// * [quality] ranges on a scale of 1-31, where 1 is the best & 31 is the worst.
|
|
/// * if [atDuration] is not specified, it will try to calculate based on video duration
|
|
/// (typically thumbnail at duration of 10% of the original duration),
|
|
/// if failed then a thumbnail at Duration.zero will be extracted.
|
|
Future<bool> extractVideoThumbnail({
|
|
required String videoPath,
|
|
required String thumbnailSavePath,
|
|
int quality = 1,
|
|
Duration? atDuration,
|
|
}) async {
|
|
assert(quality >= 1 && quality <= 31, 'quality ranges only between 1 & 31');
|
|
|
|
final didExecute = await _executer.ffmpegExecute(['-i', videoPath, '-map', '0:v', '-map', '-0:V', '-c', 'copy', '-y', thumbnailSavePath]);
|
|
if (didExecute) return true;
|
|
|
|
int? atMillisecond = atDuration?.inMilliseconds;
|
|
if (atMillisecond == null) {
|
|
final duration = await getMediaDuration(videoPath);
|
|
if (duration != null) atMillisecond = duration.inMilliseconds;
|
|
}
|
|
|
|
final totalSeconds = (atMillisecond ?? 0) / 1000; // converting to decimal seconds.
|
|
final extractFromSecond = totalSeconds * 0.1; // thumbnail at 10% of duration.
|
|
return await _executer.ffmpegExecute(['-ss', '$extractFromSecond', '-i', videoPath, '-frames:v', '1', '-q:v', '$quality', '-y', thumbnailSavePath]);
|
|
}
|
|
|
|
Future<Duration?> getMediaDuration(String path) async {
|
|
final output = await _executer.ffprobeExecute(['-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', path]);
|
|
final duration = output == null ? null : double.tryParse(output);
|
|
return duration == null ? null : Duration(microseconds: (duration * 1000 * 1000).floor());
|
|
}
|
|
|
|
// Future<List<String>> getTrackAndDiscField(String path) async {
|
|
// await _ffprobeExecute('-v quiet -loglevel error -show_entries format_tags=track,disc -of default=noprint_wrappers=1:nokey=1 "$path"');
|
|
// final output = await _ffmpegConfig.getLastCommandOutput();
|
|
// return output.split('\n');
|
|
// }
|
|
|
|
Future<bool> mergeAudioAndVideo({
|
|
required String videoPath,
|
|
required String audioPath,
|
|
required String outputPath,
|
|
bool override = true,
|
|
}) async {
|
|
return await _executer.ffmpegExecute([
|
|
'-i',
|
|
videoPath,
|
|
'-i',
|
|
audioPath,
|
|
'-map',
|
|
'0:v:0',
|
|
'-map',
|
|
'1:a:0', // map to ensure audio only is merged (in case file extension was mp4 etc)
|
|
'-c',
|
|
'copy',
|
|
if (override) '-y',
|
|
outputPath,
|
|
]);
|
|
}
|
|
|
|
Future<bool> convertToWav({
|
|
required String audioPath,
|
|
required String outputPath,
|
|
}) async {
|
|
return await _executer.ffmpegExecute(
|
|
['-i', audioPath, '-f', 'wav', outputPath],
|
|
);
|
|
}
|
|
|
|
/// First field is track/disc number, can be 0 or more.
|
|
///
|
|
/// Second is track/disc total, can exist or can be null.
|
|
///
|
|
/// Returns null if splitting failed or [discOrTrack] == null.
|
|
// (String, String?)? _trackAndDiscSplitter(String? discOrTrack) {
|
|
// if (discOrTrack != null) {
|
|
// final discNT = discOrTrack.split('/');
|
|
// if (discNT.length == 2) {
|
|
// // -- track/disc total exist
|
|
// final discN = discNT.first; // might be 0 or more
|
|
// final discT = discNT.last; // always more than 0
|
|
// return (discN, discT);
|
|
// } else if (discNT.length == 1) {
|
|
// // -- only track/disc number is provided
|
|
// final discN = discNT.first;
|
|
// return (discN, null);
|
|
// }
|
|
// }
|
|
// return null;
|
|
// }
|
|
}
|
|
|
|
enum FFMPEGTagField {
|
|
title('title'),
|
|
artist('artist'),
|
|
album('album'),
|
|
albumArtist('album_artist'),
|
|
composer('composer'),
|
|
synopsis('synopsis'),
|
|
description('description'),
|
|
genre('genre'),
|
|
year('date'),
|
|
trackNumber('track'),
|
|
discNumber('disc'),
|
|
trackTotal('TRACKTOTAL'),
|
|
discTotal('DISCTOTAL'),
|
|
comment('comment'),
|
|
lyrics('lyrics'),
|
|
remixer('REMIXER'),
|
|
lyricist('LYRICIST'),
|
|
language('LANGUAGE'),
|
|
recordLabel('LABEL'),
|
|
country('Country'),
|
|
|
|
// -- NOT WORKING
|
|
mood('mood'),
|
|
rating('rating'),
|
|
tags('tags'),
|
|
// ---------
|
|
|
|
titleSort('titlesort'),
|
|
artistSort('artistsort'),
|
|
albumSort('albumsort'),
|
|
albumArtistSort('albumartistsort'),
|
|
composerSort('composersort'),
|
|
;
|
|
|
|
final String tagKey;
|
|
const FFMPEGTagField(this.tagKey);
|
|
|
|
@override
|
|
String toString() {
|
|
return tagKey;
|
|
}
|
|
|
|
static String? _fieldToValue(FFMPEGTagField f, FTags newTags) {
|
|
return switch (f) {
|
|
FFMPEGTagField.title => newTags.title,
|
|
FFMPEGTagField.artist => newTags.artist,
|
|
FFMPEGTagField.album => newTags.album,
|
|
FFMPEGTagField.albumArtist => newTags.albumArtist,
|
|
FFMPEGTagField.composer => newTags.composer,
|
|
FFMPEGTagField.genre => newTags.genre,
|
|
FFMPEGTagField.year => newTags.year,
|
|
FFMPEGTagField.trackNumber => newTags.trackNumber,
|
|
FFMPEGTagField.discNumber => newTags.discNumber,
|
|
FFMPEGTagField.trackTotal => newTags.trackTotal,
|
|
FFMPEGTagField.discTotal => newTags.discTotal,
|
|
FFMPEGTagField.comment => newTags.comment,
|
|
FFMPEGTagField.description => newTags.description,
|
|
FFMPEGTagField.synopsis => newTags.synopsis,
|
|
FFMPEGTagField.lyrics => newTags.lyrics,
|
|
FFMPEGTagField.remixer => newTags.remixer,
|
|
FFMPEGTagField.lyricist => newTags.lyricist,
|
|
FFMPEGTagField.language => newTags.language,
|
|
FFMPEGTagField.recordLabel => newTags.recordLabel,
|
|
FFMPEGTagField.country => newTags.country,
|
|
|
|
// -- TESTED NOT WORKING. disabling to prevent unwanted fields corruption etc.
|
|
FFMPEGTagField.mood => null,
|
|
FFMPEGTagField.tags => null,
|
|
FFMPEGTagField.rating => null,
|
|
// ----------
|
|
FFMPEGTagField.titleSort => newTags.sortInfo?.title,
|
|
FFMPEGTagField.artistSort => newTags.sortInfo?.artist,
|
|
FFMPEGTagField.albumSort => newTags.sortInfo?.album,
|
|
FFMPEGTagField.albumArtistSort => newTags.sortInfo?.albumArtist,
|
|
FFMPEGTagField.composerSort => newTags.sortInfo?.composer,
|
|
};
|
|
}
|
|
|
|
static Map<String, String?> createTagsMapfromFTag(FTags newTags) {
|
|
return {for (final f in FFMPEGTagField.values) f.tagKey: ?_fieldToValue(f, newTags)};
|
|
}
|
|
}
|
|
|
|
class OperationProgress {
|
|
final int totalFiles;
|
|
final int progress;
|
|
String? currentFilePath;
|
|
final int totalFailed;
|
|
|
|
OperationProgress({
|
|
this.totalFiles = 0,
|
|
this.progress = 0,
|
|
this.currentFilePath,
|
|
this.totalFailed = 0,
|
|
});
|
|
}
|
|
|
|
enum OperationType {
|
|
imageCompress,
|
|
ytdlpThumbnailFix,
|
|
}
|