Update frontend with Tailwind.

- Replace inline CSS with Tailwind classes
- Improve page layout and scaling
- Add icons to ControlBar
- Small refactor of play/pause logic
- Add basic (not by any means final) colours
This commit is contained in:
Rob Watson 2022-01-14 12:54:54 +01:00
parent ec3ac8996d
commit a33057651d
12 changed files with 138 additions and 176 deletions

View File

@ -3,6 +3,7 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@heroicons/react": "^1.0.5",
"@improbable-eng/grpc-web": "^0.14.1",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",

View File

@ -1,6 +1,6 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"short_name": "Clipper",
"name": "Clipper",
"icons": [
{
"src": "favicon.ico",
@ -20,6 +20,7 @@
],
"start_url": ".",
"display": "standalone",
"orientation": "landscape",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@ -1,7 +0,0 @@
body {
background-color: #333;
}
.App {
text-align: center;
}

View File

@ -13,15 +13,15 @@ import { Overview, CanvasLogicalWidth } from './Overview';
import { Waveform } from './Waveform';
import { ControlBar } from './ControlBar';
import { SeekBar } from './SeekBar';
import './App.css';
import { firstValueFrom, from, Observable } from 'rxjs';
import { first, map } from 'rxjs/operators';
import millisFromDuration from './helpers/millisFromDuration';
import { zoomViewportIn, zoomViewportOut } from './helpers/zoom';
import { ExternalLinkIcon } from '@heroicons/react/solid';
// ported from backend, where should they live?
const thumbnailWidth = 177;
const thumbnailHeight = 100;
const thumbnailWidth = 177; // height 100
const initialViewportCanvasPixels = 100;
@ -40,6 +40,11 @@ export interface VideoPosition {
percent: number;
}
export enum PlayState {
Paused,
Playing,
}
const video = document.createElement('video');
const audio = document.createElement('audio');
@ -50,6 +55,7 @@ function App(): JSX.Element {
const [overviewPeaks, setOverviewPeaks] = useState<Observable<number[]>>(
from([])
);
const [playState, setPlayState] = useState(PlayState.Paused);
// position stores the current playback position. positionRef makes it
// available inside a setInterval callback.
@ -98,7 +104,7 @@ function App(): JSX.Element {
currentTimeToFrame(position.currentTime) < selection.end &&
currentTimeToFrame(currTime) >= selection.end
) {
handlePause();
pause();
}
// update the current position
@ -114,7 +120,7 @@ function App(): JSX.Element {
useEffect(() => {
document.addEventListener('keypress', handleKeyPress);
return () => document.removeEventListener('keypress', handleKeyPress);
}, [selection]);
}, [playState]);
// load audio when MediaSet is loaded:
useEffect(() => {
@ -189,11 +195,7 @@ function App(): JSX.Element {
return;
}
if (audio.paused) {
handlePlay();
} else {
handlePause();
}
handleTogglePlay();
};
// handler called when the selection in the overview (zoom setting) is changed.
@ -227,18 +229,30 @@ function App(): JSX.Element {
video.currentTime = currentTime;
};
const handlePlay = () => {
audio.play();
video.play();
const handleTogglePlay = () => {
if (playState == PlayState.Paused) {
play();
} else {
pause();
}
};
const handlePause = () => {
const play = () => {
audio.play();
video.play();
setPlayState(PlayState.Playing);
};
const pause = () => {
video.pause();
audio.pause();
if (selection.start != selection.end) {
setPositionFromFrame(selection.start);
}
setPlayState(PlayState.Paused);
};
const handleClip = () => {
@ -370,17 +384,8 @@ function App(): JSX.Element {
// render component
const containerStyles = {
border: '1px solid black',
width: '90%',
margin: '1em auto',
minHeight: '500px',
height: '700px',
display: 'flex',
flexDirection: 'column',
} as React.CSSProperties;
const offsetPixels = Math.floor(thumbnailWidth / 2);
const marginClass = 'mx-[88px]'; // offsetPixels
if (mediaSet == null) {
// TODO: improve
@ -389,33 +394,48 @@ function App(): JSX.Element {
return (
<>
<div className="App">
<div style={containerStyles}>
<ControlBar
onPlay={handlePlay}
onPause={handlePause}
onClip={handleClip}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
/>
<div className="App bg-gray-800 h-screen flex flex-col">
<header className="bg-green-900 h-16 grow-0 flex items-center mb-12 px-[88px]">
<h1 className="text-3xl font-bold">Clipper</h1>
</header>
<div className="flex flex-col grow-1 bg-gray-800 w-full h-full mx-auto">
<div className={`flex flex-col grow ${marginClass}`}>
<div className="flex grow-0 h-8 pt-4 pb-2 items-center space-x-2 text-white">
<span className="text-gray-300">{mediaSet.author}</span>
<span>/</span>
<span>{mediaSet.title}</span>
<a
href={`https://www.youtube.com/watch?v=${mediaSet.youtubeId}`}
target="_blank"
rel="noreferrer"
title="Open in YouTube"
>
<ExternalLinkIcon className="h-6 w-6 text-gray-500 hover:text-gray-200" />
</a>
</div>
<ControlBar
playState={playState}
onTogglePlay={handleTogglePlay}
onClip={handleClip}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
/>
<Overview
peaks={overviewPeaks}
mediaSet={mediaSet}
offsetPixels={offsetPixels}
height={80}
viewport={viewport}
position={position}
onSelectionChange={handleOverviewSelectionChange}
/>
<Overview
peaks={overviewPeaks}
mediaSet={mediaSet}
viewport={viewport}
position={position}
onSelectionChange={handleOverviewSelectionChange}
/>
<Waveform
mediaSet={mediaSet}
position={position}
viewport={viewport}
offsetPixels={offsetPixels}
onSelectionChange={handleWaveformSelectionChange}
/>
<Waveform
mediaSet={mediaSet}
position={position}
viewport={viewport}
onSelectionChange={handleWaveformSelectionChange}
/>
</div>
<SeekBar
position={video.currentTime}
@ -432,10 +452,9 @@ function App(): JSX.Element {
video={video}
position={position}
duration={millisFromDuration(mediaSet.videoDuration)}
height={thumbnailHeight}
/>
</div>
<ul style={{ listStyleType: 'none' } as React.CSSProperties}>
<ul className="hidden">
<li>Frames: {mediaSet.audioFrames}</li>
<li>
Viewport (frames): {viewport.start} to {viewport.end}

View File

@ -1,42 +1,60 @@
import React from 'react';
import { PlayState } from './App';
import {
CloudDownloadIcon,
FastForwardIcon,
PauseIcon,
PlayIcon,
RewindIcon,
ZoomInIcon,
ZoomOutIcon,
} from '@heroicons/react/solid';
interface Props {
onPlay: () => void;
onPause: () => void;
playState: PlayState;
onTogglePlay: () => void;
onClip: () => void;
onZoomIn: () => void;
onZoomOut: () => void;
}
const ControlBar: React.FC<Props> = React.memo((props: Props) => {
const styles = { width: '100%', flexGrow: 0 };
const buttonStyles = {
cursor: 'pointer',
background: 'black',
outline: 'none',
border: 'none',
color: 'green',
display: 'inline-block',
margin: '0 2px',
};
const buttonStyle =
'bg-gray-700 hover:bg-gray-600 text-white font-bold py-2 px-4 rounded';
const largeButtonStyle =
'bg-green-700 hover:bg-green-600 text-white font-bold py-2 px-4 rounded absolute right-0';
const iconStyle = 'inline h-6 w-6 text-white-500';
const playPauseComponent =
props.playState == PlayState.Playing ? (
<PauseIcon className={iconStyle} />
) : (
<PlayIcon className={iconStyle} />
);
return (
<>
<div style={styles}>
<button style={buttonStyles} onClick={props.onPlay}>
Play
<div className="relative grow-0 w-full py-2 space-x-2">
<button className={buttonStyle}>
<RewindIcon className={iconStyle} />
</button>
<button style={buttonStyles} onClick={props.onPause}>
Pause
<button className={buttonStyle} onClick={props.onTogglePlay}>
{playPauseComponent}
</button>
<button style={buttonStyles} onClick={props.onClip}>
Clip
<button className={buttonStyle}>
<FastForwardIcon className={iconStyle} />
</button>
<button style={buttonStyles} onClick={props.onZoomIn}>
Zoom In
<button className={buttonStyle} onClick={props.onZoomIn}>
<ZoomInIcon className={iconStyle} />
</button>
<button style={buttonStyles} onClick={props.onZoomOut}>
Zoom Out
<button className={buttonStyle} onClick={props.onZoomOut}>
<ZoomOutIcon className={iconStyle} />
</button>
<button className={largeButtonStyle} onClick={props.onClip}>
<CloudDownloadIcon className={`${iconStyle} mr-2`} />
Download clip
</button>
</div>
</>

View File

@ -10,7 +10,6 @@ interface Styles {
interface Props {
width: number;
height: number;
zIndex: number;
emptySelectionAction: EmptySelectionAction;
styles: Styles;
position: number | null;
@ -48,7 +47,6 @@ const emptySelection: Selection = { start: 0, end: 0 };
export const HudCanvas: React.FC<Props> = ({
width,
height,
zIndex,
emptySelectionAction,
styles: {
borderLineWidth,
@ -66,7 +64,7 @@ export const HudCanvas: React.FC<Props> = ({
});
const [mode, setMode] = useState(Mode.Normal);
const [hoverState, setHoverState] = useState(HoverState.Normal);
const [cursor, setCursor] = useState('auto');
const [cursor, setCursor] = useState('cursor-auto');
const canvasRef = useRef<HTMLCanvasElement>(null);
const moveOffsetX = useRef(0);
@ -189,11 +187,11 @@ export const HudCanvas: React.FC<Props> = ({
moveOffsetX.current = x;
} else if (isHoveringSelection(x)) {
setMode(Mode.Dragging);
setCursor('pointer');
setCursor('cursor-pointer');
moveOffsetX.current = x;
} else {
setMode(Mode.Selecting);
setCursor('col-resize');
setCursor('cursor-col-resize');
moveOffsetX.current = x;
setNewSelection({ start: x, end: x });
}
@ -206,15 +204,15 @@ export const HudCanvas: React.FC<Props> = ({
case Mode.Normal: {
if (isHoveringSelectionStart(x)) {
setHoverState(HoverState.OverSelectionStart);
setCursor('col-resize');
setCursor('cursor-col-resize');
} else if (isHoveringSelectionEnd(x)) {
setHoverState(HoverState.OverSelectionEnd);
setCursor('col-resize');
setCursor('cursor-col-resize');
} else if (isHoveringSelection(x)) {
setHoverState(HoverState.OverSelection);
setCursor('pointer');
setCursor('cursor-pointer');
} else {
setCursor('auto');
setCursor('cursor-auto');
}
break;
}
@ -275,7 +273,7 @@ export const HudCanvas: React.FC<Props> = ({
}
setMode(Mode.Normal);
setCursor('auto');
setCursor('cursor-auto');
if (newSelection.start == newSelection.end) {
handleEmptySelectionAction();
@ -300,22 +298,13 @@ export const HudCanvas: React.FC<Props> = ({
setHoverState(HoverState.Normal);
};
const canvasStyles = {
display: 'block',
position: 'absolute',
width: '100%',
height: '100%',
zIndex: zIndex,
cursor: cursor,
} as React.CSSProperties;
return (
<>
<canvas
ref={canvasRef}
className={`block absolute w-full h-full ${cursor} z-20`}
width={width}
height={height}
style={canvasStyles}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}

View File

@ -13,8 +13,6 @@ export interface Selection {
interface Props {
peaks: Observable<number[]>;
mediaSet: MediaSet;
height: number;
offsetPixels: number;
position: VideoPosition;
viewport: Frames;
onSelectionChange: (selection: Selection) => void;
@ -26,8 +24,6 @@ export const CanvasLogicalHeight = 500;
export const Overview: React.FC<Props> = ({
peaks,
mediaSet,
height,
offsetPixels,
position,
viewport,
onSelectionChange,
@ -69,13 +65,6 @@ export const Overview: React.FC<Props> = ({
// render component
const containerStyles = {
flexGrow: 0,
position: 'relative',
margin: `0 ${offsetPixels}px`,
height: `${height}px`,
} as React.CSSProperties;
const hudStyles = {
borderLineWidth: 4,
borderStrokeStyle: 'red',
@ -85,7 +74,7 @@ export const Overview: React.FC<Props> = ({
return (
<>
<div style={containerStyles}>
<div className={`relative grow-0 h-[80px]`}>
<WaveformCanvas
peaks={peaks}
channels={mediaSet.audioChannels}
@ -93,13 +82,11 @@ export const Overview: React.FC<Props> = ({
height={CanvasLogicalHeight}
strokeStyle="black"
fillStyle="#003300"
zIndex={1}
alpha={1}
></WaveformCanvas>
<HudCanvas
width={CanvasLogicalWidth}
height={CanvasLogicalHeight}
zIndex={1}
emptySelectionAction={EmptySelectionAction.SelectPrevious}
styles={hudStyles}
position={positionPixels}

View File

@ -24,7 +24,7 @@ export const SeekBar: React.FC<Props> = ({
onPositionChanged,
}: Props) => {
const [mode, setMode] = useState(Mode.Normal);
const [cursor, setCursor] = useState('auto');
const [cursor, setCursor] = useState('cursor-auto');
const canvasRef = useRef<HTMLCanvasElement>(null);
// render canvas
@ -98,9 +98,9 @@ export const SeekBar: React.FC<Props> = ({
// TODO: improve mouse detection around knob.
if (y > InnerMargin && y < LogicalHeight - InnerMargin) {
setCursor('pointer');
setCursor('cursor-pointer');
} else {
setCursor('auto');
setCursor('cursor-auto');
}
if (mode == Mode.Normal) return;
@ -116,17 +116,10 @@ export const SeekBar: React.FC<Props> = ({
// render component
const styles = {
width: '100%',
height: '30px',
margin: '0 auto',
cursor: cursor,
};
return (
<>
<canvas
style={styles}
className={`w-full h-[30px] mx-0 my-auto ${cursor}`}
ref={canvasRef}
width={LogicalWidth}
height={LogicalHeight}

View File

@ -6,7 +6,6 @@ interface Props {
mediaSet: MediaSet;
position: VideoPosition;
duration: number;
height: number;
video: HTMLVideoElement;
}
@ -14,7 +13,6 @@ export const VideoPreview: React.FC<Props> = ({
mediaSet,
position,
duration,
height,
video,
}: Props) => {
const videoCanvasRef = useRef<HTMLCanvasElement>(null);
@ -76,30 +74,15 @@ export const VideoPreview: React.FC<Props> = ({
// render component
const containerStyles = {
height: height + 'px',
position: 'relative',
flexGrow: 0,
} as React.CSSProperties;
const canvasStyles = {
position: 'absolute',
width: '100%',
height: '100%',
display: 'block',
zIndex: 1,
} as React.CSSProperties;
return (
<>
<div style={containerStyles}>
<div className={`relative grow-0 h-[100px]`}>
<canvas
className="absolute block w-full h-full"
width="500"
height="100"
ref={videoCanvasRef}
style={canvasStyles}
></canvas>
<canvas style={canvasStyles}></canvas>
</div>
</>
);

View File

@ -10,7 +10,6 @@ interface Props {
mediaSet: MediaSet;
position: VideoPosition;
viewport: Frames;
offsetPixels: number;
onSelectionChange: (selection: Selection) => void;
}
@ -21,7 +20,6 @@ export const Waveform: React.FC<Props> = ({
mediaSet,
position,
viewport,
offsetPixels,
onSelectionChange,
}: Props) => {
const [peaks, setPeaks] = useState<Observable<number[]>>(from([]));
@ -115,13 +113,6 @@ export const Waveform: React.FC<Props> = ({
// render component
const containerStyles = {
background: 'black',
margin: '0 ' + offsetPixels + 'px',
flexGrow: 1,
position: 'relative',
} as React.CSSProperties;
const hudStyles = {
borderLineWidth: 0,
borderStrokeStyle: 'transparent',
@ -131,7 +122,7 @@ export const Waveform: React.FC<Props> = ({
return (
<>
<div style={containerStyles}>
<div className={`relative grow`}>
<WaveformCanvas
peaks={peaks}
channels={mediaSet.audioChannels}
@ -139,13 +130,11 @@ export const Waveform: React.FC<Props> = ({
height={CanvasLogicalHeight}
strokeStyle="green"
fillStyle="black"
zIndex={0}
alpha={1}
></WaveformCanvas>
<HudCanvas
width={CanvasLogicalWidth}
height={CanvasLogicalHeight}
zIndex={1}
emptySelectionAction={EmptySelectionAction.SelectNothing}
styles={hudStyles}
position={positionPixels}

View File

@ -10,18 +10,10 @@ interface Props {
channels: number;
strokeStyle: string;
fillStyle: string;
zIndex: number;
alpha: number;
}
// Canvas is a generic component that renders a waveform to a canvas.
//
// Properties:
//
// peaks: a 2d array of uint16s representing the peak values. Each inner array length should match logicalWidth
// strokeStyle: waveform style
// fillStyle: background style
// style: React.CSSProperties applied to canvas element
const WaveformCanvas: React.FC<Props> = React.memo((props: Props) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
@ -71,21 +63,13 @@ const WaveformCanvas: React.FC<Props> = React.memo((props: Props) => {
})();
}, [props.peaks]);
const canvasStyles = {
display: 'block',
position: 'absolute',
width: '100%',
height: '100%',
zIndex: props.zIndex,
} as React.CSSProperties;
return (
<>
<canvas
ref={canvasRef}
className={`block absolute w-full h-full z-10`}
width={props.width}
height={props.height}
style={canvasStyles}
></canvas>
</>
);

View File

@ -1102,6 +1102,11 @@
minimatch "^3.0.4"
strip-json-comments "^3.1.1"
"@heroicons/react@^1.0.5":
version "1.0.5"
resolved "https://registry.yarnpkg.com/@heroicons/react/-/react-1.0.5.tgz#2fe4df9d33eb6ce6d5178a0f862e97b61c01e27d"
integrity sha512-UDMyLM2KavIu2vlWfMspapw9yii7aoLwzI2Hudx4fyoPwfKfxU8r3cL8dEBXOjcLG0/oOONZzbT14M1HoNtEcg==
"@humanwhocodes/config-array@^0.5.0":
version "0.5.0"
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.5.0.tgz#1407967d4c6eecd7388f83acf1eaf4d0c6e58ef9"