-
- {formatTimeCode(
- currentTime,
- "HH:MM:SS:FF",
- activeProject?.fps || 30
- )}
-
+
/
{formatTimeCode(
diff --git a/apps/web/src/components/ui/editable-timecode.tsx b/apps/web/src/components/ui/editable-timecode.tsx
new file mode 100644
index 00000000..f6d29f49
--- /dev/null
+++ b/apps/web/src/components/ui/editable-timecode.tsx
@@ -0,0 +1,137 @@
+"use client";
+
+import { useState, useRef, useEffect } from "react";
+import { cn } from "@/lib/utils";
+import { formatTimeCode, parseTimeCode } from "@/lib/time";
+
+interface EditableTimecodeProps {
+ time: number;
+ duration?: number;
+ format?: "MM:SS" | "HH:MM:SS" | "HH:MM:SS:CS" | "HH:MM:SS:FF";
+ fps?: number;
+ onTimeChange?: (time: number) => void;
+ className?: string;
+ disabled?: boolean;
+}
+
+export function EditableTimecode({
+ time,
+ duration,
+ format = "HH:MM:SS:FF",
+ fps = 30,
+ onTimeChange,
+ className,
+ disabled = false,
+}: EditableTimecodeProps) {
+ const [isEditing, setIsEditing] = useState(false);
+ const [inputValue, setInputValue] = useState("");
+ const [hasError, setHasError] = useState(false);
+ const inputRef = useRef(null);
+ const enterPressedRef = useRef(false);
+
+ const formattedTime = formatTimeCode(time, format, fps);
+
+ const startEditing = () => {
+ if (disabled) return;
+ setIsEditing(true);
+ setInputValue(formattedTime);
+ setHasError(false);
+ enterPressedRef.current = false;
+ };
+
+ const cancelEditing = () => {
+ setIsEditing(false);
+ setInputValue("");
+ setHasError(false);
+ enterPressedRef.current = false;
+ };
+
+ const applyEdit = () => {
+ const parsedTime = parseTimeCode(inputValue, format, fps);
+
+ if (parsedTime === null) {
+ setHasError(true);
+ return;
+ }
+
+ // Clamp time to valid range
+ const clampedTime = Math.max(
+ 0,
+ duration ? Math.min(duration, parsedTime) : parsedTime
+ );
+
+ onTimeChange?.(clampedTime);
+ setIsEditing(false);
+ setInputValue("");
+ setHasError(false);
+ enterPressedRef.current = false;
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ enterPressedRef.current = true;
+ applyEdit();
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ cancelEditing();
+ }
+ };
+
+ const handleInputChange = (e: React.ChangeEvent) => {
+ setInputValue(e.target.value);
+ setHasError(false);
+ };
+
+ const handleBlur = () => {
+ // Only apply edit if Enter wasn't pressed (to avoid double processing)
+ if (!enterPressedRef.current && isEditing) {
+ applyEdit();
+ }
+ };
+
+ // Focus input when entering edit mode
+ useEffect(() => {
+ if (isEditing && inputRef.current) {
+ inputRef.current.focus();
+ inputRef.current.select();
+ }
+ }, [isEditing]);
+
+ if (isEditing) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {formattedTime}
+
+ );
+}
diff --git a/apps/web/src/lib/time.ts b/apps/web/src/lib/time.ts
index df02d837..f24c5961 100644
--- a/apps/web/src/lib/time.ts
+++ b/apps/web/src/lib/time.ts
@@ -23,3 +23,96 @@ export const formatTimeCode = (
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${frames.toString().padStart(2, "0")}`;
}
};
+
+export const parseTimeCode = (
+ timeCode: string,
+ format: "MM:SS" | "HH:MM:SS" | "HH:MM:SS:CS" | "HH:MM:SS:FF" = "HH:MM:SS:CS",
+ fps = 30
+): number | null => {
+ if (!timeCode || typeof timeCode !== "string") return null;
+
+ // Remove any extra whitespace
+ const cleanTimeCode = timeCode.trim();
+
+ try {
+ switch (format) {
+ case "MM:SS": {
+ const parts = cleanTimeCode.split(":");
+ if (parts.length !== 2) return null;
+ const [minutes, seconds] = parts.map((part) => parseInt(part, 10));
+ if (isNaN(minutes) || isNaN(seconds)) return null;
+ if (minutes < 0 || seconds < 0 || seconds >= 60) return null;
+ return minutes * 60 + seconds;
+ }
+
+ case "HH:MM:SS": {
+ const parts = cleanTimeCode.split(":");
+ if (parts.length !== 3) return null;
+ const [hours, minutes, seconds] = parts.map((part) =>
+ parseInt(part, 10)
+ );
+ if (isNaN(hours) || isNaN(minutes) || isNaN(seconds)) return null;
+ if (
+ hours < 0 ||
+ minutes < 0 ||
+ seconds < 0 ||
+ minutes >= 60 ||
+ seconds >= 60
+ )
+ return null;
+ return hours * 3600 + minutes * 60 + seconds;
+ }
+
+ case "HH:MM:SS:CS": {
+ const parts = cleanTimeCode.split(":");
+ if (parts.length !== 4) return null;
+ const [hours, minutes, seconds, centiseconds] = parts.map((part) =>
+ parseInt(part, 10)
+ );
+ if (
+ isNaN(hours) ||
+ isNaN(minutes) ||
+ isNaN(seconds) ||
+ isNaN(centiseconds)
+ )
+ return null;
+ if (
+ hours < 0 ||
+ minutes < 0 ||
+ seconds < 0 ||
+ centiseconds < 0 ||
+ minutes >= 60 ||
+ seconds >= 60 ||
+ centiseconds >= 100
+ )
+ return null;
+ return hours * 3600 + minutes * 60 + seconds + centiseconds / 100;
+ }
+
+ case "HH:MM:SS:FF": {
+ const parts = cleanTimeCode.split(":");
+ if (parts.length !== 4) return null;
+ const [hours, minutes, seconds, frames] = parts.map((part) =>
+ parseInt(part, 10)
+ );
+ if (isNaN(hours) || isNaN(minutes) || isNaN(seconds) || isNaN(frames))
+ return null;
+ if (
+ hours < 0 ||
+ minutes < 0 ||
+ seconds < 0 ||
+ frames < 0 ||
+ minutes >= 60 ||
+ seconds >= 60 ||
+ frames >= fps
+ )
+ return null;
+ return hours * 3600 + minutes * 60 + seconds + frames / fps;
+ }
+ }
+ } catch {
+ return null;
+ }
+
+ return null;
+};