Browse Source

Implement seeking for video previews

Add implementations for video controls.
pull/1/head
TheoryOfNekomata 1 year ago
parent
commit
a812aac511
8 changed files with 292 additions and 71 deletions
  1. +8
    -3
      packages/web/kitchen-sink/react-next/src/components/AudioFilePreview/index.tsx
  2. +1
    -1
      packages/web/kitchen-sink/react-next/src/components/AudioMiniFilePreview/index.tsx
  3. +19
    -6
      packages/web/kitchen-sink/react-next/src/components/ImageFilePreview/index.tsx
  4. +238
    -57
      packages/web/kitchen-sink/react-next/src/components/VideoFilePreview/index.tsx
  5. +6
    -1
      packages/web/kitchen-sink/react-next/src/pages/categories/blob/index.tsx
  6. +6
    -1
      packages/web/kitchen-sink/react-next/src/utils/audio.ts
  7. +6
    -1
      packages/web/kitchen-sink/react-next/src/utils/blob.ts
  8. +8
    -1
      packages/web/kitchen-sink/react-next/src/utils/numeral.ts

+ 8
- 3
packages/web/kitchen-sink/react-next/src/components/AudioFilePreview/index.tsx View File

@@ -1,7 +1,7 @@
import * as React from 'react';
import WaveSurfer from 'wavesurfer.js';
import {AudioFile, getMimeTypeDescription} from '../../utils/blob';
import {formatFileSize, formatNumeral, formatSecondsDuration} from '../../utils/numeral';
import {formatFileSize, formatNumeral, formatSecondsDurationPrecise} from '../../utils/numeral';

