'use client'; /** * ActionsBar — the "narration script" timeline: a horizontal film-editing strip * that is also a light editor for the scene's playback `actions`. * * This WAS the whole bottom bar. It is now the body of `EditDock`, which owns * the surface (border, blur, fold) and the global edit bar above it; the * timeline still renders its own header row — the row's controls and the body * share one piece of state — and drives the dock's fold through `useEditDock`. * Nothing about the timeline's own behaviour or geometry changed in the move. * (The height-drag handle was removed per product decision: only the fold moves * the dock's height, so the timeline no longer sizes itself.) * * The scene's `actions` ARE the timeline: walked left→right, each `speech` * becomes an editable clip block (one spoken line, numbered) and every non-speech * cue (spotlight / laser / board) becomes a compact card pinned at its place in * the flow. Hovering a cue replays the REAL playback effect on its bound element * (setLaser → LaserPointerOverlay, setSpotlight → SpotlightOverlay). * * Editing (persisted via useStageStore.updateScene → actions-edit ops): * - speech clip text is editable inline (commit on blur); * - the header "Add action" pill opens ActionPicker to insert a new action; * - existing items drag to reorder; each card carries a delete button; * - clicking an element-bound cue arms canvas pick mode (useCanvasStore.pickTarget * with purpose 'cue'), so the target is chosen by clicking the element directly * on the slide. * * Reactive to the stage store; collapse and height belong to the dock. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { ChevronDown, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Flag, FoldVertical, GripVertical, Loader2, Play, Plus, RefreshCw, Square, Trash2, UnfoldVertical, Volume2, } from 'lucide-react'; import { motion, useReducedMotion } from 'motion/react'; import { cn } from '@/lib/utils/cn'; import { useI18n } from '@/lib/hooks/use-i18n'; import { flushStageSave, useStageStore } from '@/lib/store/stage'; import { useCanvasStore } from '@/lib/store/canvas'; import { useSettingsStore } from '@/lib/store/settings'; import { useAgentRegistry } from '@/lib/orchestration/registry/store'; import { AvatarDisplay } from '@/components/ui/avatar-display'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import type { Action, DiscussionAction } from '@/lib/types/action'; import type { SceneType } from '@/lib/types/stage'; import { ELEMENT_BOUND, cueLabel, cueMeta, elementLabel } from './cue-meta'; import { applyCuePreview, clearCuePreview, cuePreviewFor } from './cue-preview'; import { appendDiscussion, clampInsertSlot, hasDiscussion, insertAt, makeAction, moveById, moveByIdDir, removeById, setAudioIdById, setDiscussionAgentById, setDiscussionPromptById, setDiscussionTopicById, setSpeechTextClearAudioById, } from './actions-edit'; import { useEditDock } from '@/components/edit/EditDock/dock-context'; import { ActionPicker } from './ActionPicker'; import type { PickerType } from './picker-options'; import { audioExists, audioObjectUrl, discardSpeechAudio, regenerateSpeechAudio, resolveLegacySpeechAudioId, resolveSpeechAudioId, } from '@/lib/audio/regenerate-speech-tts'; const EMPTY: Action[] = []; const EMPTY_ELEMENTS: { id?: string; type: string; content?: string }[] = []; // Stable empty set for the "no lines regenerating" state (avoids re-allocating // on every reset and keeps a constant identity between batch runs). const NO_IDS: ReadonlySet = new Set(); /** * Module-level single-flight controller for TTS preview: at most one * SpeechTtsBar may be loading or playing at any moment, across every speech * clip in the timeline. A bar registers its own `stop` handle when it starts a * preview and clears it in `stopPreview` only when it is still the registered * owner — so a stale stop (a superseded attempt, or an unmounted non-active * bar) never kills the currently active preview. */ let activePreview: { stop: () => void } | null = null; /** * Clear the canvas spotlight/laser preview when a cue glyph unmounts while it is * being hovered — most importantly when the user deletes the cue. React does not * fire `onMouseLeave` on unmount, so without this the previewed effect would stay * stuck on the slide after its cue is gone. */ function useClearCuePreviewOnUnmount() { useEffect(() => () => clearCuePreview(), []); } /** * Soft amber dashed border marking a still-incomplete clip card — an empty * narration line, a cue bound to no element, a discussion with no topic. A clip * is a card, so a dashed frame reads as "draft / unfinished" better than a dot; * the calmer amber stays clear of the blue interactive controls and is dropped * the moment the clip is filled. */ const INCOMPLETE_CLIP = 'border-dashed border-amber-400/70'; const AXIS_FROM_TOP = 20; // px from track top to the axis center (nodes hang below it) // Radix Select forbids an empty-string item value, so the discussion's // "unspecified agent" choice rides a sentinel that maps back to '' on change. const DISCUSSION_AGENT_NONE = '__none__'; type DragPayload = { kind: 'move'; id: string }; interface TooltipState { action: Action; anchor: DOMRect; } type TFn = (key: string, options?: Record) => string; function propsOf(a: Action, t: TFn): Array<[string, string]> { const rows: Array<[string, string]> = [[t('edit.timeline.fieldAction'), cueLabel(a.type, t)]]; const el = (a as { elementId?: string }).elementId; if (el) rows.push([t('edit.timeline.fieldElement'), el]); const content = (a as { content?: string }).content; if (content) rows.push([ t('edit.timeline.fieldContent'), content.length > 48 ? `${content.slice(0, 48)}…` : content, ]); return rows; } function CueTooltip({ tip }: { tip: TooltipState }) { const { t } = useI18n(); if (typeof document === 'undefined') return null; return createPortal(
{propsOf(tip.action, t).map(([k, v]) => (
{k} {v}
))}
, document.body, ); } // Native HTML5 drag snapshots the element's square bounding box, so a round // icon chip drags with white corners ("white border"). Suppress the ghost with a 1×1 // transparent image — the violet drop indicator carries the feedback instead. let blankDragImg: HTMLImageElement | null = null; function setBlankDragImage(e: React.DragEvent) { if (typeof document === 'undefined') return; if (!blankDragImg) { blankDragImg = new Image(); blankDragImg.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; } try { e.dataTransfer.setDragImage(blankDragImg, 0, 0); } catch { /* not supported — fall back to the default ghost */ } } /** Shared delete button — prominent, top-right of a card. */ function DeleteButton({ onDelete }: { onDelete: () => void }) { const { t } = useI18n(); return ( ); } /** ‹ › buttons to nudge a node left/right along the timeline. */ function MoveButtons({ onLeft, onRight, canLeft, canRight, }: { onLeft: () => void; onRight: () => void; canLeft: boolean; canRight: boolean; }) { const { t } = useI18n(); const cls = 'grid size-5 place-items-center rounded text-muted-foreground/55 transition-colors hover:bg-muted hover:text-foreground disabled:opacity-25 disabled:hover:bg-transparent'; return ( <> ); } type TtsStatus = 'none' | 'ready' | 'generating' | 'error'; /** TTS preview lifecycle: idle → loading (awaiting the blob URL) → playing → idle. */ type PreviewPhase = 'idle' | 'loading' | 'playing'; /** Audio status + preview / regenerate row, shown when managed TTS is on. */ export function SpeechTtsBar({ actionId, audioId, audioUrl, audioInvalidated, sceneOrder, language, text, refreshKey, regenerating, onGenerated, }: { actionId: string; audioId?: string; /** The legacy URL of an unconverted pair: narration exists until conversion removes it. */ audioUrl?: string; audioInvalidated?: boolean; sceneOrder: number; language?: string; text: string; refreshKey?: number; regenerating?: boolean; /** * Notification that regeneration succeeded. Carries the freshly allocated * audioId so the caller can stamp it on the action: this tree allocates pool * identities (the blob is stored under the returned id), so the id cannot be * re-derived by the caller like the reference's deterministic key. */ onGenerated: (audioId: string) => void; }) { const { t } = useI18n(); const [status, setStatus] = useState('none'); // Holds this line in the generating state across a batch ("Voice all") run // and — crucially — until its OWN audio re-check resolves, so it can't // briefly flash back to not voiced in the window between the batch clearing // `regenerating` and the async audioExists effect landing. Latched on the // rising edge of `regenerating`, cleared inside that re-check effect (which // the batch always re-triggers via `refreshKey`). const [batchPending, setBatchPending] = useState(false); const [prevRegenerating, setPrevRegenerating] = useState(regenerating); if (regenerating !== prevRegenerating) { // Adjust state during render (per React's "you might not need an effect"), // not in an effect — avoids a cascading render on the batch's hot path. setPrevRegenerating(regenerating); if (regenerating) setBatchPending(true); } // TTS preview state machine — local UI state for the preview button, kept // apart from `status`/`effStatus` (audio availability + regeneration), which // the playback lifecycle must not disturb. const [previewPhase, setPreviewPhase] = useState('idle'); // Generation token: every `stopPreview` (and every fresh `preview`) bumps it, // so an in-flight `audioObjectUrl` await can tell it was superseded and drop // its result — this is what kills the double-click double-Audio race. const previewTokenRef = useRef(0); const audioRef = useRef(null); const objUrlRef = useRef(null); const lookupId = resolveSpeechAudioId(sceneOrder, { id: actionId, audioId }); const [readAudioId, setReadAudioId] = useState(lookupId); const [seededLookupId, setSeededLookupId] = useState(lookupId); if (lookupId !== seededLookupId) { setSeededLookupId(lookupId); setReadAudioId(lookupId); } const stopPreview = useCallback(() => { // Invalidate every in-flight `preview()` await — a newer click, a takeover // by another bar, or an unmount all funnel through here. previewTokenRef.current += 1; // Only the registered owner clears the module-level handle: a stale stop // from a superseded or unmounted bar must not kill the current preview. if (activePreview?.stop === stopPreview) activePreview = null; audioRef.current?.pause(); audioRef.current = null; if (objUrlRef.current) { URL.revokeObjectURL(objUrlRef.current); objUrlRef.current = null; } setPreviewPhase('idle'); }, []); useEffect(() => { let alive = true; (async () => { try { // A missing stamped id means "not generated" for new documents. Probe // the deterministic key only to preserve pre-allocation Dexie rows. const legacyId = lookupId ? undefined : await resolveLegacySpeechAudioId(sceneOrder, { id: actionId, audioInvalidated }); const candidateId = lookupId ?? legacyId; // An unconverted pair's legacy URL is narration that exists: the id // lookup may find nothing while the URL is still live. const has = (candidateId ? await audioExists(candidateId) : false) || !!audioUrl; if (alive) { setReadAudioId(has ? candidateId : undefined); setStatus((s) => (s === 'generating' ? s : has ? 'ready' : 'none')); } } catch { /* IndexedDB read failed — leave status as-is (as before this change) */ } finally { // Clear the batch latch only once the batch itself is over — its // end-of-batch re-check runs with regenerating=false. A *stale* // pre-batch check that resolves mid-batch must NOT clear it (adding // regenerating to the deps also cancels such a check at batch start via // the cleanup below). Runs even if the read threw, so the row can never // wedge in the generating state. if (alive && !regenerating) setBatchPending(false); } })(); return () => { alive = false; }; }, [lookupId, actionId, sceneOrder, audioInvalidated, audioUrl, refreshKey, regenerating]); useEffect(() => () => stopPreview(), [stopPreview]); const preview = async () => { // Global single-flight: stop whatever is loading/playing in ANY bar first. // This also bumps the token, so this bar's own previous in-flight attempt // is already invalidated by the time we capture the fresh token below. activePreview?.stop(); const token = ++previewTokenRef.current; setPreviewPhase('loading'); activePreview = { stop: stopPreview }; // The legacy URL of an unconverted pair is the narration when no pool or // Dexie id resolved -- or when the resolved id turns out to have no local // bytes, which is exactly the dangling-id case the URL survives for. const src = (readAudioId ? await audioObjectUrl(readAudioId) : null) ?? audioUrl ?? null; if (token !== previewTokenRef.current) { // Superseded while loading (a newer click, a takeover, a stop): drop the // result and revoke any blob URL we minted — the winner is in charge. if (src && src.startsWith('blob:')) URL.revokeObjectURL(src); return; } if (!src) { stopPreview(); return; } objUrlRef.current = src; const a = new Audio(src); audioRef.current = a; a.addEventListener('ended', stopPreview); a.addEventListener('error', stopPreview); try { await a.play(); // Stopped while play() was settling (e.g. a takeover in the gap): the // stop already paused it, nothing more to do. if (token !== previewTokenRef.current) return; setPreviewPhase('playing'); } catch { // Autoplay rejection etc. — treat like any other stop. stopPreview(); } }; const regenerate = async () => { setStatus('generating'); try { const previousAudioId = audioId; const id = await regenerateSpeechAudio( sceneOrder, { id: actionId, text, audioId: previousAudioId }, language, ); if (id) { setReadAudioId(id); onGenerated(id); setStatus('ready'); } else { setStatus('none'); } } catch { setStatus('error'); } }; const STATUS: Record = { ready: { label: t('edit.tts.statusReady'), cls: 'text-emerald-600 dark:text-emerald-400' }, none: { label: t('edit.tts.statusNone'), cls: 'text-muted-foreground' }, generating: { label: t('edit.tts.statusGenerating'), cls: 'text-amber-600 dark:text-amber-400', }, error: { label: t('edit.tts.statusError'), cls: 'text-rose-500' }, }; // A batch "Voice all" run drives this line's loading state from the parent // (regenerating) — independent of the local single-line status. `batchPending` // extends the generating state past the prop clearing, until this line's own // audio re-check resolves to voiced / not voiced, so the batch end shows a // clean generating → voiced transition with no intermediate flash. const effStatus: TtsStatus = regenerating || batchPending ? 'generating' : status; const s = STATUS[effStatus]; const previewLabel = previewPhase === 'loading' ? t('edit.tts.cancelPreview') : previewPhase === 'playing' ? t('edit.tts.stopPreview') : t('edit.tts.preview'); // idle → Play; loading → spinner (click cancels the load); playing → Stop. const PreviewIcon = previewPhase === 'loading' ? Loader2 : previewPhase === 'playing' ? Square : Play; return (
{s.label}
); } /** One spoken line — a numbered, editable clip block. */ function SpeechClip({ text, index, actionId, audioId, audioUrl, audioInvalidated, sceneOrder, language, autoFocus, ttsActive, ttsRefresh, regenerating, onCommit, onGenerated, onDelete, onMoveLeft, onMoveRight, canMoveLeft, canMoveRight, onDragStart, onDragEnd, onFocused, }: { text: string; index: number; actionId: string; audioId?: string; audioUrl?: string; audioInvalidated?: boolean; sceneOrder: number; language?: string; autoFocus: boolean; ttsActive: boolean; ttsRefresh?: number; regenerating?: boolean; onCommit: (text: string) => void; onGenerated: (audioId: string) => void; onDelete: () => void; onMoveLeft: () => void; onMoveRight: () => void; canMoveLeft: boolean; canMoveRight: boolean; onDragStart: (e: React.DragEvent) => void; onDragEnd: () => void; onFocused: () => void; }) { const { t } = useI18n(); const ref = useRef(null); const [val, setVal] = useState(text); // Has the user typed since the last external sync? If not, external text // changes (e.g. an agent regeneration mid-edit) are adopted even while // focused — so a stale draft can't clobber regenerated narration on blur. const dirtyRef = useRef(false); useEffect(() => { if (document.activeElement !== ref.current || !dirtyRef.current) { // eslint-disable-next-line react-hooks/set-state-in-effect -- sync external text in only when not mid-edit setVal(text); dirtyRef.current = false; } }, [text]); useEffect(() => { if (autoFocus) { ref.current?.focus(); onFocused(); } }, [autoFocus, onFocused]); const commit = () => { if (dirtyRef.current && val !== text) onCommit(val); dirtyRef.current = false; }; const SpeechIcon = cueMeta('speech').icon; const needsText = !text.trim(); return (
{String(index).padStart(2, '0')} {t('edit.cue.speech')}