diff --git a/packages/studio/src/player/components/PlayerControls.tsx b/packages/studio/src/player/components/PlayerControls.tsx
index ed4d85538..9594a91ba 100644
--- a/packages/studio/src/player/components/PlayerControls.tsx
+++ b/packages/studio/src/player/components/PlayerControls.tsx
@@ -6,11 +6,29 @@ import { usePlayerStore, liveTime } from "../store/playerStore";
const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
const SEEK_EDGE_SNAP_PX = 8;
type TimeDisplayMode = "time" | "frame";
-const SHORTCUT_HINTS = [
- { key: "J", label: "Play backward" },
- { key: "K", label: "Stop playback" },
- { key: "L", label: "Play forward" },
- { key: "←/→", label: "Step one frame backward or forward" },
+const SHORTCUT_SECTIONS = [
+ {
+ title: "Playback",
+ hints: [
+ { key: "Space", label: "Play / Pause" },
+ { key: "J", label: "Play backward" },
+ { key: "K", label: "Stop" },
+ { key: "L", label: "Play forward" },
+ { key: "←/→", label: "Step 1 frame" },
+ { key: "⇧←/⇧→", label: "Step 10 frames" },
+ ],
+ },
+ {
+ title: "Work area",
+ hints: [
+ { key: "I", label: "Set in-point" },
+ { key: "⇧I", label: "Clear in-point" },
+ { key: "O", label: "Set out-point" },
+ { key: "⇧O", label: "Clear out-point" },
+ { key: "A", label: "Jump to in-point" },
+ { key: "E", label: "Jump to out-point" },
+ ],
+ },
] as const;
export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth: number): number {
@@ -42,7 +60,12 @@ export const PlayerControls = memo(function PlayerControls({
const loopEnabled = usePlayerStore((s) => s.loopEnabled);
const setPlaybackRate = usePlayerStore.getState().setPlaybackRate;
const setLoopEnabled = usePlayerStore.getState().setLoopEnabled;
+ const inPoint = usePlayerStore((s) => s.inPoint);
+ const outPoint = usePlayerStore((s) => s.outPoint);
+ const setInPoint = usePlayerStore.getState().setInPoint;
+ const setOutPoint = usePlayerStore.getState().setOutPoint;
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
+ const [showShortcuts, setShowShortcuts] = useState(false);
const [timeDisplayMode, setTimeDisplayMode] = useState
("time");
const [jumpFrame, setJumpFrame] = useState("");
@@ -52,6 +75,7 @@ export const PlayerControls = memo(function PlayerControls({
const seekBarRef = useRef(null);
const sliderRef = useRef(null);
const speedMenuContainerRef = useRef(null);
+ const shortcutsPanelRef = useRef(null);
const isDraggingRef = useRef(false);
const currentTimeRef = useRef(0);
const timeDisplayModeRef = useRef(timeDisplayMode);
@@ -116,6 +140,19 @@ export const PlayerControls = memo(function PlayerControls({
};
}, [showSpeedMenu]);
+ useEffect(() => {
+ if (!showShortcuts) return;
+ const handleMouseDown = (e: MouseEvent) => {
+ if (shortcutsPanelRef.current && !shortcutsPanelRef.current.contains(e.target as Node)) {
+ setShowShortcuts(false);
+ }
+ };
+ document.addEventListener("mousedown", handleMouseDown);
+ return () => {
+ document.removeEventListener("mousedown", handleMouseDown);
+ };
+ }, [showShortcuts]);
+
const seekFromClientX = useCallback(
(clientX: number) => {
if (disabled) return;
@@ -278,10 +315,14 @@ export const PlayerControls = memo(function PlayerControls({
)}
- {/* Time display */}
- setTimeDisplayMode((m) => (m === "time" ? "frame" : "time"))}
+ disabled={disabled}
+ title={timeDisplayMode === "time" ? "Switch to frame display" : "Switch to time display"}
+ className="font-mono text-[11px] tabular-nums flex-shrink-0 w-[118px] text-left transition-colors disabled:pointer-events-none hover:opacity-80"
+ style={{ color: "#A1A1AA", cursor: "pointer" }}
>
{formatTime(0)}
{timeDisplayMode === "time" ? (
@@ -290,7 +331,7 @@ export const PlayerControls = memo(function PlayerControls({
{formatTime(duration)}
>
) : null}
-
+
{/* Seek bar — teal progress fill */}
+ {/* Work-area band between in/out points */}
+ {(inPoint !== null || outPoint !== null) && duration > 0 && (
+
+ )}
{/* Progress fill — width is controlled imperatively via ref to avoid React re-render resets */}
+ {/* In-point marker */}
+ {inPoint !== null && duration > 0 && (
+
+ )}
+ {/* Out-point marker */}
+ {outPoint !== null && duration > 0 && (
+
+ )}
{/* Playhead thumb — left is controlled imperatively via ref */}
setLoopEnabled(!loopEnabled)}
disabled={disabled}
- className={`h-7 w-14 rounded-md border px-2 text-[10px] font-medium transition-colors ${
+ className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
loopEnabled
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
: "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
@@ -394,53 +476,201 @@ export const PlayerControls = memo(function PlayerControls({
aria-label={loopEnabled ? "Disable loop playback" : "Enable loop playback"}
aria-pressed={loopEnabled}
>
- Loop
-
-
-
-
-
-
- {SHORTCUT_HINTS.map((shortcut) => (
-
+
- ))}
+
+
+
+
+ {showShortcuts && (
+
+ {/* Frame jump */}
+
+
+ {/* Work area */}
+
+
+ Work area
+
+
+
+
+
+ I
+
+ In-point
+
+
+ {inPoint !== null ? (
+ <>
+
+ {formatTime(inPoint)}
+
+
+ >
+ ) : (
+
—
+ )}
+
+
+
+
+
+ O
+
+ Out-point
+
+
+ {outPoint !== null ? (
+ <>
+
+ {formatTime(outPoint)}
+
+
+ >
+ ) : (
+
—
+ )}
+
+
+
+
+
+ {/* Shortcuts */}
+
+ {SHORTCUT_SECTIONS.map((section) => (
+
+
+ {section.title}
+
+
+ {section.hints.map((hint) => (
+
+
+ {hint.key}
+
+ {hint.label}
+
+ ))}
+
+
+ ))}
+
+
+ )}
);
diff --git a/packages/studio/src/player/hooks/usePlaybackKeyboard.ts b/packages/studio/src/player/hooks/usePlaybackKeyboard.ts
index f4b18e4f7..d7adf8098 100644
--- a/packages/studio/src/player/hooks/usePlaybackKeyboard.ts
+++ b/packages/studio/src/player/hooks/usePlaybackKeyboard.ts
@@ -128,9 +128,33 @@ export function usePlaybackKeyboard({
return;
}
shuttle("forward");
+ return;
+ }
+ if (e.code === "KeyI") {
+ e.preventDefault();
+ const t = getAdapter()?.getTime() ?? usePlayerStore.getState().currentTime;
+ usePlayerStore.getState().setInPoint(e.shiftKey ? null : t);
+ return;
+ }
+ if (e.code === "KeyO") {
+ e.preventDefault();
+ const t = getAdapter()?.getTime() ?? usePlayerStore.getState().currentTime;
+ usePlayerStore.getState().setOutPoint(e.shiftKey ? null : t);
+ return;
+ }
+ if (e.code === "KeyA") {
+ e.preventDefault();
+ seek(usePlayerStore.getState().inPoint ?? 0);
+ return;
+ }
+ if (e.code === "KeyE") {
+ e.preventDefault();
+ const { outPoint } = usePlayerStore.getState();
+ seek(outPoint ?? getAdapter()?.getDuration() ?? usePlayerStore.getState().duration);
+ return;
}
},
- [pause, shuttle, stepFrames, togglePlay],
+ [pause, shuttle, stepFrames, togglePlay, getAdapter, seek],
);
const handlePlaybackKeyUp = useCallback((e: KeyboardEvent) => {
diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts
index 727f17a58..ff2d3a026 100644
--- a/packages/studio/src/player/hooks/useTimelinePlayer.ts
+++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts
@@ -185,15 +185,21 @@ export function useTimelinePlayer() {
const time = adapter.getTime();
const dur = adapter.getDuration();
liveTime.notify(time); // direct DOM updates, no React re-render
- if (time >= dur && !adapter.isPlaying()) {
+ const { inPoint, outPoint } = usePlayerStore.getState();
+ const rawLoopEnd = outPoint !== null ? outPoint : dur;
+ const rawLoopStart = inPoint !== null ? inPoint : 0;
+ const loopEnd = rawLoopStart < rawLoopEnd ? rawLoopEnd : dur;
+ const loopStart = rawLoopStart < rawLoopEnd ? rawLoopStart : 0;
+ if (time >= loopEnd) {
if (usePlayerStore.getState().loopEnabled && dur > 0) {
- adapter.seek(0);
- liveTime.notify(0);
+ adapter.seek(loopStart);
+ liveTime.notify(loopStart);
adapter.play();
setIsPlaying(true);
rafRef.current = requestAnimationFrame(tick);
return;
}
+ if (adapter.isPlaying()) adapter.pause();
setCurrentTime(time); // sync Zustand once at end
setIsPlaying(false);
cancelAnimationFrame(rafRef.current);
@@ -241,7 +247,7 @@ export function useTimelinePlayer() {
const adapter = getAdapter();
if (!adapter) return;
if (adapter.getTime() >= adapter.getDuration()) {
- adapter.seek(0);
+ adapter.seek(usePlayerStore.getState().inPoint ?? 0);
}
unmutePreviewMedia(iframeRef.current);
applyPlaybackRate(usePlayerStore.getState().playbackRate);
@@ -269,15 +275,20 @@ export function useTimelinePlayer() {
const tick = (now: number) => {
const elapsed = ((now - startedAt) / 1000) * speed;
let nextTime = startTime - elapsed;
- if (nextTime <= 0) {
+ const { inPoint, outPoint } = usePlayerStore.getState();
+ const rawLoopEnd = outPoint !== null ? outPoint : duration;
+ const rawLoopStart = inPoint !== null ? inPoint : 0;
+ const loopEnd = rawLoopStart < rawLoopEnd ? rawLoopEnd : duration;
+ const loopStart = rawLoopStart < rawLoopEnd ? rawLoopStart : 0;
+ if (nextTime <= loopStart) {
if (usePlayerStore.getState().loopEnabled && duration > 0) {
- startTime = duration;
+ startTime = loopEnd;
startedAt = now;
- nextTime = duration;
+ nextTime = loopEnd;
} else {
- adapter.seek(0);
- liveTime.notify(0);
- setCurrentTime(0);
+ adapter.seek(loopStart);
+ liveTime.notify(loopStart);
+ setCurrentTime(loopStart);
setIsPlaying(false);
shuttleDirectionRef.current = null;
reverseRafRef.current = 0;
diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts
index 2fce59381..4b96c2634 100644
--- a/packages/studio/src/player/store/playerStore.ts
+++ b/packages/studio/src/player/store/playerStore.ts
@@ -43,6 +43,10 @@ interface PlayerState {
zoomMode: ZoomMode;
/** Timeline zoom percent relative to the fit width when in manual mode */
manualZoomPercent: 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;
setIsPlaying: (playing: boolean) => void;
setCurrentTime: (time: number) => void;
@@ -58,6 +62,8 @@ interface PlayerState {
) => void;
setZoomMode: (mode: ZoomMode) => void;
setManualZoomPercent: (percent: number) => void;
+ setInPoint: (time: number | null) => void;
+ setOutPoint: (time: number | null) => void;
reset: () => void;
/**
@@ -93,6 +99,8 @@ export const usePlayerStore = create
((set) => ({
loopEnabled: false,
zoomMode: "fit",
manualZoomPercent: 100,
+ inPoint: null,
+ outPoint: null,
requestedSeekTime: null,
requestSeek: (time) => set({ requestedSeekTime: time }),
@@ -105,6 +113,23 @@ export const usePlayerStore = create((set) => ({
},
setLoopEnabled: (enabled) => set({ loopEnabled: enabled }),
setZoomMode: (mode) => set({ zoomMode: mode }),
+ 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,
+ };
+ }),
+ 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,
+ };
+ }),
setManualZoomPercent: (percent) =>
set({ manualZoomPercent: Math.max(10, Math.min(2000, Math.round(percent))) }),
setCurrentTime: (time) => set({ currentTime: Number.isFinite(time) ? time : 0 }),
@@ -129,5 +154,7 @@ export const usePlayerStore = create((set) => ({
timelineReady: false,
elements: [],
selectedElementId: null,
+ inPoint: null,
+ outPoint: null,
}),
}));