- {/* Time ruler ticks */}
+ {/* Ruler ticks */}
{Array.from({ length: Math.ceil(totalDuration) + 1 }, (_, i) => (
{
+ {boardTimings.map(({ board, startTime }, idx) => {
const blockWidth = Math.max(MIN_BLOCK_WIDTH, board.duration * PIXELS_PER_SECOND);
const isSelected = board.id === selectedBoardId;
+ const isDragging = dragState?.boardId === board.id;
+
return (
{
- e.stopPropagation();
- onSelectBoard(board.id, startTime);
- onSeek(startTime);
- }}
+ onPointerDown={(e) => handleBoardPointerDown(e, board.id, idx)}
>
- {/* Thumbnail strip on left */}
{board.imageDataUrl && (

)}
-
- {/* Text labels */}
{board.shotNumber}
@@ -440,29 +537,38 @@ const Timeline = ({
"{board.dialogue}"
)}
-
- {board.duration.toFixed(1)}s
-
+ {board.duration.toFixed(1)}s
{/* Resize handle */}
handleResizeMouseDown(e, board.id, board.duration)}
+ data-resize="true"
+ className="absolute right-0 top-0 bottom-0 w-2 rounded-r opacity-0 transition-opacity hover:bg-white/30 hover:opacity-100"
+ style={{ cursor: "ew-resize" }}
+ onPointerDown={(e) => handleResizePointerDown(e, board.id, board.duration)}
title="Drag to resize duration"
/>
);
})}
+ {/* Drag insertion caret */}
+ {dragState && (
+
+ {/* Downward-pointing triangle at top */}
+
+
+ )}
+
{/* Playhead */}
@@ -470,6 +576,99 @@ const Timeline = ({
);
};
+// ─── PlaybackControls ─────────────────────────────────────────────────────────
+
+interface PlaybackControlsProps {
+ isPlaying: boolean;
+ currentTimeSeconds: number;
+ totalDuration: number;
+ currentBoardNumber: number | null;
+ totalBoards: number;
+ onPlay: () => void;
+ onPause: () => void;
+ onPrevBoard: () => void;
+ onNextBoard: () => void;
+ onPrevScene: () => void;
+ onNextScene: () => void;
+}
+
+const PlaybackControls = ({
+ isPlaying,
+ currentTimeSeconds,
+ totalDuration,
+ currentBoardNumber,
+ totalBoards,
+ onPlay,
+ onPause,
+ onPrevBoard,
+ onNextBoard,
+ onPrevScene,
+ onNextScene,
+}: PlaybackControlsProps) => {
+ const transportBtnClass =
+ "flex h-9 w-9 items-center justify-center rounded-md text-base-fg/70 transition-colors hover:bg-white/10 hover:text-base-fg active:scale-95";
+
+ return (
+
+ {/* Left stats */}
+
+ {formatTime(currentTimeSeconds)}
+
+ {currentBoardNumber !== null ? `Shot ${currentBoardNumber}` : "—"}
+
+
+
+ {/* Transport buttons — centered */}
+
+
+
+
+ {/* Play / Pause */}
+
+
+
+
+
+
+ {/* Right stats */}
+
+ {formatTime(totalDuration)}
+ {totalBoards} shot{totalBoards !== 1 ? "s" : ""}
+
+
+ );
+};
+
// ─── Moodboard ────────────────────────────────────────────────────────────────
export const Moodboard = () => {
@@ -485,9 +684,145 @@ export const Moodboard = () => {
const dragIndexRef = useRef
(null);
const [dragOverIndex, setDragOverIndex] = useState(null);
const [currentTimeSeconds, setCurrentTimeSeconds] = useState(0);
+ const [isPlaying, setIsPlaying] = useState(false);
+ // Refs for playback rAF loop (avoid stale closures)
+ const isPlayingRef = useRef(false);
+ const currentTimeRef = useRef(0);
+ const boardsRef = useRef([]);
+ const rafRef = useRef(null);
+ const lastTimestampRef = useRef(null);
+
+ // Keep refs in sync with state/props
+ useEffect(() => { isPlayingRef.current = isPlaying; }, [isPlaying]);
+ useEffect(() => { currentTimeRef.current = currentTimeSeconds; }, [currentTimeSeconds]);
+ useEffect(() => { boardsRef.current = boards; }, [boards]);
+
+ const boardTimings = useMemo(() => buildBoardTimings(boards), [boards]);
+ const totalDuration = boardTimings.reduce((s, { board }) => s + board.duration, 0);
const selectedBoard = boards.find((b) => b.id === selectedBoardId) ?? null;
+ // ── Playback engine ────────────────────────────────────────────────────────
+
+ const stopPlayback = useCallback(() => {
+ setIsPlaying(false);
+ if (rafRef.current !== null) {
+ cancelAnimationFrame(rafRef.current);
+ rafRef.current = null;
+ }
+ lastTimestampRef.current = null;
+ }, []);
+
+ useEffect(() => {
+ if (!isPlaying) {
+ if (rafRef.current !== null) {
+ cancelAnimationFrame(rafRef.current);
+ rafRef.current = null;
+ }
+ lastTimestampRef.current = null;
+ return;
+ }
+
+ const tick = (timestamp: number) => {
+ const last = lastTimestampRef.current;
+ if (last !== null) {
+ const delta = (timestamp - last) / 1000;
+ const nextTime = currentTimeRef.current + delta;
+ const timings = buildBoardTimings(boardsRef.current);
+ const dur = timings.reduce((s, { board }) => s + board.duration, 0);
+
+ if (nextTime >= dur) {
+ // Reached end — stop
+ setCurrentTimeSeconds(dur);
+ currentTimeRef.current = dur;
+ stopPlayback();
+ return;
+ }
+
+ setCurrentTimeSeconds(nextTime);
+ currentTimeRef.current = nextTime;
+
+ // Auto-select board under playhead
+ const hit = timings.find(
+ ({ board, startTime }) =>
+ nextTime >= startTime && nextTime < startTime + board.duration,
+ );
+ if (hit && hit.board.id !== selectedBoardId) {
+ selectBoard(hit.board.id);
+ }
+ }
+ lastTimestampRef.current = timestamp;
+ rafRef.current = requestAnimationFrame(tick);
+ };
+
+ rafRef.current = requestAnimationFrame(tick);
+ return () => {
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isPlaying]);
+
+ // ── Playback control actions ───────────────────────────────────────────────
+
+ const handlePlay = useCallback(() => {
+ // If at the end, restart from beginning
+ if (currentTimeRef.current >= totalDuration && totalDuration > 0) {
+ setCurrentTimeSeconds(0);
+ currentTimeRef.current = 0;
+ }
+ setIsPlaying(true);
+ }, [totalDuration]);
+
+ const handlePause = useCallback(() => stopPlayback(), [stopPlayback]);
+
+ const handlePrevBoard = useCallback(() => {
+ const timings = buildBoardTimings(boards);
+ const curTime = currentTimeRef.current;
+ // If we're more than 0.3s into the current board, rewind to its start
+ const currentEntry = timings.find(
+ ({ startTime, board }) => curTime >= startTime && curTime < startTime + board.duration,
+ );
+ if (currentEntry && curTime - currentEntry.startTime > 0.3) {
+ setCurrentTimeSeconds(currentEntry.startTime);
+ currentTimeRef.current = currentEntry.startTime;
+ selectBoard(currentEntry.board.id);
+ return;
+ }
+ // Otherwise go to start of previous board
+ const idx = timings.findIndex(({ board }) => board.id === selectedBoardId);
+ const prevEntry = idx > 0 ? timings[idx - 1] : timings[0];
+ if (prevEntry) {
+ setCurrentTimeSeconds(prevEntry.startTime);
+ currentTimeRef.current = prevEntry.startTime;
+ selectBoard(prevEntry.board.id);
+ }
+ }, [boards, selectedBoardId, selectBoard]);
+
+ const handleNextBoard = useCallback(() => {
+ const timings = buildBoardTimings(boards);
+ const idx = timings.findIndex(({ board }) => board.id === selectedBoardId);
+ const nextEntry = idx >= 0 && idx < timings.length - 1 ? timings[idx + 1] : null;
+ if (nextEntry) {
+ setCurrentTimeSeconds(nextEntry.startTime);
+ currentTimeRef.current = nextEntry.startTime;
+ selectBoard(nextEntry.board.id);
+ }
+ }, [boards, selectedBoardId, selectBoard]);
+
+ const handlePrevScene = useCallback(() => {
+ setCurrentTimeSeconds(0);
+ currentTimeRef.current = 0;
+ if (boards.length > 0) selectBoard(boards[0].id);
+ }, [boards, selectBoard]);
+
+ const handleNextScene = useCallback(() => {
+ setCurrentTimeSeconds(totalDuration);
+ currentTimeRef.current = totalDuration;
+ stopPlayback();
+ }, [totalDuration, stopPlayback]);
+
+ // ── File upload ────────────────────────────────────────────────────────────
+
const handleUploadClick = useCallback(() => {
fileInputRef.current?.click();
}, []);
@@ -509,6 +844,8 @@ export const Moodboard = () => {
[selectedBoardId, updateBoard],
);
+ // ── Filmstrip drag handlers ────────────────────────────────────────────────
+
const handleDragStart = useCallback((e: React.DragEvent, index: number) => {
dragIndexRef.current = index;
e.dataTransfer.effectAllowed = "move";
@@ -536,6 +873,8 @@ export const Moodboard = () => {
dragIndexRef.current = null;
}, []);
+ // ── Metadata update / delete ───────────────────────────────────────────────
+
const handleUpdate = useCallback(
(patch: Partial>) => {
if (selectedBoardId) updateBoard(selectedBoardId, patch);
@@ -547,21 +886,38 @@ export const Moodboard = () => {
if (selectedBoardId) deleteBoard(selectedBoardId);
}, [selectedBoardId, deleteBoard]);
+ // ── Timeline callbacks ────────────────────────────────────────────────────
+
const handleTimelineSelect = useCallback(
(id: string, startTime: number) => {
selectBoard(id);
setCurrentTimeSeconds(startTime);
+ currentTimeRef.current = startTime;
},
[selectBoard],
);
+ const handleSeek = useCallback((time: number) => {
+ setCurrentTimeSeconds(time);
+ currentTimeRef.current = time;
+ }, []);
+
const handleUpdateDuration = useCallback(
- (id: string, duration: number) => {
- updateBoard(id, { duration });
- },
+ (id: string, duration: number) => updateBoard(id, { duration }),
[updateBoard],
);
+ // ── Derived values for PlaybackControls ──────────────────────────────────
+
+ const currentBoardNumber = useMemo(() => {
+ const hit = boardTimings.find(
+ ({ startTime, board }) =>
+ currentTimeSeconds >= startTime &&
+ currentTimeSeconds < startTime + board.duration,
+ );
+ return hit?.board.shotNumber ?? null;
+ }, [boardTimings, currentTimeSeconds]);
+
if (boards.length === 0) {
return ;
}
@@ -611,7 +967,23 @@ export const Moodboard = () => {
currentTimeSeconds={currentTimeSeconds}
onSelectBoard={handleTimelineSelect}
onUpdateDuration={handleUpdateDuration}
- onSeek={setCurrentTimeSeconds}
+ onSeek={handleSeek}
+ onReorder={reorderBoards}
+ />
+
+ {/* Playback controls */}
+