Skip to content
This repository was archived by the owner on Jun 17, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 28 additions & 12 deletions src/components/video-editor/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ import ColorPicker from "../ui/color-picker";
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
import { BlurSettingsPanel } from "./BlurSettingsPanel";
import { CropControl } from "./CropControl";
import {
formatCustomPlaybackSpeedDraft,
parseCustomPlaybackSpeedDraft,
sanitizeCustomPlaybackSpeedDraft,
} from "./customPlaybackSpeed";
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
import type {
AnnotationRegion,
Expand Down Expand Up @@ -90,46 +95,57 @@ function CustomSpeedInput({
onError: () => void;
}) {
const isPreset = SPEED_OPTIONS.some((o) => o.speed === value);
const [draft, setDraft] = useState(isPreset ? "" : String(Math.round(value)));
const [draft, setDraft] = useState(formatCustomPlaybackSpeedDraft(value, isPreset));
const [isFocused, setIsFocused] = useState(false);

const prevValue = useRef(value);
if (!isFocused && prevValue.current !== value) {
prevValue.current = value;
setDraft(isPreset ? "" : String(Math.round(value)));
setDraft(formatCustomPlaybackSpeedDraft(value, isPreset));
}

const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const digits = e.target.value.replace(/\D/g, "");
if (digits === "") {
const nextDraft = sanitizeCustomPlaybackSpeedDraft(e.target.value);
if (nextDraft === "") {
setDraft("");
return;
}
const num = Number(digits);
if (num > MAX_PLAYBACK_SPEED) {

const num = parseCustomPlaybackSpeedDraft(nextDraft);
if (num === null && Number(nextDraft) > MAX_PLAYBACK_SPEED) {
onError();
return;
}
setDraft(digits);
if (num >= 1) onChange(num);

setDraft(nextDraft);
if (num !== null) onChange(num);
},
[onChange, onError],
);

const handleBlur = useCallback(() => {
setIsFocused(false);
if (!draft || Number(draft) < 1) {
setDraft(isPreset ? "" : String(Math.round(value)));
const parsed = parseCustomPlaybackSpeedDraft(draft);
if (parsed === null) {
setDraft(formatCustomPlaybackSpeedDraft(value, isPreset));
return;
}

setDraft(
formatCustomPlaybackSpeedDraft(
parsed,
SPEED_OPTIONS.some((option) => option.speed === parsed),
),
);
}, [draft, isPreset, value]);

return (
<div className="flex items-center gap-1">
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
inputMode="decimal"
pattern="[0-9]*[.]?[0-9]*"
placeholder="--"
value={draft}
onFocus={() => setIsFocused(true)}
Expand Down
35 changes: 35 additions & 0 deletions src/components/video-editor/customPlaybackSpeed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, test } from "vitest";
import {
formatCustomPlaybackSpeedDraft,
parseCustomPlaybackSpeedDraft,
sanitizeCustomPlaybackSpeedDraft,
} from "./customPlaybackSpeed";

describe("custom playback speed helpers", () => {
test("formats non-preset values without rounding them to whole numbers", () => {
expect(formatCustomPlaybackSpeedDraft(1.1, false)).toBe("1.1");
});

test("formats preset values as an empty draft", () => {
expect(formatCustomPlaybackSpeedDraft(1.5, true)).toBe("");
});

test("preserves decimal input and limits precision to two places", () => {
expect(sanitizeCustomPlaybackSpeedDraft("1.1")).toBe("1.1");
expect(sanitizeCustomPlaybackSpeedDraft("1..234")).toBe("1.23");
});

test("normalizes leading decimal input", () => {
expect(sanitizeCustomPlaybackSpeedDraft(".9")).toBe("0.9");
});

test("parses valid decimal speeds within the supported range", () => {
expect(parseCustomPlaybackSpeedDraft("0.9")).toBe(0.9);
expect(parseCustomPlaybackSpeedDraft("1.1")).toBe(1.1);
});

test("rejects values outside the supported range", () => {
expect(parseCustomPlaybackSpeedDraft("0.05")).toBeNull();
expect(parseCustomPlaybackSpeedDraft("16.01")).toBeNull();
});
});
31 changes: 31 additions & 0 deletions src/components/video-editor/customPlaybackSpeed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { clampPlaybackSpeed, MAX_PLAYBACK_SPEED, MIN_PLAYBACK_SPEED } from "./types";

export function formatCustomPlaybackSpeedDraft(value: number, isPreset: boolean): string {
return isPreset ? "" : String(clampPlaybackSpeed(value));
}

export function sanitizeCustomPlaybackSpeedDraft(input: string): string {
const cleaned = input.replace(/[^0-9.]/g, "");
if (!cleaned) return "";

const startsWithDot = cleaned.startsWith(".");
const [integerPartRaw, ...decimalParts] = cleaned.split(".");
const integerPart = integerPartRaw.replace(/^0+(?=\d)/, "") || "0";
const decimalPart = decimalParts.join("").slice(0, 2);

if (startsWithDot || cleaned.includes(".")) {
return `${integerPart}.${decimalPart}`;
}

return integerPart;
}

export function parseCustomPlaybackSpeedDraft(draft: string): number | null {
if (!draft || draft.endsWith(".")) return null;

const parsed = Number(draft);
if (!Number.isFinite(parsed)) return null;
if (parsed < MIN_PLAYBACK_SPEED || parsed > MAX_PLAYBACK_SPEED) return null;

return clampPlaybackSpeed(parsed);
}
Loading