Skip to content

Commit

Permalink
feat: basic windows support
Browse files Browse the repository at this point in the history
* includes
- integrating ffmpeg/ffprobe executables
- refactor `FFmpegExecuter`, `NamidaChannel`, `NamidaStorage`
- rename `FAudioTaggerController` to `NamidaTaggerController`
- refactor paths to use platform path separator
- hide settings not meant for windows using `NamidaFeaturesVisibility`
- small ui changes
- build: msix packaging instructions

* things that are not done yet:
- ui redesigns to support dynamic/wide window size
- video playback
- waveforms
- folders navigation logic
- webview login

* ref
#14, #313
  • Loading branch information
MSOB7YY committed Oct 10, 2024
1 parent 1a088c5 commit 3ee9f43
Show file tree
Hide file tree
Showing 62 changed files with 1,504 additions and 676 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ migrate_working_dir/
/android/app/keystore.base64
/android/key.properties
/android/key.properties.base64
.certificates

# IntelliJ related
*.iml
Expand Down
4 changes: 3 additions & 1 deletion android/app/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,6 @@
-dontwarn com.google.android.play.core.**
-dontwarn java.awt.**
-dontwarn javax.imageio.**
-dontwarn javax.swing.filechooser.FileFilter
-dontwarn javax.swing.filechooser.FileFilter

-keep class net.sqlcipher.** { *; }
2 changes: 1 addition & 1 deletion lib/base/audio_handler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,7 @@ class NamidaAudioVideoHandler<Q extends Playable> extends BasicAudioHandler<Q> {
// -- we check if it exists to avoid refreshing notification redundently.
// -- otherwise `getArtwork` already handles duplications.
if (!exists) {
Indexer.inst.getArtwork(imagePath: tr.pathToImage, compressed: false, checkFileFirst: false).then((value) => refreshNotification());
Indexer.inst.getArtwork(imagePath: tr.pathToImage, trackPath: tr.path, compressed: false, checkFileFirst: false).then((value) => refreshNotification());
}
});

Expand Down
4 changes: 2 additions & 2 deletions lib/class/faudiomodel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,8 @@ class FAudioModel {
this.errorsMap = const {},
});

