summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/components/audio-tab.tsx
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/timeline/components/audio-tab.tsx
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/timeline/components/audio-tab.tsx')
-rw-r--r--apps/web/src/timeline/components/audio-tab.tsx151
1 files changed, 151 insertions, 0 deletions
diff --git a/apps/web/src/timeline/components/audio-tab.tsx b/apps/web/src/timeline/components/audio-tab.tsx
new file mode 100644
index 00000000..1dbb3d1a
--- /dev/null
+++ b/apps/web/src/timeline/components/audio-tab.tsx
@@ -0,0 +1,151 @@
+import { Button } from "@/components/ui/button";
+import { NumberField } from "@/components/ui/number-field";
+import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
+import { isSourceAudioSeparated } from "@/timeline/audio-separation";
+import { DEFAULTS } from "@/timeline/defaults";
+import {
+ clamp,
+ formatNumberForDisplay,
+ getFractionDigitsForStep,
+ isNearlyEqual,
+ snapToStep,
+} from "@/utils/math";
+import type { AudioElement, VideoElement } from "@/timeline";
+import { resolveNumberAtTime } from "@/animation";
+import { useEditor } from "@/editor/use-editor";
+import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
+import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
+import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { VolumeHighIcon } from "@hugeicons/core-free-icons";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+
+const VOLUME_STEP = 0.1;
+const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP });
+
+export function AudioTab({
+ element,
+ trackId,
+}: {
+ element: AudioElement | VideoElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedVolume = resolveNumberAtTime({
+ baseValue: element.volume ?? DEFAULTS.element.volume,
+ animations: element.animations,
+ propertyPath: "volume",
+ localTime,
+ });
+ const volume = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "volume",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: formatNumberForDisplay({
+ value: resolvedVolume,
+ fractionDigits: VOLUME_FRACTION_DIGITS,
+ }),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) {
+ return null;
+ }
+
+ return clamp({
+ value: snapToStep({ value: parsed, step: VOLUME_STEP }),
+ min: VOLUME_DB_MIN,
+ max: VOLUME_DB_MAX,
+ });
+ },
+ valueAtPlayhead: resolvedVolume,
+ step: VOLUME_STEP,
+ buildBaseUpdates: ({ value }) => ({
+ volume: value,
+ }),
+ });
+ const isDefault =
+ volume.hasAnimatedKeyframes && isPlayheadWithinElementRange
+ ? isNearlyEqual({
+ leftValue: resolvedVolume,
+ rightValue: DEFAULTS.element.volume,
+ })
+ : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume;
+ const isSeparated =
+ element.type === "video" && isSourceAudioSeparated({ element });
+
+ return (
+ <>
+ {isSeparated && (
+ <div className="mx-4 mt-4 rounded-md border bg-muted/30 p-3">
+ <p className="text-sm">Audio has been separated.</p>
+ <Button
+ className="mt-3"
+ size="sm"
+ variant="secondary"
+ onClick={() =>
+ editor.timeline.toggleSourceAudioSeparation({
+ trackId,
+ elementId: element.id,
+ })
+ }
+ >
+ Recover audio
+ </Button>
+ </div>
+ )}
+ <Section collapsible sectionKey={`${element.id}:audio`}>
+ <SectionHeader>
+ <SectionTitle>Audio</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ <SectionField
+ label="Volume"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={volume.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle volume keyframe"
+ onToggle={volume.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon={<HugeiconsIcon icon={VolumeHighIcon} />}
+ value={volume.displayValue}
+ onFocus={volume.onFocus}
+ onChange={volume.onChange}
+ onBlur={volume.onBlur}
+ dragSensitivity="slow"
+ scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
+ onScrub={volume.scrubTo}
+ onScrubEnd={volume.commitScrub}
+ onReset={() =>
+ volume.commitValue({
+ value: DEFAULTS.element.volume,
+ })
+ }
+ isDefault={isDefault}
+ suffix="dB"
+ />
+ </SectionField>
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ </>
+ );
+}