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",