Merge pull request #369 from webadderallorg/fix/project-autosave-media-path-handling

Improve project autosave and media path handling
This commit is contained in:
webadderall
2026-04-28 09:45:06 +10:00
committed by GitHub
30 changed files with 339 additions and 263 deletions
+5 -2
View File
@@ -398,12 +398,15 @@ interface Window {
message?: string;
error?: string;
}>;
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>;
setCurrentVideoPath: (
path: string,
options?: { preserveProjectPath?: boolean },
) => Promise<{ success: boolean; webcamPath: string | null }>;
setCurrentRecordingSession: (session: {
videoPath: string;
webcamPath?: string | null;
timeOffsetMs?: number;
}) => Promise<{ success: boolean }>;
}, options?: { preserveProjectPath?: boolean }) => Promise<{ success: boolean }>;
getCurrentRecordingSession: () => Promise<{
success: boolean;
session?: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number };
+16 -1
View File
@@ -79,12 +79,13 @@ describe("local media path policy", () => {
const videoPath = path.join(downloadsPath, "external-video.mp4");
await fs.mkdir(downloadsPath, { recursive: true });
await fs.writeFile(videoPath, "test-video");
const resolvedVideoPath = await fs.realpath(videoPath);
const { resolveApprovedLocalMediaPath } = await import("./manager");
const { isAllowedMediaPath } = await import("../../mediaServer");
expect(isAllowedMediaPath(videoPath)).toBe(false);
await expect(resolveApprovedLocalMediaPath(videoPath)).resolves.toBe(videoPath);
await expect(resolveApprovedLocalMediaPath(videoPath)).resolves.toBe(resolvedVideoPath);
expect(isAllowedMediaPath(videoPath)).toBe(true);
});
@@ -100,4 +101,18 @@ describe("local media path policy", () => {
await expect(resolveApprovedLocalMediaPath(textPath)).resolves.toBeNull();
expect(isAllowedMediaPath(textPath)).toBe(false);
});
it("preserves an existing project thumbnail when no replacement is provided", async () => {
const projectPath = path.join(tempRoot, "Projects", "demo.recordly");
const thumbnailDataUrl = `data:image/png;base64,${Buffer.from("png-thumbnail").toString("base64")}`;
await fs.mkdir(path.dirname(projectPath), { recursive: true });
const { getProjectThumbnailPath, saveProjectThumbnail } = await import("./manager");
const thumbnailPath = getProjectThumbnailPath(projectPath);
await saveProjectThumbnail(projectPath, thumbnailDataUrl);
await saveProjectThumbnail(projectPath, undefined);
await expect(fs.readFile(thumbnailPath, "utf8")).resolves.toBe("png-thumbnail");
});
});
+43 -10
View File
@@ -68,19 +68,36 @@ export async function isAllowedLocalMediaPath(candidatePath: string) {
return isAllowedLocalReadPath(normalizedCandidatePath);
}
async function collectApprovedLocalReadPaths(filePath?: string | null): Promise<string[]> {
const normalizedPath = normalizeVideoSourcePath(filePath);
if (!normalizedPath) {
return [];
}
const approvedPaths = [normalizePath(normalizedPath)];
try {
const realPath = await fs.realpath(approvedPaths[0]);
const normalizedRealPath = normalizePath(realPath);
if (!approvedPaths.includes(normalizedRealPath)) {
approvedPaths.push(normalizedRealPath);
}
} catch {
// Ignore missing files; the eventual read will surface the real error.
}
return approvedPaths;
}
export async function rememberApprovedLocalReadPath(filePath?: string | null) {
const normalizedPath = normalizeVideoSourcePath(filePath);
if (!normalizedPath) {
return;
}
const resolvedPath = normalizePath(normalizedPath);
approvedLocalReadPaths.add(resolvedPath);
try {
approvedLocalReadPaths.add(await fs.realpath(resolvedPath));
} catch {
// Ignore missing files; the eventual read will surface the real error.
const approvedPaths = await collectApprovedLocalReadPaths(normalizedPath);
for (const approvedPath of approvedPaths) {
approvedLocalReadPaths.add(approvedPath);
}
}
@@ -101,13 +118,26 @@ export async function resolveApprovedLocalMediaPath(candidatePath: string): Prom
return null;
}
await rememberApprovedLocalReadPath(realPath);
await rememberApprovedLocalReadPath(candidatePath);
return realPath;
}
export async function replaceApprovedSessionLocalReadPaths(filePaths: Array<string | null | undefined>) {
const nextApprovedPaths = new Set<string>();
const approvedPathLists = await Promise.all(
filePaths.map((filePath) => collectApprovedLocalReadPaths(filePath)),
);
for (const approvedPathList of approvedPathLists) {
for (const approvedPath of approvedPathList) {
nextApprovedPaths.add(approvedPath);
}
}
approvedLocalReadPaths.clear();
await Promise.all(filePaths.map((filePath) => rememberApprovedLocalReadPath(filePath)));
for (const approvedPath of nextApprovedPaths) {
approvedLocalReadPaths.add(approvedPath);
}
}
export async function resolveProjectMediaSources(project: unknown): Promise<
@@ -196,6 +226,10 @@ export function getProjectThumbnailPath(projectPath: string) {
export async function saveProjectThumbnail(projectPath: string, thumbnailDataUrl?: string | null) {
const thumbnailPath = getProjectThumbnailPath(projectPath);
if (thumbnailDataUrl === undefined) {
return existsSync(thumbnailPath) ? thumbnailPath : null;
}
if (!thumbnailDataUrl) {
await fs.rm(thumbnailPath, { force: true }).catch(() => undefined);
return null;
@@ -383,4 +417,3 @@ export function isTrustedProjectPath(filePath?: string | null): boolean {
if (!filePath || !currentProjectPath) return false;
return normalizePath(filePath) === normalizePath(currentProjectPath);
}
+8 -4
View File
@@ -527,7 +527,7 @@ export function registerProjectHandlers() {
return { success: false, error: String(error), message: 'Failed to open projects folder.' }
}
})
ipcMain.handle('set-current-video-path', async (_, path: string) => {
ipcMain.handle('set-current-video-path', async (_, path: string, options?: { preserveProjectPath?: boolean }) => {
setCurrentVideoPath(normalizeVideoSourcePath(path) ?? path)
approveUserPath(currentVideoPath)
const resolvedSession = await resolveRecordingSession(currentVideoPath)
@@ -547,11 +547,13 @@ export function registerProjectHandlers() {
await persistRecordingSessionManifest(resolvedSession)
}
setCurrentProjectPath(null)
if (!options?.preserveProjectPath) {
setCurrentProjectPath(null)
}
return { success: true, webcamPath: resolvedSession.webcamPath ?? null }
})
ipcMain.handle('set-current-recording-session', async (_, session: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number }) => {
ipcMain.handle('set-current-recording-session', async (_, session: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number }, options?: { preserveProjectPath?: boolean }) => {
const normalizedVideoPath = normalizeVideoSourcePath(session.videoPath) ?? session.videoPath
setCurrentVideoPath(normalizedVideoPath)
setCurrentRecordingSession({
@@ -563,7 +565,9 @@ export function registerProjectHandlers() {
currentRecordingSession!.videoPath,
currentRecordingSession!.webcamPath,
])
setCurrentProjectPath(null)
if (!options?.preserveProjectPath) {
setCurrentProjectPath(null)
}
await persistRecordingSessionManifest(currentRecordingSession!)
return { success: true }
})
+4 -4
View File
@@ -430,15 +430,15 @@ contextBridge.exposeInMainWorld("electronAPI", {
}) => {
return ipcRenderer.invoke("generate-auto-captions", options);
},
setCurrentVideoPath: (path: string) => {
return ipcRenderer.invoke("set-current-video-path", path);
setCurrentVideoPath: (path: string, options?: { preserveProjectPath?: boolean }) => {
return ipcRenderer.invoke("set-current-video-path", path, options);
},
setCurrentRecordingSession: (session: {
videoPath: string;
webcamPath?: string | null;
timeOffsetMs?: number;
}) => {
return ipcRenderer.invoke("set-current-recording-session", session);
}, options?: { preserveProjectPath?: boolean }) => {
return ipcRenderer.invoke("set-current-recording-session", session, options);
},
getCurrentRecordingSession: () => {
return ipcRenderer.invoke("get-current-recording-session");
Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

@@ -1777,14 +1777,18 @@ export function SettingsPanel({
<div
key={g}
className={wallpaperTileClass(gradient === g)}
style={{ background: g }}
aria-label={`Gradient ${idx + 1}`}
onClick={() => {
setGradient(g);
onWallpaperChange(g);
}}
role="button"
/>
>
<div
className="absolute inset-[1px] overflow-hidden rounded-[8px]"
style={{ background: g }}
/>
</div>
))}
</div>
)}
+162 -81
View File
@@ -226,6 +226,13 @@ type SmokeExportConfig = {
fps?: ExportMp4FrameRate;
};
type SaveProjectOptions = {
silent?: boolean;
remountPreviewAfterSave?: boolean;
refreshLibraryAfterSave?: boolean;
captureThumbnail?: boolean;
};
async function writeSmokeExportReport(
outputPath: string | null,
report: Record<string, unknown>,
@@ -251,6 +258,7 @@ async function writeSmokeExportReport(
const DEFAULT_MP4_EXPORT_FRAME_RATE: ExportMp4FrameRate = 30;
const SOURCE_AUDIO_FALLBACK_TOAST_ID = "source-audio-fallback-error";
const PROJECT_AUTOSAVE_DELAY_MS = 1000;
function getEncodingModeBitrateMultiplier(encodingMode: ExportEncodingMode): number {
switch (encodingMode) {
@@ -695,6 +703,8 @@ export default function VideoEditor() {
const cropSnapshotRef = useRef<CropRegion | null>(null);
const mp4SupportRequestRef = useRef(0);
const smokeExportStartedRef = useRef(false);
const projectAutosaveTimeoutRef = useRef<number | null>(null);
const projectSaveQueueRef = useRef<Promise<unknown>>(Promise.resolve());
const [historyVersion, setHistoryVersion] = useState(0);
const timelineRef = useRef<TimelineEditorHandle>(null);
@@ -982,6 +992,19 @@ export default function VideoEditor() {
setPreviewVersion((version) => version + 1);
}, []);
const clearPendingProjectAutosave = useCallback(() => {
if (projectAutosaveTimeoutRef.current !== null) {
window.clearTimeout(projectAutosaveTimeoutRef.current);
projectAutosaveTimeoutRef.current = null;
}
}, []);
const queueProjectSave = useCallback((task: () => Promise<boolean>) => {
const run = projectSaveQueueRef.current.catch(() => undefined).then(task);
projectSaveQueueRef.current = run.catch(() => undefined);
return run;
}, []);
useEffect(() => {
return () => {
exporterRef.current?.cancel();
@@ -999,6 +1022,10 @@ export default function VideoEditor() {
window.clearTimeout(pendingFreshRecordingAutoSuggestTimeoutRef.current);
pendingFreshRecordingAutoSuggestTimeoutRef.current = null;
}
if (projectAutosaveTimeoutRef.current !== null) {
window.clearTimeout(projectAutosaveTimeoutRef.current);
projectAutosaveTimeoutRef.current = null;
}
};
}, []);
@@ -1561,20 +1588,24 @@ export default function VideoEditor() {
setCurrentTime(0);
setDuration(0);
setError(null);
setVideoSourcePath(sourcePath);
setVideoPath(await resolveVideoUrl(sourcePath));
setCurrentProjectPath(path ?? null);
pendingFreshRecordingAutoZoomPathRef.current = null;
if (normalizedEditor.webcam.sourcePath) {
await window.electronAPI.setCurrentRecordingSession?.({
videoPath: sourcePath,
webcamPath: normalizedEditor.webcam.sourcePath,
timeOffsetMs: normalizedEditor.webcam.timeOffsetMs,
});
} else {
await window.electronAPI.setCurrentVideoPath(sourcePath);
}
setError(null);
setVideoSourcePath(sourcePath);
setVideoPath(await resolveVideoUrl(sourcePath));
setCurrentProjectPath(path ?? null);
pendingFreshRecordingAutoZoomPathRef.current = null;
if (normalizedEditor.webcam.sourcePath) {
await window.electronAPI.setCurrentRecordingSession?.({
videoPath: sourcePath,
webcamPath: normalizedEditor.webcam.sourcePath,
timeOffsetMs: normalizedEditor.webcam.timeOffsetMs,
}, {
preserveProjectPath: Boolean(path),
});
} else {
await window.electronAPI.setCurrentVideoPath(sourcePath, {
preserveProjectPath: Boolean(path),
});
}
setWallpaper(normalizedEditor.wallpaper);
setShadowIntensity(normalizedEditor.shadowIntensity);
@@ -1711,9 +1742,11 @@ export default function VideoEditor() {
: webcamPath
? webcam.timeOffsetMs
: DEFAULT_WEBCAM_TIME_OFFSET_MS,
}, {
preserveProjectPath: Boolean(currentProjectPath),
});
},
[currentSourcePath, webcam.timeOffsetMs],
[currentProjectPath, currentSourcePath, webcam.timeOffsetMs],
);
const syncActiveVideoSource = useCallback(
@@ -1723,13 +1756,17 @@ export default function VideoEditor() {
videoPath: sourcePath,
webcamPath,
timeOffsetMs: webcam.timeOffsetMs,
}, {
preserveProjectPath: Boolean(currentProjectPath),
});
return;
}
await window.electronAPI.setCurrentVideoPath(sourcePath);
await window.electronAPI.setCurrentVideoPath(sourcePath, {
preserveProjectPath: Boolean(currentProjectPath),
});
},
[webcam.timeOffsetMs],
[currentProjectPath, webcam.timeOffsetMs],
);
const handleUploadWebcam = useCallback(async () => {
@@ -2237,83 +2274,106 @@ export default function VideoEditor() {
}, []);
const saveProject = useCallback(
async (forceSaveAs: boolean) => {
if (!currentSourcePath) {
toast.error("No video loaded");
return false;
}
async (forceSaveAs: boolean, options?: SaveProjectOptions) => {
clearPendingProjectAutosave();
return queueProjectSave(async () => {
if (!currentSourcePath) {
if (!options?.silent) {
toast.error("No video loaded");
}
return false;
}
try {
const projectData =
currentProjectSnapshot?.videoPath === currentSourcePath
? currentProjectSnapshot
: createProjectData(
currentSourcePath,
currentPersistedEditorState,
lastSavedSnapshot?.projectId ?? null,
);
const shouldCaptureThumbnail = options?.captureThumbnail ?? true;
const shouldRefreshLibrary = options?.refreshLibraryAfterSave ?? true;
const shouldRemountPreview = options?.remountPreviewAfterSave ?? true;
const fileNameBase =
currentSourcePath
.split(/[\\/]/)
.pop()
?.replace(/\.[^.]+$/, "") || `project-${Date.now()}`;
let targetProjectPath = forceSaveAs ? undefined : (currentProjectPath ?? undefined);
try {
const projectData =
currentProjectSnapshot?.videoPath === currentSourcePath
? currentProjectSnapshot
: createProjectData(
currentSourcePath,
currentPersistedEditorState,
lastSavedSnapshot?.projectId ?? null,
);
if (!forceSaveAs && !targetProjectPath) {
const activeProjectResult = await window.electronAPI.loadCurrentProjectFile();
if (activeProjectResult.success && activeProjectResult.path) {
targetProjectPath = activeProjectResult.path;
setCurrentProjectPath(activeProjectResult.path);
const fileNameBase =
currentSourcePath
.split(/[\\/]/)
.pop()
?.replace(/\.[^.]+$/, "") || `project-${Date.now()}`;
let targetProjectPath = forceSaveAs ? undefined : (currentProjectPath ?? undefined);
if (!forceSaveAs && !targetProjectPath) {
const activeProjectResult = await window.electronAPI.loadCurrentProjectFile();
if (activeProjectResult.success && activeProjectResult.path) {
targetProjectPath = activeProjectResult.path;
setCurrentProjectPath(activeProjectResult.path);
}
}
const thumbnailDataUrl = shouldCaptureThumbnail
? await captureProjectThumbnail()
: undefined;
const result = await window.electronAPI.saveProjectFile(
projectData,
fileNameBase,
targetProjectPath,
thumbnailDataUrl,
);
if (result.canceled) {
if (!options?.silent) {
toast.info("Project save canceled");
}
return false;
}
if (!result.success) {
if (!options?.silent) {
toast.error(result.message || "Failed to save project");
}
return false;
}
if (result.path) {
setCurrentProjectPath(result.path);
}
setLastSavedSnapshot(
cloneStructured(
createProjectData(
projectData.videoPath,
projectData.editor,
result.projectId ?? projectData.projectId ?? null,
),
),
);
if (shouldRefreshLibrary) {
await refreshProjectLibrary();
}
if (!options?.silent) {
toast.success(`Project saved to ${result.path}`);
}
return true;
} finally {
if (shouldRemountPreview) {
remountPreview();
}
}
const thumbnailDataUrl = await captureProjectThumbnail();
const result = await window.electronAPI.saveProjectFile(
projectData,
fileNameBase,
targetProjectPath,
thumbnailDataUrl,
);
if (result.canceled) {
toast.info("Project save canceled");
return false;
}
if (!result.success) {
toast.error(result.message || "Failed to save project");
return false;
}
if (result.path) {
setCurrentProjectPath(result.path);
}
setLastSavedSnapshot(
cloneStructured(
createProjectData(
projectData.videoPath,
projectData.editor,
result.projectId ?? projectData.projectId ?? null,
),
),
);
await refreshProjectLibrary();
toast.success(`Project saved to ${result.path}`);
return true;
} finally {
remountPreview();
}
});
},
[
captureProjectThumbnail,
clearPendingProjectAutosave,
currentSourcePath,
currentProjectPath,
currentProjectSnapshot,
currentPersistedEditorState,
lastSavedSnapshot?.projectId,
queueProjectSave,
refreshProjectLibrary,
remountPreview,
],
@@ -2342,6 +2402,27 @@ export default function VideoEditor() {
}
}, [saveProject]);
useEffect(() => {
if (!currentProjectPath || !hasUnsavedChanges) {
clearPendingProjectAutosave();
return;
}
projectAutosaveTimeoutRef.current = window.setTimeout(() => {
projectAutosaveTimeoutRef.current = null;
void saveProject(false, {
silent: true,
remountPreviewAfterSave: false,
refreshLibraryAfterSave: false,
captureThumbnail: false,
});
}, PROJECT_AUTOSAVE_DELAY_MS);
return () => {
clearPendingProjectAutosave();
};
}, [clearPendingProjectAutosave, currentProjectPath, hasUnsavedChanges, saveProject]);
/**
* Saves the current project directly into the projects library under a chosen name.
*/
@@ -240,7 +240,7 @@ describe("editorPreferences", () => {
cursorClickBounceDuration: 350,
cursorSway: 1.5,
borderRadius: 18,
padding: 30,
padding: { top: 30, right: 30, bottom: 30, left: 30, linked: true },
frame: DEFAULT_EDITOR_PREFERENCES.frame,
aspectRatio: "4:5",
exportEncodingMode: "quality",
@@ -282,7 +282,7 @@ describe("editorPreferences", () => {
cursorClickBounceDuration: 350,
cursorSway: 1.5,
borderRadius: 18,
padding: 30,
padding: { top: 30, right: 30, bottom: 30, left: 30, linked: true },
frame: DEFAULT_EDITOR_PREFERENCES.frame,
aspectRatio: "4:5",
exportEncodingMode: "quality",
+9
View File
@@ -3,6 +3,7 @@ import { fromFileUrl, toFileUrl } from "@/components/video-editor/projectPersist
const NOOP = () => undefined;
const REMOTE_MEDIA_URL_PATTERN = /^(https?:|blob:|data:)/i;
const LOOPBACK_MEDIA_HOSTS = new Set(["127.0.0.1", "localhost"]);
const BUNDLED_ASSET_PATH_PREFIXES = ["/wallpapers/", "/app-icons/"];
export function isAbsoluteLocalPath(resource: string) {
return (
@@ -12,6 +13,10 @@ export function isAbsoluteLocalPath(resource: string) {
);
}
function isBundledAssetPath(resource: string) {
return BUNDLED_ASSET_PATH_PREFIXES.some((prefix) => resource.startsWith(prefix));
}
function getLocalMediaServerPath(resource: string) {
if (!/^https?:\/\//i.test(resource)) {
return null;
@@ -44,6 +49,10 @@ export function getLocalFilePath(resource: string) {
return fromFileUrl(resource);
}
if (isBundledAssetPath(resource)) {
return null;
}
return isAbsoluteLocalPath(resource) ? resource : null;
}
+48
View File
@@ -0,0 +1,48 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
BUILT_IN_WALLPAPERS,
DEFAULT_WALLPAPER_PATH,
DEFAULT_WALLPAPER_RELATIVE_PATH,
getAvailableWallpapers,
} from "./wallpapers";
describe("wallpapers", () => {
beforeEach(() => {
vi.unstubAllGlobals();
});
it("keeps the curated wallpaper list and default path aligned", () => {
expect(DEFAULT_WALLPAPER_PATH).toBe("/wallpapers/midnight-8.jpg");
expect(DEFAULT_WALLPAPER_RELATIVE_PATH).toBe("wallpapers/midnight-8.jpg");
expect(BUILT_IN_WALLPAPERS.at(0)?.publicPath).toBe(DEFAULT_WALLPAPER_PATH);
expect(BUILT_IN_WALLPAPERS).toHaveLength(25);
});
it("preserves the curated order when asset discovery returns extra files", async () => {
vi.stubGlobal("window", {
electronAPI: {
listAssetDirectory: vi.fn(async () => ({
success: true,
files: [
"wallpaper1.jpg",
"energy-17.jpg",
"midnight-8.jpg",
"wallpaper4.jpg",
"wispysky.mp4",
"cityscape.jpg",
"ipad-17-light.jpg",
],
})),
},
});
await expect(getAvailableWallpapers()).resolves.toEqual([
BUILT_IN_WALLPAPERS[0],
BUILT_IN_WALLPAPERS[2],
BUILT_IN_WALLPAPERS[15],
BUILT_IN_WALLPAPERS[16],
BUILT_IN_WALLPAPERS[23],
BUILT_IN_WALLPAPERS[24],
]);
});
});
+36 -157
View File
@@ -9,158 +9,39 @@ const IMAGE_FILE_PATTERN = /\.(avif|gif|jpe?g|png|svg|webp)$/i;
const VIDEO_FILE_PATTERN = /\.(avi|m4v|mkv|mov|mp4|webm)$/i;
export const BUILT_IN_WALLPAPERS: BuiltInWallpaper[] = [
{
id: "wallpaper-1",
label: "Wallpaper 1",
relativePath: "wallpapers/wallpaper1.jpg",
publicPath: "/wallpapers/wallpaper1.jpg",
},
{
id: "wallpaper-2",
label: "Wallpaper 2",
relativePath: "wallpapers/wallpaper2.jpg",
publicPath: "/wallpapers/wallpaper2.jpg",
},
{
id: "wallpaper-3",
label: "Wallpaper 3",
relativePath: "wallpapers/wallpaper3.jpg",
publicPath: "/wallpapers/wallpaper3.jpg",
},
{
id: "wallpaper-4",
label: "Wallpaper 4",
relativePath: "wallpapers/wallpaper4.jpg",
publicPath: "/wallpapers/wallpaper4.jpg",
},
{
id: "wallpaper-5",
label: "Wallpaper 5",
relativePath: "wallpapers/wallpaper5.jpg",
publicPath: "/wallpapers/wallpaper5.jpg",
},
{
id: "wallpaper-6",
label: "Wallpaper 6",
relativePath: "wallpapers/wallpaper6.jpg",
publicPath: "/wallpapers/wallpaper6.jpg",
},
{
id: "wallpaper-7",
label: "Wallpaper 7",
relativePath: "wallpapers/wallpaper7.jpg",
publicPath: "/wallpapers/wallpaper7.jpg",
},
{
id: "wallpaper-8",
label: "Wallpaper 8",
relativePath: "wallpapers/wallpaper8.jpg",
publicPath: "/wallpapers/wallpaper8.jpg",
},
{
id: "wallpaper-9",
label: "Wallpaper 9",
relativePath: "wallpapers/wallpaper9.jpg",
publicPath: "/wallpapers/wallpaper9.jpg",
},
{
id: "wallpaper-10",
label: "Wallpaper 10",
relativePath: "wallpapers/wallpaper10.jpg",
publicPath: "/wallpapers/wallpaper10.jpg",
},
{
id: "wallpaper-11",
label: "Wallpaper 11",
relativePath: "wallpapers/wallpaper11.jpg",
publicPath: "/wallpapers/wallpaper11.jpg",
},
{
id: "wallpaper-12",
label: "Wallpaper 12",
relativePath: "wallpapers/wallpaper12.jpg",
publicPath: "/wallpapers/wallpaper12.jpg",
},
{
id: "wallpaper-13",
label: "Wallpaper 13",
relativePath: "wallpapers/wallpaper13.jpg",
publicPath: "/wallpapers/wallpaper13.jpg",
},
{
id: "wallpaper-14",
label: "Wallpaper 14",
relativePath: "wallpapers/wallpaper14.jpg",
publicPath: "/wallpapers/wallpaper14.jpg",
},
{
id: "wallpaper-15",
label: "Wallpaper 15",
relativePath: "wallpapers/wallpaper15.jpg",
publicPath: "/wallpapers/wallpaper15.jpg",
},
{
id: "wallpaper-16",
label: "Wallpaper 16",
relativePath: "wallpapers/wallpaper16.jpg",
publicPath: "/wallpapers/wallpaper16.jpg",
},
{
id: "wallpaper-17",
label: "Wallpaper 17",
relativePath: "wallpapers/wallpaper17.jpg",
publicPath: "/wallpapers/wallpaper17.jpg",
},
{
id: "wallpaper-18",
label: "Wallpaper 18",
relativePath: "wallpapers/wallpaper18.jpg",
publicPath: "/wallpapers/wallpaper18.jpg",
},
{
id: "cityscape",
label: "Cityscape",
relativePath: "wallpapers/cityscape.jpg",
publicPath: "/wallpapers/cityscape.jpg",
},
{
id: "farmvalley",
label: "Farm Valley",
relativePath: "wallpapers/farmvalley.jpg",
publicPath: "/wallpapers/farmvalley.jpg",
},
{
id: "levels",
label: "Levels",
relativePath: "wallpapers/levels.jpg",
publicPath: "/wallpapers/levels.jpg",
},
{
id: "mountaintrees",
label: "Mountain Trees",
relativePath: "wallpapers/mountaintrees.jpg",
publicPath: "/wallpapers/mountaintrees.jpg",
},
{
id: "luisdelrio",
label: "Luis Del Rio",
relativePath: "wallpapers/luisdelrio.jpg",
publicPath: "/wallpapers/luisdelrio.jpg",
},
{
id: "wispysky",
label: "Wispy Sky",
relativePath: "wallpapers/wispysky.mp4",
publicPath: "/wallpapers/wispysky.mp4",
},
createWallpaperEntry("midnight-8.jpg", "Midnight 8"),
createWallpaperEntry("ipad-17-dark.jpg", "iPad 17 Dark"),
createWallpaperEntry("ipad-17-light.jpg", "iPad 17 Light"),
createWallpaperEntry("sequoia-blue.jpg", "Sequoia Blue"),
createWallpaperEntry("sequoia-blue-orange.jpg", "Sequoia Blue Orange"),
createWallpaperEntry("ventura.jpg", "Ventura"),
createWallpaperEntry("tahoe-light.jpg", "Tahoe Light"),
createWallpaperEntry("tahoe-dark.jpg", "Tahoe Dark"),
createWallpaperEntry("sonoma-clouds.jpg", "Sonoma Clouds"),
createWallpaperEntry("sonoma-light.jpg", "Sonoma Light"),
createWallpaperEntry("sonoma-dark.jpg", "Sonoma Dark"),
createWallpaperEntry("glassmorphism-3.jpg", "Glassmorphism 3"),
createWallpaperEntry("glassmorphism-4.jpg", "Glassmorphism 4"),
createWallpaperEntry("energy-19.jpg", "Energy 19"),
createWallpaperEntry("wallpaper3.jpg", "Wallpaper 3"),
createWallpaperEntry("wallpaper4.jpg", "Wallpaper 4"),
createWallpaperEntry("cityscape.jpg", "Cityscape"),
createWallpaperEntry("levels.jpg", "Levels"),
createWallpaperEntry("wallpaper10.jpg", "Wallpaper 10"),
createWallpaperEntry("ventura-dark.jpg", "Ventura Dark"),
createWallpaperEntry("sonoma-evening.jpg", "Sonoma Evening"),
createWallpaperEntry("sonoma-horizon.jpg", "Sonoma Horizon"),
createWallpaperEntry("iridescent-9.jpg", "Iridescent 9"),
createWallpaperEntry("energy-17.jpg", "Energy 17"),
createWallpaperEntry("wispysky.mp4", "Wispy Sky"),
];
export const WALLPAPER_PATHS = BUILT_IN_WALLPAPERS.map((wallpaper) => wallpaper.publicPath);
export const WALLPAPER_RELATIVE_PATHS = BUILT_IN_WALLPAPERS.map(
(wallpaper) => wallpaper.relativePath,
);
export const DEFAULT_WALLPAPER_PATH = "/wallpapers/wallpaper2.jpg";
export const DEFAULT_WALLPAPER_RELATIVE_PATH = "wallpapers/wallpaper2.jpg";
export const DEFAULT_WALLPAPER_PATH = "/wallpapers/midnight-8.jpg";
export const DEFAULT_WALLPAPER_RELATIVE_PATH = "wallpapers/midnight-8.jpg";
export function isVideoWallpaperSource(value: string): boolean {
if (!value) {
@@ -192,22 +73,16 @@ function toWallpaperLabel(fileName: string) {
.replace(/\b\w/g, (match) => match.toUpperCase());
}
function createWallpaperEntry(fileName: string): BuiltInWallpaper {
function createWallpaperEntry(fileName: string, label = toWallpaperLabel(fileName)): BuiltInWallpaper {
const encodedFileName = encodeURIComponent(fileName);
return {
id: toWallpaperId(fileName) || `wallpaper-${encodedFileName.toLowerCase()}`,
label: toWallpaperLabel(fileName),
label,
relativePath: `wallpapers/${fileName}`,
publicPath: `/wallpapers/${encodedFileName}`,
};
}
function sortWallpaperFiles(fileNames: string[]) {
return [...fileNames].sort(
new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare,
);
}
export async function getAvailableWallpapers(): Promise<BuiltInWallpaper[]> {
const fallbackWallpapers = BUILT_IN_WALLPAPERS;
@@ -221,18 +96,22 @@ export async function getAvailableWallpapers(): Promise<BuiltInWallpaper[]> {
return fallbackWallpapers;
}
const discoveredFiles = sortWallpaperFiles(
const discoveredFiles = new Set(
result.files.filter(
(fileName) =>
IMAGE_FILE_PATTERN.test(fileName) || VIDEO_FILE_PATTERN.test(fileName),
),
);
if (discoveredFiles.length === 0) {
if (discoveredFiles.size === 0) {
return fallbackWallpapers;
}
return discoveredFiles.map(createWallpaperEntry);
const curatedWallpapers = fallbackWallpapers.filter((wallpaper) =>
discoveredFiles.has(wallpaper.relativePath.replace(/^wallpapers\//, "")),
);
return curatedWallpapers.length > 0 ? curatedWallpapers : fallbackWallpapers;
} catch {
return fallbackWallpapers;
}