fix(studio): player UX — honest waveform, keyframe menu actions, beat-delete gesture (#1967)

Player and timeline fixes from the studio UX review, reconciled against six
weeks of main.

Honest media states:
- AudioWaveform no longer falls back to synthesised sine-wave peaks when a
  decode fails. The failure propagates and the clip renders a dashed flat
  line + "waveform unavailable" instead of a plausible waveform an author
  would trim and beat-align against. Main's thumbnail scheduler already
  caches the failure with a TTL, so this neither refetch-loops nor pins the
  degraded state past a transient error.
- VideoThumbnail renders a static "no preview" placeholder on a failed
  decode rather than resolving to an empty box.

Keyframe context menu, restored:
- "Edit Ease…" (showing the current ease) and "Copy Properties" (async,
  "Copied!"/"Copy failed") were plumbed but never rendered. Edit Ease routes
  to the same focused-ease-segment path a segment click takes, so the menu
  advertises the editor that exists instead of growing a second one; it is
  offered only for a keyframe that names a tween to focus. Copy Properties
  matches the keyframe cache on clip-% with the same tolerance main's
  move-to-playhead uses. Every row is a role="menuitem" with arrow-key
  navigation and focus handling via the new useMenuKeyboardNav helper, and a
  separator now isolates "Delete All Keyframes" from the single delete.

Error prevention:
- Beat dots: hit target 12→24px (WCAG 2.5.8), and delete moves off
  double-click to ⌥-click — a stuttered drag reads as a double-click and
  would destroy the beat. ⌥ starts no drag, so a slipped ⌥-drag abandons
  instead of deleting.
- ShortcutsPanel moves focus into the panel on open and returns it to the
  trigger on close; SpeedMenu's trigger is labelled and reports its popup.

Superseded by main, deliberately dropped: the seek-slider keyboard and
aria-valuenow fixes (the transport no longer owns a seek bar), the Player
load-error inline retry (main's reports the actual message and retries with
a cache-busting src), TimelineClip keyboard selection (main renders a native
button, and this PR's onKeyDown would have preventDefault'ed the synthesized
click), the keyframe-diamond keyboard guard and label (both already on main,
with a richer label), and the waveform's own cache/failure maps (main's
scheduler owns that). TimelineOverlays.tsx is a main-side file edited to
thread the two restored menu actions; BeatStrip.test.tsx tracks the new
gesture and hit target.

