import { create } from "zustand"; import type { MusicBeatAnalysis } from "@hyperframes/core/beats"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { BeatEditState } from "../../utils/beatEditing"; import type { ClipManifestClip } from "../lib/playbackTypes"; import { readStudioUiPreferences, writeStudioUiPreferences, type TimelineTimeDisplayMode, } from "../../utils/studioUiPreferences"; import { clampTimelineZoomPercent, computePinnedZoomPercent } from "../components/timelineZoom"; import { createKeyframeSlice, type KeyframeCacheEntry, type KeyframeSlice } from "./keyframeSlice"; import { createAutomationSelectionSlice, type AutomationSelectionSlice, } from "./automationSelectionSlice"; import { createTimelineFocusRequest, type TimelineFocusRequest } from "./timelineFocusState"; import { createThumbnailSlice, type ThumbnailSlice } from "./thumbnailSlice"; import { createAudioSoloSlice, type AudioSoloSlice } from "./audioSoloSlice"; export type { KeyframeCacheEntry } from "./keyframeSlice"; export { liveTime } from "./liveTime"; import type { TimelineElement, TimelineElementPatch } from "./timelineElement"; export type { TimelineElement }; export type ZoomMode = "fit" | "manual"; type TimelineTool = "select" | "razor"; export interface SelectElementOptions { preserveSet?: boolean; } function resolveElementSelection( ids: Iterable, anchor?: string | null, ): { selectedElementIds: Set; selectedElementId: string | null } { const selectedElementIds = new Set(ids); if (selectedElementIds.size === 0) { return { selectedElementIds, selectedElementId: null }; } if (anchor && selectedElementIds.has(anchor)) { return { selectedElementIds, selectedElementId: anchor }; } return { selectedElementIds, selectedElementId: selectedElementIds.values().next().value ?? null, }; } interface PlayerState extends KeyframeSlice, AutomationSelectionSlice, ThumbnailSlice, AudioSoloSlice { isPlaying: boolean; currentTime: number; duration: number; timelineReady: boolean; /** Increments exactly once when the Studio switches to a different project. */ timelineSessionEpoch: number; /** Project owning the current timeline session; null outside a project-scoped reset. */ timelineProjectId: string | null; /** True while a beat dot is being dragged — hides the playhead guideline. */ beatDragging: boolean; elements: TimelineElement[]; selectedElementId: string | null; playbackRate: number; audioMuted: boolean; audioVolume: number; loopEnabled: boolean; /** Timeline zoom: 'fit' auto-scales to viewport, 'manual' uses manualZoomPercent */ zoomMode: ZoomMode; /** Timeline zoom percent relative to the fit width when in manual mode */ manualZoomPercent: number; /** * Bumped on every live z-index edit (handleDomZIndexReorderCommit apply AND * rollback). Flashless z commits (skipReload) never reload the iframe or * bump refreshKey, so DOM-derived views (the Layers panel's z-sorted tree) * subscribe to this to re-read the live DOM while playback is paused. */ zEditVersion: number; /** Work-area in-point (seconds). When set, loop starts here and A jumps here. */ inPoint: number | null; /** Work-area out-point (seconds). When set, loop ends here and E jumps here. */ outPoint: number | null; activeTool: TimelineTool; setActiveTool: (tool: TimelineTool) => void; /** Tween-relative percentage of the last-clicked keyframe diamond. Operations * (drag, resize, rotate) target this instead of recomputing from playhead. */ activeKeyframePct: number | null; setActiveKeyframePct: (pct: number | null) => void; /** Motion-path "set destination" mode. Armed from the preview toolbar (replaces * the old double-click-on-canvas UX); while armed, one canvas click places the * new path's destination. `available` is published by MotionPathOverlay so the * toolbar shows the button only when the selected element can take a path. */ motionPathArmed: boolean; setMotionPathArmed: (armed: boolean) => void; motionPathCreateAvailable: boolean; setMotionPathCreateAvailable: (available: boolean) => void; /** Global toggle for the "Add keyframe" diamond in the timeline toolbar (#1808). * When false, a manual drag/resize/rotate edit on an element that already has * a live tween shifts every keyframe by the edit's delta (preserving the * animation's shape) instead of inserting/updating a keyframe at the playhead. */ autoKeyframeEnabled: boolean; setAutoKeyframeEnabled: (enabled: boolean) => void; /** Multi-select: additional selected elements beyond selectedElementId. */ selectedElementIds: Set; clearSelectedElementIds: () => void; /** Replace the whole multi-selection at once (marquee live updates). */ setSelectedElementIds: (ids: Set) => void; /** Timeline magnet toggle — when false, clip drags/trims/drops never snap. */ timelineSnapEnabled: boolean; setTimelineSnapEnabled: (enabled: boolean) => void; /** Transport + ruler readout: timecode ("time") or frame number ("frame"). */ timeDisplayMode: TimelineTimeDisplayMode; setTimeDisplayMode: (mode: TimelineTimeDisplayMode) => void; /** * Pin the timeline zoom to its current visual scale before a duration-changing * edit, so a subsequent duration change (which recomputes fit-pps) stops * rescaling every clip. No-op once already pinned (mode is "manual"). */ pinTimelineZoom: (currentPixelsPerSecond: number, fitPixelsPerSecond: number) => void; /** The timeline's live pixels-per-second + fit basis, published by . */ timelinePps: number; timelineFitPps: number; setTimelineScale: (pps: number, fitPps: number) => void; setSelection: (ids: Iterable, anchor?: string | null) => void; addSelectedElementId: (id: string) => void; toggleSelectedElementId: (id: string) => void; clearSelection: () => void; setIsPlaying: (playing: boolean) => void; setCurrentTime: (time: number) => void; setDuration: (duration: number) => void; setPlaybackRate: (rate: number) => void; setAudioMuted: (muted: boolean) => void; setAudioVolume: (volume: number) => void; setLoopEnabled: (enabled: boolean) => void; setTimelineReady: (ready: boolean) => void; setBeatDragging: (dragging: boolean) => void; setElements: (elements: TimelineElement[]) => void; setSelectedElementId: (id: string | null, options?: SelectElementOptions) => void; /** Move the selection anchor within an active multi-selection without collapsing it. */ setSelectionAnchor: (id: string | null) => void; updateElement: (elementId: string, updates: TimelineElementPatch) => void; setZoomMode: (mode: ZoomMode) => void; setManualZoomPercent: (percent: number) => void; bumpZEditVersion: () => void; setInPoint: (time: number | null) => void; setOutPoint: (time: number | null) => void; /** Owns the hard project boundary; repeated calls for one project are no-ops. */ beginTimelineSession: (projectId: string) => void; /** Clears project data without creating a new hard-project session. */ reset: () => void; /** * Request a seek from outside the player loop (e.g. Layers panel). * useTimelinePlayer subscribes and calls adapter.seek() + liveTime.notify(). */ requestedSeekTime: number | null; requestSeek: (time: number) => void; clearSeekRequest: () => void; /** * Request the transport start or stop from outside the player loop. * * The FX rack auditions a preset by writing it to the running graph, which is * silent while the transport is paused — so hovering one has to start * playback, and leaving has to put the playhead back where it was. Hovering is * not an edit and must not cost the author their place. * * A nonce rather than a bare boolean: two hovers in a row both want play, and * without it the second request is indistinguishable from the first having * already been served. */ playbackRequest: { playing: boolean; returnTo: number | null; nonce: number } | null; requestPlayback: (playing: boolean, returnTo?: number | null) => void; clearPlaybackRequest: () => void; /** * Request the timeline to scroll a clip into view (e.g. clicking an * already-added asset card in the sidebar). Consumed and cleared by * useTimelineRevealClip. The nonce makes repeat requests for the same * clip observable so a second click re-reveals after the user scrolls away. */ clipRevealRequest: { elementId: string; nonce: number } | null; requestClipReveal: (elementId: string) => void; clearClipRevealRequest: () => void; timelineFocus: TimelineFocusRequest | null; timelineFocusNonce: number; requestTimelineFocus: (id: string) => void; clearTimelineFocus: (nonce: number) => void; lintFindingsByElement: Map; setLintFindingsByElement: (map: Map) => void; beatAnalysis: MusicBeatAnalysis | null; setBeatAnalysis: (analysis: MusicBeatAnalysis | null) => void; /** User edits (add/move/delete) layered over the detected beat grid. */ beatEdits: BeatEditState | null; setBeatEdits: (edits: BeatEditState | null) => void; /** Undo/redo stacks for beat edits (in-memory, session-only). */ beatUndo: BeatHistoryEntry[]; beatRedo: BeatHistoryEntry[]; commitBeatEdits: (next: BeatEditState | null, label: string) => void; undoBeatEdits: () => string | null; redoBeatEdits: () => string | null; resetBeatHistory: () => void; beatPersist: (() => void) | null; setBeatPersist: (fn: (() => void) | null) => void; clipManifest: ClipManifestClip[] | null; setClipManifest: (clips: ClipManifestClip[] | null) => void; clipParentMap: Map; setClipParentMap: (map: Map) => void; /** * Sub-composition DOM descendants (groups + their children) that have no * `data-start`, so they're absent from the clip manifest/tree. Collected * studio-side from the live preview so the timeline can expand a sub-comp row * to show its DOM-only children. Keeps the manifest lean (timed clips only). */ domClipChildren: DomClipChild[]; setDomClipChildren: (children: DomClipChild[]) => void; } /** A sub-comp DOM-only timeline child (no data-start) and its nesting context. */ export interface DomClipChild { id: string; parentId: string; /** The manifest sub-comp host clip id this descendant ultimately lives under. */ hostId: string; label: string; stackingContextId: string; } interface BeatHistoryEntry { restore: BeatEditState | null; at: number; label: string; } export function createTimelineResetState() { return { isPlaying: false, currentTime: 0, duration: 0, timelineReady: false, beatDragging: false, elements: [], selectedElementId: null, zEditVersion: 0, inPoint: null, outPoint: null, activeTool: "select" as const, activeKeyframePct: null, motionPathArmed: false, motionPathCreateAvailable: false, selectedKeyframes: new Set(), // Ephemeral like every other selection here. A range surviving a project // switch can match a same-keyed clip in the new project and redirect a // paste through `sel.elementKey === paste.elementKey` to a stale t0. automationSelection: null, expandedClipIds: new Set(), // Per-composition: ids from comp A match nothing in B, silencing all of it. soloed: new Set(), collapsedGroupIds: new Set(), expandedLaneOwnerIds: new Set(), focusedEaseSegment: null, selectedElementIds: new Set(), requestedSeekTime: null, lintFindingsByElement: new Map(), timelineFocus: null, keyframeCache: new Map(), gsapAnimations: new Map(), beatAnalysis: null, beatEdits: null, beatUndo: [], beatRedo: [], beatPersist: null, clipManifest: null, clipParentMap: new Map(), domClipChildren: [], }; } export const usePlayerStore = create((set, get) => ({ isPlaying: false, currentTime: 0, duration: 0, timelineReady: false, timelineSessionEpoch: 0, timelineProjectId: null, beatDragging: false, elements: [], selectedElementId: null, playbackRate: readStudioUiPreferences().playbackRate ?? 1, audioMuted: readStudioUiPreferences().audioMuted ?? false, audioVolume: readStudioUiPreferences().audioVolume ?? 1, loopEnabled: false, zoomMode: "fit", manualZoomPercent: 100, zEditVersion: 0, timelinePps: 100, timelineFitPps: 100, inPoint: null, outPoint: null, activeTool: "select", setActiveTool: (tool) => set({ activeTool: tool }), ...createKeyframeSlice(set, () => ({ timelineProjectId: get().timelineProjectId, timelineSessionEpoch: get().timelineSessionEpoch, })), ...createThumbnailSlice(set), ...createAutomationSelectionSlice(set), ...createAudioSoloSlice(set, get), activeKeyframePct: null, setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }), motionPathArmed: false, setMotionPathArmed: (armed) => set({ motionPathArmed: armed }), motionPathCreateAvailable: false, setMotionPathCreateAvailable: (available) => set({ motionPathCreateAvailable: available }), autoKeyframeEnabled: true, setAutoKeyframeEnabled: (enabled) => set({ autoKeyframeEnabled: enabled }), selectedElementIds: new Set(), setSelection: (ids, anchor) => set(resolveElementSelection(ids, anchor)), addSelectedElementId: (id: string) => set((s) => { const next = new Set(s.selectedElementIds); next.add(id); return resolveElementSelection(next, s.selectedElementId); }), toggleSelectedElementId: (id: string) => set((s) => { const next = new Set(s.selectedElementIds); if (next.has(id)) next.delete(id); else next.add(id); return resolveElementSelection(next, s.selectedElementId); }), clearSelection: () => set({ selectedElementId: null, selectedElementIds: new Set() }), requestedSeekTime: null, requestSeek: (time) => set({ requestedSeekTime: time }), clearSeekRequest: () => set({ requestedSeekTime: null }), playbackRequest: null, requestPlayback: (playing, returnTo = null) => set((s) => ({ playbackRequest: { playing, returnTo, nonce: (s.playbackRequest?.nonce ?? 0) + 1 }, })), clearPlaybackRequest: () => set({ playbackRequest: null }), clipRevealRequest: null, requestClipReveal: (elementId) => set((s) => ({ clipRevealRequest: { elementId, nonce: (s.clipRevealRequest?.nonce ?? 0) + 1 }, })), clearClipRevealRequest: () => set({ clipRevealRequest: null }), timelineFocus: null, timelineFocusNonce: 0, requestTimelineFocus: (id) => set((s) => { const nonce = s.timelineFocusNonce + 1; return { timelineFocusNonce: nonce, timelineFocus: createTimelineFocusRequest( id, s.timelineProjectId, s.timelineSessionEpoch, nonce, ), }; }), clearTimelineFocus: (nonce) => set((s) => (s.timelineFocus?.nonce === nonce ? { timelineFocus: null } : s)), lintFindingsByElement: new Map(), setLintFindingsByElement: (map) => set({ lintFindingsByElement: map }), beatAnalysis: null, setBeatAnalysis: (analysis) => set({ beatAnalysis: analysis }), beatEdits: null, setBeatEdits: (edits) => set({ beatEdits: edits }), beatUndo: [], beatRedo: [], beatPersist: null, setBeatPersist: (fn) => set({ beatPersist: fn }), commitBeatEdits: (next, label) => { set((s) => ({ beatEdits: next, beatUndo: [...s.beatUndo, { restore: s.beatEdits, at: Date.now(), label }], beatRedo: [], })); get().beatPersist?.(); }, undoBeatEdits: () => { const s = get(); const entry = s.beatUndo[s.beatUndo.length - 1]; if (!entry) return null; set({ beatEdits: entry.restore, beatUndo: s.beatUndo.slice(0, -1), beatRedo: [...s.beatRedo, { restore: s.beatEdits, at: entry.at, label: entry.label }], }); get().beatPersist?.(); return entry.label; }, resetBeatHistory: () => set({ beatUndo: [], beatRedo: [] }), redoBeatEdits: () => { const s = get(); const entry = s.beatRedo[s.beatRedo.length - 1]; if (!entry) return null; set({ beatEdits: entry.restore, beatRedo: s.beatRedo.slice(0, -1), beatUndo: [...s.beatUndo, { restore: s.beatEdits, at: entry.at, label: entry.label }], }); get().beatPersist?.(); return entry.label; }, clipManifest: null, setClipManifest: (clips) => set({ clipManifest: clips }), clipParentMap: new Map(), setClipParentMap: (map) => set({ clipParentMap: map }), domClipChildren: [], setDomClipChildren: (children) => set({ domClipChildren: children }), setIsPlaying: (playing) => { if (get().isPlaying === playing) return; set({ isPlaying: playing }); }, setPlaybackRate: (rate) => { writeStudioUiPreferences({ playbackRate: rate }); set({ playbackRate: rate }); }, setAudioMuted: (muted) => { writeStudioUiPreferences({ audioMuted: muted }); set({ audioMuted: muted }); }, setAudioVolume: (volume) => { const nextVolume = Number.isFinite(volume) ? Math.max(0, Math.min(1, volume)) : 1; writeStudioUiPreferences({ audioVolume: nextVolume }); set({ audioVolume: nextVolume }); }, setLoopEnabled: (enabled) => set({ loopEnabled: enabled }), setZoomMode: (mode) => set({ zoomMode: mode }), clearSelectedElementIds: () => set({ selectedElementIds: new Set() }), setSelectedElementIds: (ids: Set) => set({ selectedElementIds: new Set(ids) }), timelineSnapEnabled: readStudioUiPreferences().timelineSnapEnabled ?? true, setTimelineSnapEnabled: (enabled) => { writeStudioUiPreferences({ timelineSnapEnabled: enabled }); set({ timelineSnapEnabled: enabled }); }, timeDisplayMode: readStudioUiPreferences().timeDisplayMode ?? "time", setTimeDisplayMode: (mode) => { writeStudioUiPreferences({ timeDisplayMode: mode }); set({ timeDisplayMode: mode }); }, pinTimelineZoom: (currentPixelsPerSecond, fitPixelsPerSecond) => set((s) => { // Already pinned (or the user manually zoomed) — never clobber that. if (s.zoomMode === "manual") return {}; const percent = computePinnedZoomPercent(currentPixelsPerSecond, fitPixelsPerSecond); writeStudioUiPreferences({ timelineZoomMode: "manual", timelineManualZoomPercent: percent, }); return { zoomMode: "manual", manualZoomPercent: percent }; }), setTimelineScale: (pps, fitPps) => { const state = get(); if (state.timelinePps === pps && state.timelineFitPps === fitPps) return; set({ timelinePps: pps, timelineFitPps: fitPps }); }, setInPoint: (time) => set((state) => { const t = time !== null && Number.isFinite(time) ? time : null; return { inPoint: t, outPoint: t !== null && state.outPoint !== null && t >= state.outPoint ? null : state.outPoint, // Setting a work-area marker implies the user wants playback bounded by it. // Auto-enable loop so the playhead respects the marker instead of running past. loopEnabled: t !== null ? true : state.loopEnabled, }; }), setOutPoint: (time) => set((state) => { const t = time !== null && Number.isFinite(time) ? time : null; return { outPoint: t, inPoint: t !== null && state.inPoint !== null && t <= state.inPoint ? null : state.inPoint, loopEnabled: t !== null ? true : state.loopEnabled, }; }), setManualZoomPercent: (percent) => set((state) => ({ manualZoomPercent: clampTimelineZoomPercent(percent, state.timelineFitPps), })), bumpZEditVersion: () => set((state) => ({ zEditVersion: state.zEditVersion + 1 })), setCurrentTime: (time) => set({ currentTime: Number.isFinite(time) ? time : 0 }), setDuration: (duration) => set({ duration: Number.isFinite(duration) ? duration : 0 }), setTimelineReady: (ready) => set({ timelineReady: ready }), setBeatDragging: (dragging) => set({ beatDragging: dragging }), setElements: (elements) => set({ elements }), // A genuine single selection: always collapse the set to just this element. User // intent (timeline click, preview click via applyDomSelection) flows here; DOM sync // echoes that must preserve a group go through setSelectionAnchor instead. setSelectedElementId: (id, options) => set((s) => { const preserveSet = Boolean(options?.preserveSet && id && s.selectedElementIds.has(id)); const selectedElementIds = preserveSet ? new Set(s.selectedElementIds) : options?.preserveSet ? new Set() : id ? new Set([id]) : new Set(); // Selecting a different element drops any active keyframe selection — otherwise // a stale activeKeyframePct from a prior diamond click would force the next drag // to "modify" a keyframe on the new element. A diamond click sets the pct AFTER // calling setSelectedElementId, so this never clobbers a genuine keyframe select. return id !== s.selectedElementId ? { selectedElementId: id, selectedElementIds, activeKeyframePct: null, motionPathArmed: false, focusedEaseSegment: null, } : { selectedElementId: id, selectedElementIds }; }), // Move the anchor within an active multi-selection WITHOUT collapsing it — used by // DOM->store sync echoes while a group gesture re-patches the preview. A non-member // id is treated as a genuine new single selection. setSelectionAnchor: (id) => set((s) => { if (id != null && s.selectedElementIds.size > 1 && s.selectedElementIds.has(id)) { return { selectedElementId: id, focusedEaseSegment: id === s.selectedElementId ? s.focusedEaseSegment : null, }; } return { selectedElementId: id, selectedElementIds: id ? new Set([id]) : new Set(), focusedEaseSegment: id === s.selectedElementId ? s.focusedEaseSegment : null, }; }), updateElement: (elementId, updates) => set((state) => ({ elements: state.elements.map((el) => (el.key ?? el.id) === elementId ? { ...el, ...updates } : el, ), })), // UI preferences intentionally survive reset. So do timelineSessionEpoch and // focusedEaseRequestNonce: the epoch advances only when project identity // changes, while a monotonic nonce prevents collisions with stale consumers. beginTimelineSession: (projectId) => set((state) => { if (state.timelineProjectId === projectId) return state; return { ...createTimelineResetState(), timelineSessionEpoch: state.timelineSessionEpoch + 1, timelineProjectId: projectId, }; }), reset: () => set(createTimelineResetState()), })); function isDevBuild(): boolean { try { return import.meta.env.DEV === true; } catch { // Turbopack and other non-Vite bundlers may not provide import.meta.env. return false; } } if (isDevBuild() && typeof window !== "undefined") { // Console handle for dumping live Studio state during bug-bash reproduction. (window as unknown as { __playerStore?: typeof usePlayerStore }).__playerStore = usePlayerStore; }