export interface VideoFilePreviewProps {
file: AudioFile;
@@ -41,6 +41,11 @@ export const AudioFilePreview: React.FC<VideoFilePreviewProps> = ({
}

mediaContainerRef.current.innerHTML = '';

if (f.type === 'audio/mid') {
return;
}

const mediaControllerRefMutable = mediaControllerRef as React.MutableRefObject<WaveSurfer>;
mediaControllerRefMutable.current = WaveSurfer.create({
container: mediaContainerRef.current,
@@ -128,9 +133,9 @@ export const AudioFilePreview: React.FC<VideoFilePreviewProps> = ({
</dt>
<dd
className="m-0 w-full text-ellipsis overflow-hidden"
title={`${formatNumeral(f.metadata.duration)} seconds`}
title={`${formatNumeral(f.metadata.duration ?? 0)} seconds`}
>
{formatSecondsDuration(f.metadata.duration)}
{formatSecondsDurationPrecise(f.metadata.duration)}
</dd>
</div>
)


+ 1
- 1
packages/web/kitchen-sink/react-next/src/components/AudioMiniFilePreview/index.tsx View File

@@ -1,7 +1,7 @@
import * as React from 'react';
import WaveSurfer from 'wavesurfer.js';
import {AudioFile, getMimeTypeDescription} from '../../utils/blob';
import {formatFileSize, formatNumeral, formatSecondsDuration} from '../../utils/numeral';
import {formatFileSize, formatNumeral, formatSecondsDurationPrecise} from '../../utils/numeral';

export interface VideoFilePreviewProps {
file: AudioFile;


+ 19
- 6
packages/web/kitchen-sink/react-next/src/components/ImageFilePreview/index.tsx View File

@@ -9,18 +9,31 @@ export interface ImageFilePreviewProps {
export const ImageFilePreview: React.FC<ImageFilePreviewProps> = ({
file: f,
}) => {
const [fullScreen, setFullScreen] = React.useState(false);

const toggleFullScreen: React.MouseEventHandler<HTMLButtonElement> = (e) => {
e.preventDefault();
setFullScreen((b) => !b);
};

return (
<div className="flex gap-4 w-full h-full relative">
<div className={`h-full w-1/3 flex-shrink-0`}>
{
typeof f.metadata?.previewUrl === 'string'
&& (
<img
className="block w-full h-full object-center object-cover"
src={f.metadata.previewUrl}
alt={f.name}
data-testid="preview"
/>
<button
type="button"
className={`block p-0 border-0 bg-black w-full h-full ${fullScreen ? 'fixed top-0 left-0 z-50' : ''}`.trim()}
onClick={toggleFullScreen}
>
<img
className={`inline-block align-top max-h-full max-w-full object-center ${fullScreen ? 'object-contain' : 'object-cover w-full'}`}
src={f.metadata.previewUrl}
alt={f.name}
data-testid="preview"
/>
</button>
)
}
</div>


+ 238
- 57
packages/web/kitchen-sink/react-next/src/components/VideoFilePreview/index.tsx View File

@@ -1,109 +1,256 @@
import * as React from 'react';
import {getMimeTypeDescription, VideoFile} from '../../utils/blob';
import {formatFileSize, formatNumeral} from '../../utils/numeral';
import {formatFileSize, formatNumeral, formatSecondsDurationConcise} from '../../utils/numeral';

export interface VideoFilePreviewProps {
file: VideoFile;
}

export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePreviewProps>(({
file: f,
}, forwardedRef) => {
const defaultRef = React.useRef<HTMLVideoElement>(null);
const mediaControllerRef = forwardedRef ?? defaultRef;
interface UseVideoControlsOptions {
mediaControllerRef: React.Ref<HTMLVideoElement>;
}

const useVideoControls = ({
mediaControllerRef,
}: UseVideoControlsOptions) => {
const seekRef = React.useRef<HTMLInputElement>(null);
const volumeRef = React.useRef<HTMLInputElement>(null);
const [isPlaying, setIsPlaying] = React.useState(false);
const [isSeeking, setIsSeeking] = React.useState(false);
const [currentTimeDisplay, setCurrentTimeDisplay] = React.useState<number>();
const [seekTimeDisplay, setSeekTimeDisplay] = React.useState<number>();
const [durationDisplay, setDurationDisplay] = React.useState<number>();
const [isSeekTimeCountingDown, setIsSeekTimeCountingDown] = React.useState(false);

React.useEffect(() => {
if (typeof mediaControllerRef !== 'object') {
const refreshControls: React.ReactEventHandler<HTMLVideoElement> = (e) => {
const { currentTarget: mediaController } = e;
const { current: seek } = seekRef;
if (!seek) {
return;
}

if (!mediaControllerRef.current) {
setCurrentTimeDisplay(mediaController.currentTime);
setDurationDisplay(mediaController.duration);
};

const playMedia = React.useCallback(() => {
setIsPlaying((p) => !p);
}, []);

const startSeek: React.MouseEventHandler<HTMLInputElement> = React.useCallback(() => {
setIsSeeking(true);
}, []);

const doSetSeek = (thisElement: HTMLInputElement, mediaController: HTMLVideoElement) => {
mediaController.currentTime = thisElement.valueAsNumber;
};

const setSeek: React.ChangeEventHandler<HTMLInputElement> = React.useCallback((e) => {
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) {
return;
}

if (isPlaying) {
void mediaControllerRef.current.play();
return
const { current: mediaController } = mediaControllerRef;
if (!mediaController) {
return;
}

mediaControllerRef.current.pause();
}, [isPlaying, mediaControllerRef]);
const { currentTarget: thisElement } = e;
setSeekTimeDisplay(thisElement.valueAsNumber);

React.useEffect(() => {
if (typeof mediaControllerRef !== 'object') {
if (isSeeking) {
return;
}

if (!mediaControllerRef.current) {
doSetSeek(thisElement, mediaController);
}, [mediaControllerRef, isSeeking]);

const endSeek: React.MouseEventHandler<HTMLInputElement> = React.useCallback((e) => {
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) {
return;
}

const { current: mediaController } = mediaControllerRef;

if (!mediaController) {
return;
}

const { currentTarget: thisElement } = e;
setIsSeeking(false);
doSetSeek(thisElement, mediaController);
}, [mediaControllerRef]);

const resetVideo: React.ReactEventHandler<HTMLVideoElement> = React.useCallback((e) => {
const videoElement = e.currentTarget;
setIsPlaying(false);
videoElement.currentTime = 0;
}, []);

const updateSeekFromPlayback: React.ReactEventHandler<HTMLVideoElement> = React.useCallback((e) => {
if (!seekRef.current) {
return;
}

const { current: mediaController } = mediaControllerRef;
if (isSeeking) {
return;
}

const { current: seek } = seekRef;
seek.max = String(mediaController.duration);
seek.value = String(mediaController.currentTime);
}, [f, mediaControllerRef]);

React.useEffect(() => {
if (!volumeRef.current) {
if (!seek) {
return;
}

if (typeof mediaControllerRef !== 'object') {
const videoElement = e.currentTarget;
const currentTime = videoElement.currentTime;
setCurrentTimeDisplay(currentTime);
seek.value = String(currentTime);
}, [isSeeking]);

const adjustVolume: React.ChangeEventHandler<HTMLInputElement> = React.useCallback((e) => {
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) {
return;
}

if (!mediaControllerRef.current) {
return;
}
const { value } = e.currentTarget;
mediaControllerRef.current.volume = Number(value);
}, [mediaControllerRef]);

const { current: mediaController } = mediaControllerRef;
const { current: volume } = volumeRef;
volume.value = String(mediaController.volume);
}, [f, mediaControllerRef]);
const toggleSeekTimeCountMode: React.MouseEventHandler<HTMLButtonElement> = React.useCallback((e) => {
e.preventDefault();
setIsSeekTimeCountingDown((b) => !b);
}, []);

const playMedia = () => {
setIsPlaying((p) => !p);
};
React.useEffect(() => {
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) {
return;
}

const seekMedia = () => {
if (!mediaControllerRef.current) {
return;
}

};
if (isPlaying) {
void mediaControllerRef.current.play();
return
}

const resetVideo: React.ReactEventHandler<HTMLVideoElement> = (e) => {
const videoElement = e.currentTarget;
setIsPlaying(false);
videoElement.currentTime = 0;
};
mediaControllerRef.current.pause();
}, [isPlaying, mediaControllerRef]);

const updateSeekFromPlayback: React.ReactEventHandler<HTMLVideoElement> = (e) => {
React.useEffect(() => {
if (!seekRef.current) {
return;
}

const { current: seek } = seekRef;
const videoElement = e.currentTarget;
seek.value = String(videoElement.currentTime);
};
if (!seek) {
return;
}

seek.value = String(currentTimeDisplay);
}, [currentTimeDisplay]);

React.useEffect(() => {
if (!seekRef.current) {
return;
}

const { current: seek } = seekRef;
if (!seek) {
return;
}

seek.max = String(durationDisplay);
}, [durationDisplay]);

React.useEffect(() => {
if (!volumeRef.current) {
return;
}

const adjustVolume: React.ChangeEventHandler<HTMLInputElement> = (e) => {
if (typeof mediaControllerRef !== 'object') {
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) {
return;
}

if (!mediaControllerRef.current) {
return;
}
const { value } = e.currentTarget;
mediaControllerRef.current.volume = Number(value);
};

const { current: mediaController } = mediaControllerRef;
const { current: volume } = volumeRef;
volume.value = String(mediaController.volume);
}, [mediaControllerRef]);

return React.useMemo(() => ({
seekRef,
volumeRef,
isPlaying,
refreshControls,
adjustVolume,
resetVideo,
startSeek,
endSeek,
setSeek,
updateSeekFromPlayback,
playMedia,
durationDisplay,
currentTimeDisplay,
seekTimeDisplay,
isSeeking,
isSeekTimeCountingDown,
toggleSeekTimeCountMode,
}), [
isPlaying,
isSeeking,
adjustVolume,
playMedia,
resetVideo,
startSeek,
endSeek,
setSeek,
updateSeekFromPlayback,
durationDisplay,
currentTimeDisplay,
seekTimeDisplay,
isSeekTimeCountingDown,
toggleSeekTimeCountMode,
]);
};

export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePreviewProps>(({
file: f,
}, forwardedRef) => {
const defaultRef = React.useRef<HTMLVideoElement>(null);
const mediaControllerRef = forwardedRef ?? defaultRef;
const {
seekRef,
volumeRef,
isPlaying,
adjustVolume,
playMedia,
resetVideo,
startSeek,
endSeek,
setSeek,
updateSeekFromPlayback,
refreshControls,
durationDisplay = 0,
currentTimeDisplay = 0,
seekTimeDisplay = 0,
isSeeking,
isSeekTimeCountingDown,
toggleSeekTimeCountMode,
} = useVideoControls({
mediaControllerRef,
});

const finalSeekTimeDisplay = isSeekTimeCountingDown ? (durationDisplay - seekTimeDisplay) : seekTimeDisplay;
const finalCurrentTimeDisplay = isSeekTimeCountingDown ? (durationDisplay - currentTimeDisplay) : currentTimeDisplay;

return (
<div className="flex gap-4 w-full h-full relative">
@@ -117,8 +264,10 @@ export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePrev
>
<div className="w-full flex-auto relative">
<video
className="absolute w-full h-full top-0 left-0 block w-full h-full object-center object-contain flex-auto"
className="absolute w-full h-full top-0 left-0 block object-center object-contain flex-auto"
ref={mediaControllerRef as React.RefObject<HTMLVideoElement>}
onLoadedMetadata={refreshControls}
onDurationChange={refreshControls}
onEnded={resetVideo}
onTimeUpdate={updateSeekFromPlayback}
>
@@ -128,20 +277,50 @@ export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePrev
/>
</video>
</div>
<div className="w-full flex-shrink-0 h-10 flex">
<div className="w-full flex-shrink-0 h-10 flex gap-4">
<button
onClick={playMedia}
className="w-10 h-full"
className="w-10 h-full flex-shrink-0"
type="button"
>
{isPlaying ? '⏸' : '▶'}
</button>
<input
type="range"
className="flex-auto"
ref={seekRef}
onChange={seekMedia}
defaultValue="0"
/>
<div className="flex-auto w-full flex items-center gap-1 font-mono text-sm relative">
<button
className="absolute overflow-hidden w-12 opacity-0 h-10"
onClick={toggleSeekTimeCountMode}
title="Toggle Seek Time Count Mode"
type="button"
>
Toggle Seek Time Count Mode
</button>
<span
className="font-mono before:block before:content-[attr(title)] contents"
title={
`${
isSeekTimeCountingDown ? '-' : '+'
}${
isSeeking
? formatSecondsDurationConcise(finalSeekTimeDisplay)
: formatSecondsDurationConcise(finalCurrentTimeDisplay)
}`
}
>
<input
type="range"
className="flex-auto w-full"
ref={seekRef}
onMouseDown={startSeek}
onMouseUp={endSeek}
onChange={setSeek}
defaultValue="0"

/>
</span>
<span>
{formatSecondsDurationConcise(durationDisplay)}
</span>
</div>
<input
type="range"
ref={volumeRef}
@@ -149,7 +328,9 @@ export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePrev
min={0}
onChange={adjustVolume}
step="any"
className="flex-shrink-0 w-12"
defaultValue="1"
title="Volume"
/>
</div>
</div>


+ 6
- 1
packages/web/kitchen-sink/react-next/src/pages/categories/blob/index.tsx View File

@@ -166,6 +166,7 @@ export const FileSelectBox = React.forwardRef<HTMLInputElement, FileButtonProps>
) => {
const [renderEnhanced, setRenderEnhanced] = React.useState(false);
const [fileList, setFileList] = React.useState<FileList>();
const [lastSelectedFileAt, setLastSelectedFileAt] = React.useState<number>();
const defaultRef = React.useRef<HTMLInputElement>(null);
const ref = forwardedRef ?? defaultRef;

@@ -176,6 +177,7 @@ export const FileSelectBox = React.forwardRef<HTMLInputElement, FileButtonProps>
}

setFileList(e.currentTarget.files as FileList);
setLastSelectedFileAt(Date.now());
onChange?.(e);
};

@@ -283,7 +285,10 @@ export const FileSelectBox = React.forwardRef<HTMLInputElement, FileButtonProps>
&& (
<>
<div className={`absolute top-0 left-0 w-full h-full pointer-events-none pb-12 box-border overflow-hidden pt-8`}>
<div className={`pointer-events-auto w-full h-full px-4 pb-4 box-border`}>
<div
className={`pointer-events-auto w-full h-full px-4 pb-4 box-border`}
key={lastSelectedFileAt}
>
{
multiple
&& (


+ 6
- 1
packages/web/kitchen-sink/react-next/src/utils/audio.ts View File

@@ -1,6 +1,11 @@
import WaveSurfer from 'wavesurfer.js';

export const getAudioMetadata = (audioUrl: string) => new Promise<Record<string, string | number>>(async (resolve, reject) => {
export const getAudioMetadata = (audioUrl: string, fileType: string) => new Promise<Record<string, string | number>>(async (resolve, reject) => {
if (fileType === 'audio/mid') {
resolve({});
return;
}

try {
const dummyContainer = window.document.createElement('div');
const waveSurferInstance = WaveSurfer.create({


+ 6
- 1
packages/web/kitchen-sink/react-next/src/utils/blob.ts View File

@@ -5,6 +5,7 @@ import {getImageMetadata} from './image';
import {getAudioMetadata} from './audio';

const MIME_TYPE_DESCRIPTIONS = {
'image/gif': 'GIF Image',
'image/jpeg': 'JPEG Image',
'image/png': 'PNG Image',
'image/tiff': 'TIFF Image',
@@ -12,6 +13,7 @@ const MIME_TYPE_DESCRIPTIONS = {
'audio/wav': 'WAVE Audio',
'audio/ogg': 'OGG Audio',
'audio/mpeg': 'MPEG Audio',
'audio/mid': 'MIDI Track',
'application/json': 'JSON Data',
'application/xml': 'XML Data',
'application/x-bittorrent': 'Torrent File',
@@ -23,6 +25,9 @@ const MIME_TYPE_DESCRIPTIONS = {
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'Slideshow Presentation',
'application/msword': 'Microsoft Word Document',
'application/pdf': 'PDF Document',
'application/postscript': 'PostScript Document',
'application/epub+zip': 'EPUB Document',
'message/rfc822': 'Email Message',
} as const;

const EXTENSION_DESCRIPTIONS = {
@@ -200,7 +205,7 @@ export interface AudioFile extends FileWithResolvedType<ContentType.AUDIO> {

const augmentAudioFile = async (f: File): Promise<AudioFile> => {
const previewUrl = await readAsDataURL(f);
const audioExtensions = await getAudioMetadata(previewUrl) as AudioFileMetadata;
const audioExtensions = await getAudioMetadata(previewUrl, f.type) as AudioFileMetadata;
return {
name: f.name,
type: f.type,


+ 8
- 1
packages/web/kitchen-sink/react-next/src/utils/numeral.ts View File

@@ -36,7 +36,7 @@ export const formatFileSize = (size?: number) => {
}
};

export const formatSecondsDuration = (seconds: number) => {
export const formatSecondsDurationPrecise = (seconds: number) => {
const secondsInt = Math.floor(seconds);
const secondsFrac = seconds - secondsInt;
const hh = Math.floor(secondsInt / 3600).toString().padStart(2, '0');
@@ -45,3 +45,10 @@ export const formatSecondsDuration = (seconds: number) => {
const sss = Math.floor(secondsFrac * 1000).toString().padStart(3, '0');
return `${hh}:${mm}:${ss}.${sss}`;
};

export const formatSecondsDurationConcise = (seconds: number) => {
const secondsInt = Math.floor(seconds);
const mm = Math.floor(secondsInt / 60).toString().padStart(2, '0');
const ss = (secondsInt % 60).toString().padStart(2, '0');
return `${mm}:${ss}`;
};

Loading…
Cancel
Save