mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-27 00:05:39 +00:00
feat: Implement a new video editor with dedicated components for video playback, timeline editing, and event handling.
This commit is contained in:
@@ -2066,10 +2066,19 @@ export default function VideoEditor() {
|
||||
if (!video.paused && !video.ended) {
|
||||
playback.pause();
|
||||
} else {
|
||||
// Selection awareness: if playing with a selection active, jump to start if out of bounds
|
||||
if (timeSelection) {
|
||||
const currentTimeMs = Math.round(currentTime * 1000);
|
||||
const bufferMs = 50; // Small buffer for end boundary
|
||||
if (currentTimeMs < timeSelection.startMs || currentTimeMs >= timeSelection.endMs - bufferMs) {
|
||||
handleSeek(timeSelection.startMs / 1000);
|
||||
}
|
||||
}
|
||||
playback.play().catch((err) => console.error("Video play failed:", err));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function handleSeek(time: number) {
|
||||
const video = videoPlaybackRef.current?.video;
|
||||
if (!video) return;
|
||||
@@ -3653,7 +3662,9 @@ export default function VideoEditor() {
|
||||
cursorClickBounce={cursorClickBounce}
|
||||
cursorClickBounceDuration={cursorClickBounceDuration}
|
||||
cursorSway={cursorSway}
|
||||
timeSelection={timeSelection}
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -202,8 +202,10 @@ interface VideoPlaybackProps {
|
||||
cursorClickBounceDuration?: number;
|
||||
cursorSway?: number;
|
||||
volume?: number;
|
||||
timeSelection?: import("./types").TimeSelection | null;
|
||||
}
|
||||
|
||||
|
||||
export interface VideoPlaybackRef {
|
||||
video: HTMLVideoElement | null;
|
||||
app: Application | null;
|
||||
@@ -269,7 +271,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
cursorClickBounceDuration = DEFAULT_CURSOR_CLICK_BOUNCE_DURATION,
|
||||
cursorSway = DEFAULT_CURSOR_SWAY,
|
||||
volume = 1,
|
||||
timeSelection = null,
|
||||
},
|
||||
|
||||
ref,
|
||||
) => {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
@@ -347,6 +351,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const cursorClickBounceRef = useRef(cursorClickBounce);
|
||||
const cursorClickBounceDurationRef = useRef(cursorClickBounceDuration);
|
||||
const cursorSwayRef = useRef(cursorSway);
|
||||
const timeSelectionRef = useRef(timeSelection);
|
||||
|
||||
|
||||
const activeCaptionLayout = useMemo(() => {
|
||||
if (!autoCaptionSettings?.enabled || autoCaptions.length === 0 || typeof document === "undefined") {
|
||||
@@ -841,6 +847,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
cursorSwayRef.current = cursorSway;
|
||||
}, [cursorSway]);
|
||||
|
||||
useEffect(() => {
|
||||
timeSelectionRef.current = timeSelection;
|
||||
}, [timeSelection]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
currentTimeRef.current = currentTime * 1000;
|
||||
}, [currentTime]);
|
||||
@@ -1183,8 +1194,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
onTimeUpdate,
|
||||
trimRegionsRef,
|
||||
speedRegionsRef,
|
||||
timeSelectionRef,
|
||||
});
|
||||
|
||||
|
||||
video.addEventListener("play", handlePlay);
|
||||
video.addEventListener("pause", handlePause);
|
||||
video.addEventListener("ended", handlePause);
|
||||
|
||||
@@ -238,13 +238,17 @@ function PlaybackCursor({
|
||||
onSeek,
|
||||
timelineRef,
|
||||
keyframes = [],
|
||||
timeSelection = null,
|
||||
}: {
|
||||
|
||||
currentTimeMs: number;
|
||||
videoDurationMs: number;
|
||||
onSeek?: (time: number) => void;
|
||||
timelineRef: React.RefObject<HTMLDivElement>;
|
||||
keyframes?: { id: string; time: number }[];
|
||||
timeSelection?: import('../types').TimeSelection | null;
|
||||
}) {
|
||||
|
||||
const { sidebarWidth = 0, direction, range, valueToPixels, pixelsToValue } = useTimelineContext();
|
||||
const sideProperty = direction === "rtl" ? "right" : "left";
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
@@ -278,11 +282,25 @@ function PlaybackCursor({
|
||||
onSeek(absoluteMs / 1000);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
if (isDragging && timeSelection && onSeek) {
|
||||
const rect = timelineRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
const clickX = e.clientX - rect.left - sidebarWidth;
|
||||
const relativeMs = pixelsToValue(clickX);
|
||||
const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
|
||||
|
||||
// If released outside selection, jump back to selection start
|
||||
if (absoluteMs < timeSelection.startMs || absoluteMs > timeSelection.endMs) {
|
||||
onSeek(timeSelection.startMs / 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
setIsDragging(false);
|
||||
document.body.style.cursor = "";
|
||||
};
|
||||
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
document.body.style.cursor = "ew-resize";
|
||||
@@ -564,8 +582,10 @@ function Timeline({
|
||||
|
||||
const isDraggingSelectionRef = useRef(false);
|
||||
const selectionAnchorMsRef = useRef<number | null>(null);
|
||||
const selectionCurrentMsRef = useRef<number | null>(null);
|
||||
const initialMouseDownPosRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// In Move mode, the timeline background never starts a selection drag.
|
||||
@@ -604,9 +624,11 @@ function Timeline({
|
||||
} else {
|
||||
// Plain drag: anchor starts at the click point itself
|
||||
selectionAnchorMsRef.current = absoluteMs;
|
||||
selectionCurrentMsRef.current = absoluteMs;
|
||||
onTimeSelectionChange?.({ startMs: absoluteMs, endMs: absoluteMs });
|
||||
}
|
||||
|
||||
|
||||
const handleGlobalMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (selectionAnchorMsRef.current === null || initialMouseDownPosRef.current === null) return;
|
||||
|
||||
@@ -621,6 +643,8 @@ function Timeline({
|
||||
const moveX = moveEvent.clientX - capturedRect.left - sidebarWidth;
|
||||
const moveRelativeMs = pixelsToValue(moveX);
|
||||
const moveAbsoluteMs = Math.max(0, Math.min(range.start + moveRelativeMs, videoDurationMs));
|
||||
selectionCurrentMsRef.current = moveAbsoluteMs;
|
||||
|
||||
|
||||
const start = Math.min(selectionAnchorMsRef.current, moveAbsoluteMs);
|
||||
const end = Math.max(selectionAnchorMsRef.current, moveAbsoluteMs);
|
||||
@@ -629,12 +653,21 @@ function Timeline({
|
||||
};
|
||||
|
||||
const handleGlobalMouseUp = () => {
|
||||
if (isDraggingSelectionRef.current && selectionAnchorMsRef.current !== null && selectionCurrentMsRef.current !== null) {
|
||||
// When finished dragging a selection, jump playhead to the start of selection
|
||||
const start = Math.min(selectionAnchorMsRef.current, selectionCurrentMsRef.current);
|
||||
onSeek?.(start / 1000);
|
||||
}
|
||||
|
||||
selectionAnchorMsRef.current = null;
|
||||
selectionCurrentMsRef.current = null;
|
||||
initialMouseDownPosRef.current = null;
|
||||
window.removeEventListener("mousemove", handleGlobalMouseMove);
|
||||
window.removeEventListener("mouseup", handleGlobalMouseUp);
|
||||
};
|
||||
|
||||
|
||||
|
||||
window.addEventListener("mousemove", handleGlobalMouseMove);
|
||||
window.addEventListener("mouseup", handleGlobalMouseUp);
|
||||
},
|
||||
@@ -655,11 +688,9 @@ function Timeline({
|
||||
// In Select mode, shift+click updates the selection (already done in mousedown) — don't seek
|
||||
if (timelineMode === 'select' && e.shiftKey) return;
|
||||
|
||||
// Plain click: deselect all blocks, then seek
|
||||
// Only clear timeSelection in Select mode; in Move mode leave it as-is
|
||||
if (timelineMode === 'select') {
|
||||
onTimeSelectionChange?.(null);
|
||||
}
|
||||
// Plain click: deselect all blocks, then seek and clear time selection
|
||||
onTimeSelectionChange?.(null);
|
||||
|
||||
onSelectZoom?.(null);
|
||||
onSelectTrim?.(null);
|
||||
onSelectAnnotation?.(null);
|
||||
@@ -834,8 +865,10 @@ function Timeline({
|
||||
onSeek={onSeek}
|
||||
timelineRef={localTimelineRef}
|
||||
keyframes={keyframes}
|
||||
timeSelection={timeSelection}
|
||||
/>
|
||||
|
||||
|
||||
<div className="relative z-10 flex flex-1 min-h-0 flex-col">
|
||||
<Row id={ZOOM_ROW_ID} isEmpty={zoomItems.length === 0} hint="Press Z to add zoom">
|
||||
{zoomItems.map((item) => (
|
||||
|
||||
@@ -12,8 +12,10 @@ interface VideoEventHandlersParams {
|
||||
onTimeUpdate: (time: number) => void;
|
||||
trimRegionsRef: React.MutableRefObject<TrimRegion[]>;
|
||||
speedRegionsRef: React.MutableRefObject<SpeedRegion[]>;
|
||||
timeSelectionRef: React.MutableRefObject<import('../types').TimeSelection | null>;
|
||||
}
|
||||
|
||||
|
||||
export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
const {
|
||||
video,
|
||||
@@ -26,8 +28,10 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
onTimeUpdate,
|
||||
trimRegionsRef,
|
||||
speedRegionsRef,
|
||||
timeSelectionRef,
|
||||
} = params;
|
||||
|
||||
|
||||
const emitTime = (timeValue: number) => {
|
||||
currentTimeRef.current = timeValue * 1000;
|
||||
onTimeUpdate(timeValue);
|
||||
@@ -52,8 +56,21 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
if (!video) return;
|
||||
|
||||
const currentTimeMs = video.currentTime * 1000;
|
||||
|
||||
// Selection awareness: stop playback at the end of selection range
|
||||
const selection = timeSelectionRef.current;
|
||||
if (selection && !video.paused && !isSeekingRef.current) {
|
||||
if (currentTimeMs >= selection.endMs) {
|
||||
video.pause();
|
||||
video.currentTime = selection.startMs / 1000;
|
||||
emitTime(selection.startMs / 1000);
|
||||
return; // Selection boundary reached, stop update loop
|
||||
}
|
||||
}
|
||||
|
||||
const activeTrimRegion = findActiveTrimRegion(currentTimeMs);
|
||||
|
||||
|
||||
// If we're in a trim region during playback, skip to the end of it
|
||||
if (activeTrimRegion && !video.paused && !video.ended) {
|
||||
const skipToTime = activeTrimRegion.endMs / 1000;
|
||||
|
||||
Reference in New Issue
Block a user