diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
| commit | 3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch) | |
| tree | 57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/timeline/components/timeline-playhead.tsx | |
| parent | 729d10592f4e802f91c555c309ff3ba4b119f843 (diff) | |
| download | opencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz | |
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/timeline/components/timeline-playhead.tsx')
| -rw-r--r-- | apps/web/src/timeline/components/timeline-playhead.tsx | 117 |
1 files changed, 117 insertions, 0 deletions
diff --git a/apps/web/src/timeline/components/timeline-playhead.tsx b/apps/web/src/timeline/components/timeline-playhead.tsx new file mode 100644 index 00000000..4dd3329f --- /dev/null +++ b/apps/web/src/timeline/components/timeline-playhead.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useRef } from "react"; +import { + getCenteredLineLeft, + TIMELINE_INDICATOR_LINE_WIDTH_PX, + timelineTimeToSnappedPixels, +} from "@/timeline"; +import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead"; +import { TICKS_PER_SECOND } from "@/wasm"; +import { useEditor } from "@/editor/use-editor"; +import { TIMELINE_SCROLLBAR_SIZE_PX } from "./layout"; +import { TIMELINE_LAYERS } from "./layers"; + +interface TimelinePlayheadProps { + zoomLevel: number; + hasHorizontalScrollbar: boolean; + rulerRef: React.RefObject<HTMLDivElement | null>; + rulerScrollRef: React.RefObject<HTMLDivElement | null>; + tracksScrollRef: React.RefObject<HTMLDivElement | null>; + timelineRef: React.RefObject<HTMLDivElement | null>; + playheadRef?: React.RefObject<HTMLDivElement | null>; + isSnappingToPlayhead?: boolean; +} + +export function TimelinePlayhead({ + zoomLevel, + hasHorizontalScrollbar, + rulerRef, + rulerScrollRef, + tracksScrollRef, + timelineRef, + playheadRef: externalPlayheadRef, + isSnappingToPlayhead = false, +}: TimelinePlayheadProps) { + const editor = useEditor(); + const duration = editor.timeline.getTotalDuration(); + const internalPlayheadRef = useRef<HTMLDivElement>(null); + const playheadRef = externalPlayheadRef || internalPlayheadRef; + + const { handlePlayheadMouseDown } = useTimelinePlayhead({ + zoomLevel, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, + }); + + const timelineContainerHeight = + timelineRef.current?.clientHeight ?? + tracksScrollRef.current?.clientHeight ?? + 400; + const totalHeight = Math.max( + 0, + timelineContainerHeight - + (hasHorizontalScrollbar ? TIMELINE_SCROLLBAR_SIZE_PX - 5 : 0), + ); + + const currentTime = editor.playback.getCurrentTime(); + const centerPosition = timelineTimeToSnappedPixels({ + time: currentTime, + zoomLevel, + }); + const scrollLeft = tracksScrollRef.current?.scrollLeft ?? 0; + const leftPosition = + getCenteredLineLeft({ centerPixel: centerPosition }) - scrollLeft; + + const handlePlayheadKeyDown = ( + event: React.KeyboardEvent<HTMLDivElement>, + ) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + + event.preventDefault(); + const fps = editor.project.getActive().settings.fps; + const ticksPerFrame = Math.round( + (TICKS_PER_SECOND * fps.denominator) / fps.numerator, + ); + const direction = event.key === "ArrowRight" ? 1 : -1; + const now = editor.playback.getCurrentTime(); + const nextTime = Math.max( + 0, + Math.min(duration, now + direction * ticksPerFrame), + ); + + editor.playback.seek({ time: nextTime }); + }; + + return ( + <div + ref={playheadRef} + role="slider" + aria-label="Timeline playhead" + aria-valuemin={0} + aria-valuemax={duration} + aria-valuenow={currentTime} + tabIndex={0} + className="pointer-events-none absolute" + style={{ + left: `${leftPosition}px`, + top: 0, + height: `${totalHeight}px`, + width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`, + zIndex: TIMELINE_LAYERS.playhead, + }} + onKeyDown={handlePlayheadKeyDown} + > + <div className="bg-primary pointer-events-none absolute left-0 h-full w-0.5" /> + + <button + type="button" + aria-label="Drag playhead" + className={`pointer-events-auto absolute top-1 left-1/2 size-3 -translate-x-1/2 transform cursor-col-resize rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-primary border-primary/50"}`} + onMouseDown={handlePlayheadMouseDown} + /> + </div> + ); +} |
