Skip to content

Commit

Permalink
Movable cut marks
Browse files Browse the repository at this point in the history
  • Loading branch information
Dennis Benz committed Apr 19, 2024
1 parent 9acd178 commit 968df33
Show file tree
Hide file tree
Showing 5 changed files with 187 additions and 27 deletions.
3 changes: 2 additions & 1 deletion src/i18n/locales/de-DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
169 changes: 146 additions & 23 deletions src/main/Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -275,7 +276,8 @@ export const Scrubber: React.FC<{
<div css={scrubberDragHandleStyle} aria-grabbed={isGrabbed}
aria-label={t("timeline.scrubber-text-aria",
{
currentTime: convertMsToReadableString(currentlyAt), segment: activeSegmentIndex,
currentTime: convertMsToReadableString(currentlyAt),
segment: activeSegmentIndex,
segmentStatus: (segments[activeSegmentIndex].deleted ? "Deleted" : "Alive"),
moveLeft: rewriteKeys(KEYMAP.timeline.left.key),
moveRight: rewriteKeys(KEYMAP.timeline.right.key),
Expand All @@ -299,6 +301,7 @@ export const SegmentsList: React.FC<{
styleByActiveSegment?: boolean,
tabable?: boolean,
}> = ({
timelineWidth,
timelineHeight,
styleByActiveSegment = true,
tabable = true,
Expand Down Expand Up @@ -341,28 +344,37 @@ export const SegmentsList: React.FC<{
const renderedSegments = () => {
return (
segments.map((segment: Segment, index: number) => (
<ThemedTooltip title={t("timeline.segment-tooltip", { segment: index })} key={segment.id}>
<div
aria-label={t("timeline.segments-text-aria",
{
segment: index,
segmentStatus: (segment.deleted ? "Deleted" : "Alive"),
start: convertMsToReadableString(segment.start),
end: convertMsToReadableString(segment.end),
})}
tabIndex={tabable ? 0 : -1}
css={{
background: bgColor(segment.deleted, styleByActiveSegment ? activeSegmentIndex === index : false),
borderStyle: styleByActiveSegment ? (activeSegmentIndex === index ? "dashed" : "solid") : "solid",
borderColor: "white",
borderWidth: "1px",
boxSizing: "border-box",
width: ((segment.end - segment.start) / duration) * 100 + "%",
height: timelineHeight + "px", // CHECK IF 100%
zIndex: 1,
}}>
</div>
</ThemedTooltip>
<React.Fragment key={segment.id}>
<ThemedTooltip title={t("timeline.segment-tooltip", { segment: index })}>
<div
aria-label={t("timeline.segments-text-aria",
{
segment: index,
segmentStatus: (segment.deleted ? "Deleted" : "Alive"),
start: convertMsToReadableString(segment.start),
end: convertMsToReadableString(segment.end),
})}
tabIndex={tabable ? 0 : -1}
css={{
background: bgColor(segment.deleted, styleByActiveSegment ? activeSegmentIndex === index : false),
borderStyle: styleByActiveSegment ? (activeSegmentIndex === index ? "dashed" : "solid") : "solid",
borderColor: "white",
borderWidth: "1px",
boxSizing: "border-box",
width: ((segment.end - segment.start) / duration) * 100 + "%",
height: timelineHeight + "px", // CHECK IF 100%
zIndex: 1,
}}>
</div>
</ThemedTooltip>
{index + 1 < segments.length && // Check if not rightmost section
<CutMark
leftSegmentIndex={index}
timelineWidth={timelineWidth}
timelineHeight={timelineHeight}
/>
}
</React.Fragment>
))
);
};
Expand All @@ -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 (
<Draggable
onDrag={onControlledDrag}
onStart={onStartDrag}
onStop={onStopDrag}
onMouseDown={e => 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}
>
<div
ref={nodeRef}
css={cutStyle}
aria-label={t("timeline.cut-text-aria",
{
time: convertMsToReadableString(currentTime),
leftSegment: leftSegmentIndex,
rightSegment: rightSegmentIndex,
})}>
<div css={cutDragAreaStyle} />
</div>
</Draggable>
);
};

/**
* Generates waveform images and displays them
*/
Expand Down
34 changes: 32 additions & 2 deletions src/redux/videoSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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],
Expand Down
5 changes: 5 additions & 0 deletions src/themes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down

0 comments on commit 968df33

Please sign in to comment.