summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/timeline/timeline-playhead.tsx
diff options
context:
space:
mode:
authorMaze <167211895+mazeincoding@users.noreply.github.com>2026-01-31 00:20:04 +0100
committerGitHub <noreply@github.com>2026-01-31 00:20:04 +0100
commit7bf0984698e43b9aa2dd879f65951318ed410390 (patch)
treeccf2cd590a47d550152afc07129cb55c71a9ec35 /apps/web/src/components/editor/timeline/timeline-playhead.tsx
parent0173db99448d1c4c6415dcf15272e17551b48b04 (diff)
downloadopencut-7bf0984698e43b9aa2dd879f65951318ed410390.tar.gz
codebase overhaul (#697)
Diffstat (limited to 'apps/web/src/components/editor/timeline/timeline-playhead.tsx')
-rw-r--r--apps/web/src/components/editor/timeline/timeline-playhead.tsx228
1 files changed, 74 insertions, 154 deletions
diff --git a/apps/web/src/components/editor/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/timeline/timeline-playhead.tsx
index a1be5b26..58d0adb0 100644
--- a/apps/web/src/components/editor/timeline/timeline-playhead.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-playhead.tsx
@@ -1,172 +1,92 @@
"use client";
-import { useRef, useState, useEffect } from "react";
-import { TimelineTrack } from "@/types/timeline";
+import { useRef } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead";
+import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
+import { useEditor } from "@/hooks/use-editor";
interface TimelinePlayheadProps {
- currentTime: number;
- duration: number;
- zoomLevel: number;
- tracks: TimelineTrack[];
- seek: (time: number) => void;
- rulerRef: React.RefObject<HTMLDivElement>;
- rulerScrollRef: React.RefObject<HTMLDivElement>;
- tracksScrollRef: React.RefObject<HTMLDivElement>;
- trackLabelsRef?: React.RefObject<HTMLDivElement>;
- timelineRef: React.RefObject<HTMLDivElement>;
- playheadRef?: React.RefObject<HTMLDivElement>;
- isSnappingToPlayhead?: boolean;
+ zoomLevel: number;
+ rulerRef: React.RefObject<HTMLDivElement>;
+ rulerScrollRef: React.RefObject<HTMLDivElement>;
+ tracksScrollRef: React.RefObject<HTMLDivElement>;
+ timelineRef: React.RefObject<HTMLDivElement>;
+ playheadRef?: React.RefObject<HTMLDivElement>;
+ isSnappingToPlayhead?: boolean;
}
export function TimelinePlayhead({
- currentTime,
- duration,
- zoomLevel,
- tracks,
- seek,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- trackLabelsRef,
- timelineRef,
- playheadRef: externalPlayheadRef,
- isSnappingToPlayhead = false,
+ zoomLevel,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ timelineRef,
+ playheadRef: externalPlayheadRef,
+ isSnappingToPlayhead = false,
}: TimelinePlayheadProps) {
- const internalPlayheadRef = useRef<HTMLDivElement>(null);
- const playheadRef = externalPlayheadRef || internalPlayheadRef;
- const [scrollLeft, setScrollLeft] = useState(0);
+ const editor = useEditor();
+ const duration = editor.timeline.getTotalDuration();
+ const internalPlayheadRef = useRef<HTMLDivElement>(null);
+ const playheadRef = externalPlayheadRef || internalPlayheadRef;
- const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
- currentTime,
- duration,
- zoomLevel,
- seek,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- playheadRef,
- });
+ const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
+ zoomLevel,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
+ });
- // Track scroll position to lock playhead to frame
- useEffect(() => {
- const tracksViewport = tracksScrollRef.current;
+ const timelineContainerHeight =
+ tracksScrollRef.current?.clientHeight ??
+ timelineRef.current?.clientHeight ??
+ 400;
+ const totalHeight = Math.max(0, timelineContainerHeight - 4);
- if (!tracksViewport) return;
+ const timelinePosition =
+ playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const leftPosition = timelinePosition;
- const handleScroll = () => {
- setScrollLeft(tracksViewport.scrollLeft);
- };
+ const handlePlayheadKeyDown = (
+ event: React.KeyboardEvent<HTMLDivElement>,
+ ) => {
+ if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
- // Set initial scroll position
- setScrollLeft(tracksViewport.scrollLeft);
+ event.preventDefault();
+ const step = 1 / Math.max(1, editor.project.getActive().settings.fps);
+ const direction = event.key === "ArrowRight" ? 1 : -1;
+ const nextTime = Math.max(
+ 0,
+ Math.min(duration, playheadPosition + direction * step),
+ );
- tracksViewport.addEventListener("scroll", handleScroll);
- return () => tracksViewport.removeEventListener("scroll", handleScroll);
- }, [tracksScrollRef]);
+ editor.playback.seek({ time: nextTime });
+ };
- // Use timeline container height minus a few pixels for breathing room
- const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
- const totalHeight = timelineContainerHeight - 4;
+ return (
+ <div
+ ref={playheadRef}
+ role="slider"
+ aria-label="Timeline playhead"
+ aria-valuemin={0}
+ aria-valuemax={duration}
+ aria-valuenow={playheadPosition}
+ tabIndex={0}
+ className="pointer-events-auto absolute z-60"
+ style={{
+ left: `${leftPosition}px`,
+ top: 0,
+ height: `${totalHeight}px`,
+ width: "2px",
+ }}
+ onMouseDown={handlePlayheadMouseDown}
+ onKeyDown={handlePlayheadKeyDown}
+ >
+ <div className="bg-foreground absolute left-0 h-full w-0.5 cursor-col-resize" />
- // Get dynamic track labels width, fallback to 0 if no tracks or no ref
- const trackLabelsWidth =
- tracks.length > 0 && trackLabelsRef?.current
- ? trackLabelsRef.current.offsetWidth
- : 0;
-
- // Calculate position locked to timeline content (accounting for scroll)
- const timelinePosition =
- playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
- const rawLeftPosition = trackLabelsWidth + timelinePosition - scrollLeft;
-
- // Get the timeline content width and viewport width for right boundary
- const timelineContentWidth =
- duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
- const tracksViewport = tracksScrollRef.current;
- const viewportWidth = tracksViewport?.clientWidth || 1000;
-
- // Constrain playhead to never appear outside the timeline area
- const leftBoundary = trackLabelsWidth;
- const rightBoundary = Math.min(
- trackLabelsWidth + timelineContentWidth - scrollLeft, // Don't go beyond timeline content
- trackLabelsWidth + viewportWidth // Don't go beyond viewport
- );
-
- const leftPosition = Math.max(
- leftBoundary,
- Math.min(rightBoundary, rawLeftPosition)
- );
-
- // Debug logging when playhead might go outside
- if (rawLeftPosition < leftBoundary || rawLeftPosition > rightBoundary) {
- console.log(
- "PLAYHEAD VISUAL DEBUG:",
- JSON.stringify({
- playheadPosition,
- timelinePosition,
- trackLabelsWidth,
- scrollLeft,
- rawLeftPosition,
- constrainedLeftPosition: leftPosition,
- leftBoundary,
- rightBoundary,
- timelineContentWidth,
- viewportWidth,
- zoomLevel,
- })
- );
- }
-
- return (
- <div
- ref={playheadRef}
- className="absolute pointer-events-auto z-40"
- style={{
- left: `${leftPosition}px`,
- top: 0,
- height: `${totalHeight}px`,
- width: "2px", // Slightly wider for better click target
- }}
- onMouseDown={handlePlayheadMouseDown}
- >
- {/* The playhead line spanning full height */}
- <div
- className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-foreground" : "bg-foreground"}`}
- />
-
- {/* Playhead dot indicator at the top (in ruler area) */}
- <div
- className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
- />
- </div>
- );
+ <div
+ className={`absolute top-1 left-1/2 size-3 -translate-x-1/2 transform rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
+ />
+ </div>
+ );
}
-
-// Also export a hook for getting ruler handlers
-export function useTimelinePlayheadRuler({
- currentTime,
- duration,
- zoomLevel,
- seek,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- playheadRef,
-}: Omit<TimelinePlayheadProps, "tracks" | "trackLabelsRef" | "timelineRef">) {
- const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({
- currentTime,
- duration,
- zoomLevel,
- seek,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- playheadRef,
- });
-
- return { handleRulerMouseDown, isDraggingRuler };
-}
-
-export { TimelinePlayhead as default };