@@ -2,6 +2,7 @@ import * as React from 'react'; | |||
import {augmentImageFile, getMimeTypeDescription, ImageFile} from '@/utils/blob'; | |||
import {formatFileSize, formatNumeral} from '@/utils/numeral'; | |||
import clsx from 'clsx'; | |||
import {useAugmentedFile, useImageControls} from '@/categories/blob/react'; | |||
type ImageFilePreviewDerivedComponent = HTMLImageElement; | |||
@@ -10,90 +11,19 @@ export interface ImageFilePreviewProps extends Omit<React.HTMLProps<ImageFilePre | |||
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, | |||
className, | |||
style, | |||
disabled = false, | |||
...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) { | |||
@@ -109,12 +39,13 @@ export const ImageFilePreview: React.FC<ImageFilePreviewProps> = ({ | |||
style={style} | |||
> | |||
<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' | |||
&& ( | |||
<img | |||
{...etcProps} | |||
ref={imageRef} | |||
className={clsx( | |||
'block h-full max-w-full object-center bg-[#000000]', | |||
{ | |||
@@ -149,6 +80,7 @@ export const ImageFilePreview: React.FC<ImageFilePreviewProps> = ({ | |||
<dd | |||
className="m-0 w-full text-ellipsis overflow-hidden" | |||
title={augmentedFile.name} | |||
ref={filenameRef} | |||
> | |||
{augmentedFile.name} | |||
</dd> | |||
@@ -212,7 +144,7 @@ export const ImageFilePreview: React.FC<ImageFilePreviewProps> = ({ | |||
'focus:outline-0', | |||
'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> | |||
); | |||
} | |||
}); | |||
ImageFilePreview.displayName = 'ImageFilePreview'; |
@@ -1,23 +1,34 @@ | |||
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 {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>(({ | |||
file: f, | |||
file, | |||
className, | |||
style, | |||
disabled = false, | |||
enhanced = false, | |||
...etcProps | |||
}, forwardedRef) => { | |||
const defaultRef = React.useRef<HTMLVideoElement>(null); | |||
const mediaControllerRef = forwardedRef ?? defaultRef; | |||
const { augmentedFile, error } = useAugmentedFile<VideoFile>({ | |||
file, | |||
augmentFunction: augmentVideoFile, | |||
}); | |||
const { | |||
seekRef, | |||
volumeRef, | |||
isPlaying, | |||
adjustVolume, | |||
playMedia, | |||
resetVideo, | |||
startSeek, | |||
endSeek, | |||
@@ -29,19 +40,32 @@ export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePrev | |||
seekTimeDisplay = 0, | |||
isSeeking, | |||
isSeekTimeCountingDown, | |||
toggleSeekTimeCountMode, | |||
} = useVideoControls({ | |||
mediaControllerRef, | |||
handleAction, | |||
filenameRef, | |||
} = useVideoControls({ | |||
mediaControllerRef: forwardedRef, | |||
}); | |||
const formId = React.useId(); | |||
if (!augmentedFile) { | |||
return null; | |||
} | |||
const finalSeekTimeDisplay = isSeekTimeCountingDown ? (durationDisplay - seekTimeDisplay) : seekTimeDisplay; | |||
const finalCurrentTimeDisplay = isSeekTimeCountingDown ? (durationDisplay - currentTimeDisplay) : currentTimeDisplay; | |||
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 | |||
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"> | |||
<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} | |||
onDurationChange={refreshControls} | |||
onEnded={resetVideo} | |||
onTimeUpdate={updateSeekFromPlayback} | |||
data-testid="preview" | |||
controls={!enhanced} | |||
> | |||
<source | |||
src={f.metadata.previewUrl} | |||
type={f.type} | |||
src={augmentedFile.metadata.previewUrl} | |||
type={augmentedFile.type} | |||
/> | |||
</video> | |||
</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 | |||
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> | |||
<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)} | |||
</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> | |||
<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> | |||
<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> | |||
); | |||
}); | |||
@@ -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 './video'; | |||
export * from './image'; |
@@ -2,13 +2,18 @@ import * as React from 'react'; | |||
export interface UseVideoControlsOptions { | |||
mediaControllerRef: React.Ref<HTMLVideoElement>; | |||
actionFormKey?: string; | |||
} | |||
export const useVideoControls = ({ | |||
mediaControllerRef, | |||
mediaControllerRef: forwardedRef, | |||
actionFormKey = 'action' as const, | |||
}: UseVideoControlsOptions) => { | |||
const defaultRef = React.useRef<HTMLVideoElement>(null); | |||
const mediaControllerRef = forwardedRef ?? defaultRef; | |||
const seekRef = React.useRef<HTMLInputElement>(null); | |||
const volumeRef = React.useRef<HTMLInputElement>(null); | |||
const filenameRef = React.useRef<HTMLElement>(null); | |||
const [isPlaying, setIsPlaying] = React.useState(false); | |||
const [isSeeking, setIsSeeking] = React.useState(false); | |||
const [currentTimeDisplay, setCurrentTimeDisplay] = React.useState<number>(); | |||
@@ -27,7 +32,7 @@ export const useVideoControls = ({ | |||
setDurationDisplay(mediaController.duration); | |||
}; | |||
const playMedia = React.useCallback(() => { | |||
const togglePlayback = React.useCallback(() => { | |||
setIsPlaying((p) => !p); | |||
}, []); | |||
@@ -102,6 +107,42 @@ export const useVideoControls = ({ | |||
seek.value = String(currentTime); | |||
}, [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) => { | |||
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) { | |||
return; | |||
@@ -114,11 +155,6 @@ export const useVideoControls = ({ | |||
mediaControllerRef.current.volume = Number(value); | |||
}, [mediaControllerRef]); | |||
const toggleSeekTimeCountMode: React.MouseEventHandler<HTMLButtonElement> = React.useCallback((e) => { | |||
e.preventDefault(); | |||
setIsSeekTimeCountingDown((b) => !b); | |||
}, []); | |||
React.useEffect(() => { | |||
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) { | |||
return; | |||
@@ -191,18 +227,18 @@ export const useVideoControls = ({ | |||
endSeek, | |||
setSeek, | |||
updateSeekFromPlayback, | |||
playMedia, | |||
durationDisplay, | |||
currentTimeDisplay, | |||
seekTimeDisplay, | |||
isSeeking, | |||
isSeekTimeCountingDown, | |||
toggleSeekTimeCountMode, | |||
mediaControllerRef, | |||
handleAction, | |||
filenameRef, | |||
}), [ | |||
isPlaying, | |||
isSeeking, | |||
adjustVolume, | |||
playMedia, | |||
resetVideo, | |||
startSeek, | |||
endSeek, | |||
@@ -212,6 +248,8 @@ export const useVideoControls = ({ | |||
currentTimeDisplay, | |||
seekTimeDisplay, | |||
isSeekTimeCountingDown, | |||
toggleSeekTimeCountMode, | |||
mediaControllerRef, | |||
handleAction, | |||
filenameRef, | |||
]); | |||
}; |
@@ -5,6 +5,7 @@ | |||
//export * from './components/FileSelectBox'; | |||
export * from './components/ImageFilePreview'; | |||
//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 {getImageMetadata} from './image'; | |||
import {getAudioMetadata} from './audio'; | |||
import {getVideoMetadata} from '@/utils/video'; | |||
const MIME_TYPE_DESCRIPTIONS = { | |||
'image/gif': 'GIF Image', | |||
@@ -256,8 +257,9 @@ export interface VideoFile extends FileWithResolvedType<ContentType.VIDEO> { | |||
metadata?: VideoFileMetadata; | |||
} | |||
const augmentVideoFile = async (f: File): Promise<VideoFile> => { | |||
export const augmentVideoFile = async (f: File): Promise<VideoFile> => { | |||
const previewUrl = await readAsDataURL(f); | |||
const videoMetadata = await getVideoMetadata(previewUrl); | |||
return { | |||
name: f.name, | |||
type: f.type, | |||
@@ -267,6 +269,8 @@ const augmentVideoFile = async (f: File): Promise<VideoFile> => { | |||
originalFile: f, | |||
metadata: { | |||
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); | |||
}); |