clipper/frontend/src/Overview.tsx

361 lines
9.4 KiB
TypeScript

import { useState, useEffect, useRef, MouseEvent } from 'react';
import { MediaSetServiceClientImpl, MediaSet } from './generated/media_set';
import { Frames, newRPC, VideoPosition } from './App';
import { WaveformCanvas } from './WaveformCanvas';
import { from, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
export interface Selection {
start: number;
end: number;
}
interface Props {
mediaSet: MediaSet;
height: number;
offsetPixels: number;
position: VideoPosition;
onSelectionChange: (selection: Frames) => void;
}
enum Mode {
Normal,
Selecting,
Dragging,
ResizingStart,
ResizingEnd,
}
enum HoverState {
Normal,
OverSelectionStart,
OverSelectionEnd,
OverSelection,
}
const CanvasLogicalWidth = 2_000;
const CanvasLogicalHeight = 500;
const emptySelection = { start: 0, end: 0 };
export const Overview: React.FC<Props> = ({
mediaSet,
height,
offsetPixels,
position,
onSelectionChange,
}: Props) => {
const hudCanvasRef = useRef<HTMLCanvasElement>(null);
const [peaks, setPeaks] = useState<Observable<number[]>>(from([]));
const [mode, setMode] = useState(Mode.Normal);
const [hoverState, setHoverState] = useState(HoverState.Normal);
const [newSelection, setNewSelection] = useState({
...emptySelection,
});
const [selection, setSelection] = useState({ start: 0, end: 100 });
const [cursor, setCursor] = useState('auto');
const moveOffsetX = useRef(0);
// effects
// handle global mouse up.
useEffect(() => {
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mouseup', handleMouseUp);
};
}, [mode, newSelection]);
// publish onSelectionChange event
useEffect(() => {
if (mediaSet == null) {
return;
}
const canvas = hudCanvasRef.current;
if (canvas == null) {
console.error('no hud canvas ref available');
return;
}
const ctx = canvas.getContext('2d');
if (ctx == null) {
console.error('no hud 2d context available');
return;
}
const width = canvas.getBoundingClientRect().width;
const selectionPercent = {
start: (selection.start / width) * 100,
end: (selection.end / width) * 100,
};
onSelectionChange(selectionPercent);
}, [selection]);
// load peaks on mediaset change
useEffect(() => {
(async function () {
if (mediaSet == null) {
return;
}
const canvas = hudCanvasRef.current;
if (canvas == null) {
console.error('no hud canvas ref available');
return;
}
const ctx = canvas.getContext('2d');
if (ctx == null) {
console.error('no hud 2d context available');
return;
}
console.log('fetching audio...');
const service = new MediaSetServiceClientImpl(newRPC());
const audioProgressStream = service.GetAudio({
id: mediaSet.id,
numBins: CanvasLogicalWidth,
});
const peaks = audioProgressStream.pipe(map((progress) => progress.peaks));
setPeaks(peaks);
})();
}, [mediaSet]);
// draw the overview HUD
useEffect(() => {
requestAnimationFrame(() => {
const canvas = hudCanvasRef.current;
if (canvas == null) {
console.error('no hud canvas ref available');
return;
}
const ctx = canvas.getContext('2d');
if (ctx == null) {
console.error('no hud 2d context available');
return;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
// draw selection
let currentSelection: Selection;
if (
mode == Mode.Selecting ||
mode == Mode.Dragging ||
mode == Mode.ResizingStart ||
mode == Mode.ResizingEnd
) {
currentSelection = newSelection;
} else {
currentSelection = selection;
}
const elementWidth = canvas.getBoundingClientRect().width;
const start =
(currentSelection.start / elementWidth) * CanvasLogicalWidth;
const end = (currentSelection.end / elementWidth) * CanvasLogicalWidth;
ctx.beginPath();
ctx.strokeStyle = 'red';
ctx.lineWidth = 4;
const alpha = hoverState == HoverState.OverSelection ? '0.15' : '0.13';
ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`;
ctx.rect(start, 2, end - start, canvas.height - 10);
ctx.fill();
ctx.stroke();
// draw position marker
const markerX = canvas.width * (position.percent / 100);
ctx.beginPath();
ctx.moveTo(markerX, 0);
ctx.lineWidth = 4;
ctx.lineTo(markerX, canvas.height - 4);
ctx.stroke();
});
});
// handlers
const isHoveringSelectionStart = (elementX: number): boolean => {
return elementX > selection.start - 10 && elementX < selection.start + 10;
};
const isHoveringSelectionEnd = (elementX: number): boolean => {
return elementX > selection.end - 10 && elementX < selection.end + 10;
};
const isHoveringSelection = (elementX: number): boolean => {
return elementX >= selection.start && elementX <= selection.end;
};
const handleMouseDown = (evt: MouseEvent<HTMLCanvasElement>) => {
if (mode != Mode.Normal) {
return;
}
const elementX = Math.round(
evt.clientX - evt.currentTarget.getBoundingClientRect().x
);
if (isHoveringSelectionStart(elementX)) {
setMode(Mode.ResizingStart);
moveOffsetX.current = elementX;
return;
} else if (isHoveringSelectionEnd(elementX)) {
setMode(Mode.ResizingEnd);
moveOffsetX.current = elementX;
return;
} else if (isHoveringSelection(elementX)) {
setMode(Mode.Dragging);
setCursor('pointer');
moveOffsetX.current = elementX;
return;
}
setMode(Mode.Selecting);
setCursor('col-resize');
moveOffsetX.current = elementX;
setNewSelection({ start: elementX, end: elementX });
};
const handleMouseMove = (evt: MouseEvent<HTMLCanvasElement>) => {
const x = Math.round(
evt.clientX - evt.currentTarget.getBoundingClientRect().x
);
switch (mode) {
case Mode.Normal: {
if (isHoveringSelectionStart(x)) {
setHoverState(HoverState.OverSelectionStart);
setCursor('col-resize');
} else if (isHoveringSelectionEnd(x)) {
setHoverState(HoverState.OverSelectionEnd);
setCursor('col-resize');
} else if (isHoveringSelection(x)) {
setHoverState(HoverState.OverSelection);
setCursor('pointer');
} else {
setCursor('auto');
}
break;
}
case Mode.ResizingStart: {
const diff = x - moveOffsetX.current;
const start = selection.start + diff;
if (start > selection.end) {
setNewSelection({ start: selection.end, end: start });
break;
}
setNewSelection({ ...newSelection, start: start });
break;
}
case Mode.ResizingEnd: {
const diff = x - moveOffsetX.current;
const start = selection.end + diff;
if (start < selection.start) {
setNewSelection({ start: Math.max(0, start), end: selection.start });
break;
}
setNewSelection({ ...newSelection, end: start });
break;
}
case Mode.Dragging: {
const diff = x - moveOffsetX.current;
const width = selection.end - selection.start;
let start = Math.max(0, selection.start + diff);
let end = start + width;
if (end > evt.currentTarget.getBoundingClientRect().width) {
end = evt.currentTarget.getBoundingClientRect().width;
start = end - width;
}
setNewSelection({ start: start, end: end });
break;
}
case Mode.Selecting: {
if (x < moveOffsetX.current) {
setNewSelection({
start: x,
end: moveOffsetX.current,
});
} else {
setNewSelection({ start: moveOffsetX.current, end: x });
}
break;
}
}
};
const handleMouseUp = () => {
if (mode == Mode.Normal) {
return;
}
setMode(Mode.Normal);
setCursor('auto');
if (newSelection.start == newSelection.end) {
setSelection({ start: newSelection.start, end: newSelection.end + 5 });
return;
}
if (newSelection.start == newSelection.end) {
setSelection({ ...emptySelection });
return;
}
setSelection({ ...newSelection });
};
const handleMouseLeave = (_evt: MouseEvent<HTMLCanvasElement>) => {
setHoverState(HoverState.Normal);
};
// render component
const containerStyles = {
flexGrow: 0,
position: 'relative',
margin: `0 ${offsetPixels}px`,
height: `${height}px`,
} as React.CSSProperties;
const hudCanvasStyles = {
position: 'absolute',
width: '100%',
height: '100%',
display: 'block',
zIndex: 2,
cursor: cursor,
} as React.CSSProperties;
return (
<>
<div style={containerStyles}>
<WaveformCanvas
peaks={peaks}
channels={mediaSet.audioChannels}
width={CanvasLogicalWidth}
height={CanvasLogicalHeight}
strokeStyle="black"
fillStyle="#003300"
zIndex={1}
alpha={1}
></WaveformCanvas>
<canvas
ref={hudCanvasRef}
width={CanvasLogicalWidth}
height={CanvasLogicalHeight}
style={hudCanvasStyles}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
></canvas>
</div>
</>
);
};