813 lines
25 KiB
TypeScript
813 lines
25 KiB
TypeScript
|
import type { FSModule } from "browserfs/dist/node/core/FS";
|
||
|
import { monacoExtensions } from "components/apps/MonacoEditor/extensions";
|
||
|
import extensions from "components/system/Files/FileEntry/extensions";
|
||
|
import type { FileInfo } from "components/system/Files/FileEntry/useFileInfo";
|
||
|
import type { FileStat } from "components/system/Files/FileManager/functions";
|
||
|
import { get9pModifiedTime } from "contexts/fileSystem/functions";
|
||
|
import type { RootFileSystem } from "contexts/fileSystem/useAsyncFs";
|
||
|
import processDirectory from "contexts/process/directory";
|
||
|
import ini from "ini";
|
||
|
import { extname, join } from "path";
|
||
|
import {
|
||
|
AUDIO_FILE_EXTENSIONS,
|
||
|
BASE_2D_CONTEXT_OPTIONS,
|
||
|
DYNAMIC_EXTENSION,
|
||
|
FOLDER_BACK_ICON,
|
||
|
FOLDER_FRONT_ICON,
|
||
|
FOLDER_ICON,
|
||
|
ICON_CACHE,
|
||
|
ICON_CACHE_EXTENSION,
|
||
|
ICON_GIF_FPS,
|
||
|
ICON_GIF_SECONDS,
|
||
|
IMAGE_FILE_EXTENSIONS,
|
||
|
MOUNTED_FOLDER_ICON,
|
||
|
MP3_MIME_TYPE,
|
||
|
NEW_FOLDER_ICON,
|
||
|
ONE_TIME_PASSIVE_EVENT,
|
||
|
PHOTO_ICON,
|
||
|
SHORTCUT_EXTENSION,
|
||
|
SHORTCUT_ICON,
|
||
|
SMALLEST_PNG_SIZE,
|
||
|
SYSTEM_FILES,
|
||
|
SYSTEM_PATHS,
|
||
|
TIFF_IMAGE_FORMATS,
|
||
|
UNKNOWN_ICON_PATH,
|
||
|
VIDEO_FALLBACK_MIME_TYPE,
|
||
|
VIDEO_FILE_EXTENSIONS,
|
||
|
YT_ICON_CACHE,
|
||
|
} from "utils/constants";
|
||
|
import {
|
||
|
blobToBase64,
|
||
|
bufferToUrl,
|
||
|
decodeJxl,
|
||
|
getGifJs,
|
||
|
getHtmlToImage,
|
||
|
imageToBufferUrl,
|
||
|
imgDataToBuffer,
|
||
|
isSafari,
|
||
|
isYouTubeUrl,
|
||
|
} from "utils/functions";
|
||
|
|
||
|
type InternetShortcut = {
|
||
|
BaseURL: string;
|
||
|
Comment: string;
|
||
|
IconFile: string;
|
||
|
Type: string;
|
||
|
URL: string;
|
||
|
};
|
||
|
|
||
|
type ShellClassInfo = {
|
||
|
ShellClassInfo: {
|
||
|
IconFile: string;
|
||
|
};
|
||
|
};
|
||
|
|
||
|
type VideoElementWithSeek = HTMLVideoElement & {
|
||
|
seekToNextFrame: () => Promise<void>;
|
||
|
};
|
||
|
|
||
|
export const getModifiedTime = (path: string, stats: FileStat): number => {
|
||
|
const { atimeMs, ctimeMs, mtimeMs } = stats;
|
||
|
|
||
|
return atimeMs === ctimeMs && ctimeMs === mtimeMs
|
||
|
? get9pModifiedTime(path) || mtimeMs
|
||
|
: mtimeMs;
|
||
|
};
|
||
|
|
||
|
export const getIconFromIni = (
|
||
|
fs: FSModule,
|
||
|
directory: string
|
||
|
): Promise<string> =>
|
||
|
new Promise((resolve) => {
|
||
|
fs.readFile(
|
||
|
join(directory, "desktop.ini"),
|
||
|
(error, contents = Buffer.from("")) => {
|
||
|
if (error) resolve("");
|
||
|
else {
|
||
|
const {
|
||
|
ShellClassInfo: { IconFile = "" },
|
||
|
} = ini.parse(contents.toString()) as ShellClassInfo;
|
||
|
|
||
|
resolve(IconFile);
|
||
|
}
|
||
|
}
|
||
|
);
|
||
|
});
|
||
|
|
||
|
export const getDefaultFileViewer = (extension: string): string => {
|
||
|
if (AUDIO_FILE_EXTENSIONS.has(extension)) return "VideoPlayer";
|
||
|
if (VIDEO_FILE_EXTENSIONS.has(extension)) return "VideoPlayer";
|
||
|
if (IMAGE_FILE_EXTENSIONS.has(extension)) return "Photos";
|
||
|
if (monacoExtensions.has(extension)) return "MonacoEditor";
|
||
|
|
||
|
return "";
|
||
|
};
|
||
|
|
||
|
export const getIconByFileExtension = (extension: string): string => {
|
||
|
const { icon: extensionIcon = "", process: [defaultProcess = ""] = [] } =
|
||
|
extension in extensions ? extensions[extension] : {};
|
||
|
|
||
|
if (extensionIcon) return `/System/Icons/${extensionIcon}.webp`;
|
||
|
|
||
|
return (
|
||
|
processDirectory[defaultProcess || getDefaultFileViewer(extension)]?.icon ||
|
||
|
UNKNOWN_ICON_PATH
|
||
|
);
|
||
|
};
|
||
|
|
||
|
export const getProcessByFileExtension = (extension: string): string => {
|
||
|
const [defaultProcess = ""] =
|
||
|
extension in extensions
|
||
|
? extensions[extension].process
|
||
|
: [getDefaultFileViewer(extension)];
|
||
|
|
||
|
return defaultProcess;
|
||
|
};
|
||
|
|
||
|
export const getMimeType = (url: string): string => {
|
||
|
switch (extname(url).toLowerCase()) {
|
||
|
case ".jpg":
|
||
|
case ".jpeg":
|
||
|
return "image/jpeg";
|
||
|
case ".m3u8":
|
||
|
return "application/x-mpegURL";
|
||
|
case ".m4v":
|
||
|
case ".mkv":
|
||
|
case ".mov":
|
||
|
case ".mp4":
|
||
|
return "video/mp4";
|
||
|
case ".oga":
|
||
|
return "audio/ogg";
|
||
|
case ".ogg":
|
||
|
case ".ogm":
|
||
|
case ".ogv":
|
||
|
return "video/ogg";
|
||
|
case ".png":
|
||
|
return "image/png";
|
||
|
case ".wav":
|
||
|
return "audio/wav";
|
||
|
case ".webm":
|
||
|
return "video/webm";
|
||
|
default:
|
||
|
return "";
|
||
|
}
|
||
|
};
|
||
|
|
||
|
export const getShortcutInfo = (contents: Buffer): FileInfo => {
|
||
|
const {
|
||
|
InternetShortcut: {
|
||
|
BaseURL: pid = "",
|
||
|
Comment: comment = "",
|
||
|
IconFile: icon = "",
|
||
|
Type: type = "",
|
||
|
URL: url = "",
|
||
|
},
|
||
|
} = (ini.parse(contents.toString()) || {}) as {
|
||
|
InternetShortcut: InternetShortcut;
|
||
|
};
|
||
|
|
||
|
return {
|
||
|
comment,
|
||
|
icon:
|
||
|
!icon && pid && pid !== "FileExplorer"
|
||
|
? processDirectory[pid]?.icon
|
||
|
: icon,
|
||
|
pid,
|
||
|
type,
|
||
|
url,
|
||
|
};
|
||
|
};
|
||
|
|
||
|
export const createShortcut = (shortcut: Partial<InternetShortcut>): string =>
|
||
|
ini
|
||
|
.encode(shortcut, {
|
||
|
section: "InternetShortcut",
|
||
|
whitespace: false,
|
||
|
})
|
||
|
.replace(/"/g, "");
|
||
|
|
||
|
export const makeExternalShortcut = (contents: Buffer): Buffer => {
|
||
|
const { pid, url } = getShortcutInfo(contents);
|
||
|
|
||
|
return Buffer.from(
|
||
|
createShortcut({
|
||
|
URL: encodeURI(
|
||
|
`${window.location.origin}${pid ? `/?app=${pid}` : ""}${
|
||
|
url ? `${pid ? "&" : "/?"}url=${url}` : ""
|
||
|
}`
|
||
|
),
|
||
|
})
|
||
|
);
|
||
|
};
|
||
|
|
||
|
const getIconsFromCache = (fs: FSModule, path: string): Promise<string[]> =>
|
||
|
new Promise((resolveIcons) => {
|
||
|
const iconCacheDirectory = join(ICON_CACHE, path);
|
||
|
|
||
|
fs?.readdir(
|
||
|
iconCacheDirectory,
|
||
|
async (dirError, [firstIcon, ...otherIcons] = []) => {
|
||
|
if (dirError) resolveIcons([]);
|
||
|
else {
|
||
|
resolveIcons(
|
||
|
(
|
||
|
await Promise.all(
|
||
|
[firstIcon, otherIcons[otherIcons.length - 1]]
|
||
|
.filter(Boolean)
|
||
|
.map(
|
||
|
(cachedIcon): Promise<string> =>
|
||
|
new Promise((resolveIcon) => {
|
||
|
fs?.readFile(
|
||
|
join(iconCacheDirectory, cachedIcon),
|
||
|
(fileError, contents = Buffer.from("")) => {
|
||
|
resolveIcon(fileError ? "" : bufferToUrl(contents));
|
||
|
}
|
||
|
);
|
||
|
})
|
||
|
)
|
||
|
)
|
||
|
).filter(Boolean)
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
);
|
||
|
});
|
||
|
|
||
|
export const getInfoWithoutExtension = (
|
||
|
fs: FSModule,
|
||
|
rootFs: RootFileSystem,
|
||
|
path: string,
|
||
|
isDirectory: boolean,
|
||
|
useNewFolderIcon: boolean,
|
||
|
callback: (value: FileInfo) => void
|
||
|
): void => {
|
||
|
if (isDirectory) {
|
||
|
const setFolderInfo = (
|
||
|
icon: string,
|
||
|
subIcons?: string[],
|
||
|
getIcon?: () => Promise<void>
|
||
|
): void =>
|
||
|
callback({ getIcon, icon, pid: "FileExplorer", subIcons, url: path });
|
||
|
const getFolderIcon = (): string => {
|
||
|
if (rootFs?.mntMap[path]?.getName() === "FileSystemAccess") {
|
||
|
return MOUNTED_FOLDER_ICON;
|
||
|
}
|
||
|
if (useNewFolderIcon) return NEW_FOLDER_ICON;
|
||
|
return FOLDER_ICON;
|
||
|
};
|
||
|
const folderIcon = getFolderIcon();
|
||
|
|
||
|
setFolderInfo(folderIcon, [], async () => {
|
||
|
const iconFromIni = await getIconFromIni(fs, path);
|
||
|
|
||
|
if (iconFromIni) setFolderInfo(iconFromIni);
|
||
|
else if (folderIcon === FOLDER_ICON) {
|
||
|
const iconsFromCache = await getIconsFromCache(fs, path);
|
||
|
|
||
|
if (iconsFromCache.length > 0) {
|
||
|
setFolderInfo(FOLDER_BACK_ICON, [
|
||
|
...iconsFromCache,
|
||
|
FOLDER_FRONT_ICON,
|
||
|
]);
|
||
|
}
|
||
|
}
|
||
|
});
|
||
|
} else {
|
||
|
callback({ icon: UNKNOWN_ICON_PATH, pid: "", url: path });
|
||
|
}
|
||
|
};
|
||
|
|
||
|
const getFirstAniImage = async (
|
||
|
imageBuffer: Buffer
|
||
|
): Promise<Buffer | undefined> => {
|
||
|
const { parseAni } = await import("ani-cursor/dist/parser");
|
||
|
let firstImage: Uint8Array;
|
||
|
|
||
|
try {
|
||
|
({
|
||
|
images: [firstImage],
|
||
|
} = parseAni(imageBuffer));
|
||
|
|
||
|
return Buffer.from(firstImage);
|
||
|
} catch {
|
||
|
// Can't parse ani
|
||
|
}
|
||
|
|
||
|
return undefined;
|
||
|
};
|
||
|
|
||
|
export const getInfoWithExtension = (
|
||
|
fs: FSModule,
|
||
|
path: string,
|
||
|
extension: string,
|
||
|
callback: (value: FileInfo) => void
|
||
|
): void => {
|
||
|
const subIcons: string[] = [];
|
||
|
const getInfoByFileExtension = (
|
||
|
icon?: string,
|
||
|
getIcon?: true | ((signal: AbortSignal) => void)
|
||
|
): void =>
|
||
|
callback({
|
||
|
getIcon,
|
||
|
icon: icon || getIconByFileExtension(extension),
|
||
|
pid: getProcessByFileExtension(extension),
|
||
|
subIcons,
|
||
|
url: path,
|
||
|
});
|
||
|
|
||
|
switch (extension) {
|
||
|
case SHORTCUT_EXTENSION:
|
||
|
fs.readFile(path, (error, contents = Buffer.from("")) => {
|
||
|
subIcons.push(SHORTCUT_ICON);
|
||
|
|
||
|
if (error) {
|
||
|
getInfoByFileExtension();
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const { comment, icon, pid, url } = getShortcutInfo(contents);
|
||
|
const urlExt = extname(url).toLowerCase();
|
||
|
|
||
|
if (pid === "FileExplorer" && !icon) {
|
||
|
const getIcon = (): void => {
|
||
|
getIconFromIni(fs, url).then((iniIcon) =>
|
||
|
callback({
|
||
|
comment,
|
||
|
icon: iniIcon || processDirectory[pid]?.icon,
|
||
|
pid,
|
||
|
subIcons,
|
||
|
url,
|
||
|
})
|
||
|
);
|
||
|
};
|
||
|
|
||
|
callback({ comment, getIcon, icon, pid, subIcons, url });
|
||
|
} else if (DYNAMIC_EXTENSION.has(urlExt)) {
|
||
|
const cachedIconPath = join(
|
||
|
ICON_CACHE,
|
||
|
`${url}${ICON_CACHE_EXTENSION}`
|
||
|
);
|
||
|
|
||
|
fs.lstat(cachedIconPath, (statError, cachedIconStats) => {
|
||
|
if (!statError && cachedIconStats) {
|
||
|
if (cachedIconStats.birthtimeMs === cachedIconStats.ctimeMs) {
|
||
|
callback({
|
||
|
comment,
|
||
|
icon: cachedIconPath,
|
||
|
pid,
|
||
|
subIcons,
|
||
|
url,
|
||
|
});
|
||
|
} else {
|
||
|
fs.readFile(cachedIconPath, (_readError, cachedIconData) =>
|
||
|
callback({
|
||
|
comment,
|
||
|
icon: bufferToUrl(cachedIconData as Buffer),
|
||
|
pid,
|
||
|
subIcons,
|
||
|
url,
|
||
|
})
|
||
|
);
|
||
|
}
|
||
|
} else {
|
||
|
getInfoWithExtension(fs, url, urlExt, (fileInfo) => {
|
||
|
const {
|
||
|
icon: urlIcon = icon,
|
||
|
getIcon,
|
||
|
subIcons: fileSubIcons = [],
|
||
|
} = fileInfo;
|
||
|
|
||
|
if (fileSubIcons.length > 0) {
|
||
|
subIcons.push(
|
||
|
...fileSubIcons.filter(
|
||
|
(subIcon) => !subIcons.includes(subIcon)
|
||
|
)
|
||
|
);
|
||
|
}
|
||
|
|
||
|
callback({
|
||
|
comment,
|
||
|
getIcon,
|
||
|
icon: urlIcon,
|
||
|
pid,
|
||
|
subIcons,
|
||
|
url,
|
||
|
});
|
||
|
});
|
||
|
}
|
||
|
});
|
||
|
} else if (isYouTubeUrl(url)) {
|
||
|
const ytId = new URL(url).pathname.replace("/", "");
|
||
|
const cachedIconPath = join(
|
||
|
YT_ICON_CACHE,
|
||
|
`${ytId}${ICON_CACHE_EXTENSION}`
|
||
|
);
|
||
|
const baseFileInfo = {
|
||
|
comment,
|
||
|
pid,
|
||
|
url,
|
||
|
};
|
||
|
const isDefaultIcon = icon === processDirectory.VideoPlayer.icon;
|
||
|
const videoSubIcons = [processDirectory.VideoPlayer.icon];
|
||
|
|
||
|
callback({
|
||
|
...baseFileInfo,
|
||
|
getIcon: isDefaultIcon
|
||
|
? () =>
|
||
|
fs.exists(cachedIconPath, (cachedIconExists) =>
|
||
|
callback({
|
||
|
...baseFileInfo,
|
||
|
icon: cachedIconExists
|
||
|
? cachedIconPath
|
||
|
: `https://i.ytimg.com/vi/${ytId}/mqdefault.jpg`,
|
||
|
subIcons: videoSubIcons,
|
||
|
})
|
||
|
)
|
||
|
: undefined,
|
||
|
icon: icon || processDirectory.VideoPlayer.icon,
|
||
|
subIcons: icon && !isDefaultIcon ? videoSubIcons : undefined,
|
||
|
});
|
||
|
} else {
|
||
|
callback({
|
||
|
comment,
|
||
|
icon: icon || UNKNOWN_ICON_PATH,
|
||
|
pid,
|
||
|
subIcons,
|
||
|
url,
|
||
|
});
|
||
|
}
|
||
|
});
|
||
|
break;
|
||
|
case ".ani":
|
||
|
getInfoByFileExtension(PHOTO_ICON, (signal) =>
|
||
|
fs.readFile(path, async (error, contents = Buffer.from("")) => {
|
||
|
if (!error && contents.length > 0 && !signal.aborted) {
|
||
|
const firstImage = await getFirstAniImage(contents);
|
||
|
|
||
|
if (firstImage && !signal.aborted) {
|
||
|
getInfoByFileExtension(imageToBufferUrl(path, firstImage));
|
||
|
}
|
||
|
}
|
||
|
})
|
||
|
);
|
||
|
break;
|
||
|
case ".exe":
|
||
|
getInfoByFileExtension("/System/Icons/executable.webp", (signal) =>
|
||
|
fs.readFile(path, async (error, contents = Buffer.from("")) => {
|
||
|
if (!error && contents.length > 0 && !signal.aborted) {
|
||
|
const { extractExeIcon } = await import(
|
||
|
"components/system/Files/FileEntry/exeIcons"
|
||
|
);
|
||
|
const exeIcon = await extractExeIcon(contents);
|
||
|
|
||
|
if (exeIcon && !signal.aborted) {
|
||
|
getInfoByFileExtension(bufferToUrl(exeIcon));
|
||
|
}
|
||
|
}
|
||
|
})
|
||
|
);
|
||
|
break;
|
||
|
case ".mp3":
|
||
|
getInfoByFileExtension(
|
||
|
`/System/Icons/${extensions[".mp3"].icon as string}.webp`,
|
||
|
(signal) =>
|
||
|
fs.readFile(path, (error, contents = Buffer.from("")) => {
|
||
|
if (!error && !signal.aborted) {
|
||
|
import("music-metadata-browser").then(
|
||
|
({ parseBuffer, selectCover }) => {
|
||
|
if (signal.aborted) return;
|
||
|
|
||
|
parseBuffer(
|
||
|
contents,
|
||
|
{
|
||
|
mimeType: MP3_MIME_TYPE,
|
||
|
size: contents.length,
|
||
|
},
|
||
|
{ skipPostHeaders: true }
|
||
|
).then(({ common: { picture } = {} }) => {
|
||
|
if (signal.aborted) return;
|
||
|
|
||
|
const { data: coverPicture } = selectCover(picture) || {};
|
||
|
|
||
|
if (coverPicture) {
|
||
|
getInfoByFileExtension(bufferToUrl(coverPicture));
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
);
|
||
|
}
|
||
|
})
|
||
|
);
|
||
|
break;
|
||
|
case ".sav":
|
||
|
getInfoByFileExtension(UNKNOWN_ICON_PATH, true);
|
||
|
break;
|
||
|
case ".jxl":
|
||
|
getInfoByFileExtension(PHOTO_ICON, (signal) =>
|
||
|
fs.readFile(path, async (error, contents = Buffer.from("")) => {
|
||
|
if (!error && contents.length > 0 && !signal.aborted) {
|
||
|
getInfoByFileExtension(
|
||
|
imageToBufferUrl(path, imgDataToBuffer(await decodeJxl(contents)))
|
||
|
);
|
||
|
}
|
||
|
})
|
||
|
);
|
||
|
break;
|
||
|
case ".qoi":
|
||
|
getInfoByFileExtension(PHOTO_ICON, (signal) =>
|
||
|
fs.readFile(path, async (error, contents = Buffer.from("")) => {
|
||
|
if (!error && contents.length > 0 && !signal.aborted) {
|
||
|
const { decodeQoi } = await import("components/apps/Photos/qoi");
|
||
|
const icon = decodeQoi(contents);
|
||
|
|
||
|
if (icon && !signal.aborted) {
|
||
|
getInfoByFileExtension(imageToBufferUrl(path, icon));
|
||
|
}
|
||
|
}
|
||
|
})
|
||
|
);
|
||
|
break;
|
||
|
case ".whtml":
|
||
|
getInfoByFileExtension("/System/Icons/tinymce.webp", (signal) =>
|
||
|
fs.readFile(path, async (error, contents = Buffer.from("")) => {
|
||
|
if (!error && contents.length > 0 && !signal.aborted) {
|
||
|
const htmlToImage = await getHtmlToImage();
|
||
|
const containerElement = document.createElement("div");
|
||
|
|
||
|
containerElement.style.height = "600px";
|
||
|
containerElement.style.width = "600px";
|
||
|
containerElement.style.padding = "32px";
|
||
|
containerElement.style.backgroundColor = "#fff";
|
||
|
containerElement.style.zIndex = "-1";
|
||
|
|
||
|
containerElement.innerHTML = contents.toString();
|
||
|
|
||
|
document.body.append(containerElement);
|
||
|
|
||
|
let documentImage: string | undefined;
|
||
|
|
||
|
try {
|
||
|
documentImage = await htmlToImage?.toPng(containerElement, {
|
||
|
skipAutoScale: true,
|
||
|
});
|
||
|
} catch {
|
||
|
// Ignore failure to captrure
|
||
|
}
|
||
|
|
||
|
containerElement.remove();
|
||
|
|
||
|
if (documentImage && documentImage.length > SMALLEST_PNG_SIZE) {
|
||
|
getInfoByFileExtension(documentImage);
|
||
|
}
|
||
|
}
|
||
|
})
|
||
|
);
|
||
|
break;
|
||
|
default:
|
||
|
if (TIFF_IMAGE_FORMATS.has(extension)) {
|
||
|
getInfoByFileExtension(PHOTO_ICON, (signal) =>
|
||
|
fs.readFile(path, async (error, contents = Buffer.from("")) => {
|
||
|
if (!error && contents.length > 0 && !signal.aborted) {
|
||
|
const firstImage = (await import("utif")).bufferToURI(contents);
|
||
|
|
||
|
if (firstImage && !signal.aborted) {
|
||
|
getInfoByFileExtension(firstImage);
|
||
|
}
|
||
|
}
|
||
|
})
|
||
|
);
|
||
|
} else if (IMAGE_FILE_EXTENSIONS.has(extension)) {
|
||
|
getInfoByFileExtension(PHOTO_ICON, (signal) =>
|
||
|
fs.readFile(path, (error, contents = Buffer.from("")) => {
|
||
|
if (!error && contents.length > 0 && !signal.aborted) {
|
||
|
const imageIcon = new Image();
|
||
|
|
||
|
imageIcon.addEventListener(
|
||
|
"load",
|
||
|
() => getInfoByFileExtension(imageIcon.src),
|
||
|
{ signal, ...ONE_TIME_PASSIVE_EVENT }
|
||
|
);
|
||
|
imageIcon.addEventListener(
|
||
|
"error",
|
||
|
async () => {
|
||
|
if (extension === ".cur") {
|
||
|
const firstImage = await getFirstAniImage(contents);
|
||
|
|
||
|
if (firstImage && !signal.aborted) {
|
||
|
getInfoByFileExtension(
|
||
|
imageToBufferUrl(path, firstImage)
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
},
|
||
|
{ signal, ...ONE_TIME_PASSIVE_EVENT }
|
||
|
);
|
||
|
imageIcon.src = imageToBufferUrl(path, contents);
|
||
|
}
|
||
|
})
|
||
|
);
|
||
|
} else if (AUDIO_FILE_EXTENSIONS.has(extension)) {
|
||
|
getInfoByFileExtension(processDirectory.VideoPlayer.icon);
|
||
|
} else if (VIDEO_FILE_EXTENSIONS.has(extension)) {
|
||
|
subIcons.push(processDirectory.VideoPlayer.icon);
|
||
|
getInfoByFileExtension(processDirectory.VideoPlayer.icon, (signal) =>
|
||
|
fs.readFile(path, async (error, contents = Buffer.from("")) => {
|
||
|
if (!error) {
|
||
|
const video = document.createElement("video");
|
||
|
const canvas = document.createElement("canvas");
|
||
|
const gif = await getGifJs();
|
||
|
let framesRemaining = ICON_GIF_FPS * ICON_GIF_SECONDS;
|
||
|
const getFrame = (
|
||
|
second: number,
|
||
|
firstFrame: boolean
|
||
|
): Promise<void> =>
|
||
|
new Promise((resolve) => {
|
||
|
video.addEventListener(
|
||
|
"canplaythrough",
|
||
|
() => {
|
||
|
const context = canvas.getContext("2d", {
|
||
|
...BASE_2D_CONTEXT_OPTIONS,
|
||
|
willReadFrequently: true,
|
||
|
});
|
||
|
|
||
|
if (!context || !canvas.width || !canvas.height) return;
|
||
|
|
||
|
context.drawImage(
|
||
|
video,
|
||
|
0,
|
||
|
0,
|
||
|
canvas.width,
|
||
|
canvas.height
|
||
|
);
|
||
|
const imageData = context.getImageData(
|
||
|
0,
|
||
|
0,
|
||
|
canvas.width,
|
||
|
canvas.height
|
||
|
);
|
||
|
gif.addFrame(imageData, {
|
||
|
copy: true,
|
||
|
delay: 100,
|
||
|
});
|
||
|
framesRemaining -= 1;
|
||
|
|
||
|
if (framesRemaining === 0) {
|
||
|
gif
|
||
|
.on("finished", (blob) =>
|
||
|
blobToBase64(blob).then(getInfoByFileExtension)
|
||
|
)
|
||
|
.render();
|
||
|
}
|
||
|
|
||
|
resolve();
|
||
|
},
|
||
|
{ signal, ...ONE_TIME_PASSIVE_EVENT }
|
||
|
);
|
||
|
video.currentTime = second;
|
||
|
if ("seekToNextFrame" in video) {
|
||
|
(video as VideoElementWithSeek)
|
||
|
.seekToNextFrame?.()
|
||
|
.catch(() => {
|
||
|
// Ignore error during seekToNextFrame
|
||
|
});
|
||
|
} else if (firstFrame) {
|
||
|
video.load();
|
||
|
}
|
||
|
});
|
||
|
|
||
|
video.addEventListener(
|
||
|
"loadeddata",
|
||
|
() => {
|
||
|
canvas.height = video.videoHeight;
|
||
|
canvas.width = video.videoWidth;
|
||
|
|
||
|
const capturePoints = [
|
||
|
video.duration / 4,
|
||
|
video.duration / 2,
|
||
|
];
|
||
|
const frameStep = 4 / ICON_GIF_FPS;
|
||
|
const frameCount = framesRemaining / capturePoints.length;
|
||
|
|
||
|
capturePoints.forEach(async (capturePoint, index) => {
|
||
|
if (signal.aborted) return;
|
||
|
|
||
|
for (
|
||
|
let frame = capturePoint;
|
||
|
frame < capturePoint + frameCount * frameStep;
|
||
|
frame += frameStep
|
||
|
) {
|
||
|
if (signal.aborted) return;
|
||
|
|
||
|
const firstFrame = index === 0;
|
||
|
|
||
|
// eslint-disable-next-line no-await-in-loop
|
||
|
await getFrame(frame, firstFrame);
|
||
|
|
||
|
if (firstFrame && frame === capturePoint) {
|
||
|
getInfoByFileExtension(canvas.toDataURL("image/jpeg"));
|
||
|
}
|
||
|
}
|
||
|
});
|
||
|
},
|
||
|
{ signal, ...ONE_TIME_PASSIVE_EVENT }
|
||
|
);
|
||
|
|
||
|
video.src = bufferToUrl(
|
||
|
contents,
|
||
|
isSafari()
|
||
|
? getMimeType(path) || VIDEO_FALLBACK_MIME_TYPE
|
||
|
: undefined
|
||
|
);
|
||
|
}
|
||
|
})
|
||
|
);
|
||
|
} else {
|
||
|
getInfoByFileExtension();
|
||
|
}
|
||
|
}
|
||
|
};
|
||
|
|
||
|
export const filterSystemFiles =
|
||
|
(directory: string) =>
|
||
|
(file: string): boolean =>
|
||
|
!SYSTEM_PATHS.has(join(directory, file)) && !SYSTEM_FILES.has(file);
|
||
|
|
||
|
type WrapData = {
|
||
|
lines: string[];
|
||
|
width: number;
|
||
|
};
|
||
|
|
||
|
const canvasContexts = Object.create(null) as Record<
|
||
|
string,
|
||
|
CanvasRenderingContext2D
|
||
|
>;
|
||
|
|
||
|
const measureText = (
|
||
|
text: string,
|
||
|
fontSize: string,
|
||
|
fontFamily: string
|
||
|
): number => {
|
||
|
const font = `${fontSize} ${fontFamily}`;
|
||
|
|
||
|
if (!canvasContexts[font]) {
|
||
|
const canvas = document.createElement("canvas");
|
||
|
const context = canvas.getContext(
|
||
|
"2d",
|
||
|
BASE_2D_CONTEXT_OPTIONS
|
||
|
) as CanvasRenderingContext2D;
|
||
|
|
||
|
context.font = font;
|
||
|
canvasContexts[font] = context;
|
||
|
}
|
||
|
const { actualBoundingBoxLeft, actualBoundingBoxRight } =
|
||
|
canvasContexts[font].measureText(text);
|
||
|
|
||
|
return Math.abs(actualBoundingBoxLeft) + Math.abs(actualBoundingBoxRight);
|
||
|
};
|
||
|
|
||
|
export const getTextWrapData = (
|
||
|
text: string,
|
||
|
fontSize: string,
|
||
|
fontFamily: string,
|
||
|
maxWidth?: number
|
||
|
): WrapData => {
|
||
|
const lines = [""];
|
||
|
|
||
|
const totalWidth = measureText(text, fontSize, fontFamily);
|
||
|
|
||
|
if (!maxWidth) return { lines: [text], width: totalWidth };
|
||
|
|
||
|
if (totalWidth > maxWidth) {
|
||
|
const words = text.split(" ");
|
||
|
|
||
|
[...text].forEach((character) => {
|
||
|
const lineIndex = lines.length - 1;
|
||
|
const lineText = `${lines[lineIndex]}${character}`;
|
||
|
const lineWidth = measureText(lineText, fontSize, fontFamily);
|
||
|
|
||
|
if (lineWidth > maxWidth) {
|
||
|
const spacesInLine = lineText.split(" ").length - 1;
|
||
|
const lineWithWords = words.splice(0, spacesInLine).join(" ");
|
||
|
|
||
|
if (
|
||
|
lines.length === 1 &&
|
||
|
spacesInLine > 0 &&
|
||
|
lines[0] !== lineWithWords
|
||
|
) {
|
||
|
lines[0] = lineText.slice(0, lineWithWords.length);
|
||
|
lines.push(lineText.slice(lineWithWords.length));
|
||
|
} else {
|
||
|
lines.push(character);
|
||
|
}
|
||
|
} else {
|
||
|
lines[lineIndex] = lineText;
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
|
||
|
return {
|
||
|
lines,
|
||
|
width: Math.min(maxWidth, totalWidth),
|
||
|
};
|
||
|
};
|