diff --git a/src/i18n/locales/de-DE.json b/src/i18n/locales/de-DE.json index 0488191cc..94fc5f933 100644 --- a/src/i18n/locales/de-DE.json +++ b/src/i18n/locales/de-DE.json @@ -91,7 +91,8 @@ "generateWaveform-text": "Waveform wird generiert", "segment-tooltip": "Segment {{segment}}", "scrubber-text-aria": "Zeitmarker. {{currentTime}}. Aktives Segment: {{segment}}. {{segmentStatus}}. Steuerung: {{moveLeft}} und {{moveRight}}, um den Zeitmarker zu bewegen. {{increase}} und {{decrease}}, um das Verschiebungdelta zu erhöhen/verringern.\n", - "segments-text-aria": "Segment {{index}}. {{segmentStatus}}. Start: {{start}}. Ende: {{end}}.\n" + "segments-text-aria": "Segment {{index}}. {{segmentStatus}}. Start: {{start}}. Ende: {{end}}.\n", + "cut-text-aria": "Schnittmarker. {{time}}. Zwischen Segment {{leftSegment}} und {{rightSegment}}.\n" }, "workflowConfig": { "headline-text": "Workflow Konfiguration", diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 640df790d..c1cd880f1 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -99,7 +99,8 @@ "generateWaveform-text": "Generating Waveform", "segment-tooltip": "Segment {{segment}}", "scrubber-text-aria": "Timeline marker. {{currentTime}}. Active segment: {{segment}}. {{segmentStatus}}. Controls: {{moveLeft}} and {{moveRight}} to move the timeline marker. {{increase}} and {{decrease}} to increase/decrease the move delta.\n", - "segments-text-aria": "Segment {{index}}. {{segmentStatus}}. Start: {{start}}. End: {{end}}.\n" + "segments-text-aria": "Segment {{index}}. {{segmentStatus}}. Start: {{start}}. End: {{end}}.\n", + "cut-text-aria": "Cut marker. {{time}}. Between segment {{leftSegment}} and {{rightSegment}}.\n" }, "workflowConfig": { diff --git a/src/main/Timeline.tsx b/src/main/Timeline.tsx index aabb7ebda..36e9f0a04 100644 --- a/src/main/Timeline.tsx +++ b/src/main/Timeline.tsx @@ -8,6 +8,7 @@ import { useAppDispatch, useAppSelector } from "../redux/store"; import { Segment, httpRequestState } from "../types"; import { selectSegments, selectActiveSegmentIndex, selectDuration, selectVideoURL, selectWaveformImages, setWaveformImages, + moveCut, } from "../redux/videoSlice"; import { LuMenu, LuLoader } from "react-icons/lu"; @@ -275,7 +276,8 @@ export const Scrubber: React.FC<{
= ({ + timelineWidth, timelineHeight, styleByActiveSegment = true, tabable = true, @@ -341,28 +344,37 @@ export const SegmentsList: React.FC<{ const renderedSegments = () => { return ( segments.map((segment: Segment, index: number) => ( - -
-
-
+ + +
+
+
+ {index + 1 < segments.length && // Check if not rightmost section + + } +
)) ); }; @@ -381,6 +393,117 @@ export const SegmentsList: React.FC<{ ); }; +export const CutMark: React.FC<{ + leftSegmentIndex: number, + timelineWidth: number, + timelineHeight: number, +}> = ({ + leftSegmentIndex, + timelineWidth, + timelineHeight, +}) => { + + // Init redux variables + const dispatch = useAppDispatch(); + const segments = useAppSelector(selectSegments); + const duration = useAppSelector(selectDuration); + const rightSegmentIndex = leftSegmentIndex + 1; + const leftSegment = segments[leftSegmentIndex]; + const rightSegment = segments[rightSegmentIndex]; + const theme = useTheme(); + + // Init state variables + const [controlledPosition, setControlledPosition] = useState({ x: 0, y: 0 }); + const [currentTime, setCurrentTime] = useState(rightSegment.start); + const [isGrabbed, setIsGrabbed] = useState(false); + const nodeRef = React.useRef(null); // For supressing "ReactDOM.findDOMNode() is deprecated" warning + + const { t } = useTranslation(); + + const updateCurrentTime = (x: number) => { + setCurrentTime(rightSegment.start + (x * duration) / timelineWidth); + }; + + // Callback for when the cut gets dragged by the user + const onControlledDrag: DraggableEventHandler = (_e, position) => { + // Update time + const { x } = position; + updateCurrentTime(x); + }; + + const onStartDrag: DraggableEventHandler = () => { + setIsGrabbed(true); + }; + + const onStopDrag: DraggableEventHandler = (_e, position) => { + // Move cut to new position + const { x } = position; + updateCurrentTime(x); + + dispatch(moveCut({ + leftSegmentIndex: leftSegmentIndex, + time: currentTime, + })); + + // Reset position to origin + setControlledPosition({ x: 0, y: 0 }); + + setIsGrabbed(false); + }; + + const cutStyle = css({ + height: timelineHeight, + width: "2px", + zIndex: 3, + cursor: "col-resize", + position: "absolute", + left: (leftSegment.end / duration) * timelineWidth - 1 + "px", + background: isGrabbed ? `repeating-linear-gradient( + 180deg, ${theme.cut}, + ${theme.scrubber} 4px, + transparent 4px, + transparent 8px)` + : "transparent", + display: "flex", + flexDirection: "column", + alignItems: "center", + }); + + const cutDragAreaStyle = css({ + width: "10px", + height: "100%", + }); + + return ( + e.stopPropagation()} // Prevent timeline click + + axis="x" + bounds={{ + left: -((leftSegment.end - leftSegment.start) / duration) * timelineWidth, + right: ((rightSegment.end - rightSegment.start) / duration) * timelineWidth, + }} + position={controlledPosition} + nodeRef={nodeRef} + > +
+
+
+ + ); +}; + /** * Generates waveform images and displays them */ diff --git a/src/redux/videoSlice.ts b/src/redux/videoSlice.ts index fdcbb0aa1..b3fe2c35c 100644 --- a/src/redux/videoSlice.ts +++ b/src/redux/videoSlice.ts @@ -195,6 +195,36 @@ const videoSlice = createSlice({ state.hasChanges = true; }, + moveCut: ( + state, + action: PayloadAction<{ leftSegmentIndex: number, time: Segment["start"] }> + ) => { + const leftSegmentIndex = action.payload.leftSegmentIndex; + const rightSegmentIndex = action.payload.leftSegmentIndex + 1; + + if (leftSegmentIndex < 0 || rightSegmentIndex >= state.segments.length) { + return; + } + + // Merge overlapping left cut + if (action.payload.time <= state.segments[leftSegmentIndex].start) { + mergeSegments(state, rightSegmentIndex, leftSegmentIndex); + state.hasChanges = true; + return; + } + + // Merge overlapping right cut + if (action.payload.time >= state.segments[rightSegmentIndex].end) { + mergeSegments(state, leftSegmentIndex, rightSegmentIndex); + state.hasChanges = true; + return; + } + + // Move segment edges + state.segments[leftSegmentIndex].end = action.payload.time; + state.segments[rightSegmentIndex].start = action.payload.time; + state.hasChanges = true; + }, markAsDeletedOrAlive: state => { state.segments[state.activeSegmentIndex].deleted = !state.segments[state.activeSegmentIndex].deleted; state.hasChanges = true; @@ -423,8 +453,8 @@ const setThumbnailHelper = (state: video, id: Track["id"], uri: Track["thumbnail export const { setTrackEnabled, setIsPlaying, setIsPlayPreview, setIsMuted, setVolume, setCurrentlyAt, setCurrentlyAtInSeconds, addSegment, setAspectRatio, setHasChanges, setWaveformImages, setThumbnails, setThumbnail, - removeThumbnail, setLock, cut, markAsDeletedOrAlive, setSelectedWorkflowIndex, mergeLeft, mergeRight, mergeAll, - setPreviewTriggered, setClickTriggered } = videoSlice.actions; + removeThumbnail, setLock, cut, moveCut, markAsDeletedOrAlive, setSelectedWorkflowIndex, + mergeLeft, mergeRight, mergeAll, setPreviewTriggered, setClickTriggered } = videoSlice.actions; export const selectVideos = createSelector( [(state: { videoState: { tracks: video["tracks"]; }; }) => state.videoState.tracks], diff --git a/src/themes.ts b/src/themes.ts index 7ac7a090f..4eea59655 100644 --- a/src/themes.ts +++ b/src/themes.ts @@ -50,6 +50,7 @@ export interface Theme { background_preview_icon: string; waveform_filter: string; waveform_bg: string; + cut: string; scrubber: string; scrubber_handle: string; scrubber_icon: string; @@ -107,6 +108,7 @@ export const lightMode: Theme = { // All this just to turn the black part of the waveform image blue. Generated with: https://isotropic.co/tool/hex-color-to-css-filter/ waveform_filter: "invert(44%) sepia(8%) saturate(3893%) hue-rotate(169deg) brightness(99%) contrast(90%)", waveform_bg: "", + cut: COLORS.neutral60, scrubber: COLORS.neutral60, scrubber_handle: COLORS.neutral05, scrubber_icon: COLORS.neutral60, @@ -165,6 +167,7 @@ export const darkMode: Theme = { background_preview_icon: COLORS.neutral70, waveform_filter: "invert(11%)", waveform_bg: "#fff", + cut: COLORS.neutral60, scrubber: COLORS.neutral60, scrubber_handle: COLORS.neutral70, scrubber_icon: COLORS.neutral20, @@ -223,6 +226,7 @@ export const highContrastDarkMode: Theme = { background_preview_icon: "#fff", waveform_filter: "invert(100%)", waveform_bg: "#80B8AC", + cut: "#fff", scrubber: "#fff", scrubber_handle: "#fff", scrubber_icon: "#000", @@ -279,6 +283,7 @@ export const highContrastLightMode: Theme = { background_preview_icon: "#000", waveform_filter: "invert(0%)", waveform_bg: "#fff", + cut: "#000", scrubber: "#000", scrubber_handle: "#000", scrubber_icon: "#fff",