mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-29 01:05:37 +00:00
308 lines
8.3 KiB
TypeScript
308 lines
8.3 KiB
TypeScript
import { resolveAvailableWallpaperPath } from "./wallpapers";
|
|
|
|
function encodeRelativeAssetPath(relativePath: string): string {
|
|
return relativePath
|
|
.replace(/^\/+/, "")
|
|
.split("/")
|
|
.filter(Boolean)
|
|
.map((part) => encodeURIComponent(part))
|
|
.join("/");
|
|
}
|
|
|
|
function ensureTrailingSlash(value: string): string {
|
|
return value.endsWith("/") ? value : `${value}/`;
|
|
}
|
|
|
|
const WINDOWS_ABSOLUTE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
|
|
const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\]+\\[^\\]+/;
|
|
|
|
function encodeFilePathSegments(pathname: string, keepWindowsDrive = false): string {
|
|
return pathname
|
|
.split("/")
|
|
.map((segment, index) => {
|
|
if (!segment) return "";
|
|
if (keepWindowsDrive && index === 1 && /^[A-Za-z]:$/.test(segment)) {
|
|
return segment;
|
|
}
|
|
return encodeURIComponent(segment);
|
|
})
|
|
.join("/");
|
|
}
|
|
|
|
function toFileUrl(filePath: string): string {
|
|
const normalized = filePath.replace(/\\/g, "/");
|
|
|
|
if (WINDOWS_ABSOLUTE_PATH_PATTERN.test(normalized)) {
|
|
return `file://${encodeFilePathSegments(`/${normalized}`, true)}`;
|
|
}
|
|
|
|
if (normalized.startsWith("//")) {
|
|
const [host, ...pathParts] = normalized.replace(/^\/+/, "").split("/");
|
|
const encodedPath = pathParts.map((part) => encodeURIComponent(part)).join("/");
|
|
return encodedPath ? `file://${host}/${encodedPath}` : `file://${host}/`;
|
|
}
|
|
|
|
const absolutePath = normalized.startsWith("/") ? normalized : `/${normalized}`;
|
|
return `file://${encodeFilePathSegments(absolutePath)}`;
|
|
}
|
|
|
|
export function isAbsoluteLocalAssetPath(asset: string): boolean {
|
|
return (
|
|
asset.startsWith("/") ||
|
|
WINDOWS_ABSOLUTE_PATH_PATTERN.test(asset) ||
|
|
WINDOWS_UNC_PATH_PATTERN.test(asset)
|
|
);
|
|
}
|
|
|
|
export async function getAssetPath(relativePath: string): Promise<string> {
|
|
const encodedRelativePath = encodeRelativeAssetPath(relativePath);
|
|
const isWebContext =
|
|
typeof window !== "undefined" && Boolean(window.location?.protocol?.startsWith("http"));
|
|
|
|
if (isWebContext) {
|
|
return `/${encodedRelativePath}`;
|
|
}
|
|
|
|
try {
|
|
if (typeof window !== "undefined") {
|
|
if (typeof window.electronAPI?.getAssetBasePath === "function") {
|
|
const base = await window.electronAPI.getAssetBasePath();
|
|
if (!base) {
|
|
throw new Error(`Failed to resolve asset base path for ${relativePath}`);
|
|
}
|
|
|
|
return new URL(encodedRelativePath, ensureTrailingSlash(base)).toString();
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (!isWebContext) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Fallback for web/dev server: public/wallpapers are served at '/wallpapers/...'
|
|
return `/${encodedRelativePath}`;
|
|
}
|
|
|
|
const BASE64_CHUNK_SIZE = 0x8000;
|
|
const localFileDataUrlCache = new Map<string, string>();
|
|
|
|
function toLocalFilePath(resourceUrl: string) {
|
|
if (!resourceUrl.startsWith("file://")) {
|
|
return null;
|
|
}
|
|
|
|
const decodedPath = decodeURIComponent(resourceUrl.replace(/^file:\/\//, ""));
|
|
if (/^\/[A-Za-z]:/.test(decodedPath)) {
|
|
return decodedPath.slice(1);
|
|
}
|
|
|
|
return decodedPath;
|
|
}
|
|
|
|
function getMimeTypeForAsset(resourceUrl: string) {
|
|
const normalized = resourceUrl.split("?")[0].toLowerCase();
|
|
|
|
if (normalized.endsWith(".png")) return "image/png";
|
|
if (normalized.endsWith(".webp")) return "image/webp";
|
|
if (normalized.endsWith(".gif")) return "image/gif";
|
|
if (normalized.endsWith(".svg")) return "image/svg+xml";
|
|
if (normalized.endsWith(".avif")) return "image/avif";
|
|
return "image/jpeg";
|
|
}
|
|
|
|
function toBase64(bytes: Uint8Array) {
|
|
let binary = "";
|
|
|
|
for (let index = 0; index < bytes.length; index += BASE64_CHUNK_SIZE) {
|
|
const chunk = bytes.subarray(index, index + BASE64_CHUNK_SIZE);
|
|
binary += String.fromCharCode(...chunk);
|
|
}
|
|
|
|
return btoa(binary);
|
|
}
|
|
|
|
export async function getRenderableAssetUrl(asset: string): Promise<string> {
|
|
if (
|
|
!asset ||
|
|
asset.startsWith("data:") ||
|
|
asset.startsWith("http") ||
|
|
asset.startsWith("#") ||
|
|
asset.startsWith("linear-gradient") ||
|
|
asset.startsWith("radial-gradient")
|
|
) {
|
|
return asset;
|
|
}
|
|
|
|
const availableAsset = await resolveAvailableWallpaperPath(asset);
|
|
const resolvedAsset =
|
|
isAbsoluteLocalAssetPath(availableAsset) && !isBundledAssetPath(availableAsset)
|
|
? toFileUrl(availableAsset)
|
|
: isBundledAssetPath(availableAsset)
|
|
? await getAssetPath(availableAsset.replace(/^\//, ""))
|
|
: availableAsset;
|
|
|
|
const localFilePath = toLocalFilePath(resolvedAsset);
|
|
if (!localFilePath || typeof window === "undefined" || !window.electronAPI?.readLocalFile) {
|
|
return resolvedAsset;
|
|
}
|
|
|
|
const cached = localFileDataUrlCache.get(resolvedAsset);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
try {
|
|
const result = await window.electronAPI.readLocalFile(localFilePath);
|
|
if (!result.success || !result.data) {
|
|
return resolvedAsset;
|
|
}
|
|
|
|
const bytes = result.data instanceof Uint8Array ? result.data : new Uint8Array(result.data);
|
|
const dataUrl = `data:${getMimeTypeForAsset(localFilePath)};base64,${toBase64(bytes)}`;
|
|
localFileDataUrlCache.set(resolvedAsset, dataUrl);
|
|
return dataUrl;
|
|
} catch {
|
|
return resolvedAsset;
|
|
}
|
|
}
|
|
|
|
async function resolveLocalMediaUrl(filePath: string): Promise<string> {
|
|
if (typeof window !== "undefined" && window.electronAPI?.getLocalMediaUrl) {
|
|
try {
|
|
const result = await window.electronAPI.getLocalMediaUrl(filePath);
|
|
if (result.success && result.url) {
|
|
return result.url;
|
|
}
|
|
} catch {
|
|
// Fall through to a file URL when the media server is unavailable.
|
|
}
|
|
}
|
|
|
|
return toFileUrl(filePath);
|
|
}
|
|
|
|
function isBundledAssetPath(asset: string): boolean {
|
|
return asset.startsWith("/wallpapers/") || asset.startsWith("/app-icons/");
|
|
}
|
|
|
|
export async function getRenderableVideoUrl(asset: string): Promise<string> {
|
|
if (
|
|
!asset ||
|
|
asset.startsWith("blob:") ||
|
|
asset.startsWith("data:") ||
|
|
asset.startsWith("file://") ||
|
|
asset.startsWith("http")
|
|
) {
|
|
return asset;
|
|
}
|
|
|
|
if (isAbsoluteLocalAssetPath(asset) && !isBundledAssetPath(asset)) {
|
|
return resolveLocalMediaUrl(asset);
|
|
}
|
|
|
|
if (asset.startsWith("/") && !asset.startsWith("//")) {
|
|
return getAssetPath(asset.replace(/^\/+/, ""));
|
|
}
|
|
|
|
return asset;
|
|
}
|
|
|
|
export async function getExportableVideoUrl(asset: string): Promise<string> {
|
|
if (
|
|
!asset ||
|
|
asset.startsWith("blob:") ||
|
|
asset.startsWith("data:") ||
|
|
asset.startsWith("file://") ||
|
|
asset.startsWith("http")
|
|
) {
|
|
return asset;
|
|
}
|
|
|
|
if (isAbsoluteLocalAssetPath(asset) && !isBundledAssetPath(asset)) {
|
|
return toFileUrl(asset);
|
|
}
|
|
|
|
if (asset.startsWith("/") && !asset.startsWith("//")) {
|
|
return getAssetPath(asset.replace(/^\/+/, ""));
|
|
}
|
|
|
|
return asset;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Wallpaper thumbnail helper — generates a tiny JPEG thumbnail via the main
|
|
// process (nativeImage resize) and returns a data URL for fast grid rendering.
|
|
// Concurrency is capped to avoid OOM from loading many full-res images at once.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const thumbnailCache = new Map<string, string>();
|
|
|
|
const THUMB_CONCURRENCY = 3;
|
|
let thumbActive = 0;
|
|
const thumbQueue: Array<() => void> = [];
|
|
|
|
function acquireThumbSlot(): Promise<void> {
|
|
if (thumbActive < THUMB_CONCURRENCY) {
|
|
thumbActive++;
|
|
return Promise.resolve();
|
|
}
|
|
return new Promise<void>((resolve) => thumbQueue.push(resolve));
|
|
}
|
|
|
|
function releaseThumbSlot(): void {
|
|
const next = thumbQueue.shift();
|
|
if (next) {
|
|
next();
|
|
} else {
|
|
thumbActive--;
|
|
}
|
|
}
|
|
|
|
export async function getWallpaperThumbnailUrl(asset: string): Promise<string> {
|
|
if (
|
|
!asset ||
|
|
asset.startsWith("data:") ||
|
|
asset.startsWith("http") ||
|
|
asset.startsWith("#") ||
|
|
asset.startsWith("linear-gradient") ||
|
|
asset.startsWith("radial-gradient")
|
|
) {
|
|
return asset;
|
|
}
|
|
|
|
const cached = thumbnailCache.get(asset);
|
|
if (cached) return cached;
|
|
|
|
const localFilePath = toLocalFilePath(
|
|
asset.startsWith("/") && !asset.startsWith("//")
|
|
? await getAssetPath(asset.replace(/^\//, ""))
|
|
: asset,
|
|
);
|
|
if (
|
|
!localFilePath ||
|
|
typeof window === "undefined" ||
|
|
!window.electronAPI?.generateWallpaperThumbnail
|
|
) {
|
|
return getRenderableAssetUrl(asset);
|
|
}
|
|
|
|
await acquireThumbSlot();
|
|
try {
|
|
const result = await window.electronAPI.generateWallpaperThumbnail(localFilePath);
|
|
if (!result.success || !result.data) {
|
|
return getRenderableAssetUrl(asset);
|
|
}
|
|
const bytes = result.data instanceof Uint8Array ? result.data : new Uint8Array(result.data);
|
|
const dataUrl = `data:image/jpeg;base64,${toBase64(bytes)}`;
|
|
thumbnailCache.set(asset, dataUrl);
|
|
return dataUrl;
|
|
} catch {
|
|
return getRenderableAssetUrl(asset);
|
|
} finally {
|
|
releaseThumbSlot();
|
|
}
|
|
}
|
|
|
|
export default getAssetPath;
|