Fix video wallpaper sync and streamline updater UI

This commit is contained in:
webadderall
2026-04-27 15:48:38 +10:00
parent f58cd07250
commit bbd46b44ac
8 changed files with 246 additions and 46 deletions
-20
View File
@@ -292,15 +292,6 @@ export function getUpdateStatusSummary() {
return updateStatusSummary;
}
async function showNoUpdatesDialog(getMainWindow: () => BrowserWindow | null) {
await showMessageBox(getMainWindow, {
type: "info",
title: "No Updates Available",
message: "Recordly is up to date.",
detail: `You are running version ${app.getVersion()}.`,
});
}
async function showUpdateErrorDialog(getMainWindow: () => BrowserWindow | null, error: unknown) {
await showMessageBox(getMainWindow, {
type: "error",
@@ -647,13 +638,6 @@ export async function checkForAppUpdates(
if (updateCheckInProgress) {
writeUpdaterLog("Skipped update check because a previous check is still running.");
if (options?.manual) {
await showMessageBox(getMainWindow, {
type: "info",
title: "Update Check In Progress",
message: "Recordly is already checking for updates.",
});
}
return;
}
@@ -758,11 +742,7 @@ export function setupAutoUpdates(
detail: `Recordly ${app.getVersion()} is up to date.`,
});
clearVisibleUpdateToast(sendToRenderer);
const shouldReport = manualCheckRequested;
manualCheckRequested = false;
if (shouldReport) {
void showNoUpdatesDialog(getMainWindow);
}
});
autoUpdater.on("download-progress", (progress) => {
+12
View File
@@ -72,6 +72,7 @@ const COUNTDOWN_OPTIONS = [0, 3, 5, 10];
const WEBCAM_PREVIEW_DRAG_THRESHOLD = 6;
const DEFAULT_WEBCAM_PREVIEW_OFFSET = { x: 0, y: 0 };
const DEFAULT_RECORDING_HUD_OFFSET = { x: 0, y: 0 };
const SHOW_DEV_UPDATE_PREVIEW = import.meta.env.DEV;
function IconButton({
onClick,
@@ -1472,6 +1473,17 @@ export function LaunchWindow() {
>
{t("recording.openProject")}
</DropdownItem>
{SHOW_DEV_UPDATE_PREVIEW ? (
<DropdownItem
icon={<RefreshCw size={16} />}
onClick={() => {
void window.electronAPI.previewUpdateToast();
setActiveDropdown("none");
}}
>
{t("recording.previewUpdateUi", "Preview Update UI")}
</DropdownItem>
) : null}
<div className={styles.ddLabel} style={{ marginTop: 4 }}>
{t("recording.language")}
</div>
+49 -15
View File
@@ -13,7 +13,12 @@ import {
import { Switch } from "@/components/ui/switch";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useTheme } from "@/contexts/ThemeContext";
import { getAssetPath, getRenderableAssetUrl, getWallpaperThumbnailUrl } from "@/lib/assetPath";
import {
getAssetPath,
getRenderableAssetUrl,
getRenderableVideoUrl,
getWallpaperThumbnailUrl,
} from "@/lib/assetPath";
import type { ExtensionSettingField } from "@/lib/extensions";
import { extensionHost, type FrameInstance } from "@/lib/extensions";
import { cn } from "@/lib/utils";
@@ -147,6 +152,47 @@ function SectionLabel({ children }: { children: React.ReactNode }) {
);
}
function WallpaperVideoPreview({ src }: { src: string }) {
const [resolvedSrc, setResolvedSrc] = useState(src);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const nextSrc = await getRenderableVideoUrl(src);
if (!cancelled) {
setResolvedSrc(nextSrc);
}
} catch {
if (!cancelled) {
setResolvedSrc(src);
}
}
})();
return () => {
cancelled = true;
};
}, [src]);
return (
<video
src={resolvedSrc}
muted
playsInline
preload="metadata"
className="h-full w-full select-none object-cover [transform:translateZ(0)]"
draggable={false}
onMouseEnter={(e) => e.currentTarget.play().catch(() => undefined)}
onMouseLeave={(e) => {
e.currentTarget.pause();
e.currentTarget.currentTime = 0;
}}
/>
);
}
/**
* Renders extension-contributed settings fields (toggle, slider, select, color, text).
*/
@@ -847,7 +893,7 @@ export function SettingsPanel({
const assetUrl = await getAssetPath(wallpaper.relativePath);
// Use tiny thumbnails for the grid; full-res loads on selection
if (isVideoWallpaperSource(wallpaper.publicPath)) {
return getRenderableAssetUrl(assetUrl);
return getRenderableVideoUrl(assetUrl);
}
return getWallpaperThumbnailUrl(assetUrl);
}),
@@ -1260,19 +1306,7 @@ export function SettingsPanel({
>
<div className="absolute inset-[1px] overflow-hidden rounded-[8px] bg-editor-dialog">
{isVideoWallpaperSource(wallpaperUrl) ? (
<video
src={wallpaperUrl}
muted
playsInline
preload="metadata"
className="h-full w-full select-none object-cover [transform:translateZ(0)]"
draggable={false}
onMouseEnter={(e) => e.currentTarget.play().catch(() => undefined)}
onMouseLeave={(e) => {
e.currentTarget.pause();
e.currentTarget.currentTime = 0;
}}
/>
<WallpaperVideoPreview src={wallpaperUrl} />
) : (
<img
src={wallpaperUrl}
+53 -9
View File
@@ -18,8 +18,8 @@ import {
useRef,
useState,
} from "react";
import { getAssetPath, getRenderableAssetUrl } from "@/lib/assetPath";
import { clampMediaTimeToDuration } from "@/lib/mediaTiming";
import { getAssetPath, getRenderableAssetUrl, getRenderableVideoUrl } from "@/lib/assetPath";
import { clampMediaTimeToDuration, getMediaSyncPlaybackRate } from "@/lib/mediaTiming";
import {
DEFAULT_WALLPAPER_PATH,
DEFAULT_WALLPAPER_RELATIVE_PATH,
@@ -398,6 +398,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const trimRegionsRef = useRef<TrimRegion[]>([]);
const speedRegionsRef = useRef<SpeedRegion[]>([]);
const lastWebcamSyncTimeRef = useRef<number | null>(null);
const lastBackgroundSyncTimeRef = useRef<number | null>(null);
const bgVideoRef = useRef<HTMLVideoElement | null>(null);
const zoomMotionBlurRef = useRef(zoomMotionBlur);
const connectZoomsRef = useRef(connectZooms);
@@ -1006,13 +1007,55 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
}
}, [isPlaying]);
// Sync background video wallpaper with timeline scrubbing
// Keep video wallpapers locked to the same source timestamp as the main clip.
useEffect(() => {
const bgVideo = bgVideoRef.current;
if (!bgVideo) return;
if (!isPlaying && bgVideo.duration && Number.isFinite(bgVideo.duration)) {
bgVideo.currentTime = currentTime % bgVideo.duration;
const videoDuration =
Number.isFinite(bgVideo.duration) && bgVideo.duration > 0 ? bgVideo.duration : null;
const targetTime = videoDuration
? currentTime % videoDuration
: clampMediaTimeToDuration(currentTime, videoDuration);
const activeSpeedRegion = speedRegionsRef.current.find(
(region) => currentTime * 1000 >= region.startMs && currentTime * 1000 < region.endMs,
);
const targetPlaybackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1;
const syncedPlaybackRate = getMediaSyncPlaybackRate({
basePlaybackRate: targetPlaybackRate,
currentTime: bgVideo.currentTime,
targetTime,
toleranceSeconds: 0.02,
correctionWindowSeconds: 1.5,
maxAdjustment: 0.12,
});
if (Math.abs(bgVideo.playbackRate - syncedPlaybackRate) > 0.001) {
bgVideo.playbackRate = syncedPlaybackRate;
}
const previousTimelineTime = lastBackgroundSyncTimeRef.current;
const timelineJumped =
previousTimelineTime === null || Math.abs(targetTime - previousTimelineTime) > 0.25;
const driftThreshold = isPlaying ? 0.35 : 0.01;
if (timelineJumped || Math.abs(bgVideo.currentTime - targetTime) > driftThreshold) {
try {
bgVideo.currentTime = targetTime;
} catch {
// no-op
}
}
if (isPlaying) {
const playPromise = bgVideo.play();
if (playPromise) {
playPromise.catch(() => undefined);
}
} else {
bgVideo.pause();
}
lastBackgroundSyncTimeRef.current = targetTime;
}, [currentTime, isPlaying]);
useEffect(() => {
@@ -1275,6 +1318,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
lastWebcamSyncTimeRef.current = null;
}, [webcamVideoPath]);
useEffect(() => {
lastBackgroundSyncTimeRef.current = null;
}, [wallpaper]);
useEffect(() => {
const overlayEl = overlayRef.current;
if (!overlayEl) return;
@@ -2119,10 +2166,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
}
if (isVideoWallpaperSource(wallpaper)) {
let videoSrc = wallpaper;
if (wallpaper.startsWith("/") && !wallpaper.startsWith("//")) {
videoSrc = await getAssetPath(wallpaper.replace(/^\//, ""));
}
const videoSrc = await getRenderableVideoUrl(wallpaper);
if (mounted) {
setResolvedWallpaper(videoSrc);
setResolvedWallpaperKind("video");
+41
View File
@@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getRenderableVideoUrl } from "./assetPath";
describe("getRenderableVideoUrl", () => {
beforeEach(() => {
vi.unstubAllGlobals();
});
it("uses the local media server for absolute local video paths when available", async () => {
vi.stubGlobal("window", {
electronAPI: {
getLocalMediaUrl: vi.fn(async (filePath: string) => ({
success: true,
url: `http://127.0.0.1:4321/video?path=${encodeURIComponent(filePath)}`,
})),
},
});
await expect(getRenderableVideoUrl("/Users/egg/Desktop/bg.mp4")).resolves.toBe(
"http://127.0.0.1:4321/video?path=%2FUsers%2Fegg%2FDesktop%2Fbg.mp4",
);
});
it("falls back to a file URL for absolute local video paths", async () => {
await expect(getRenderableVideoUrl("/Users/egg/Desktop/bg.mp4")).resolves.toBe(
"file:///Users/egg/Desktop/bg.mp4",
);
});
it("keeps bundled wallpaper paths routed through the app asset directory", async () => {
vi.stubGlobal("window", {
location: {
protocol: "http:",
},
});
await expect(getRenderableVideoUrl("/wallpapers/wispysky.mp4")).resolves.toBe(
"/wallpapers/wispysky.mp4",
);
});
});
+81
View File
@@ -11,6 +11,47 @@ 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 =
@@ -121,6 +162,46 @@ export async function getRenderableAssetUrl(asset: string): Promise<string> {
}
}
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);
}
export async function getRenderableVideoUrl(asset: string): Promise<string> {
if (
!asset ||
asset.startsWith("blob:") ||
asset.startsWith("data:") ||
asset.startsWith("file://") ||
asset.startsWith("http")
) {
return asset;
}
const isBundledAssetPath =
asset.startsWith("/wallpapers/") || asset.startsWith("/app-icons/");
if (isAbsoluteLocalAssetPath(asset) && !isBundledAssetPath) {
return resolveLocalMediaUrl(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.
+5 -1
View File
@@ -51,7 +51,7 @@ import {
getWebcamOverlaySizePx,
} from "@/components/video-editor/webcamOverlay";
import { getWebcamMediaTargetTimeSeconds } from "@/components/video-editor/videoPlayback/webcamSync";
import { getAssetPath, getRenderableAssetUrl } from "@/lib/assetPath";
import { getAssetPath, getRenderableAssetUrl, getRenderableVideoUrl } from "@/lib/assetPath";
import { extensionHost } from "@/lib/extensions";
import {
mapCursorToCanvasNormalized,
@@ -580,6 +580,10 @@ export class FrameRenderer {
return wallpaper;
}
if (isVideoWallpaperSource(wallpaper)) {
return getRenderableVideoUrl(wallpaper);
}
if (
wallpaper.startsWith("#") ||
wallpaper.startsWith("linear-gradient") ||
+5 -1
View File
@@ -57,7 +57,7 @@ import {
getWebcamOverlaySizePx,
} from "@/components/video-editor/webcamOverlay";
import { getWebcamMediaTargetTimeSeconds } from "@/components/video-editor/videoPlayback/webcamSync";
import { getAssetPath, getRenderableAssetUrl } from "@/lib/assetPath";
import { getAssetPath, getRenderableAssetUrl, getRenderableVideoUrl } from "@/lib/assetPath";
import { extensionHost } from "@/lib/extensions";
import {
mapCursorToCanvasNormalized,
@@ -1487,6 +1487,10 @@ export class FrameRenderer {
return wallpaper;
}
if (isVideoWallpaperSource(wallpaper)) {
return getRenderableVideoUrl(wallpaper);
}
if (
wallpaper.startsWith("#") ||
wallpaper.startsWith("linear-gradient") ||