Implement video file preview interactive controls.pull/1/head
@@ -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'; |
@@ -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)}×{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)}×{formatNumeral(f.metadata.height)} pixels | |||||
</dd> | |||||
</div> | |||||
) | |||||
} | |||||
</dl> | |||||
Download | |||||
</span> | |||||
</button> | |||||
</fieldset> | |||||
</form> | |||||
</div> | |||||
</div> | </div> | ||||
); | ); | ||||
}); | }); | ||||
@@ -0,0 +1 @@ | |||||
export * from './metadata'; |
@@ -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]); | |||||
}; |
@@ -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,2 +1,3 @@ | |||||
export * from './audio'; | export * from './audio'; | ||||
export * from './video'; | export * from './video'; | ||||
export * from './image'; |
@@ -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, | |||||
]); | ]); | ||||
}; | }; |
@@ -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'; |
@@ -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, | |||||
}, | }, | ||||
}; | }; | ||||
}; | }; | ||||
@@ -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); | |||||
}); |