import { type ReactNode } from "react"; import { Eye, EyeSlash } from "@phosphor-icons/react"; import { BeatStrip, BeatBackgroundLines } from "./BeatStrip"; import { TimelineClip } from "./TimelineClip"; import { TimelineClipDiamonds } from "./TimelineClipDiamonds"; import type { MusicBeatAnalysis } from "@hyperframes/core/beats"; import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing"; import type { TimelineTheme } from "./timelineTheme"; import { GUTTER, TRACK_H, TRACKS_LEFT_PAD, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout"; import { usePlayerStore, type TimelineElement, type KeyframeCacheEntry, } from "../store/playerStore"; import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag"; import { isMultiDragPassenger, multiDragPassengerOffsetPx, type MultiDragPreviewInput, } from "./timelineMultiDragPreview"; import type { TrackVisualStyle } from "./timelineIcons"; import type { TimelineEditCallbacks } from "./timelineCallbacks"; import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability"; import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit"; import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector"; import { Music } from "../../icons/SystemIcons"; import { renderClipChildren } from "./timelineClipChildren"; /** * Props shared by the scroll container ({@link TimelineCanvas}) and the lane * renderer below. TimelineCanvas passes these straight through via spread, so * they are declared once here and both prop types compose from this base — no * duplicated prop list. */ export interface TimelineLaneBaseProps { pps: number; trackContentWidth: number; theme: TimelineTheme; displayTrackOrder: number[]; trackOrder: number[]; tracks: [number, TimelineElement[]][]; trackStyles: Map; selectedElementId: string | null; selectedElementIds: Set; hoveredClip: string | null; draggedClip: DraggedClipState | null; blockedClipRef: React.RefObject; suppressClickRef: React.RefObject; scrollRef: React.RefObject; renderClipContent?: ( element: TimelineElement, style: { clip: string; label: string }, ) => ReactNode; renderClipOverlay?: (element: TimelineElement) => ReactNode; onDrillDown?: (element: TimelineElement) => void; onSelectElement?: (element: TimelineElement | null) => void; setHoveredClip: (key: string | null) => void; setShowPopover: (v: boolean) => void; setRangeSelection: (v: null) => void; setResizingClip: (v: ResizingClipState | null) => void; setDraggedClip: (v: DraggedClipState | null) => void; setSelectedElementId: (id: string | null) => void; syncClipDragAutoScroll: (x: number, y: number) => void; shiftClickClipRef: React.RefObject<{ element: TimelineElement; anchorX: number; anchorY: number; } | null>; getPreviewElement: (element: TimelineElement) => TimelineElement; getTrackStyle: (tag: string) => TrackVisualStyle; keyframeCache?: Map; selectedKeyframes: Set; currentTime: number; onClickKeyframe?: (element: TimelineElement, percentage: number) => void; onShiftClickKeyframe?: (elementId: string, percentage: number) => void; onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void; onMoveKeyframe?: ( elementId: string, fromClipPercentage: number, toClipPercentage: number, ) => void; onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void; /** * Right-click on EMPTY lane space (not on a clip — those preventDefault * before this fires — not the gutter/ruler, not below the lanes). `time` is * the timeline time (seconds) under the pointer on that lane. */ onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void; beatAnalysis?: MusicBeatAnalysis | null; } interface TimelineLanesProps extends TimelineLaneBaseProps { /** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */ draggedElement: TimelineElement | null; multiDragPreview: MultiDragPreviewInput | null; onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"]; onResizeElement: TimelineEditCallbacks["onResizeElement"]; onMoveElement: TimelineEditCallbacks["onMoveElement"]; onRazorSplit: TimelineEditCallbacks["onRazorSplit"]; onRazorSplitAll: TimelineEditCallbacks["onRazorSplitAll"]; } export function TimelineLanes({ pps, trackContentWidth, theme, displayTrackOrder, trackOrder, tracks, trackStyles, selectedElementId, selectedElementIds, hoveredClip, draggedClip, draggedElement, multiDragPreview, blockedClipRef, suppressClickRef, scrollRef, renderClipContent, renderClipOverlay, onDrillDown, onSelectElement, setHoveredClip, setShowPopover, setRangeSelection, setResizingClip, setDraggedClip, setSelectedElementId, syncClipDragAutoScroll, shiftClickClipRef, getPreviewElement, getTrackStyle, keyframeCache, selectedKeyframes, currentTime, onClickKeyframe, onShiftClickKeyframe, onContextMenuKeyframe, onMoveKeyframe, onContextMenuClip, onContextMenuLane, beatAnalysis, onToggleTrackHidden, onResizeElement, onMoveElement, onRazorSplit, onRazorSplitAll, }: TimelineLanesProps) { return ( <> { // NOTE (deliberate no-virtualization): lanes and their clips render via a // plain `.map()` inside the scroll container rather than a windowing/virtualized // list. NLE clip counts are small (dozens to low hundreds), so the DOM cost is // bounded and virtualization's complexity isn't worth it. TODO: revisit and swap // in a virtualizer if editorial workflows ever push very high clip counts. // fallow-ignore-next-line complexity displayTrackOrder.map((trackNum) => { const els = tracks.find(([t]) => t === trackNum)?.[1] ?? []; const ts = trackStyles.get(trackNum) ?? getTrackStyle(""); const isPendingTrack = draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0; // All lanes use the same uniform color — no alternating stripes. const rowBackground = theme.rowBackground; // The beat-dot strip occupies the top of this track's lane (active track, // or the music track when nothing is selected). When shown, keyframe // diamonds shrink + drop to the bottom half so they don't collide with it. const beatStripOnTrack = (beatAnalysis?.beatTimes?.length ?? 0) >= 2 && (selectedElementId ? els.some((e) => (e.key ?? e.id) === selectedElementId) : els.some(isMusicTrack)); const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true); const isAudioTrack = els.length > 0 && els.some(isAudioTimelineElement); return (
{isAudioTrack && (
{/* Left breathing pad — empty lane surface before t=0, scrolling with the content (the horizontal TRACKS_TOP_PAD). Sits OUTSIDE the time-mapped content div so clip/beat/menu math stays content-relative (clip left = t·pps). */} ); }) } ); }