factory FAudioModel.dummy(String? path) {
return FAudioModel(tags: FTags(path: path ?? '', artwork: FArtwork(size: 0)), hasError: true);
factory FAudioModel.dummy(String? path, FArtwork? artwork) {
return FAudioModel(tags: FTags(path: path ?? '', artwork: artwork ?? FArtwork(size: 0)), hasError: true);
}

factory FAudioModel.fromMap(Map<String, dynamic> map) {
Expand Down
23 changes: 23 additions & 0 deletions lib/class/file_parts.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import 'dart:io';

import 'package:path/path.dart' as p;

class FileParts {
const FileParts._();

static String joinPath(String part1, String part2, [String? part3]) {
return p.join(part1, part2, part3);
}

static String joinAllPath(Iterable<String> parts) {
return p.joinAll(parts);
}

static File join(String part1, String part2, [String? part3]) {
return File(joinPath(part1, part2, part3));
}

static File joinAll(Iterable<String> parts) {
return File(joinAllPath(parts));
}
}
4 changes: 2 additions & 2 deletions lib/class/track.dart
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ extension SelectableListUtils on Iterable<Selectable> {
}

class Track extends Selectable<String> {
Folder get folder => Folder.explicit(folderPath);

@override
Track get track => this;

Expand Down Expand Up @@ -622,8 +624,6 @@ extension TrackExtUtils on TrackExtended {
}

extension TrackUtils on Track {
Folder get folder => Folder.explicit(folderPath);

bool hasInfoInLibrary() => toTrackExtOrNull() != null;
TrackExtended toTrackExt() => toTrackExtOrNull() ?? kDummyExtendedTrack.copyWith(title: path.getFilenameWOExt, path: path);
TrackExtended? toTrackExtOrNull() => Indexer.inst.allTracksMappedByPath[path];
Expand Down
11 changes: 6 additions & 5 deletions lib/controller/backup_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter_archive/flutter_archive.dart';
import 'package:intl/intl.dart';

import 'package:namida/class/file_parts.dart';
import 'package:namida/controller/file_browser.dart';
import 'package:namida/controller/history_controller.dart';
import 'package:namida/controller/indexer_controller.dart';
Expand Down Expand Up @@ -92,7 +93,7 @@ class BackupController {

// creates directories and file
final dir = await Directory(backupDirPath).create();
final backupFile = await File("${dir.path}/Namida Backup - $date$fileSuffix.zip").create();
final backupFile = await FileParts.join(dir.path, "Namida Backup - $date$fileSuffix.zip").create();
final sourceDir = Directory(AppDirs.USER_DATA);

// prepares files
Expand All @@ -117,7 +118,7 @@ class BackupController {
for (final d in dirsOnly) {
try {
final prefix = d.path.startsWith(AppDirs.YOUTUBE_MAIN_DIRECTORY) ? 'YOUTUBE_' : '';
final dirZipFile = File("${AppDirs.USER_DATA}/${prefix}TEMPDIR_${d.path.getFilename}.zip");
final dirZipFile = FileParts.join(AppDirs.USER_DATA, "${prefix}TEMPDIR_${d.path.getFilename}.zip");
await ZipFile.createFromDirectory(sourceDir: d, zipFile: dirZipFile);
compressedDirectories.add(dirZipFile);
} catch (e) {
Expand All @@ -126,12 +127,12 @@ class BackupController {
}

if (localFilesOnly.isNotEmpty) {
tempAllLocal = await File("${AppDirs.USER_DATA}/LOCAL_FILES.zip").create();
tempAllLocal = await FileParts.join(AppDirs.USER_DATA, "LOCAL_FILES.zip").create();
await ZipFile.createFromFiles(sourceDir: sourceDir, files: localFilesOnly, zipFile: tempAllLocal);
}

if (youtubeFilesOnly.isNotEmpty) {
tempAllYoutube = await File("${AppDirs.USER_DATA}/YOUTUBE_FILES.zip").create();
tempAllYoutube = await FileParts.join(AppDirs.USER_DATA, "YOUTUBE_FILES.zip").create();
await ZipFile.createFromFiles(sourceDir: sourceDir, files: youtubeFilesOnly, zipFile: tempAllYoutube);
}

Expand Down Expand Up @@ -265,7 +266,7 @@ class BackupController {

await ZipFile.extractToDirectory(
zipFile: backupItem,
destinationDir: Directory("$dir/${filename.replaceFirst(prefixToReplace, '').replaceFirst('.zip', '')}"),
destinationDir: Directory(FileParts.joinPath(dir, filename.replaceFirst(prefixToReplace, '').replaceFirst('.zip', ''))),
);
await backupItem.tryDeleting();
}
Expand Down
5 changes: 3 additions & 2 deletions lib/controller/edit_delete_controller.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:io';
import 'dart:isolate';

import 'package:namida/class/file_parts.dart';
import 'package:namida/class/track.dart';
import 'package:namida/class/video.dart';
import 'package:namida/controller/history_controller.dart';
Expand Down Expand Up @@ -108,7 +109,7 @@ class EditDeleteController {
}
final saveDir = await Directory(AppDirs.SAVED_ARTWORKS).create(recursive: true);
final saveDirPath = saveDir.path;
final info = await FAudioTaggerController.inst.extractMetadata(
final info = await NamidaTaggerController.inst.extractMetadata(
trackPath: track.path,
cacheDirectoryPath: saveDirPath,
isVideo: track is Video,
Expand All @@ -125,7 +126,7 @@ class EditDeleteController {
}
final saveDir = await Directory(AppDirs.SAVED_ARTWORKS).create(recursive: true);
final saveDirPath = saveDir.path;
final newPath = "$saveDirPath${Platform.pathSeparator}${imageFile.path.getFilenameWOExt}.png";
final newPath = FileParts.joinPath(saveDirPath, "${imageFile.path.getFilenameWOExt}.png");
try {
await imageFile.copy(newPath);
return saveDirPath;
Expand Down
128 changes: 77 additions & 51 deletions lib/controller/ffmpeg_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:ffmpeg_kit_flutter_min/ffmpeg_kit.dart';
import 'package:ffmpeg_kit_flutter_min/ffmpeg_kit_config.dart';
import 'package:ffmpeg_kit_flutter_min/ffprobe_kit.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';
Expand All @@ -17,21 +14,24 @@ import 'package:namida/core/utils.dart';
import 'package:namida/main.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() {
FFmpegKitConfig.disableLogs();
FFmpegKitConfig.setSessionHistorySize(99);
_executer.init();
}

final _executer = FFMPEGExecuter.platform();

final currentOperations = <OperationType, Rx<OperationProgress>>{
OperationType.imageCompress: OperationProgress().obs,
OperationType.ytdlpThumbnailFix: OperationProgress().obs,
};

Future<MediaInfo?> extractMetadata(String path) async {
final output = await _ffprobeExecute('-show_streams -show_format -show_entries stream_tags:format_tags -of json "$path"');

final output = await _executer.ffprobeExecute(['-show_streams', '-show_format', '-show_entries', 'stream_tags:format_tags', '-of', 'json', path]);
if (output != null && output != '') {
try {
final decoded = jsonDecode(output);
Expand All @@ -43,15 +43,12 @@ class NamidaFFMPEG {
} catch (_) {}
}

final mediaInfo = await FFprobeKit.getMediaInformation(path);
final information = mediaInfo.getMediaInformation();

final map = information?.getAllProperties();
final map = await _executer.getMediaInformation(path);
if (map != null) {
map["PATH"] = path;
final miBackup = MediaInfo.fromMap(map);
final format = miBackup.format;
Map? tags = information?.getTags();
Map? tags = map['tags'];
if (tags == null) {
try {
final mainTags = (map['streams'] as List?)?.firstWhereEff((e) {
Expand All @@ -65,15 +62,15 @@ class NamidaFFMPEG {
path: path,
streams: miBackup.streams,
format: MIFormat(
bitRate: format?.bitRate ?? information?.getBitrate(),
duration: format?.duration ?? information?.getDuration().getDuration(),
filename: format?.filename ?? information?.getFilename(),
formatName: format?.formatName ?? information?.getFormat(),
bitRate: format?.bitRate ?? map['bit_rate'] ?? map['bitrate'],
duration: format?.duration ?? (map['duration'] as String?).getDuration(),
filename: format?.filename ?? map['filename'],
formatName: format?.formatName ?? map['format_name'],
nbPrograms: format?.nbPrograms,
nbStreams: format?.nbStreams,
probeScore: format?.probeScore,
size: format?.size ?? information?.getSize().getIntValue(),
startTime: format?.startTime ?? information?.getStartTime(),
size: format?.size ?? (map['size'] as String?).getIntValue(),
startTime: format?.startTime ?? map['start_time'],
tags: tags == null ? null : MIFormatTags.fromMap(tags),
),
);
Expand All @@ -91,9 +88,7 @@ class NamidaFFMPEG {
}) async {
final originalFile = File(path);
final originalStats = keepFileStats ? await originalFile.stat() : null;
final tempFile = await originalFile.copy("${AppDirs.INTERNAL_STORAGE}/.temp_${path.hashCode}");

tagsMap.updateAll((key, value) => value?.replaceAll('"', r'\"'));
final tempFile = await originalFile.copy(FileParts.joinPath(AppDirs.INTERNAL_STORAGE, ".temp_${path.hashCode}"));

// if (tagsMap[FFMPEGTagField.trackNumber] != null || tagsMap[FFMPEGTagField.discNumber] != null) {
// oldTags ??= await extractMetadata(path).then((value) => value?.format?.tags);
Expand All @@ -115,9 +110,30 @@ class NamidaFFMPEG {
// plsAddDT("disc", (tagsMapToEditConverted["disc"] ?? discNT?.$1 ?? "0", trackNT?.$2));
// }

final tagsString = tagsMap.entries.map((e) => e.value == null ? '' : '-metadata ${e.key}="${e.value}"').join(' '); // check if need to remove empty value tag

final didExecute = await _ffmpegExecute('-i "${tempFile.path}" $tagsString -id3v2_version 3 -write_id3v2 1 -c copy -y "$path"');
final params = [
'-i',
tempFile.path,
];
for (final e in tagsMap.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',
path,
]);

final didExecute = await _executer.ffmpegExecute(params);
// -- restoring original stats.
if (originalStats != null) {
await setFileStats(originalFile, originalStats);
Expand All @@ -136,12 +152,12 @@ class NamidaFFMPEG {
return File(thumbnailSavePath);
}

final codec = compress ? '-filter:v scale=-2:250 -an' : '-c copy';
final didSuccess = await _ffmpegExecute('-i "$audioPath" -map 0:v -map -0:V $codec -y "$thumbnailSavePath"');
final codecParams = compress ? ['-filter:v', 'scale=-2:250', '-an'] : ['-c', 'copy'];
final didSuccess = await _executer.ffmpegExecute(['-i', audioPath, '-map', '0:v', '-map', '-0:V', ...codecParams, '-y', thumbnailSavePath]);
if (didSuccess) {
return File(thumbnailSavePath);
} else {
final didSuccess = await _ffmpegExecute('-i "$audioPath" -an -c:v copy -y "$thumbnailSavePath"');
final didSuccess = await _executer.ffmpegExecute(['-i', audioPath, '-an', '-c:v', 'copy', '-y', thumbnailSavePath]);
return didSuccess ? File(thumbnailSavePath) : null;
}
}
Expand All @@ -159,8 +175,23 @@ class NamidaFFMPEG {
ext = audioPath.getExtension;
} catch (_) {}

final cacheFile = File("${AppDirs.APP_CACHE}/${audioPath.hashCode}.$ext");
final didSuccess = await _ffmpegExecute('-i "$audioPath" -i "$thumbnailPath" -map 0:a -map 1 -codec copy -disposition:v attached_pic -y "${cacheFile.path}"');
final cacheFile = FileParts.join(AppDirs.APP_CACHE, "${audioPath.hashCode}.$ext");
final didSuccess = await _executer.ffmpegExecute([
'-i',
audioPath,
'-i',
thumbnailPath,
'-map',
'0',
'-map',
'1',
'-codec',
'copy',
'-disposition:v',
'attached_pic',
'-y',
cacheFile.path,
]);
bool canSafelyMoveBack = false;
try {
canSafelyMoveBack = didSuccess && await cacheFile.exists() && await cacheFile.length() > 0;
Expand Down Expand Up @@ -201,8 +232,8 @@ class NamidaFFMPEG {

final imageFile = File(path);
final originalStats = keepOriginalFileStats ? await imageFile.stat() : null;
final newFilePath = "$saveDir/${path.getFilenameWOExt}.jpg";
final didSuccess = await _ffmpegExecute('-i "$path" -qscale:v $toQSC -y "$newFilePath"');
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);
Expand Down Expand Up @@ -298,7 +329,7 @@ class NamidaFFMPEG {
if (cachedThumbnail == null) {
currentFailed++;
} else {
final copiedArtwork = await FAudioTaggerController.inst.copyArtworkToCache(
final copiedArtwork = await NamidaTaggerController.inst.copyArtworkToCache(
trackPath: filee.path,
trackExtended: tr,
artworkFile: cachedThumbnail,
Expand Down Expand Up @@ -339,7 +370,7 @@ class NamidaFFMPEG {
}) async {
assert(quality >= 1 && quality <= 31, 'quality ranges only between 1 & 31');

final didExecute = await _ffmpegExecute('-i "$videoPath" -map 0:v -map -0:V -c copy -y "$thumbnailSavePath"');
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;
Expand All @@ -350,11 +381,11 @@ class NamidaFFMPEG {

final totalSeconds = (atMillisecond ?? 0) / 1000; // converting to decimal seconds.
final extractFromSecond = totalSeconds * 0.1; // thumbnail at 10% of duration.
return await _ffmpegExecute('-ss $extractFromSecond -i "$videoPath" -frames:v 1 -q:v $quality -y "$thumbnailSavePath"');
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 _ffprobeExecute('-show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$path"');
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());
}
Expand All @@ -371,21 +402,16 @@ class NamidaFFMPEG {
required String outputPath,
bool override = true,
}) async {
final ovrr = override ? '-y' : '';
return await _ffmpegExecute('-i "$videoPath" -i "$audioPath" -c copy $ovrr "$outputPath"');
}

/// Automatically appends `-hide_banner -loglevel quiet` for fast execution
Future<bool> _ffmpegExecute(String command) async {
final res = await FFmpegKit.execute("-hide_banner -loglevel quiet $command");
final rc = await res.getReturnCode();
return rc?.isValueSuccess() ?? false;
}

/// Automatically appends `-loglevel quiet -v quiet ` for fast execution
Future<String?> _ffprobeExecute(String command) async {
final res = await FFprobeKit.execute("-loglevel quiet -v quiet $command");
return await res.getOutput();
return await _executer.ffmpegExecute([
'-i',
videoPath,
'-i',
audioPath,
'-c',
'copy',
if (override) '-y',
outputPath,
]);
}

/// First field is track/disc number, can be 0 or more.
Expand Down
Loading

0 comments on commit 3ee9f43

Please sign in to comment.