feat: Implement a new video editor with dedicated components for video playback, timeline editing, and event handling.

This commit is contained in:
Mahdy Arief
2026-03-28 02:09:03 +07:00
parent 63b7205759
commit 1bb8eeebfa
4 changed files with 80 additions and 6 deletions
@@ -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;