Browse Source

Implement video file preview

Implement video file preview interactive controls.
pull/1/head
TheoryOfNekomata 1 year ago
parent
commit
50bb3b62e7
12 changed files with 393 additions and 1914 deletions
  1. BIN
      packages/web-kitchensink-reactnext/public/image.png
  2. +16
    -82
      packages/web-kitchensink-reactnext/src/categories/blob/react/components/ImageFilePreview/index.tsx
  3. +183
    -116
      packages/web-kitchensink-reactnext/src/categories/blob/react/components/VideoFilePreview/index.tsx
  4. +1
    -0
      packages/web-kitchensink-reactnext/src/categories/blob/react/hooks/blob/index.ts
  5. +31
    -0
      packages/web-kitchensink-reactnext/src/categories/blob/react/hooks/blob/metadata.ts
  6. +56
    -0
      packages/web-kitchensink-reactnext/src/categories/blob/react/hooks/media/image.ts
  7. +1
    -0
      packages/web-kitchensink-reactnext/src/categories/blob/react/hooks/media/index.ts
  8. +49
    -11
      packages/web-kitchensink-reactnext/src/categories/blob/react/hooks/media/video.ts
  9. +3
    -2
      packages/web-kitchensink-reactnext/src/categories/blob/react/index.ts
  10. +26
    -1702
      packages/web-kitchensink-reactnext/src/pages/categories/blob/index.tsx
  11. +5
    -1
      packages/web-kitchensink-reactnext/src/utils/blob.ts
  12. +22
    -0
      packages/web-kitchensink-reactnext/src/utils/video.ts

BIN
packages/web-kitchensink-reactnext/public/image.png View File

Before After
Width: 75  |  Height: 106  |  Size: 20 KiB Width: 2480  |  Height: 3508  |  Size: 16 MiB

+ 16
- 82
packages/web-kitchensink-reactnext/src/categories/blob/react/components/ImageFilePreview/index.tsx View File

@@ -2,6 +2,7 @@ import * as React from 'react';
import {augmentImageFile, getMimeTypeDescription, ImageFile} from '@/utils/blob'; import {augmentImageFile, getMimeTypeDescription, ImageFile} from '@/utils/blob';
import {formatFileSize, formatNumeral} from '@/utils/numeral'; import {formatFileSize, formatNumeral} from '@/utils/numeral';
import clsx from 'clsx'; import clsx from 'clsx';
import {useAugmentedFile, useImageControls} from '@/categories/blob/react';


type ImageFilePreviewDerivedComponent = HTMLImageElement; type ImageFilePreviewDerivedComponent = HTMLImageElement;


@@ -10,90 +11,19 @@ export interface ImageFilePreviewProps extends Omit<React.HTMLProps<ImageFilePre
disabled?: boolean; disabled?: boolean;
} }


const useImageFilePreview = (file?: File) => {
const [augmentedFile, setAugmentedFile] = React.useState<ImageFile>();
const [error, setError] = React.useState<Error>();

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

augmentImageFile(file)
.then((theAugmentedFile) => {
setAugmentedFile(theAugmentedFile);
})
.catch((error) => {
setError(error);
});
}, [file]);

return React.useMemo(() => ({
augmentedFile: (augmentedFile ?? file) as ImageFile | undefined,
error,
}), [augmentedFile, file, error]);
};

interface UseImageControlsOptions {
file?: ImageFile;
actionFormKey?: string;
}

const useImageControls = (options = {} as UseImageControlsOptions) => {
const { actionFormKey = 'action' as const, file } = options;
const [fullScreen, setFullScreen] = React.useState(false);

const toggleFullScreen = React.useCallback(() => {
setFullScreen((b) => !b);
}, []);

const download = React.useCallback(() => {
if (!file) {
return;
}

if (!file.metadata?.previewUrl) {
return;
}

const downloadLink = window.document.createElement('a');
downloadLink.download = file.name ?? 'file';
downloadLink.href = file.metadata.previewUrl;
downloadLink.addEventListener('click', () => {
downloadLink.remove();
});
downloadLink.click();
}, [file]);

const actions = React.useMemo(() => ({
toggleFullScreen,
download,
}), [toggleFullScreen, download]);

const handleAction: React.FormEventHandler<HTMLFormElement> = React.useCallback((e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget, (e.nativeEvent as unknown as { submitter: HTMLElement }).submitter);
const actionName = formData.get(actionFormKey) as keyof typeof actions;
const { [actionName]: actionFunction } = actions;
actionFunction?.();
}, [actions, actionFormKey]);

