Update frontend

- Add HudCanvas component to Waveform
- Allow waveform to be selectable
- Fix selection rendering on viewport change
- Add spacebar handler
This commit is contained in:
Rob Watson 2021-12-11 17:25:43 +01:00
parent 65cc365717
commit b876fb915a
5 changed files with 241 additions and 112 deletions

View File

@ -25,7 +25,7 @@ const initialViewportCanvasPixels = 100;
const apiURL = process.env.REACT_APP_API_URL || 'http://localhost:8888'; const apiURL = process.env.REACT_APP_API_URL || 'http://localhost:8888';
// Frames represents a selection of audio frames. // Frames represents a range of audio frames.
export interface Frames { export interface Frames {
start: number; start: number;
end: number; end: number;
@ -41,7 +41,8 @@ const audio = document.createElement('audio');
function App(): JSX.Element { function App(): JSX.Element {
const [mediaSet, setMediaSet] = useState<MediaSet | null>(null); const [mediaSet, setMediaSet] = useState<MediaSet | null>(null);
const [viewport, setViewport] = useState({ start: 0, end: 0 }); const [viewport, setViewport] = useState<Frames>({ start: 0, end: 0 });
const [selection, setSelection] = useState<Frames>({ start: 0, end: 0 });
const [overviewPeaks, setOverviewPeaks] = useState<Observable<number[]>>( const [overviewPeaks, setOverviewPeaks] = useState<Observable<number[]>>(
from([]) from([])
); );
@ -81,17 +82,35 @@ function App(): JSX.Element {
} }
const intervalID = setInterval(() => { const intervalID = setInterval(() => {
if (video.currentTime == positionRef.current.currentTime) { const currTime = audio.currentTime;
if (currTime == positionRef.current.currentTime) {
return; return;
} }
const duration = mediaSet.audioFrames / mediaSet.audioSampleRate; const duration = mediaSet.audioFrames / mediaSet.audioSampleRate;
const percent = (video.currentTime / duration) * 100; const percent = (currTime / duration) * 100;
setPosition({ currentTime: video.currentTime, percent: percent }); // check if the end of selection has been passed, and pause if so:
if (
currentTimeToFrame(position.currentTime) < selection.end &&
currentTimeToFrame(currTime) >= selection.end
) {
handlePause();
}
// update the current position
setPosition({ currentTime: audio.currentTime, percent: percent });
}, updatePlayerPositionIntevalMillis); }, updatePlayerPositionIntevalMillis);
return () => clearInterval(intervalID); return () => clearInterval(intervalID);
}, [mediaSet]); }, [mediaSet, selection]);
// bind to keypress handler.
// selection is a dependency of the handleKeyPress handler, and must be
// included here.
useEffect(() => {
document.addEventListener('keypress', handleKeyPress);
return () => document.removeEventListener('keypress', handleKeyPress);
}, [selection]);
// load audio when MediaSet is loaded: // load audio when MediaSet is loaded:
useEffect(() => { useEffect(() => {
@ -161,23 +180,57 @@ function App(): JSX.Element {
// handlers // handlers
const handleOverviewSelectionChange = (newViewport: Frames) => { const handleKeyPress = useCallback(
if (mediaSet == null) { (evt: KeyboardEvent) => {
return; if (evt.code != 'Space') {
} return;
console.log('set new viewport', newViewport); }
setViewport({ ...newViewport });
if (!audio.paused) { if (audio.paused) {
return; handlePlay();
} } else {
handlePause();
}
},
[selection]
);
const ratio = newViewport.start / mediaSet.audioFrames; // handler called when the selection in the overview (zoom setting) is changed.
const currentTime = const handleOverviewSelectionChange = useCallback(
(mediaSet.audioFrames / mediaSet.audioSampleRate) * ratio; (newViewport: Frames) => {
audio.currentTime = currentTime; if (mediaSet == null) {
video.currentTime = currentTime; return;
}; }
console.log('set new viewport', newViewport);
setViewport({ ...newViewport });
if (!audio.paused) {
return;
}
setPositionFromFrame(newViewport.start);
},
[mediaSet, audio, video]
);
// handler called when the selection in the main waveform view is changed.
const handleWaveformSelectionChange = useCallback(
(selection: Frames) => {
setSelection(selection);
if (mediaSet == null) {
return;
}
// move playback position to start of selection
const ratio = selection.start / mediaSet.audioFrames;
const currentTime =
(mediaSet.audioFrames / mediaSet.audioSampleRate) * ratio;
audio.currentTime = currentTime;
video.currentTime = currentTime;
},
[mediaSet, audio, video]
);
const handlePlay = useCallback(() => { const handlePlay = useCallback(() => {
audio.play(); audio.play();
@ -187,7 +240,39 @@ function App(): JSX.Element {
const handlePause = useCallback(() => { const handlePause = useCallback(() => {
video.pause(); video.pause();
audio.pause(); audio.pause();
}, [audio, video]);
if (selection.start != selection.end) {
setPositionFromFrame(selection.start);
}
}, [audio, video, selection]);
const setPositionFromFrame = useCallback(
(frame: number) => {
if (mediaSet == null) {
return;
}
const ratio = frame / mediaSet.audioFrames;
const currentTime =
(mediaSet.audioFrames / mediaSet.audioSampleRate) * ratio;
audio.currentTime = currentTime;
video.currentTime = currentTime;
},
[mediaSet, audio, video]
);
// helpers
const currentTimeToFrame = useCallback(
(currentTime: number): number => {
if (mediaSet == null) {
return 0;
}
const dur = mediaSet.audioFrames / mediaSet.audioSampleRate;
const ratio = currentTime / dur;
return Math.round(mediaSet.audioFrames * ratio);
},
[mediaSet]
);
// render component // render component
@ -229,6 +314,7 @@ function App(): JSX.Element {
position={position} position={position}
viewport={viewport} viewport={viewport}
offsetPixels={offsetPixels} offsetPixels={offsetPixels}
onSelectionChange={handleWaveformSelectionChange}
/> />
<SeekBar <SeekBar

View File

@ -1,23 +1,10 @@
import { MouseEvent } from 'react'; import { MouseEvent } from 'react';
import { Frames } from './App';
// TODO: pass CanvasLogicalWidth as an argument instead.
import { CanvasLogicalWidth } from './Waveform';
interface Point { interface Point {
x: number; x: number;
y: number; y: number;
} }
// TODO: add tests
export const mouseEventToCanvasX = (
evt: MouseEvent<HTMLCanvasElement>
): number => {
const rect = evt.currentTarget.getBoundingClientRect();
const elementX = evt.clientX - rect.left;
return (elementX * CanvasLogicalWidth) / rect.width;
};
// TODO: add tests // TODO: add tests
export const mouseEventToCanvasPoint = ( export const mouseEventToCanvasPoint = (
evt: MouseEvent<HTMLCanvasElement> evt: MouseEvent<HTMLCanvasElement>
@ -31,26 +18,3 @@ export const mouseEventToCanvasPoint = (
y: (elementY * evt.currentTarget.height) / rect.height, y: (elementY * evt.currentTarget.height) / rect.height,
}; };
}; };
// TODO: add tests
export const canvasXToFrame = (x: number, numFrames: number): number => {
return Math.floor((x / CanvasLogicalWidth) * numFrames);
};
// TODO: add tests
// secsToCanvasX returns the logical x coordinate for a given position
// marker. It is null if the marker is outside of the current viewport.
export const secsToCanvasX = (
secs: number,
sampleRate: number,
viewport: Frames
): number | null => {
const frame = Math.round(secs * sampleRate);
if (frame < viewport.start || frame > viewport.end) {
return null;
}
const logicalPixelsPerFrame =
CanvasLogicalWidth / (viewport.end - viewport.start);
return (frame - viewport.start) * logicalPixelsPerFrame;
};

View File

@ -1,11 +1,18 @@
import { useState, useEffect, useRef, MouseEvent } from 'react'; import { useState, useEffect, useRef, MouseEvent } from 'react';
import { VideoPosition } from './App';
interface Styles {
borderLineWidth: number;
borderStrokeStyle: string;
positionLineWidth: number;
positionStrokeStyle: string;
}
interface Props { interface Props {
width: number; width: number;
height: number; height: number;
zIndex: number; zIndex: number;
position: VideoPosition; styles: Styles;
position: number | null;
selection: Selection; selection: Selection;
onSelectionChange: (selection: Selection) => void; onSelectionChange: (selection: Selection) => void;
} }
@ -36,6 +43,12 @@ export const HudCanvas: React.FC<Props> = ({
width, width,
height, height,
zIndex, zIndex,
styles: {
borderLineWidth,
borderStrokeStyle,
positionLineWidth,
positionStrokeStyle,
},
position, position,
selection, selection,
onSelectionChange, onSelectionChange,
@ -91,26 +104,31 @@ export const HudCanvas: React.FC<Props> = ({
} }
ctx.beginPath(); ctx.beginPath();
ctx.strokeStyle = 'red'; ctx.strokeStyle = borderStrokeStyle;
ctx.lineWidth = 4; ctx.lineWidth = borderLineWidth;
const alpha = hoverState == HoverState.OverSelection ? '0.15' : '0.13'; const alpha = hoverState == HoverState.OverSelection ? '0.15' : '0.13';
ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`; ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`;
ctx.rect( ctx.rect(
currentSelection.start, currentSelection.start,
2, borderLineWidth,
currentSelection.end - currentSelection.start, currentSelection.end - currentSelection.start,
canvas.height - 10 canvas.height - borderLineWidth * 2
); );
ctx.fill(); ctx.fill();
ctx.stroke(); ctx.stroke();
// draw position marker // draw position marker
const markerX = canvas.width * (position.percent / 100); if (position == null) {
return;
}
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(markerX, 0); ctx.strokeStyle = positionStrokeStyle;
ctx.lineWidth = positionLineWidth;
ctx.moveTo(position, 0);
ctx.lineWidth = 4; ctx.lineWidth = 4;
ctx.lineTo(markerX, canvas.height - 4); ctx.lineTo(position, canvas.height - 4);
ctx.stroke(); ctx.stroke();
}); });
}, [selection, newSelection, position]); }, [selection, newSelection, position]);
@ -202,19 +220,19 @@ export const HudCanvas: React.FC<Props> = ({
break; break;
} }
setNewSelection({ ...newSelection, start: start }); setNewSelection({ ...selection, start: start });
break; break;
} }
case Mode.ResizingEnd: { case Mode.ResizingEnd: {
const diff = x - moveOffsetX.current; const diff = x - moveOffsetX.current;
const start = constrainXToCanvas(selection.end + diff); const end = constrainXToCanvas(selection.end + diff);
if (start < selection.start) { if (end < selection.start) {
setNewSelection({ start: Math.max(0, start), end: selection.start }); setNewSelection({ start: Math.max(0, end), end: selection.start });
break; break;
} }
setNewSelection({ ...newSelection, end: start }); setNewSelection({ ...selection, end: end });
break; break;
} }
case Mode.Dragging: { case Mode.Dragging: {

View File

@ -33,6 +33,7 @@ export const Overview: React.FC<Props> = ({
onSelectionChange, onSelectionChange,
}: Props) => { }: Props) => {
const [selectedPixels, setSelectedPixels] = useState({ start: 0, end: 0 }); const [selectedPixels, setSelectedPixels] = useState({ start: 0, end: 0 });
const [positionPixels, setPositionPixels] = useState(0);
// side effects // side effects
@ -48,6 +49,14 @@ export const Overview: React.FC<Props> = ({
}); });
}, [viewport, mediaSet]); }, [viewport, mediaSet]);
// convert position from frames to canvas pixels:
useEffect(() => {
const ratio =
position.currentTime / (mediaSet.audioFrames / mediaSet.audioSampleRate);
setPositionPixels(Math.round(ratio * CanvasLogicalWidth));
frames;
}, [mediaSet, position]);
// handlers // handlers
// convert selection change from canvas pixels to frames, and trigger callback. // convert selection change from canvas pixels to frames, and trigger callback.
@ -70,6 +79,13 @@ export const Overview: React.FC<Props> = ({
height: `${height}px`, height: `${height}px`,
} as React.CSSProperties; } as React.CSSProperties;
const hudStyles = {
borderLineWidth: 4,
borderStrokeStyle: 'red',
positionLineWidth: 4,
positionStrokeStyle: 'red',
};
return ( return (
<> <>
<div style={containerStyles}> <div style={containerStyles}>
@ -87,7 +103,8 @@ export const Overview: React.FC<Props> = ({
width={CanvasLogicalWidth} width={CanvasLogicalWidth}
height={CanvasLogicalHeight} height={CanvasLogicalHeight}
zIndex={1} zIndex={1}
position={position} styles={hudStyles}
position={positionPixels}
selection={selectedPixels} selection={selectedPixels}
onSelectionChange={handleSelectionChange} onSelectionChange={handleSelectionChange}
/> />

View File

@ -1,8 +1,8 @@
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { Frames, VideoPosition, newRPC } from './App'; import { Frames, VideoPosition, newRPC } from './App';
import { MediaSetServiceClientImpl, MediaSet } from './generated/media_set'; import { MediaSetServiceClientImpl, MediaSet } from './generated/media_set';
import { WaveformCanvas } from './WaveformCanvas'; import { WaveformCanvas } from './WaveformCanvas';
import { secsToCanvasX } from './Helpers'; import { Selection, HudCanvas } from './HudCanvas';
import { from, Observable } from 'rxjs'; import { from, Observable } from 'rxjs';
import { bufferCount } from 'rxjs/operators'; import { bufferCount } from 'rxjs/operators';
@ -11,6 +11,7 @@ interface Props {
position: VideoPosition; position: VideoPosition;
viewport: Frames; viewport: Frames;
offsetPixels: number; offsetPixels: number;
onSelectionChange: (selection: Selection) => void;
} }
export const CanvasLogicalWidth = 2000; export const CanvasLogicalWidth = 2000;
@ -21,9 +22,15 @@ export const Waveform: React.FC<Props> = ({
position, position,
viewport, viewport,
offsetPixels, offsetPixels,
onSelectionChange,
}: Props) => { }: Props) => {
const [peaks, setPeaks] = useState<Observable<number[]>>(from([])); const [peaks, setPeaks] = useState<Observable<number[]>>(from([]));
const hudCanvasRef = useRef<HTMLCanvasElement>(null); const [selectedFrames, setSelectedFrames] = useState({ start: 0, end: 0 });
const [selectedPixels, setSelectedPixels] = useState({
start: 0,
end: 0,
});
const [positionPixels, setPositionPixels] = useState<number | null>(0);
// effects // effects
@ -57,41 +64,75 @@ export const Waveform: React.FC<Props> = ({
})(); })();
}, [viewport]); }, [viewport]);
// render HUD // convert position to canvas pixels
useEffect(() => { useEffect(() => {
const canvas = hudCanvasRef.current; const frame = Math.round(position.currentTime * mediaSet.audioSampleRate);
if (canvas == null) { if (frame < viewport.start || frame > viewport.end) {
setPositionPixels(null);
return; return;
} }
const logicalPixelsPerFrame =
CanvasLogicalWidth / (viewport.end - viewport.start);
const positionPixels = (frame - viewport.start) * logicalPixelsPerFrame;
setPositionPixels(positionPixels);
}, [mediaSet, position, viewport]);
const ctx = canvas.getContext('2d'); // update selectedPixels on viewport change
if (ctx == null) { useEffect(() => {
console.error('no hud 2d context available'); const start = frameToCanvasX(selectedFrames.start);
return; const end = frameToCanvasX(selectedFrames.end);
// more verbose than it has to be to make TypeScript happy
if (start == null && end == null) {
setSelectedPixels({ start: 0, end: 0 });
} else if (start == null && end != null) {
setSelectedPixels({ start: 0, end: end });
} else if (start != null && end == null) {
setSelectedPixels({ start: 0, end: CanvasLogicalWidth });
} else if (start != null && end != null) {
setSelectedPixels({ start, end });
} else {
console.error('unreachable');
} }
}, [viewport]);
ctx.clearRect(0, 0, canvas.width, canvas.height); // handlers
if (mediaSet == null) { const handleSelectionChange = useCallback(
return; (selection: Selection) => {
} setSelectedPixels(selection);
const x = secsToCanvasX( const framesPerPixel =
position.currentTime, (viewport.end - viewport.start) / CanvasLogicalWidth;
mediaSet.audioSampleRate, const selectedFrames = {
viewport start: Math.round(viewport.start + selection.start * framesPerPixel),
); end: Math.round(viewport.start + selection.end * framesPerPixel),
if (x == null) { };
return;
}
ctx.strokeStyle = 'red'; setSelectedFrames(selectedFrames);
ctx.beginPath(); onSelectionChange(selectedFrames);
ctx.moveTo(x, 0); },
ctx.lineWidth = 4; [viewport]
ctx.lineTo(x, canvas.height); );
ctx.stroke();
}, [mediaSet, position]); // helpers
const frameToCanvasX = useCallback(
(frame: number): number | null => {
if (mediaSet == null) {
return null;
}
if (frame < viewport.start || frame > viewport.end) {
return null;
}
const pixelsPerFrame =
CanvasLogicalWidth / (viewport.end - viewport.start);
return (frame - viewport.start) * pixelsPerFrame;
},
[mediaSet, viewport]
);
// render component // render component
@ -102,12 +143,12 @@ export const Waveform: React.FC<Props> = ({
position: 'relative', position: 'relative',
} as React.CSSProperties; } as React.CSSProperties;
const canvasStyles = { const hudStyles = {
position: 'absolute', borderLineWidth: 0,
width: '100%', borderStrokeStyle: 'transparent',
height: '100%', positionLineWidth: 6,
display: 'block', positionStrokeStyle: 'red',
} as React.CSSProperties; };
return ( return (
<> <>
@ -122,12 +163,15 @@ export const Waveform: React.FC<Props> = ({
zIndex={0} zIndex={0}
alpha={1} alpha={1}
></WaveformCanvas> ></WaveformCanvas>
<canvas <HudCanvas
width={CanvasLogicalWidth} width={CanvasLogicalWidth}
height={CanvasLogicalHeight} height={CanvasLogicalHeight}
ref={hudCanvasRef} zIndex={1}
style={canvasStyles} styles={hudStyles}
></canvas> position={positionPixels}
selection={selectedPixels}
onSelectionChange={handleSelectionChange}
/>
</div> </div>
</> </>
); );