Skip to content

Commit f4df9d4

Browse files
qinnyqinny
authored andcommitted
Allow decimal custom playback speeds
1 parent 9f7f498 commit f4df9d4

3 files changed

Lines changed: 120 additions & 64 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { fireEvent, render, screen } from "@testing-library/react";
2+
import { useState } from "react";
3+
import { describe, expect, it, vi } from "vitest";
4+
import { CustomSpeedInput } from "./CustomSpeedInput";
5+
6+
function TestHarness({
7+
initialValue,
8+
onError = vi.fn(),
9+
}: {
10+
initialValue: number;
11+
onError?: () => void;
12+
}) {
13+
const [value, setValue] = useState(initialValue);
14+
15+
return <CustomSpeedInput value={value} onChange={setValue} onError={onError} />;
16+
}
17+
18+
describe("CustomSpeedInput", () => {
19+
it("shows non-preset decimal values without rounding", () => {
20+
render(<CustomSpeedInput value={1.1} onChange={vi.fn()} onError={vi.fn()} />);
21+
22+
expect((screen.getByRole("spinbutton") as HTMLInputElement).value).toBe("1.1");
23+
});
24+
25+
it("accepts decimal speeds and preserves them after blur", () => {
26+
render(<TestHarness initialValue={1.5} />);
27+
28+
const input = screen.getByRole("spinbutton") as HTMLInputElement;
29+
fireEvent.focus(input);
30+
fireEvent.change(input, { target: { value: "1.1" } });
31+
expect(input.value).toBe("1.1");
32+
33+
fireEvent.blur(input);
34+
expect(input.value).toBe("1.1");
35+
});
36+
37+
it("accepts sub-1 decimal speeds", () => {
38+
render(<TestHarness initialValue={1.5} />);
39+
40+
const input = screen.getByRole("spinbutton") as HTMLInputElement;
41+
fireEvent.focus(input);
42+
fireEvent.change(input, { target: { value: ".9" } });
43+
expect(input.value).toBe(".9");
44+
45+
fireEvent.blur(input);
46+
expect(input.value).toBe("0.9");
47+
});
48+
49+
it("rejects values above the maximum", () => {
50+
const onError = vi.fn();
51+
render(<CustomSpeedInput value={1.1} onChange={vi.fn()} onError={onError} />);
52+
53+
const input = screen.getByRole("spinbutton") as HTMLInputElement;
54+
fireEvent.focus(input);
55+
fireEvent.change(input, { target: { value: "16.01" } });
56+
57+
expect(onError).toHaveBeenCalledTimes(1);
58+
expect(input.value).toBe("1.1");
59+
});
60+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { useCallback, useState } from "react";
2+
import { clampPlaybackSpeed, MAX_PLAYBACK_SPEED, MIN_PLAYBACK_SPEED, SPEED_OPTIONS } from "./types";
3+
4+
interface CustomSpeedInputProps {
5+
value: number;
6+
onChange: (val: number) => void;
7+
onError: () => void;
8+
}
9+
10+
export function CustomSpeedInput({ value, onChange, onError }: CustomSpeedInputProps) {
11+
const isPreset = SPEED_OPTIONS.some((option) => option.speed === value);
12+
const [draft, setDraft] = useState<string | null>(null);
13+
const display = isPreset ? "" : String(clampPlaybackSpeed(value));
14+
15+
const handleChange = useCallback(
16+
(e: React.ChangeEvent<HTMLInputElement>) => {
17+
const nextDraft = e.target.value;
18+
if (nextDraft === "") {
19+
setDraft("");
20+
return;
21+
}
22+
if (!/^\d*\.?\d{0,2}$/.test(nextDraft)) {
23+
return;
24+
}
25+
26+
const parsed = Number(nextDraft);
27+
if (Number.isFinite(parsed) && parsed > MAX_PLAYBACK_SPEED) {
28+
onError();
29+
return;
30+
}
31+
32+
setDraft(nextDraft);
33+
if (Number.isFinite(parsed) && parsed >= MIN_PLAYBACK_SPEED) {
34+
onChange(clampPlaybackSpeed(parsed));
35+
}
36+
},
37+
[onChange, onError],
38+
);
39+
40+
return (
41+
<div className="flex items-center gap-1">
42+
<input
43+
type="number"
44+
inputMode="decimal"
45+
min={MIN_PLAYBACK_SPEED}
46+
max={MAX_PLAYBACK_SPEED}
47+
step={0.01}
48+
placeholder="--"
49+
value={draft ?? display}
50+
onFocus={() => setDraft(display)}
51+
onChange={handleChange}
52+
onBlur={() => setDraft(null)}
53+
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
54+
className="w-12 bg-white/5 border border-white/10 rounded-md px-1 py-0.5 text-[11px] font-semibold text-[#d97706] text-center focus:outline-none focus:border-[#d97706]/40 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
55+
/>
56+
<span className="text-[11px] font-semibold text-slate-500">×</span>
57+
</div>
58+
);
59+
}

src/components/video-editor/SettingsPanel.tsx

Lines changed: 1 addition & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import ColorPicker from "../ui/color-picker";
5353
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
5454
import { BlurSettingsPanel } from "./BlurSettingsPanel";
5555
import { CropControl } from "./CropControl";
56+
import { CustomSpeedInput } from "./CustomSpeedInput";
5657
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
5758
import type {
5859
AnnotationRegion,
@@ -71,7 +72,6 @@ import type {
7172
} from "./types";
7273
import {
7374
DEFAULT_WEBCAM_SIZE_PRESET,
74-
MAX_PLAYBACK_SPEED,
7575
MAX_ZOOM_SCALE,
7676
MIN_ZOOM_SCALE,
7777
ROTATION_3D_PRESET_ORDER,
@@ -80,69 +80,6 @@ import {
8080
} from "./types";
8181
import { getFocusBoundsForScale } from "./videoPlayback/focusUtils";
8282

83-
function CustomSpeedInput({
84-
value,
85-
onChange,
86-
onError,
87-
}: {
88-
value: number;
89-
onChange: (val: number) => void;
90-
onError: () => void;
91-
}) {
92-
const isPreset = SPEED_OPTIONS.some((o) => o.speed === value);
93-
const [draft, setDraft] = useState(isPreset ? "" : String(Math.round(value)));
94-
const [isFocused, setIsFocused] = useState(false);
95-
96-
const prevValue = useRef(value);
97-
if (!isFocused && prevValue.current !== value) {
98-
prevValue.current = value;
99-
setDraft(isPreset ? "" : String(Math.round(value)));
100-
}
101-
102-
const handleChange = useCallback(
103-
(e: React.ChangeEvent<HTMLInputElement>) => {
104-
const digits = e.target.value.replace(/\D/g, "");
105-
if (digits === "") {
106-
setDraft("");
107-
return;
108-
}
109-
const num = Number(digits);
110-
if (num > MAX_PLAYBACK_SPEED) {
111-
onError();
112-
return;
113-
}
114-
setDraft(digits);
115-
if (num >= 1) onChange(num);
116-
},
117-
[onChange, onError],
118-
);
119-
120-
const handleBlur = useCallback(() => {
121-
setIsFocused(false);
122-
if (!draft || Number(draft) < 1) {
123-
setDraft(isPreset ? "" : String(Math.round(value)));
124-
}
125-
}, [draft, isPreset, value]);
126-
127-
return (
128-
<div className="flex items-center gap-1">
129-
<input
130-
type="text"
131-
inputMode="numeric"
132-
pattern="[0-9]*"
133-
placeholder="--"
134-
value={draft}
135-
onFocus={() => setIsFocused(true)}
136-
onChange={handleChange}
137-
onBlur={handleBlur}
138-
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
139-
className="w-12 bg-white/5 border border-white/10 rounded-md px-1 py-0.5 text-[11px] font-semibold text-[#d97706] text-center focus:outline-none focus:border-[#d97706]/40"
140-
/>
141-
<span className="text-[11px] font-semibold text-slate-500">×</span>
142-
</div>
143-
);
144-
}
145-
14683
function ZoomFocusCoordInput({
14784
percent,
14885
onChange,

0 commit comments

Comments
 (0)