diff options
| author | cashmere <cashmere@autistici.org> | 2026-08-16 06:16:56 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-08-16 06:16:56 +0200 |
| commit | f1043734438c8518a302f698d9ae6583a75f0c14 (patch) | |
| tree | d3e636a96e6349c1ad17afa7b28c9ffe5c882e37 /apps/web/src/timeline/components/index.tsx | |
| parent | 37d22b1e390bd6cea04bf8e15069281ee125cf10 (diff) | |
| download | opencut-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.tsx | 140 |
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"> |