Restacked onto main now that PRs 1962-1966 have squash-merged, so this
carries only its own changes.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-21 11:02:47 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent 44791c3f7d
commit a01a5d7b3c
12 changed files with 381 additions and 116 deletions
@@ -35,37 +35,19 @@ function extractPeaks(channelData: Float32Array, barCount: number): number[] {
return peaks.map((peak) => peak / maxPeak);
}
function fakePeaks(url: string, count: number): number[] {
let seed = 0;
for (let index = 0; index < url.length; index++) {
seed = ((seed << 5) - seed + url.charCodeAt(index)) | 0;
}
seed = Math.abs(seed) || 42;
const random = () => {
seed = (seed * 16807) % 2147483647;
return (seed & 0x7fffffff) / 2147483647;
};
return Array.from({ length: count }, (_, index) => {
const time = index / count;
const envelope =
0.3 + 0.3 * Math.sin(time * Math.PI * 3.2) + 0.2 * Math.sin(time * Math.PI * 7.1);
return Math.max(0.05, Math.min(1, envelope * (0.4 + 0.6 * random())));
});
}
async function loadWaveform(
audioUrl: string,
waveformUrl: string | undefined,
signal: AbortSignal,
): Promise<number[]> {
try {
return waveformUrl
? await fetchWaveformPeaks(waveformUrl, signal)
: await decodeWaveformPeaks(audioUrl, signal);
} catch (error) {
if (signal.aborted) throw error;
return fakePeaks(waveformUrl ?? audioUrl, 4000);
}
// Failures propagate. Synthesised peaks are worse than an honest gap: an
// author trims and beat-aligns against this waveform, and a plausible
// fabrication is indistinguishable from the real thing while being wrong.
// The scheduler caches the failure (metadataFailureTtlMs) so the degraded
// state neither refetch-loops nor pins itself past a transient error.
return waveformUrl
? await fetchWaveformPeaks(waveformUrl, signal)
: await decodeWaveformPeaks(audioUrl, signal);
}
async function fetchWaveformPeaks(url: string, signal: AbortSignal): Promise<number[]> {
@@ -193,6 +175,27 @@ export const AudioWaveform = memo(function AudioWaveform({
}}
/>
)}
{/* Degraded state — the decode failed; say so rather than paint a
waveform the author could edit against. */}
{snapshot.status === "error" && (
<div
className="absolute inset-x-0 flex items-center justify-center gap-1.5"
style={{ top: 16, bottom: 0 }}
>
<div
className="absolute inset-x-0"
style={{
bottom: "20%",
height: 2,
background:
"repeating-linear-gradient(90deg, rgba(75,163,210,0.35) 0 2px, transparent 2px 5px)",
}}
/>
<span className="relative rounded bg-black/50 px-1 text-[8px] text-neutral-500">
waveform unavailable
</span>
</div>
)}
{label && (
<div className="absolute inset-x-0 top-0 z-10 px-1.5 py-0.5">
<span
@@ -74,9 +74,7 @@ function mountBeatStrip(renderTimeRange?: { start: number; end: number }) {
}
function firstBeat(): HTMLDivElement {
const beat = document.querySelector<HTMLDivElement>(
'[title="Drag to move · double-click to delete"]',
);
const beat = document.querySelector<HTMLDivElement>('[title="Drag to move · ⌥-click to delete"]');
if (!beat) throw new Error("Expected a beat handle");
return beat;
}
@@ -256,9 +254,7 @@ describe("BeatStrip gesture ownership", () => {
);
});
expect(
document.querySelectorAll('[title="Drag to move · double-click to delete"]'),
).toHaveLength(2);
expect(document.querySelectorAll('[title="Drag to move · ⌥-click to delete"]')).toHaveLength(2);
expect(commitBeatEditsSpy).not.toHaveBeenCalled();
act(() => {
@@ -297,10 +293,10 @@ describe("BeatStrip gesture ownership", () => {
});
const lefts = Array.from(
document.querySelectorAll<HTMLDivElement>('[title="Drag to move · double-click to delete"]'),
document.querySelectorAll<HTMLDivElement>('[title="Drag to move · -click to delete"]'),
(beat) => beat.style.left,
);
expect(lefts).toContain("134px");
expect(lefts).toContain("128px");
releaseBeatDrag(140);
expectCommittedBeatAt(1.4);
@@ -310,7 +306,7 @@ describe("BeatStrip gesture ownership", () => {
mountBeatStrip();
startBeatDrag();
const beats = document.querySelectorAll<HTMLDivElement>(
'[title="Drag to move · double-click to delete"]',
'[title="Drag to move · -click to delete"]',
);
act(() => {
beats[1]?.dispatchEvent(
@@ -14,7 +14,7 @@ import {
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
export const BEAT_BAND_H = 14; // dark band height at top of track
const BEAT_HIT_W = 12; // grab width per beat (px)
const BEAT_HIT_W = 24; // grab width per beat (px) — ≥24px pointer target
interface BeatDragActor {
readonly pointerId: number;
@@ -355,8 +355,9 @@ export const BeatBackgroundLines = memo(function BeatBackgroundLines({
/**
* Green beat dots on the music track's row. Drag a dot to move its beat,
* double-click to delete; both scrub the audio. Dot size/brightness scale with
* beat loudness (gamma-curved for contrast).
* -click to delete (kept off double-click so a stuttered drag can't destroy
* a beat); both scrub the audio. Dot size/brightness scale with beat loudness
* (gamma-curved for contrast). Deletes remain undoable via ⌘Z.
*/
export const BeatStrip = memo(function BeatStrip({
beatTimes,
@@ -412,7 +413,7 @@ export const BeatStrip = memo(function BeatStrip({
<div
key={`${t}-${i}`}
className="absolute select-none"
title="Drag to move · double-click to delete"
title="Drag to move · -click to delete"
draggable={false}
style={{
left: x - BEAT_HIT_W / 2,
@@ -428,9 +429,15 @@ export const BeatStrip = memo(function BeatStrip({
// selection (which otherwise "selects" the whole panel mid-drag).
e.preventDefault();
e.stopPropagation();
// ⌥ starts no drag: the delete lands on release below, so a
// slipped ⌥-drag abandons instead of destroying the beat.
if (e.altKey) return;
beginBeatDrag(e, t, pps);
}}
onDoubleClick={(e) => {
onClick={(e) => {
if (!e.altKey) return;
// ⌥-click deletes. Deliberately NOT double-click: a stuttered drag
// attempt reads as a double-click and would destroy the beat.
e.stopPropagation();
deleteBeatAtCompositionTime(t);
usePlayerStore.getState().requestSeek(Math.max(0, t)); // park scrubber at deleted beat
@@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
import type { TimelineElement } from "../store/playerStore";
import { canSplitElement } from "../../utils/timelineElementSplit";
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
import { useMenuKeyboardNav } from "./menuKeyboardNav";
interface ClipContextMenuProps {
x: number;
@@ -24,6 +25,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
onDelete,
}: ClipContextMenuProps) {
const menuRef = useContextMenuDismiss(onClose);
useMenuKeyboardNav(menuRef);
const menuWidth = 200;
const menuHeight = 80;
@@ -44,6 +46,8 @@ export const ClipContextMenu = memo(function ClipContextMenu({
return createPortal(
<div
ref={menuRef}
role="menu"
aria-label="Clip actions"
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
style={{ left: adjustedX, top: adjustedY }}
>
@@ -51,7 +55,8 @@ export const ClipContextMenu = memo(function ClipContextMenu({
<>
<button
type="button"
className={`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left ${
role="menuitem"
className={`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none focus-visible:bg-neutral-800 ${
canSplit
? "text-neutral-300 hover:bg-neutral-800 cursor-pointer"
: "text-neutral-600 cursor-not-allowed"
@@ -73,7 +78,8 @@ export const ClipContextMenu = memo(function ClipContextMenu({
<button
type="button"
className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
role="menuitem"
className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-none cursor-pointer text-left"
onClick={() => {
onDelete(element);
onClose();
@@ -13,16 +13,43 @@ interface EditPopoverProps {
onClose: () => void;
}
function draftKey(start: number, end: number): string {
return `hf-edit-draft:${start.toFixed(2)}:${end.toFixed(2)}`;
}
function readDraft(key: string): string {
try {
return sessionStorage.getItem(key) ?? "";
} catch {
return "";
}
}
function writeDraft(key: string, value: string): void {
try {
if (value) sessionStorage.setItem(key, value);
else sessionStorage.removeItem(key);
} catch {
/* storage unavailable — draft persistence degrades gracefully */
}
}
export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }: EditPopoverProps) {
const elements = usePlayerStore((s) => s.elements);
const [prompt, setPrompt] = useState("");
const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
const [copiedPromptOnly, setCopiedPromptOnly] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const start = Math.min(rangeStart, rangeEnd);
const end = Math.max(rangeStart, rangeEnd);
// Persist the typed prompt per range so Escape/outside-click doesn't destroy it.
const storageKey = draftKey(start, end);
const [prompt, setPromptState] = useState(() => readDraft(storageKey));
const setPrompt = (value: string) => {
setPromptState(value);
writeDraft(storageKey, value);
};
const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
const [copiedPromptOnly, setCopiedPromptOnly] = useState(false);
const [copyError, setCopyError] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const elementsInRange = useMemo(() => {
return elements.filter((el) => {
@@ -64,19 +91,28 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
const handleCopy = useCallback(async () => {
const copied = await copyTextToClipboard(buildClipboardText());
if (!copied) return;
if (!copied) {
setCopyError(true);
return;
}
setCopyError(false);
writeDraft(storageKey, "");
setCopiedAgentPrompt(true);
setTimeout(() => {
setCopiedAgentPrompt(false);
onClose();
}, 800);
}, [buildClipboardText, onClose]);
}, [buildClipboardText, onClose, storageKey]);
const handleCopyPrompt = useCallback(async () => {
const promptText = buildPromptCopyText(prompt);
if (!promptText) return;
const copied = await copyTextToClipboard(promptText);
if (!copied) return;
if (!copied) {
setCopyError(true);
return;
}
setCopyError(false);
setCopiedPromptOnly(true);
setTimeout(() => {
setCopiedPromptOnly(false);
@@ -137,6 +173,11 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
</div>
{/* Action */}
{copyError && (
<p className="px-3 pb-2 text-[10px] text-red-400" role="alert">
Copy failed check clipboard permissions and try again.
</p>
)}
<div className="grid grid-cols-2 gap-2 px-3 pb-3">
<button
onClick={handleCopyPrompt}
@@ -1,5 +1,7 @@
import { useState } from "react";
import { createPortal } from "react-dom";
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
import { useMenuKeyboardNav } from "./menuKeyboardNav";
import type { TimelineElement } from "../store/playerStore";
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
@@ -25,45 +27,83 @@ interface KeyframeDiamondContextMenuProps {
* worse than no entry. */
onDelete?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
onDeleteAll: (element: TimelineElement, animationId?: string) => void;
/** Focus this keyframe's ease segment in the inspector. Omitted when the
* keyframe carries no tween identity to focus. */
onEditEase?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
/** Copy the keyframe's properties to the clipboard; resolves false on failure. */
onCopyProperties?: (
elementId: string,
keyframe: TimelineKeyframeTarget,
) => Promise<boolean> | boolean | void;
/** Retime the keyframe to the current playhead, preserving its value + ease. */
onMoveToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
}
const ITEM_CLS =
"w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-200 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-none cursor-pointer text-left";
const DESTRUCTIVE_ITEM_CLS =
"w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-none cursor-pointer text-left";
export function KeyframeDiamondContextMenu({
state,
onClose,
onDelete,
onDeleteAll,
onEditEase,
onCopyProperties,
onMoveToPlayhead,
}: KeyframeDiamondContextMenuProps) {
const menuRef = useContextMenuDismiss(onClose);
// The clicked diamond's identity, built once: the menu's two mutating entries
// both act on it, and they must not disagree about which keyframe was clicked.
// The clicked diamond's identity, built once: the menu's mutating entries
// all act on it, and they must not disagree about which keyframe was clicked.
const keyframe: TimelineKeyframeTarget = {
percentage: state.percentage,
tweenPercentage: state.tweenPercentage,
propertyGroup: state.propertyGroup,
animationId: state.animationId,
};
useMenuKeyboardNav(menuRef);
const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "failed">("idle");
const handleCopyProperties = async () => {
if (!onCopyProperties) return;
const result = await onCopyProperties(state.elementId, keyframe);
if (result === false) {
setCopyStatus("failed");
setTimeout(() => setCopyStatus("idle"), 1500);
return;
}
setCopyStatus("copied");
setTimeout(onClose, 700);
};
const menuWidth = 200;
// Measured off the rendered rows, so the flip-up test below stays right as
// optional entries drop out.
const menuHeight = 10 + (1 + (onMoveToPlayhead ? 1 : 0) + (onDelete ? 1 : 0)) * 30;
// optional entries drop out. The separator counts as roughly a third of a row.
const rows =
1 +
(onMoveToPlayhead ? 1 : 0) +
(onEditEase ? 1 : 0) +
(onCopyProperties ? 1 : 0) +
(onDelete ? 1 : 0);
const menuHeight = 10 + rows * 30 + 9;
const overflowY = state.y + menuHeight - window.innerHeight;
const adjustedX = state.x + menuWidth > window.innerWidth ? state.x - menuWidth : state.x;
const adjustedY = overflowY > 0 ? state.y - overflowY - 8 : state.y;
const adjustedY = Math.max(8, overflowY > 0 ? state.y - overflowY - 8 : state.y);
return createPortal(
<div
ref={menuRef}
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
style={{ left: adjustedX, top: adjustedY }}
role="menu"
aria-label="Keyframe actions"
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px] overflow-y-auto"
style={{ left: adjustedX, top: adjustedY, maxHeight: `calc(100vh - ${adjustedY + 8}px)` }}
>
{onMoveToPlayhead && (
<button
type="button"
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-200 hover:bg-neutral-800 cursor-pointer text-left"
role="menuitem"
className={ITEM_CLS}
onClick={() => {
// Pass clip-% — resolveKeyframeTarget keys the cache lookup on clip-%
// and returns the tween-% for the mutation. Passing tween-% here would
@@ -76,11 +116,44 @@ export function KeyframeDiamondContextMenu({
</button>
)}
{onEditEase && (
<button
type="button"
role="menuitem"
className={`${ITEM_CLS} justify-between`}
onClick={() => {
onEditEase(state.elementId, keyframe);
onClose();
}}
>
<span>Edit Ease</span>
<span className="text-[10px] text-neutral-500">{state.currentEase ?? "default"}</span>
</button>
)}
{onCopyProperties && (
<button
type="button"
role="menuitem"
className={ITEM_CLS}
onClick={() => {
void handleCopyProperties();
}}
>
{copyStatus === "copied"
? "Copied!"
: copyStatus === "failed"
? "Copy failed — check permissions"
: "Copy Properties"}
</button>
)}
{/* Delete */}
{onDelete && (
<button
type="button"
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
role="menuitem"
className={DESTRUCTIVE_ITEM_CLS}
onClick={() => {
onDelete(state.elementId, keyframe);
onClose();
@@ -90,9 +163,16 @@ export function KeyframeDiamondContextMenu({
</button>
)}
{/* Deleting every keyframe sat adjacent to the single delete and styled
identically. Separate and mark it so the two cannot be misread. */}
<div className="my-1 border-t border-neutral-700/60" role="separator" />
<div className="my-1 border-t border-neutral-700/60" role="separator" />
<button
type="button"
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
role="menuitem"
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-red-950/40 focus-visible:bg-red-950/40 outline-none cursor-pointer text-left"
onClick={() => {
onDeleteAll(state.element, state.animationId);
onClose();
@@ -135,6 +135,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
const [assetsLoading, setAssetsLoading] = useState(false);
const [assetOverlayVisible, setAssetOverlayVisible] = useState(false);
const [assetOverlayFading, setAssetOverlayFading] = useState(false);
const [assetWaitLong, setAssetWaitLong] = useState(false);
const [shaderTransitionLoading, setShaderTransitionLoading] = useState(false);
const [compositionLoading, setCompositionLoading] = useState(true);
const [compositionOverlayDeferred, setCompositionOverlayDeferred] = useState(true);
@@ -236,6 +237,11 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
attempts += 1;
lastUnloaded = hasUnloadedAssets(iframe, lastUnloaded);
if (!lastUnloaded || attempts > 100) {
if (lastUnloaded && attempts > 100) {
console.debug(
"[studio] asset readiness poll hit the 10s cap — continuing with unloaded assets",
);
}
if (assetPollRef.current) clearInterval(assetPollRef.current);
assetPollRef.current = null;
setAssetsLoading(false);
@@ -327,6 +333,17 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
};
});
// Surface a "Continue anyway" escape hatch once the asset wait drags on.
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!assetsLoading) {
setAssetWaitLong(false);
return;
}
const timer = setTimeout(() => setAssetWaitLong(true), 3000);
return () => clearTimeout(timer);
}, [assetsLoading]);
useEffect(() => {
if (assetFadeRef.current) {
clearTimeout(assetFadeRef.current);
@@ -354,12 +371,20 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
};
}, [assetsLoading]);
const handleContinueAnyway = () => {
if (assetPollRef.current) {
clearInterval(assetPollRef.current);
assetPollRef.current = null;
}
setAssetsLoading(false);
};
const showCompositionOverlay =
!suppressLoadingOverlay &&
!compositionOverlayDeferred &&
shouldShowCompositionLoadingOverlay(compositionLoading);
const showAssetOverlay =
assetOverlayVisible && !shaderTransitionLoading && !showCompositionOverlay;
assetOverlayVisible && !shaderTransitionLoading && !showCompositionOverlay && !previewError;
useEffect(() => {
onCompositionLoadingChange?.(showCompositionOverlay || showAssetOverlay);
@@ -396,17 +421,27 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
style={{
opacity: assetOverlayFading ? 0 : 1,
pointerEvents: assetOverlayFading ? "none" : "auto",
transition: "opacity 240ms ease-out",
transition: "opacity 180ms ease-in",
}}
onDragStart={(event) => event.preventDefault()}
onMouseDown={(event) => event.preventDefault()}
onPointerDown={(event) => event.preventDefault()}
>
<HyperframesLoader
title="Preparing preview assets"
detail="Waiting for media and motion assets before playback starts."
size={56}
/>
<div className="flex flex-col items-center gap-3">
<HyperframesLoader
title="Preparing preview assets"
detail="Waiting for media and motion assets before playback starts."
size={56}
/>
{assetWaitLong && (
<button
type="button"
onClick={handleContinueAnyway}
className="px-3 py-1.5 text-[11px] rounded-md border border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800 transition-colors"
>
Continue anyway
</button>
)}
</div>
</div>
)}
{previewError && (
@@ -1,4 +1,4 @@
import { useState, useCallback, useId, memo } from "react";
import { useState, useCallback, useEffect, useId, useRef, memo } from "react";
import { formatTime, frameToSeconds } from "../lib/time";
import { Tooltip } from "../../components/ui";
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
@@ -19,7 +19,7 @@ const SHORTCUT_SECTIONS = [
],
},
{
title: "Keyframes",
title: "Keyframes (when an element is selected)",
hints: [
{ key: "K", label: "Add keyframe at playhead" },
{ key: "Del", label: "Delete selected keyframe" },
@@ -37,9 +37,10 @@ const SHORTCUT_SECTIONS = [
{ key: "⌘V", label: "Paste element" },
{ key: "⌘X", label: "Cut element" },
{ key: "S", label: "Split clip at playhead" },
{ key: "⇧Click", label: "Razor tool: split all tracks" },
{ key: "⌘G", label: "Group elements" },
{ key: "⌘⇧G", label: "Ungroup" },
{ key: "Del", label: "Delete selected element" },
{ key: "Del", label: "Delete selected element (no keyframe selected)" },
],
},
{
@@ -112,6 +113,20 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
const shortcutsPanelId = useId();
const closeShortcuts = useCallback(() => setShowShortcuts(false), []);
const shortcutsPanelRef = useContextMenuDismiss(closeShortcuts);
const panelBodyRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
// Move focus into the panel on open so keyboard users can scroll and read
// it; hand focus back to the trigger on close.
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!showShortcuts) return;
const trigger = triggerRef.current;
panelBodyRef.current?.focus();
return () => {
trigger?.focus();
};
}, [showShortcuts]);
const commitJumpFrame = useCallback(() => {
if (disabled) return;
@@ -141,6 +156,7 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
<div ref={shortcutsPanelRef} className="relative flex-shrink-0">
<Tooltip label="Shortcuts and tools">
<button
ref={triggerRef}
type="button"
onClick={() => setShowShortcuts((v) => !v)}
className={`flex h-7 w-7 items-center justify-center rounded-md transition-colors ${
@@ -169,11 +185,14 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
{showShortcuts && (
<div
id={shortcutsPanelId}
ref={panelBodyRef}
tabIndex={-1}
role="dialog"
aria-label="Keyboard shortcuts and tools"
// Deliberately NOT aria-modal. This is a non-modal disclosure: focus is
// not trapped and the rest of the editor stays operable, so claiming
// modality would make assistive tech treat the whole app as inert.
className="absolute bottom-full right-0 mb-2 z-50 rounded-lg shadow-xl min-w-[220px] overflow-y-auto"
className="absolute bottom-full right-0 mb-2 z-50 rounded-lg shadow-xl min-w-[220px] overflow-y-auto outline-none"
style={{
background: "#161618",
border: "1px solid rgba(255,255,255,0.08)",
@@ -1,6 +1,7 @@
import { useState, useRef, useEffect, memo } from "react";
import { useState, useCallback, memo } from "react";
import { trackStudioEvent } from "../../utils/studioTelemetry";
import { Tooltip } from "../../components/ui";
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
@@ -16,23 +17,10 @@ export const SpeedMenu = memo(function SpeedMenu({
disabled,
}: SpeedMenuProps) {
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
const speedMenuContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!showSpeedMenu) return;
const handleMouseDown = (e: MouseEvent) => {
if (
speedMenuContainerRef.current &&
!speedMenuContainerRef.current.contains(e.target as Node)
) {
setShowSpeedMenu(false);
}
};
document.addEventListener("mousedown", handleMouseDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
};
}, [showSpeedMenu]);
const closeMenu = useCallback(() => setShowSpeedMenu(false), []);
// Ref on the container (trigger + menu) so trigger clicks toggle instead of
// close-then-reopen; Escape also dismisses.
const speedMenuContainerRef = useContextMenuDismiss(closeMenu);
return (
<div ref={speedMenuContainerRef} className="relative flex-shrink-0">
@@ -41,6 +29,9 @@ export const SpeedMenu = memo(function SpeedMenu({
type="button"
onClick={() => setShowSpeedMenu((v) => !v)}
disabled={disabled}
aria-haspopup="menu"
aria-expanded={showSpeedMenu}
aria-label="Playback speed"
className="h-7 w-8 rounded-md font-mono text-[10px] tabular-nums text-neutral-500 transition-colors hover:text-neutral-200 disabled:opacity-30"
>
{playbackRate === 1 ? "1x" : `${playbackRate}x`}
@@ -48,33 +39,34 @@ export const SpeedMenu = memo(function SpeedMenu({
</Tooltip>
{showSpeedMenu && (
<div
role="menu"
aria-label="Playback speed options"
className="absolute bottom-full right-0 mb-1.5 rounded-lg shadow-xl z-50 min-w-[56px] overflow-hidden"
style={{ background: "#161618", border: "1px solid rgba(255,255,255,0.08)" }}
>
{SPEED_OPTIONS.map((rate) => (
<button
key={rate}
onClick={() => {
trackStudioEvent("playback", { action: "speed_change", rate });
setPlaybackRate(rate);
setShowSpeedMenu(false);
}}
className="block w-full px-3 py-1.5 text-[11px] text-left font-mono tabular-nums transition-colors"
style={{
color: rate === playbackRate ? "#FAFAFA" : "#71717A",
background: rate === playbackRate ? "rgba(255,255,255,0.06)" : "transparent",
}}
onMouseEnter={(e) => {
if (rate !== playbackRate)
e.currentTarget.style.background = "rgba(255,255,255,0.04)";
}}
onMouseLeave={(e) => {
if (rate !== playbackRate) e.currentTarget.style.background = "transparent";
}}
>
{rate}x
</button>
))}
{SPEED_OPTIONS.map((rate) => {
const isCurrent = rate === playbackRate;
return (
<button
key={rate}
type="button"
role="menuitemradio"
aria-checked={isCurrent}
onClick={() => {
trackStudioEvent("playback", { action: "speed_change", rate });
setPlaybackRate(rate);
setShowSpeedMenu(false);
}}
className={`block w-full px-3 py-1.5 text-[11px] text-left font-mono tabular-nums transition-colors outline-none focus-visible:bg-white/[0.04] ${
isCurrent
? "text-neutral-50 bg-white/[0.06]"
: "text-neutral-500 hover:bg-white/[0.04]"
}`}
>
{rate}x
</button>
);
})}
</div>
)}
</div>
@@ -12,6 +12,8 @@ import {
import { ClipContextMenu } from "./ClipContextMenu";
import { TrackGapContextMenu } from "./TrackGapContextMenu";
import { TimelineShortcutHint } from "./TimelineShortcutHint";
import { copyTextToClipboard } from "../../utils/clipboard";
import { trackStudioSegmentEaseEdit } from "../../telemetry/events";
export interface ClipContextMenuState {
x: number;
@@ -195,6 +197,38 @@ export function TimelineOverlays({
}
: undefined
}
// Routed to the same focused-ease-segment path a segment click takes,
// so the menu advertises the editor that exists rather than growing a
// second one. Offered only for a keyframe that names a tween to focus.
onEditEase={
kfContextMenu.animationId !== undefined && kfContextMenu.tweenPercentage !== undefined
? (elementId, keyframe) => {
if (
keyframe.animationId === undefined ||
keyframe.tweenPercentage === undefined
) {
return;
}
usePlayerStore.getState().setFocusedEaseSegment({
animationId: keyframe.animationId,
collidingAnimationTargets: keyframe.collidingAnimationTargets,
tweenPercentage: keyframe.tweenPercentage,
elementId,
});
trackStudioSegmentEaseEdit({ action: "open" });
}
: undefined
}
onCopyProperties={(elementId, keyframe) => {
const entry = usePlayerStore.getState().keyframeCache.get(elementId);
// Tolerance match on clip-%, the same basis the cache is keyed on —
// an exact float compare misses a keyframe the menu just opened over.
const kf = entry?.keyframes.find(
(item) => Math.abs(item.percentage - keyframe.percentage) < 0.5,
);
if (!kf) return false;
return copyTextToClipboard(JSON.stringify(kf.properties, null, 2));
}}
/>
)}
@@ -110,13 +110,18 @@ export const VideoThumbnail = memo(function VideoThumbnail({
)}
{snapshot.status === "loading" && urls.length === 0 && (
<div
className="absolute inset-0 animate-pulse"
className="absolute inset-0 animate-pulse motion-reduce:animate-none"
style={{
background:
"linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
}}
/>
)}
{snapshot.status === "error" && (
<div className="absolute inset-0 flex items-center justify-center bg-neutral-900/60">
<span className="rounded bg-black/50 px-1 text-[8px] text-neutral-500">no preview</span>
</div>
)}
{label && (
<div
className="absolute inset-x-0 bottom-0 z-10 px-1.5 pb-0.5 pt-3"
@@ -0,0 +1,47 @@
import { useEffect, type RefObject } from "react";
/**
* APG menu keyboard basics for the timeline context menus: focuses the first
* menu item on open, moves focus with ArrowUp/ArrowDown/Home/End, and restores
* focus to the previously focused element when the menu unmounts. Pair with
* `role="menu"` on the container and `role="menuitem"` on the buttons
* (dismiss/Escape handling stays in useContextMenuDismiss).
*/
export function useMenuKeyboardNav(menuRef: RefObject<HTMLDivElement | null>): void {
useEffect(() => {
const menu = menuRef.current;
if (!menu) return;
const previouslyFocused = document.activeElement;
const items = () =>
Array.from(menu.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')).filter(
(el) => !el.disabled,
);
items()[0]?.focus();
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End") {
return;
}
const list = items();
if (list.length === 0) return;
e.preventDefault();
const idx = list.findIndex((el) => el === document.activeElement);
let next: number;
if (e.key === "ArrowDown") next = idx < 0 ? 0 : (idx + 1) % list.length;
else if (e.key === "ArrowUp")
next = idx < 0 ? list.length - 1 : (idx - 1 + list.length) % list.length;
else if (e.key === "Home") next = 0;
else next = list.length - 1;
list[next]?.focus();
};
menu.addEventListener("keydown", onKeyDown);
return () => {
menu.removeEventListener("keydown", onKeyDown);
if (previouslyFocused instanceof HTMLElement && document.contains(previouslyFocused)) {
previouslyFocused.focus();
}
};
}, [menuRef]);
}