Combine Player and VideoPreview components.
This makes sense semantically and also simplifies the component structure as it avoids leaking the Video reference outside of the component (at least for now).
This commit is contained in:
parent
f2d7d1f5bb
commit
26b51b8c93
|
@ -6,12 +6,11 @@ import {
|
||||||
} from './generated/media_set';
|
} from './generated/media_set';
|
||||||
|
|
||||||
import { useEffect, useCallback, useReducer } from 'react';
|
import { useEffect, useCallback, useReducer } from 'react';
|
||||||
import { State, stateReducer, zoomFactor } from './AppState';
|
import { State, stateReducer, zoomFactor, PlayState } from './AppState';
|
||||||
import { AudioFormat } from './generated/media_set';
|
import { AudioFormat } from './generated/media_set';
|
||||||
import { VideoPreview } from './VideoPreview';
|
|
||||||
import { WaveformCanvas } from './WaveformCanvas';
|
import { WaveformCanvas } from './WaveformCanvas';
|
||||||
import { HudCanvas } from './HudCanvas';
|
import { HudCanvas } from './HudCanvas';
|
||||||
import { Player, PlayState } from './Player';
|
import { Player } from './Player';
|
||||||
import {
|
import {
|
||||||
CanvasWidth,
|
CanvasWidth,
|
||||||
CanvasHeight,
|
CanvasHeight,
|
||||||
|
@ -21,7 +20,6 @@ import { ControlBar } from './ControlBar';
|
||||||
import { SeekBar } from './SeekBar';
|
import { SeekBar } from './SeekBar';
|
||||||
import { firstValueFrom, from, Observable } from 'rxjs';
|
import { firstValueFrom, from, Observable } from 'rxjs';
|
||||||
import { first, map, bufferCount } from 'rxjs/operators';
|
import { first, map, bufferCount } from 'rxjs/operators';
|
||||||
import millisFromDuration from './helpers/millisFromDuration';
|
|
||||||
import { canZoomViewportIn, canZoomViewportOut } from './helpers/zoom';
|
import { canZoomViewportIn, canZoomViewportOut } from './helpers/zoom';
|
||||||
import toHHMMSS from './helpers/toHHMMSS';
|
import toHHMMSS from './helpers/toHHMMSS';
|
||||||
import framesToDuration from './helpers/framesToDuration';
|
import framesToDuration from './helpers/framesToDuration';
|
||||||
|
@ -280,15 +278,6 @@ function App(): JSX.Element {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Player
|
|
||||||
playState={playState}
|
|
||||||
audioSrc={state.audioSrc}
|
|
||||||
videoSrc={state.videoSrc}
|
|
||||||
currentTime={state.currentTime}
|
|
||||||
onPositionChanged={(currentTime) =>
|
|
||||||
dispatch({ type: 'positionchanged', currentTime: currentTime })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div className="App bg-gray-800 h-screen flex flex-col">
|
<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]">
|
<header className="bg-green-900 h-16 grow-0 flex items-center mb-12 px-[88px]">
|
||||||
<h1 className="text-3xl font-bold">Clipper</h1>
|
<h1 className="text-3xl font-bold">Clipper</h1>
|
||||||
|
@ -403,11 +392,15 @@ function App(): JSX.Element {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<VideoPreview
|
<Player
|
||||||
mediaSet={mediaSet}
|
mediaSet={mediaSet}
|
||||||
video={document.createElement('video')}
|
playState={playState}
|
||||||
position={position}
|
audioSrc={state.audioSrc}
|
||||||
duration={millisFromDuration(mediaSet.videoDuration)}
|
videoSrc={state.videoSrc}
|
||||||
|
currentTime={state.currentTime}
|
||||||
|
onPositionChanged={(currentTime) =>
|
||||||
|
dispatch({ type: 'positionchanged', currentTime: currentTime })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { MediaSet } from './generated/media_set';
|
import { MediaSet } from './generated/media_set';
|
||||||
import { stateReducer, State } from './AppState';
|
import { stateReducer, State, PlayState } from './AppState';
|
||||||
import { from } from 'rxjs';
|
import { from } from 'rxjs';
|
||||||
import { PlayState } from './Player';
|
|
||||||
import { CanvasWidth, SelectionMode } from './HudCanvasState';
|
import { CanvasWidth, SelectionMode } from './HudCanvasState';
|
||||||
|
|
||||||
const initialState: State = {
|
const initialState: State = {
|
||||||
|
|
|
@ -2,7 +2,6 @@ import { MediaSet } from './generated/media_set';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { SelectionChangeEvent } from './HudCanvas';
|
import { SelectionChangeEvent } from './HudCanvas';
|
||||||
import { CanvasRange, SelectionMode, CanvasWidth } from './HudCanvasState';
|
import { CanvasRange, SelectionMode, CanvasWidth } from './HudCanvasState';
|
||||||
import { PlayState } from './Player';
|
|
||||||
import { zoomViewportIn, zoomViewportOut } from './helpers/zoom';
|
import { zoomViewportIn, zoomViewportOut } from './helpers/zoom';
|
||||||
import frameToWaveformCanvasX from './helpers/frameToWaveformCanvasX';
|
import frameToWaveformCanvasX from './helpers/frameToWaveformCanvasX';
|
||||||
|
|
||||||
|
@ -21,6 +20,11 @@ interface Position {
|
||||||
percent: number;
|
percent: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum PlayState {
|
||||||
|
Paused,
|
||||||
|
Playing,
|
||||||
|
}
|
||||||
|
|
||||||
export interface State {
|
export interface State {
|
||||||
mediaSet?: MediaSet;
|
mediaSet?: MediaSet;
|
||||||
selection: FrameRange;
|
selection: FrameRange;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { PlayState } from './Player';
|
import { PlayState } from './AppState';
|
||||||
import {
|
import {
|
||||||
CloudDownloadIcon,
|
CloudDownloadIcon,
|
||||||
FastForwardIcon,
|
FastForwardIcon,
|
||||||
|
|
|
@ -1,6 +1,11 @@
|
||||||
|
import { MediaSet, MediaSetServiceClientImpl } from './generated/media_set';
|
||||||
|
import { newRPC } from './App';
|
||||||
|
import { PlayState } from './AppState';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
import millisFromDuration from './helpers/millisFromDuration';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
mediaSet: MediaSet;
|
||||||
playState: PlayState;
|
playState: PlayState;
|
||||||
audioSrc: string;
|
audioSrc: string;
|
||||||
videoSrc: string;
|
videoSrc: string;
|
||||||
|
@ -9,20 +14,20 @@ interface Props {
|
||||||
onPositionChanged: (currentTime: number) => void;
|
onPositionChanged: (currentTime: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum PlayState {
|
|
||||||
Paused,
|
|
||||||
Playing,
|
|
||||||
}
|
|
||||||
|
|
||||||
const triggerCallbackIntervalMillis = 20;
|
const triggerCallbackIntervalMillis = 20;
|
||||||
|
|
||||||
|
// eslint is complaining about prop validation which doesn't make much sense.
|
||||||
|
/* eslint-disable react/prop-types */
|
||||||
export const Player: React.FC<Props> = ({
|
export const Player: React.FC<Props> = ({
|
||||||
|
mediaSet,
|
||||||
playState,
|
playState,
|
||||||
audioSrc,
|
audioSrc,
|
||||||
videoSrc,
|
videoSrc,
|
||||||
currentTime,
|
currentTime,
|
||||||
onPositionChanged,
|
onPositionChanged,
|
||||||
}) => {
|
}) => {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
const audioRef = useRef(new Audio());
|
const audioRef = useRef(new Audio());
|
||||||
const videoRef = useRef(document.createElement('video'));
|
const videoRef = useRef(document.createElement('video'));
|
||||||
|
|
||||||
|
@ -75,5 +80,76 @@ export const Player: React.FC<Props> = ({
|
||||||
onPositionChanged(currentTime);
|
onPositionChanged(currentTime);
|
||||||
}, [currentTime]);
|
}, [currentTime]);
|
||||||
|
|
||||||
return null;
|
// render canvas
|
||||||
|
useEffect(() => {
|
||||||
|
// TODO: not sure if requestAnimationFrame is recommended here.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
(async function () {
|
||||||
|
if (!mediaSet) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (canvas == null) {
|
||||||
|
console.error('no canvas ref available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (ctx == null) {
|
||||||
|
console.error('no 2d context available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set aspect ratio.
|
||||||
|
canvas.width =
|
||||||
|
canvas.height * (canvas.clientWidth / canvas.clientHeight);
|
||||||
|
|
||||||
|
// If the required position is 0, display the thumbnail instead of
|
||||||
|
// trying to render the video. The most important use case is before a
|
||||||
|
// click event has happened, when autoplay restrictions will prevent
|
||||||
|
// the video being rendered to canvas.
|
||||||
|
if (videoRef.current.currentTime == 0) {
|
||||||
|
const service = new MediaSetServiceClientImpl(newRPC());
|
||||||
|
const thumbnail = await service.GetVideoThumbnail({
|
||||||
|
id: mediaSet.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: avoid fetching the image every re-render:
|
||||||
|
const url = URL.createObjectURL(
|
||||||
|
new Blob([thumbnail.image], { type: 'image/jpeg' })
|
||||||
|
);
|
||||||
|
const img = new Image(thumbnail.width, thumbnail.height);
|
||||||
|
|
||||||
|
img.src = url;
|
||||||
|
img.onload = () => ctx.drawImage(img, 0, 0, 177, 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// otherwise, render the video, which (should) work now.
|
||||||
|
const duration = millisFromDuration(mediaSet.videoDuration);
|
||||||
|
const durSecs = duration / 1000;
|
||||||
|
const ratio = videoRef.current.currentTime / durSecs;
|
||||||
|
const x = (canvas.width - 177) * ratio;
|
||||||
|
ctx.clearRect(0, 0, x, canvas.height);
|
||||||
|
ctx.clearRect(x + 177, 0, canvas.width - 177 - x, canvas.height);
|
||||||
|
ctx.drawImage(videoRef.current, x, 0, 177, 100);
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}, [mediaSet, videoRef.current.currentTime]);
|
||||||
|
|
||||||
|
// render component
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={`relative grow-0 h-[100px]`}>
|
||||||
|
<canvas
|
||||||
|
className="absolute block w-full h-full"
|
||||||
|
width="500"
|
||||||
|
height="100"
|
||||||
|
ref={canvasRef}
|
||||||
|
></canvas>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,89 +0,0 @@
|
||||||
import { MediaSet, MediaSetServiceClientImpl } from './generated/media_set';
|
|
||||||
import { newRPC, VideoPosition } from './App';
|
|
||||||
import { useEffect, useRef } from 'react';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
mediaSet: MediaSet;
|
|
||||||
position: VideoPosition;
|
|
||||||
duration: number;
|
|
||||||
video: HTMLVideoElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const VideoPreview: React.FC<Props> = ({
|
|
||||||
mediaSet,
|
|
||||||
position,
|
|
||||||
duration,
|
|
||||||
video,
|
|
||||||
}: Props) => {
|
|
||||||
const videoCanvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
|
|
||||||
// effects
|
|
||||||
|
|
||||||
// render canvas
|
|
||||||
useEffect(() => {
|
|
||||||
// TODO: not sure if requestAnimationFrame is recommended here.
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
(async function () {
|
|
||||||
const canvas = videoCanvasRef.current;
|
|
||||||
if (canvas == null) {
|
|
||||||
console.error('no canvas ref available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (ctx == null) {
|
|
||||||
console.error('no 2d context available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set aspect ratio.
|
|
||||||
canvas.width =
|
|
||||||
canvas.height * (canvas.clientWidth / canvas.clientHeight);
|
|
||||||
|
|
||||||
// If the required position is 0, display the thumbnail instead of
|
|
||||||
// trying to render the video. The most important use case is before a
|
|
||||||
// click event has happened, when autoplay restrictions will prevent
|
|
||||||
// the video being rendered to canvas.
|
|
||||||
if (position.currentTime == 0) {
|
|
||||||
const service = new MediaSetServiceClientImpl(newRPC());
|
|
||||||
const thumbnail = await service.GetVideoThumbnail({
|
|
||||||
id: mediaSet.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: avoid fetching the image every re-render:
|
|
||||||
const url = URL.createObjectURL(
|
|
||||||
new Blob([thumbnail.image], { type: 'image/jpeg' })
|
|
||||||
);
|
|
||||||
const img = new Image(thumbnail.width, thumbnail.height);
|
|
||||||
|
|
||||||
img.src = url;
|
|
||||||
img.onload = () => ctx.drawImage(img, 0, 0, 177, 100);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// otherwise, render the video, which (should) work now.
|
|
||||||
const durSecs = duration / 1000;
|
|
||||||
const ratio = position.currentTime / durSecs;
|
|
||||||
const x = (canvas.width - 177) * ratio;
|
|
||||||
ctx.clearRect(0, 0, x, canvas.height);
|
|
||||||
ctx.clearRect(x + 177, 0, canvas.width - 177 - x, canvas.height);
|
|
||||||
ctx.drawImage(video, x, 0, 177, 100);
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
}, [mediaSet, position.currentTime]);
|
|
||||||
|
|
||||||
// render component
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={`relative grow-0 h-[100px]`}>
|
|
||||||
<canvas
|
|
||||||
className="absolute block w-full h-full"
|
|
||||||
width="500"
|
|
||||||
height="100"
|
|
||||||
ref={videoCanvasRef}
|
|
||||||
></canvas>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
Loading…
Reference in New Issue