summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/components/index.tsx
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-08-16 06:16:56 +0200
committercashmere <cashmere@autistici.org>2026-08-16 06:16:56 +0200
commitf1043734438c8518a302f698d9ae6583a75f0c14 (patch)
treed3e636a96e6349c1ad17afa7b28c9ffe5c882e37 /apps/web/src/timeline/components/index.tsx
parent37d22b1e390bd6cea04bf8e15069281ee125cf10 (diff)
downloadopencut-f1043734438c8518a302f698d9ae6583a75f0c14.tar.gz
feat: add transitions, track mixing, and voiceover controls
Diffstat (limited to 'apps/web/src/timeline/components/index.tsx')
-rw-r--r--apps/web/src/timeline/components/index.tsx140
1 files changed, 101 insertions, 39 deletions
diff --git a/apps/web/src/timeline/components/index.tsx b/apps/web/src/timeline/components/index.tsx
index 8edbb6fc..527f6d8d 100644
--- a/apps/web/src/timeline/components/index.tsx
+++ b/apps/web/src/timeline/components/index.tsx
@@ -53,6 +53,8 @@ import {
getTimelineZoomMin,
getTimelinePaddingPx,
} from "@/timeline";
+import { doesElementHaveEnabledAudio } from "@/timeline/audio-separation";
+import type { MediaAsset } from "@/media/types";
import { timelineTimeToPixels } from "@/timeline/pixel-utils";
import {
getTrackHeight,
@@ -84,9 +86,11 @@ import { useEditor } from "@/editor/use-editor";
import { useScrollPosition } from "@/timeline/hooks/use-scroll-position";
import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead";
import { DragLine } from "./drag-line";
+import { TIMELINE_LAYERS } from "./layers";
import { invokeAction } from "@/actions";
import { resolveTimelineElementIntersections } from "./selection-hit-testing";
import { cn } from "@/utils/ui";
+import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store";
const TRACKS_CONTAINER_MAX_HEIGHT = 800;
const FALLBACK_CONTAINER_WIDTH = 1000;
@@ -302,12 +306,12 @@ export function Timeline() {
const { dragView, handleElementMouseDown, handleElementClick } =
useElementInteraction({
- zoomLevel,
- tracksContainerRef,
- tracksScrollRef,
- snappingEnabled,
- onSnapPointChange: handleSnapPointChange,
- });
+ zoomLevel,
+ tracksContainerRef,
+ tracksScrollRef,
+ snappingEnabled,
+ onSnapPointChange: handleSnapPointChange,
+ });
const isElementDragging = dragView.kind === "dragging";
const {
@@ -455,13 +459,13 @@ export function Timeline() {
className="relative isolate flex flex-1 flex-col overflow-hidden"
ref={tracksContainerRef}
>
- <SelectionBox
- bounds={selectionBox?.bounds ?? null}
- />
+ <SelectionBox bounds={selectionBox?.bounds ?? null} />
<DragLine
dropTarget={dropTarget}
tracks={tracks}
- isVisible={isDragOver && !dropTarget?.targetElement}
+ isVisible={
+ isDragOver && !dropTarget?.targetElement && !dropTarget?.targetCut
+ }
headerHeight={timelineHeaderHeight}
/>
<DragLine
@@ -612,6 +616,8 @@ function TrackLabelsPanel({
}) {
const editor = useEditor();
const scene = useEditor((e) => e.scenes.getActiveSceneOrNull());
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+ const selectTrack = usePropertiesStore((state) => state.selectTrack);
const tracks = useMemo<TimelineTrack[]>(
() =>
scene
@@ -619,7 +625,7 @@ function TrackLabelsPanel({
: [],
[scene],
);
- const { selectedElements } = useElementSelection();
+ const { selectedElements, clearElementSelection } = useElementSelection();
const tracksWithSelection = useMemo(
() => new Set(selectedElements.map((el) => el.trackId)),
[selectedElements],
@@ -653,6 +659,7 @@ function TrackLabelsPanel({
{tracks.map((track, index) => {
const expandedRows = trackExpandedRowsMap[index];
const baseHeight = getTrackHeight({ type: track.type });
+ const hasAudio = trackContainsAudio({ track, mediaAssets });
return (
<div
@@ -667,21 +674,16 @@ function TrackLabelsPanel({
}}
>
<div
- className="flex shrink-0 items-center justify-end gap-2 px-3"
+ className="flex shrink-0 items-center justify-end gap-1.5 px-2"
style={{ height: `${baseHeight}px` }}
>
- {canTrackHaveAudio(track) && (
- <TrackToggleIcon
- isOff={track.muted}
- icons={{
- on: VolumeHighIcon,
- off: VolumeOffIcon,
+ {hasAudio && canTrackHaveAudio(track) && (
+ <TrackAudioButton
+ track={track}
+ onClick={() => {
+ clearElementSelection();
+ selectTrack({ trackId: track.id });
}}
- onClick={() =>
- editor.timeline.toggleTrackMute({
- trackId: track.id,
- })
- }
/>
)}
{canTrackBeHidden(track) && (
@@ -780,8 +782,8 @@ function TimelineTrackRows({
const draggingElementIds = useMemo(
() =>
dragView.kind === "dragging"
- ? dragView.memberTimeOffsets
- : (null as ReadonlyMap<string, MediaTime> | null),
+ ? dragView.memberTimeOffsets
+ : (null as ReadonlyMap<string, MediaTime> | null),
[dragView],
);
const sortedTracks = useMemo(() => {
@@ -833,6 +835,18 @@ function TimelineTrackRows({
: null
}
/>
+ {isDragOver && dropTarget?.targetCut?.trackId === track.id && (
+ <div
+ className="bg-primary pointer-events-none absolute inset-y-0 w-0.5 -translate-x-1/2 shadow-[0_0_8px_var(--primary)]"
+ style={{
+ left: timelineTimeToPixels({
+ time: dropTarget.targetCut.time,
+ zoomLevel,
+ }),
+ zIndex: TIMELINE_LAYERS.dragLine,
+ }}
+ />
+ )}
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-40">
@@ -915,25 +929,73 @@ function TrackToggleIcon({
};
onClick: () => void;
}) {
+ const icon = isOff ? icons.off : icons.on;
return (
- <>
- {isOff ? (
- <HugeiconsIcon
- icon={icons.off}
- className="text-destructive size-4 cursor-pointer"
- onClick={onClick}
- />
- ) : (
- <HugeiconsIcon
- icon={icons.on}
- className="text-muted-foreground size-4 cursor-pointer"
- onClick={onClick}
- />
+ <button
+ type="button"
+ className={cn(
+ "flex size-6 items-center justify-center rounded-sm hover:bg-accent",
+ isOff ? "text-destructive" : "text-muted-foreground",
)}
- </>
+ onClick={onClick}
+ >
+ <HugeiconsIcon icon={icon} className="size-4" />
+ </button>
+ );
+}
+function TrackAudioButton({
+ track,
+ onClick,
+}: {
+ track: Extract<TimelineTrack, { muted: boolean }>;
+ onClick: () => void;
+}) {
+ return (
+ <button
+ type="button"
+ className={cn(
+ "flex size-6 items-center justify-center rounded-sm hover:bg-accent",
+ track.muted ? "text-destructive" : "text-muted-foreground",
+ )}
+ aria-label={`Edit ${track.name} audio`}
+ onClick={onClick}
+ >
+ <HugeiconsIcon
+ icon={track.muted ? VolumeOffIcon : VolumeHighIcon}
+ className="size-4"
+ />
+ </button>
);
}
+function trackContainsAudio({
+ track,
+ mediaAssets,
+}: {
+ track: TimelineTrack;
+ mediaAssets: MediaAsset[];
+}): boolean {
+ if (!canTrackHaveAudio(track)) {
+ return false;
+ }
+
+ for (const element of track.elements) {
+ if (element.type === "audio") {
+ return true;
+ }
+ if (element.type !== "video") {
+ continue;
+ }
+ const mediaAsset = mediaAssets.find(
+ (asset) => asset.id === element.mediaId,
+ );
+ if (doesElementHaveEnabledAudio({ element, mediaAsset })) {
+ return true;
+ }
+ }
+ return false;
+}
+
function PropertyTree({ rows }: { rows: ExpandedRow[] }) {
return (
<div className="flex flex-col overflow-hidden">