mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 07:16:02 +00:00
Fix video wallpaper sync and streamline updater UI
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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") ||
|
||||
|
||||
@@ -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") ||
|
||||
|
||||
Reference in New Issue
Block a user