mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 23:05:49 +00:00
feat(wallpaper): video wallpaper system with wispysky default, upload, thumbnails, preview and export rendering
This commit is contained in:
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
import { Film, Palette, Trash2, Upload, X } from "lucide-react";
|
||||
import { Palette, Trash2, Upload, X } from "lucide-react";
|
||||
import { AnimatePresence, LayoutGroup, motion } from "motion/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -802,12 +802,16 @@ export function SettingsPanel({
|
||||
>
|
||||
<div className="absolute inset-[1px] overflow-hidden rounded-[8px] bg-[#0d0e11]">
|
||||
{isVideoWallpaperSource(wallpaperUrl) ? (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-1 bg-[radial-gradient(circle_at_top,#1f3b68,transparent_58%),linear-gradient(135deg,#0f172a,#111827_48%,#1d4ed8)] px-1 text-center text-white">
|
||||
<Film className="h-4 w-4 opacity-90" />
|
||||
<span className="line-clamp-2 text-[8px] font-medium leading-tight text-white/80">
|
||||
{props?.title ?? props?.ariaLabel ?? tSettings("background.video", "Video background")}
|
||||
</span>
|
||||
</div>
|
||||
<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(() => {})}
|
||||
onMouseLeave={(e) => { e.currentTarget.pause(); e.currentTarget.currentTime = 0; }}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={wallpaperUrl}
|
||||
@@ -982,6 +986,23 @@ export function SettingsPanel({
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
const handleVideoUpload = async () => {
|
||||
try {
|
||||
const result = await (window as any).electronAPI.openVideoFilePicker();
|
||||
if (!result?.success || !result.path) return;
|
||||
const filePath = result.path as string;
|
||||
if (!isVideoWallpaperSource(filePath)) {
|
||||
toast.error("Unsupported format", { description: "Please select a video file (mp4, webm, mov, etc.)" });
|
||||
return;
|
||||
}
|
||||
setCustomImages((prev) => [filePath, ...prev]);
|
||||
onWallpaperChange(filePath);
|
||||
toast.success("Video background added");
|
||||
} catch {
|
||||
toast.error("Failed to import video background");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCustomImage = (imageUrl: string, event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
setCustomImages((prev) => prev.filter((img) => img !== imageUrl));
|
||||
@@ -1115,23 +1136,62 @@ export function SettingsPanel({
|
||||
{(wallpaperPreviewPaths.length > 0
|
||||
? wallpaperPreviewPaths
|
||||
: builtInWallpaperPaths
|
||||
).map((previewPath, index) => {
|
||||
const wallpaper = builtInWallpapers[index] ?? BUILT_IN_WALLPAPERS[index];
|
||||
).filter(p => !isVideoWallpaperSource(p)).map((previewPath, filteredIndex) => {
|
||||
const imageWallpapers = builtInWallpapers.filter(w => !isVideoWallpaperSource(w.publicPath));
|
||||
const wallpaper = imageWallpapers[filteredIndex];
|
||||
const wallpaperValue =
|
||||
wallpaper?.publicPath ?? builtInWallpaperPaths[index] ?? previewPath;
|
||||
wallpaper?.publicPath ?? previewPath;
|
||||
const isSelected = getWallpaperTileState(wallpaperValue, previewPath);
|
||||
return renderWallpaperImageTile(previewPath, isSelected, {
|
||||
key: wallpaperValue,
|
||||
ariaLabel: wallpaper?.label ?? `Wallpaper ${index + 1}`,
|
||||
title: wallpaper?.label ?? `Wallpaper ${index + 1}`,
|
||||
ariaLabel: wallpaper?.label ?? `Wallpaper ${filteredIndex + 1}`,
|
||||
title: wallpaper?.label ?? `Wallpaper ${filteredIndex + 1}`,
|
||||
onClick: () => onWallpaperChange(wallpaperValue),
|
||||
});
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : backgroundTab === "video" ? (
|
||||
<div className="mt-0 flex min-h-24 items-center justify-center rounded-xl border border-dashed border-white/10 bg-white/[0.03] text-[11px] text-slate-400">
|
||||
Video backgrounds coming next.
|
||||
<div className="mt-0 space-y-2">
|
||||
<Button
|
||||
onClick={handleVideoUpload}
|
||||
variant="outline"
|
||||
className="w-full gap-2 bg-white/5 text-slate-200 border-white/10 hover:bg-[#2563EB] hover:text-white hover:border-[#2563EB] transition-all h-7 text-[10px]"
|
||||
>
|
||||
<Upload className="w-3 h-3" />
|
||||
{tSettings("background.uploadCustomVideo", "Upload Video")}
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-8 gap-1.5">
|
||||
{customImages.filter(isVideoWallpaperSource).map((videoUrl, idx) => {
|
||||
const isSelected = getWallpaperTileState(videoUrl);
|
||||
return renderWallpaperImageTile(videoUrl, isSelected, {
|
||||
key: `custom-video-${idx}`,
|
||||
ariaLabel: videoUrl.split(/[\\/]/).pop() ?? "Video background",
|
||||
title: videoUrl.split(/[\\/]/).pop(),
|
||||
onClick: () => onWallpaperChange(videoUrl),
|
||||
children: (
|
||||
<button
|
||||
onClick={(e) => handleRemoveCustomImage(videoUrl, e)}
|
||||
className="absolute top-0.5 right-0.5 w-3 h-3 bg-red-500/90 hover:bg-red-500 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10"
|
||||
>
|
||||
<X className="w-2 h-2 text-white" />
|
||||
</button>
|
||||
),
|
||||
});
|
||||
})}
|
||||
|
||||
{BUILT_IN_WALLPAPERS.filter(w => isVideoWallpaperSource(w.publicPath)).map((wallpaper) => {
|
||||
const wallpaperValue = wallpaper.publicPath;
|
||||
const isSelected = selected === wallpaperValue;
|
||||
return renderWallpaperImageTile(wallpaperValue, isSelected, {
|
||||
key: wallpaperValue,
|
||||
ariaLabel: wallpaper.label,
|
||||
title: wallpaper.label,
|
||||
onClick: () => onWallpaperChange(wallpaperValue),
|
||||
});
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : backgroundTab === "color" ? (
|
||||
<div className="mt-0 space-y-2">
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
DEFAULT_WALLPAPER_RELATIVE_PATH,
|
||||
isVideoWallpaperSource,
|
||||
} from "@/lib/wallpapers";
|
||||
import { resolveMediaElementSource } from "@/lib/exporter/localMediaSource";
|
||||
import {
|
||||
Application,
|
||||
Container,
|
||||
@@ -329,6 +328,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const trimRegionsRef = useRef<TrimRegion[]>([]);
|
||||
const speedRegionsRef = useRef<SpeedRegion[]>([]);
|
||||
const lastWebcamSyncTimeRef = useRef<number | null>(null);
|
||||
const bgVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const zoomMotionBlurRef = useRef(zoomMotionBlur);
|
||||
const connectZoomsRef = useRef(connectZooms);
|
||||
const zoomInDurationMsRef = useRef(zoomInDurationMs);
|
||||
@@ -753,8 +753,25 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
|
||||
useEffect(() => {
|
||||
isPlayingRef.current = isPlaying;
|
||||
const bgVideo = bgVideoRef.current;
|
||||
if (bgVideo) {
|
||||
if (isPlaying) {
|
||||
bgVideo.play().catch(() => {});
|
||||
} else {
|
||||
bgVideo.pause();
|
||||
}
|
||||
}
|
||||
}, [isPlaying]);
|
||||
|
||||
// Sync background video wallpaper with timeline scrubbing
|
||||
useEffect(() => {
|
||||
const bgVideo = bgVideoRef.current;
|
||||
if (!bgVideo) return;
|
||||
if (!isPlaying && bgVideo.duration && Number.isFinite(bgVideo.duration)) {
|
||||
bgVideo.currentTime = currentTime % bgVideo.duration;
|
||||
}
|
||||
}, [currentTime, isPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
trimRegionsRef.current = trimRegions;
|
||||
}, [trimRegions]);
|
||||
@@ -1506,10 +1523,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
}
|
||||
|
||||
if (isVideoWallpaperSource(wallpaper)) {
|
||||
const resolved = await resolveMediaElementSource(wallpaper);
|
||||
revokeResolvedWallpaper = resolved.revoke;
|
||||
let videoSrc = wallpaper;
|
||||
if (wallpaper.startsWith("/") && !wallpaper.startsWith("//")) {
|
||||
videoSrc = await getAssetPath(wallpaper.replace(/^\//, ""));
|
||||
}
|
||||
if (mounted) {
|
||||
setResolvedWallpaper(resolved.src);
|
||||
setResolvedWallpaper(videoSrc);
|
||||
setResolvedWallpaperKind("video");
|
||||
}
|
||||
return;
|
||||
@@ -1610,11 +1629,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
{resolvedWallpaperKind === "video" && resolvedWallpaper ? (
|
||||
<video
|
||||
key={resolvedWallpaper}
|
||||
ref={bgVideoRef}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
src={resolvedWallpaper}
|
||||
muted
|
||||
loop
|
||||
autoPlay
|
||||
playsInline
|
||||
style={{
|
||||
filter: backgroundBlur > 0 ? `blur(${backgroundBlur}px)` : "none",
|
||||
|
||||
@@ -47,6 +47,7 @@ import { getWebcamOverlayPosition, getWebcamOverlaySizePx } from "@/components/v
|
||||
import { drawSquircleOnCanvas, drawSquircleOnGraphics } from "@/lib/geometry/squircle";
|
||||
import { ForwardFrameSource } from "./forwardFrameSource";
|
||||
import { resolveMediaElementSource } from "./localMediaSource";
|
||||
import { isVideoWallpaperSource } from "@/lib/wallpapers";
|
||||
|
||||
interface FrameRenderConfig {
|
||||
width: number;
|
||||
@@ -143,6 +144,7 @@ export class FrameRenderer {
|
||||
private compositeCanvas: HTMLCanvasElement | null = null;
|
||||
private compositeCtx: CanvasRenderingContext2D | null = null;
|
||||
private backgroundVideoElement: HTMLVideoElement | null = null;
|
||||
private backgroundCtx: CanvasRenderingContext2D | null = null;
|
||||
private cleanupBackgroundSource: (() => void) | null = null;
|
||||
private config: FrameRenderConfig;
|
||||
private animationState: AnimationState;
|
||||
@@ -301,7 +303,34 @@ export class FrameRenderer {
|
||||
throw new Error("Failed to get 2D context for background canvas");
|
||||
}
|
||||
|
||||
this.backgroundCtx = bgCtx;
|
||||
|
||||
try {
|
||||
// Check for video wallpaper first
|
||||
if (isVideoWallpaperSource(wallpaper)) {
|
||||
let videoSrc = wallpaper;
|
||||
if (wallpaper.startsWith("/") && !wallpaper.startsWith("//")) {
|
||||
videoSrc = await getAssetPath(wallpaper.replace(/^\//, ""));
|
||||
}
|
||||
|
||||
const video = document.createElement("video");
|
||||
video.muted = true;
|
||||
video.loop = true;
|
||||
video.playsInline = true;
|
||||
video.preload = "auto";
|
||||
video.src = videoSrc;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
video.onloadeddata = () => resolve();
|
||||
video.onerror = () => reject(new Error(`Failed to load video wallpaper: ${wallpaper}`));
|
||||
});
|
||||
|
||||
this.backgroundVideoElement = video;
|
||||
this.drawVideoFrameToBackground();
|
||||
this.backgroundSprite = bgCanvas as any;
|
||||
return;
|
||||
}
|
||||
|
||||
// Render background based on type
|
||||
if (
|
||||
wallpaper.startsWith("file://") ||
|
||||
@@ -427,6 +456,53 @@ export class FrameRenderer {
|
||||
this.backgroundSprite = bgCanvas as any;
|
||||
}
|
||||
|
||||
private drawVideoFrameToBackground(): void {
|
||||
const video = this.backgroundVideoElement;
|
||||
const ctx = this.backgroundCtx;
|
||||
if (!video || !ctx) return;
|
||||
|
||||
const w = this.config.width;
|
||||
const h = this.config.height;
|
||||
const videoAspect = video.videoWidth / video.videoHeight;
|
||||
const canvasAspect = w / h;
|
||||
|
||||
let drawWidth: number, drawHeight: number, drawX: number, drawY: number;
|
||||
if (videoAspect > canvasAspect) {
|
||||
drawHeight = h;
|
||||
drawWidth = drawHeight * videoAspect;
|
||||
drawX = (w - drawWidth) / 2;
|
||||
drawY = 0;
|
||||
} else {
|
||||
drawWidth = w;
|
||||
drawHeight = drawWidth / videoAspect;
|
||||
drawX = 0;
|
||||
drawY = (h - drawHeight) / 2;
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.drawImage(video, drawX, drawY, drawWidth, drawHeight);
|
||||
}
|
||||
|
||||
private async syncBackgroundVideo(timeSeconds: number): Promise<void> {
|
||||
const video = this.backgroundVideoElement;
|
||||
if (!video) return;
|
||||
|
||||
if (video.duration && Number.isFinite(video.duration)) {
|
||||
const targetTime = timeSeconds % video.duration;
|
||||
if (Math.abs(video.currentTime - targetTime) > 0.01) {
|
||||
video.currentTime = targetTime;
|
||||
await new Promise<void>((resolve) => {
|
||||
const onSeeked = () => {
|
||||
video.removeEventListener("seeked", onSeeked);
|
||||
resolve();
|
||||
};
|
||||
video.addEventListener("seeked", onSeeked);
|
||||
});
|
||||
}
|
||||
}
|
||||
this.drawVideoFrameToBackground();
|
||||
}
|
||||
|
||||
private async resolveWallpaperImageUrl(wallpaper: string): Promise<string> {
|
||||
if (
|
||||
wallpaper.startsWith("file://") ||
|
||||
@@ -731,6 +807,11 @@ export class FrameRenderer {
|
||||
await this.syncWebcamFrame(targetTime);
|
||||
}
|
||||
|
||||
// Sync video wallpaper frame
|
||||
if (this.backgroundVideoElement) {
|
||||
await this.syncBackgroundVideo(this.currentVideoTime);
|
||||
}
|
||||
|
||||
// Create or update video sprite from VideoFrame
|
||||
if (!this.videoSprite) {
|
||||
const texture = Texture.from(videoFrame as any);
|
||||
@@ -1306,6 +1387,7 @@ export class FrameRenderer {
|
||||
this.shadowCtx = null;
|
||||
this.compositeCanvas = null;
|
||||
this.compositeCtx = null;
|
||||
this.backgroundCtx = null;
|
||||
if (this.backgroundVideoElement) {
|
||||
this.backgroundVideoElement.pause();
|
||||
this.backgroundVideoElement.src = "";
|
||||
|
||||
@@ -32,6 +32,7 @@ export const BUILT_IN_WALLPAPERS: BuiltInWallpaper[] = [
|
||||
{ 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' },
|
||||
];
|
||||
|
||||
export const WALLPAPER_PATHS = BUILT_IN_WALLPAPERS.map((wallpaper) => wallpaper.publicPath);
|
||||
@@ -99,7 +100,7 @@ export async function getAvailableWallpapers(): Promise<BuiltInWallpaper[]> {
|
||||
}
|
||||
|
||||
const discoveredFiles = sortWallpaperFiles(
|
||||
result.files.filter((fileName) => IMAGE_FILE_PATTERN.test(fileName)),
|
||||
result.files.filter((fileName) => IMAGE_FILE_PATTERN.test(fileName) || VIDEO_FILE_PATTERN.test(fileName)),
|
||||
);
|
||||
|
||||
if (discoveredFiles.length === 0) {
|
||||
|
||||
Reference in New Issue
Block a user