return React.useMemo(() => ({
fullScreen,
handleAction,
}), [fullScreen, handleAction]);
};

export const ImageFilePreview: React.FC<ImageFilePreviewProps> = ({
export const ImageFilePreview = React.forwardRef<ImageFilePreviewDerivedComponent, ImageFilePreviewProps>(({
file, file,
className, className,
style, style,
disabled = false, disabled = false,
...etcProps ...etcProps
}) => {
const { augmentedFile, error } = useImageFilePreview(file);
const { fullScreen, handleAction } = useImageControls({
file: augmentedFile,
}, forwardedRef) => {
const { augmentedFile, error } = useAugmentedFile<ImageFile>({
file,
augmentFunction: augmentImageFile,
});
const { fullScreen, handleAction, imageRef, filenameRef } = useImageControls({
forwardedRef,
}); });


if (!augmentedFile) { if (!augmentedFile) {
@@ -109,12 +39,13 @@ export const ImageFilePreview: React.FC<ImageFilePreviewProps> = ({
style={style} style={style}
> >
<div className="h-full relative"> <div className="h-full relative">
<div className="sm:absolute top-0 left-0 w-full sm:h-full">
<div className="sm:absolute top-0 left-0 w-full sm:h-full z-[1]">
{ {
typeof augmentedFile.metadata?.previewUrl === 'string' typeof augmentedFile.metadata?.previewUrl === 'string'
&& ( && (
<img <img
{...etcProps} {...etcProps}
ref={imageRef}
className={clsx( className={clsx(
'block h-full max-w-full object-center bg-[#000000]', 'block h-full max-w-full object-center bg-[#000000]',
{ {
@@ -149,6 +80,7 @@ export const ImageFilePreview: React.FC<ImageFilePreviewProps> = ({
<dd <dd
className="m-0 w-full text-ellipsis overflow-hidden" className="m-0 w-full text-ellipsis overflow-hidden"
title={augmentedFile.name} title={augmentedFile.name}
ref={filenameRef}
> >
{augmentedFile.name} {augmentedFile.name}
</dd> </dd>
@@ -212,7 +144,7 @@ export const ImageFilePreview: React.FC<ImageFilePreviewProps> = ({
'focus:outline-0', 'focus:outline-0',
'disabled:opacity-50 disabled:cursor-not-allowed', 'disabled:opacity-50 disabled:cursor-not-allowed',
{ {
'fixed top-0 left-0 w-full h-full opacity-0': fullScreen,
'fixed top-0 left-0 w-full h-full opacity-0 z-[1]': fullScreen,
} }
)} )}
> >
@@ -244,4 +176,6 @@ export const ImageFilePreview: React.FC<ImageFilePreviewProps> = ({
</div> </div>
</div> </div>
); );
}
});

ImageFilePreview.displayName = 'ImageFilePreview';

+ 183
- 116
packages/web-kitchensink-reactnext/src/categories/blob/react/components/VideoFilePreview/index.tsx View File

@@ -1,23 +1,34 @@
import * as React from 'react'; import * as React from 'react';
import {getMimeTypeDescription, VideoFile} from '@/utils/blob';
import {augmentVideoFile, getMimeTypeDescription, VideoFile} from '@/utils/blob';
import {formatFileSize, formatNumeral, formatSecondsDurationConcise} from '@/utils/numeral'; import {formatFileSize, formatNumeral, formatSecondsDurationConcise} from '@/utils/numeral';
import {useVideoControls} from '@tesseract-design/web-blob-react';
import {useAugmentedFile, useVideoControls} from '@tesseract-design/web-blob-react';
import clsx from 'clsx';


export interface VideoFilePreviewProps {
file: VideoFile;
type VideoFilePreviewDerivedComponent = HTMLVideoElement;

export interface VideoFilePreviewProps extends Omit<React.HTMLProps<VideoFilePreviewDerivedComponent>, 'controls'> {
file?: File;
disabled?: boolean;
enhanced?: boolean;
} }


export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePreviewProps>(({ export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePreviewProps>(({
file: f,
file,
className,
style,
disabled = false,
enhanced = false,
...etcProps
}, forwardedRef) => { }, forwardedRef) => {
const defaultRef = React.useRef<HTMLVideoElement>(null);
const mediaControllerRef = forwardedRef ?? defaultRef;
const { augmentedFile, error } = useAugmentedFile<VideoFile>({
file,
augmentFunction: augmentVideoFile,
});
const { const {
seekRef, seekRef,
volumeRef, volumeRef,
isPlaying, isPlaying,
adjustVolume, adjustVolume,
playMedia,
resetVideo, resetVideo,
startSeek, startSeek,
endSeek, endSeek,
@@ -29,19 +40,32 @@ export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePrev
seekTimeDisplay = 0, seekTimeDisplay = 0,
isSeeking, isSeeking,
isSeekTimeCountingDown, isSeekTimeCountingDown,
toggleSeekTimeCountMode,
} = useVideoControls({
mediaControllerRef, mediaControllerRef,
handleAction,
filenameRef,
} = useVideoControls({
mediaControllerRef: forwardedRef,
}); });
const formId = React.useId();

if (!augmentedFile) {
return null;
}


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


return ( return (
<div className="flex gap-4 w-full h-full">
<div className={`h-full w-1/3 flex-shrink-0`}>
<div
className={clsx(
'flex flex-col sm:grid sm:grid-cols-3 gap-8 w-full',
className,
)}
style={style}
>
<div className="h-full relative col-span-2">
{ {
typeof f.metadata?.previewUrl === 'string'
typeof augmentedFile.metadata?.previewUrl === 'string'
&& ( && (
<div <div
className="w-full h-full bg-black flex flex-col items-stretch" className="w-full h-full bg-black flex flex-col items-stretch"
@@ -49,130 +73,173 @@ export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePrev
> >
<div className="w-full flex-auto relative"> <div className="w-full flex-auto relative">
<video <video
className="absolute w-full h-full top-0 left-0 block object-center object-contain flex-auto"
ref={mediaControllerRef as React.RefObject<HTMLVideoElement>}
{...etcProps}
className="sm:absolute w-full sm:h-full top-0 left-0 block object-center object-contain flex-auto aspect-video sm:aspect-auto bg-[#000000]"
ref={mediaControllerRef}
onLoadedMetadata={refreshControls} onLoadedMetadata={refreshControls}
onDurationChange={refreshControls} onDurationChange={refreshControls}
onEnded={resetVideo} onEnded={resetVideo}
onTimeUpdate={updateSeekFromPlayback} onTimeUpdate={updateSeekFromPlayback}
data-testid="preview"
controls={!enhanced}
> >
<source <source
src={f.metadata.previewUrl}
type={f.type}
src={augmentedFile.metadata.previewUrl}
type={augmentedFile.type}
/> />
</video> </video>
</div> </div>
<div className="w-full flex-shrink-0 h-10 flex gap-4">
<button
onClick={playMedia}
className="w-10 h-full flex-shrink-0"
type="button"
>
{isPlaying ? '⏸' : '▶'}
</button>
<div className="flex-auto w-full flex items-center gap-1 font-mono text-sm relative">
{enhanced && (
<div className="w-full flex-shrink-0 h-10 flex gap-4">
<button <button
className="absolute overflow-hidden w-12 opacity-0 h-10"
onClick={toggleSeekTimeCountMode}
title="Toggle Seek Time Count Mode"
type="button"
className="w-10 h-full flex-shrink-0 text-primary"
type="submit"
name="action"
value="togglePlayback"
form={formId}
> >
Toggle Seek Time Count Mode
{isPlaying ? '⏸' : '▶'}
</button> </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>
<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"
title="Toggle Seek Time Count Mode"
type="submit"
name="action"
value="toggleSeekTimeCountMode"
form={formId}
>
Toggle Seek Time Count Mode
</button>
<span
className="font-mono before:block before:content-[attr(title)] contents tabular-nums"
title={
`${
isSeekTimeCountingDown ? '−' : '+'
}${
isSeeking
? formatSecondsDurationConcise(finalSeekTimeDisplay)
: formatSecondsDurationConcise(finalCurrentTimeDisplay)
}`
}
>
<input
type="range"
className="flex-auto w-full tabular-nums"
ref={seekRef}
onMouseDown={startSeek}
onMouseUp={endSeek}
onChange={setSeek}
defaultValue="0"
/>
</span>
<span>
{formatSecondsDurationConcise(durationDisplay)} {formatSecondsDurationConcise(durationDisplay)}
</span> </span>
</div>
<input
type="range"
ref={volumeRef}
max={1}
min={0}
onChange={adjustVolume}
step="any"
className="flex-shrink-0 w-12"
defaultValue="1"
title="Volume"
/>
</div> </div>
<input
type="range"
ref={volumeRef}
max={1}
min={0}
onChange={adjustVolume}
step="any"
className="flex-shrink-0 w-12"
defaultValue="1"
title="Volume"
/>
</div>
)}
</div> </div>
) )
} }
</div> </div>
<dl className="w-2/3 flex-shrink-0 m-0" data-testid="infoBox">
<div className="w-full">
<dt className="sr-only">
Name
</dt>
<dd
className="m-0 w-full text-ellipsis overflow-hidden"
title={f.name}
>
{f.name}
</dd>
</div>
<div className="w-full">
<dt className="sr-only">
Type
</dt>
<dd
title={f.type}
className="m-0 w-full text-ellipsis overflow-hidden"
>
{getMimeTypeDescription(f.type, f.name)}
</dd>
</div>
<div className="w-full">
<dt className="sr-only">
Size
</dt>
<dd
className="m-0 w-full text-ellipsis overflow-hidden"
title={`${formatNumeral(f.size ?? 0)} bytes`}
<div
className="flex-shrink-0 m-0 flex flex-col gap-4 justify-between"
>
<dl data-testid="infoBox">
<div className="w-full font-bold">
<dt className="sr-only">
Name
</dt>
<dd
className="m-0 w-full text-ellipsis overflow-hidden"
title={augmentedFile.name}
ref={filenameRef}
>
{augmentedFile.name}
</dd>
</div>
<div className="w-full">
<dt className="sr-only">
Type
</dt>
<dd
title={augmentedFile.type}
className="m-0 w-full text-ellipsis overflow-hidden"
>
{getMimeTypeDescription(augmentedFile.type, augmentedFile.name)}
</dd>
</div>
<div className="w-full">
<dt className="sr-only">
Size
</dt>
<dd
className="m-0 w-full text-ellipsis overflow-hidden"
title={`${formatNumeral(augmentedFile.size ?? 0)} bytes`}
>
{formatFileSize(augmentedFile.size)}
</dd>
</div>
{
typeof augmentedFile.metadata?.width === 'number'
&& typeof augmentedFile.metadata?.height === 'number'
&& (
<div>
<dt className="sr-only">
Pixel Dimensions
</dt>
<dd
className="m-0 w-full text-ellipsis overflow-hidden"
>
{formatNumeral(augmentedFile.metadata.width)}&times;{formatNumeral(augmentedFile.metadata.height)} pixels
</dd>
</div>
)
}
</dl>
<form
id={formId}
onSubmit={handleAction}
className="flex gap-4"
>
<fieldset
disabled={disabled || typeof error !== 'undefined'}
className="contents"
> >
{formatFileSize(f.size)}
</dd>
</div>
{
typeof f.metadata?.width === 'number'
&& typeof f.metadata?.height === 'number'
&& (
<div>
<dt className="sr-only">
Pixel Dimensions
</dt>
<dd
className="m-0 w-full text-ellipsis overflow-hidden"
<legend className="sr-only">
Controls
</legend>
<button
type="submit"
name="action"
value="download"
className={clsx(
'h-12 flex bg-negative text-primary disabled:text-primary focus:text-secondary active:text-tertiary items-center justify-center leading-none gap-4 select-none',
'focus:outline-0',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
>
<span
className="block uppercase font-bold h-[1.1em] w-full whitespace-nowrap overflow-hidden text-ellipsis font-semi-expanded"
> >
{formatNumeral(f.metadata.width)}&times;{formatNumeral(f.metadata.height)} pixels
</dd>
</div>
)
}
</dl>
Download
</span>
</button>
</fieldset>
</form>
</div>
</div> </div>
); );
}); });


+ 1
- 0
packages/web-kitchensink-reactnext/src/categories/blob/react/hooks/blob/index.ts View File

@@ -0,0 +1 @@
export * from './metadata';

+ 31
- 0
packages/web-kitchensink-reactnext/src/categories/blob/react/hooks/blob/metadata.ts View File

@@ -0,0 +1,31 @@
import * as React from 'react';

export interface UseAugmentedFileOptions<T extends Partial<File> = Partial<File>> {
file?: File;
augmentFunction: (file: File) => Promise<T>;
}

export const useAugmentedFile = <T extends Partial<File>>(options = {} as UseAugmentedFileOptions<T>) => {
const { file, augmentFunction } = options;
const [augmentedFile, setAugmentedFile] = React.useState<T>();
const [error, setError] = React.useState<Error>();

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

augmentFunction(file)
.then((theAugmentedFile) => {
setAugmentedFile(theAugmentedFile);
})
.catch((error) => {
setError(error);
});
}, [file]);

return React.useMemo(() => ({
augmentedFile: (augmentedFile ?? file) as T | undefined,
error,
}), [augmentedFile, file, error]);
};

+ 56
- 0
packages/web-kitchensink-reactnext/src/categories/blob/react/hooks/media/image.ts View File

@@ -0,0 +1,56 @@
import * as React from 'react';

export interface UseImageControlsOptions {
actionFormKey?: string;
forwardedRef?: React.Ref<HTMLImageElement>;
}

export const useImageControls = (options = {} as UseImageControlsOptions) => {
const { actionFormKey = 'action' as const, forwardedRef } = options;
const [fullScreen, setFullScreen] = React.useState(false);
const defaultRef = React.useRef<HTMLImageElement>(null);
const imageRef = forwardedRef ?? defaultRef;
const filenameRef = React.useRef<HTMLElement>(null);

const toggleFullScreen = React.useCallback(() => {
setFullScreen((b) => !b);
}, []);

const download = React.useCallback(() => {
if (!(typeof imageRef === 'object' && imageRef?.current !== null)) {
return;
}

if (!(typeof filenameRef === 'object' && filenameRef?.current !== null)) {
return;
}

const downloadLink = window.document.createElement('a');
downloadLink.download = filenameRef.current.textContent ?? 'image';
downloadLink.href = imageRef.current.src;
downloadLink.addEventListener('click', () => {
downloadLink.remove();
});
downloadLink.click();
}, [imageRef, filenameRef]);

const actions = React.useMemo(() => ({
toggleFullScreen,
download,
}), [toggleFullScreen, download]);

const handleAction: React.FormEventHandler<HTMLFormElement> = React.useCallback((e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget, (e.nativeEvent as unknown as { submitter: HTMLElement }).submitter);
const actionName = formData.get(actionFormKey) as keyof typeof actions;
const { [actionName]: actionFunction } = actions;
actionFunction?.();
}, [actions, actionFormKey]);

return React.useMemo(() => ({
fullScreen,
handleAction,
imageRef,
filenameRef,
}), [fullScreen, handleAction, imageRef, filenameRef]);
};

+ 1
- 0
packages/web-kitchensink-reactnext/src/categories/blob/react/hooks/media/index.ts View File

@@ -1,2 +1,3 @@
export * from './audio'; export * from './audio';
export * from './video'; export * from './video';
export * from './image';

+ 49
- 11
packages/web-kitchensink-reactnext/src/categories/blob/react/hooks/media/video.ts View File

@@ -2,13 +2,18 @@ import * as React from 'react';


export interface UseVideoControlsOptions { export interface UseVideoControlsOptions {
mediaControllerRef: React.Ref<HTMLVideoElement>; mediaControllerRef: React.Ref<HTMLVideoElement>;
actionFormKey?: string;
} }


export const useVideoControls = ({ export const useVideoControls = ({
mediaControllerRef,
mediaControllerRef: forwardedRef,
actionFormKey = 'action' as const,
}: UseVideoControlsOptions) => { }: UseVideoControlsOptions) => {
const defaultRef = React.useRef<HTMLVideoElement>(null);
const mediaControllerRef = forwardedRef ?? defaultRef;
const seekRef = React.useRef<HTMLInputElement>(null); const seekRef = React.useRef<HTMLInputElement>(null);
const volumeRef = React.useRef<HTMLInputElement>(null); const volumeRef = React.useRef<HTMLInputElement>(null);
const filenameRef = React.useRef<HTMLElement>(null);
const [isPlaying, setIsPlaying] = React.useState(false); const [isPlaying, setIsPlaying] = React.useState(false);
const [isSeeking, setIsSeeking] = React.useState(false); const [isSeeking, setIsSeeking] = React.useState(false);
const [currentTimeDisplay, setCurrentTimeDisplay] = React.useState<number>(); const [currentTimeDisplay, setCurrentTimeDisplay] = React.useState<number>();
@@ -27,7 +32,7 @@ export const useVideoControls = ({
setDurationDisplay(mediaController.duration); setDurationDisplay(mediaController.duration);
}; };


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


@@ -102,6 +107,42 @@ export const useVideoControls = ({
seek.value = String(currentTime); seek.value = String(currentTime);
}, [isSeeking]); }, [isSeeking]);


const toggleSeekTimeCountMode = React.useCallback(() => {
setIsSeekTimeCountingDown((b) => !b);
}, []);

const download = React.useCallback(() => {
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef?.current !== null)) {
return;
}

if (!(typeof filenameRef === 'object' && filenameRef?.current !== null)) {
return;
}

const downloadLink = window.document.createElement('a');
downloadLink.download = filenameRef.current.textContent ?? 'image';
downloadLink.href = mediaControllerRef.current.currentSrc;
downloadLink.addEventListener('click', () => {
downloadLink.remove();
});
downloadLink.click();
}, [mediaControllerRef, filenameRef]);

const actions = React.useMemo(() => ({
togglePlayback,
toggleSeekTimeCountMode,
download,
}), [togglePlayback, toggleSeekTimeCountMode, download]);

const handleAction: React.FormEventHandler<HTMLFormElement> = React.useCallback((e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget, (e.nativeEvent as unknown as { submitter: HTMLElement }).submitter);
const actionName = formData.get(actionFormKey) as keyof typeof actions;
const { [actionName]: actionFunction } = actions;
actionFunction?.();
}, [actions, actionFormKey]);

const adjustVolume: React.ChangeEventHandler<HTMLInputElement> = React.useCallback((e) => { const adjustVolume: React.ChangeEventHandler<HTMLInputElement> = React.useCallback((e) => {
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) { if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) {
return; return;
@@ -114,11 +155,6 @@ export const useVideoControls = ({
mediaControllerRef.current.volume = Number(value); mediaControllerRef.current.volume = Number(value);
}, [mediaControllerRef]); }, [mediaControllerRef]);


const toggleSeekTimeCountMode: React.MouseEventHandler<HTMLButtonElement> = React.useCallback((e) => {
e.preventDefault();
setIsSeekTimeCountingDown((b) => !b);
}, []);

React.useEffect(() => { React.useEffect(() => {
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) { if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) {
return; return;
@@ -191,18 +227,18 @@ export const useVideoControls = ({
endSeek, endSeek,
setSeek, setSeek,
updateSeekFromPlayback, updateSeekFromPlayback,
playMedia,
durationDisplay, durationDisplay,
currentTimeDisplay, currentTimeDisplay,
seekTimeDisplay, seekTimeDisplay,
isSeeking, isSeeking,
isSeekTimeCountingDown, isSeekTimeCountingDown,
toggleSeekTimeCountMode,
mediaControllerRef,
handleAction,
filenameRef,
}), [ }), [
isPlaying, isPlaying,
isSeeking, isSeeking,
adjustVolume, adjustVolume,
playMedia,
resetVideo, resetVideo,
startSeek, startSeek,
endSeek, endSeek,
@@ -212,6 +248,8 @@ export const useVideoControls = ({
currentTimeDisplay, currentTimeDisplay,
seekTimeDisplay, seekTimeDisplay,
isSeekTimeCountingDown, isSeekTimeCountingDown,
toggleSeekTimeCountMode,
mediaControllerRef,
handleAction,
filenameRef,
]); ]);
}; };

+ 3
- 2
packages/web-kitchensink-reactnext/src/categories/blob/react/index.ts View File

@@ -5,6 +5,7 @@
//export * from './components/FileSelectBox'; //export * from './components/FileSelectBox';
export * from './components/ImageFilePreview'; export * from './components/ImageFilePreview';
//export * from './components/TextFilePreview'; //export * from './components/TextFilePreview';
//export * from './components/VideoFilePreview';
export * from './components/VideoFilePreview';


//export * from './hooks/media';
export * from './hooks/blob';
export * from './hooks/media';

+ 26
- 1702
packages/web-kitchensink-reactnext/src/pages/categories/blob/index.tsx
File diff suppressed because it is too large
View File


+ 5
- 1
packages/web-kitchensink-reactnext/src/utils/blob.ts View File

@@ -3,6 +3,7 @@ import Blob from '../pages/categories/blob';
import {getTextMetadata} from './text'; import {getTextMetadata} from './text';
import {getImageMetadata} from './image'; import {getImageMetadata} from './image';
import {getAudioMetadata} from './audio'; import {getAudioMetadata} from './audio';
import {getVideoMetadata} from '@/utils/video';


const MIME_TYPE_DESCRIPTIONS = { const MIME_TYPE_DESCRIPTIONS = {
'image/gif': 'GIF Image', 'image/gif': 'GIF Image',
@@ -256,8 +257,9 @@ export interface VideoFile extends FileWithResolvedType<ContentType.VIDEO> {
metadata?: VideoFileMetadata; metadata?: VideoFileMetadata;
} }


const augmentVideoFile = async (f: File): Promise<VideoFile> => {
export const augmentVideoFile = async (f: File): Promise<VideoFile> => {
const previewUrl = await readAsDataURL(f); const previewUrl = await readAsDataURL(f);
const videoMetadata = await getVideoMetadata(previewUrl);
return { return {
name: f.name, name: f.name,
type: f.type, type: f.type,
@@ -267,6 +269,8 @@ const augmentVideoFile = async (f: File): Promise<VideoFile> => {
originalFile: f, originalFile: f,
metadata: { metadata: {
previewUrl, previewUrl,
width: videoMetadata.width as number,
height: videoMetadata.height as number,
}, },
}; };
}; };


+ 22
- 0
packages/web-kitchensink-reactnext/src/utils/video.ts View File

@@ -0,0 +1,22 @@
export const getVideoMetadata = (videoUrl: string) => new Promise<Record<string, string | number>>((resolve, reject) => {
const video = window.document.createElement('video');
const source = window.document.createElement('source');

video.addEventListener('loadedmetadata', (videoLoadEvent) => {
const thisVideo = videoLoadEvent.currentTarget as HTMLVideoElement;
const metadata = {
width: thisVideo.videoWidth,
height: thisVideo.videoHeight,
};
video.remove();
resolve(metadata);
});

video.addEventListener('error', () => {
reject(new Error('Could not load file as video'));
video.remove();
});

source.src = videoUrl;
video.appendChild(source);
});

Loading…
Cancel
Save