Use Wavesurfer for reimplementing audio file preview component.pull/1/head
@@ -26,10 +26,11 @@ | |||
"react-dom": "18.2.0", | |||
"tailwindcss": "3.3.2", | |||
"typescript": "5.1.3", | |||
"wavesurfer.js": "7.0.0-beta.6" | |||
"wavesurfer.js": "7.0.0-beta.11" | |||
}, | |||
"devDependencies": { | |||
"@types/mime-types": "^2.1.1", | |||
"@types/prismjs": "^1.26.0" | |||
"@types/prismjs": "^1.26.0", | |||
"@types/wavesurfer.js": "^6.0.6" | |||
} | |||
} |
@@ -57,8 +57,8 @@ dependencies: | |||
specifier: 5.1.3 | |||
version: 5.1.3 | |||
wavesurfer.js: | |||
specifier: 7.0.0-beta.6 | |||
version: 7.0.0-beta.6 | |||
specifier: 7.0.0-beta.11 | |||
version: 7.0.0-beta.11 | |||
devDependencies: | |||
'@types/mime-types': | |||
@@ -67,6 +67,9 @@ devDependencies: | |||
'@types/prismjs': | |||
specifier: ^1.26.0 | |||
version: 1.26.0 | |||
'@types/wavesurfer.js': | |||
specifier: ^6.0.6 | |||
version: 6.0.6 | |||
packages: | |||
@@ -349,6 +352,10 @@ packages: | |||
tslib: 2.5.3 | |||
dev: false | |||
/@types/debounce@1.2.1: | |||
resolution: {integrity: sha512-epMsEE85fi4lfmJUH/89/iV/LI+F5CvNIvmgs5g5jYFPfhO2S/ae8WSsLOKWdwtoaZw9Q2IhJ4tQ5tFCcS/4HA==} | |||
dev: true | |||
/@types/json5@0.0.29: | |||
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} | |||
dev: false | |||
@@ -387,6 +394,12 @@ packages: | |||
resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==} | |||
dev: false | |||
/@types/wavesurfer.js@6.0.6: | |||
resolution: {integrity: sha512-fD54o0RXZXxkOb+69Rt6rGViaHpIc1Mmde2aOX9qPhlQhrCPepybGnsekiG407+7scPlaK+hmuPez5AnnmlzGg==} | |||
dependencies: | |||
'@types/debounce': 1.2.1 | |||
dev: true | |||
/@typescript-eslint/parser@5.60.0(eslint@8.43.0)(typescript@5.1.3): | |||
resolution: {integrity: sha512-jBONcBsDJ9UoTWrARkRRCgDz6wUggmH5RpQVlt7BimSwaTkTjwypGzKORXbR4/2Hqjk9hgwlon2rVQAjWNpkyQ==} | |||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |||
@@ -2702,8 +2715,8 @@ packages: | |||
graceful-fs: 4.2.11 | |||
dev: false | |||
/wavesurfer.js@7.0.0-beta.6: | |||
resolution: {integrity: sha512-vB8J1ppZ58vozmBDmqADDdKBYY6bebSYKUgIaDXB56Qo/CpPdExSlg91tN1FAubN5swZ1IUyB8Z9xOY/TsYRoA==} | |||
/wavesurfer.js@7.0.0-beta.11: | |||
resolution: {integrity: sha512-PwcnEIcV3x8Zi0XMWFkzRy0NsJyTaFITIXByoQn/y6OqtJn9W5jzryTvt/mxv+FcKAWA7yGqkxRGX336D0iWTQ==} | |||
dev: false | |||
/which-boxed-primitive@1.0.2: | |||
@@ -1,87 +1,253 @@ | |||
import * as React from 'react'; | |||
import {AudioFile, getMimeTypeDescription} from '@/utils/blob'; | |||
import {formatFileSize, formatNumeral, formatSecondsDurationPrecise} from '@/utils/numeral'; | |||
import {useAudioControls} from '../../hooks/media'; | |||
import {augmentAudioFile, getMimeTypeDescription} from '@/utils/blob'; | |||
import { | |||
formatFileSize, | |||
formatNumeral, | |||
formatSecondsDurationConcise, | |||
formatSecondsDurationPrecise, | |||
} from '@/utils/numeral'; | |||
import {useMediaControls} from '../../hooks/media'; | |||
import {useAugmentedFile} from '@/categories/blob/react'; | |||
export interface AudioFilePreviewProps { | |||
file: AudioFile; | |||
import clsx from 'clsx'; | |||
import {WaveSurferCanvas} from '@/packages/react-wavesurfer'; | |||
type AudioFilePreviewDerivedComponent = HTMLAudioElement; | |||
export interface AudioFilePreviewProps extends Omit<React.HTMLProps<AudioFilePreviewDerivedComponent>, 'controls'> { | |||
file?: File; | |||
disabled?: boolean; | |||
enhanced?: boolean; | |||
} | |||
export const AudioFilePreview: React.FC<AudioFilePreviewProps> = ({ | |||
file: f, | |||
}) => { | |||
export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedComponent, AudioFilePreviewProps>(({ | |||
file, | |||
style, | |||
className, | |||
enhanced = false, | |||
disabled = false, | |||
}, forwardedRef) => { | |||
const { augmentedFile, error } = useAugmentedFile({ | |||
file, | |||
augmentFunction: augmentAudioFile, | |||
}); | |||
const { | |||
mediaContainerRef, | |||
playMedia, | |||
mediaControllerRef, | |||
refreshControls, | |||
reset, | |||
updateSeekFromPlayback, | |||
isPlaying, | |||
} = useAudioControls({ file: f }); | |||
isSeeking, | |||
currentTimeDisplay = 0, | |||
seekTimeDisplay = 0, | |||
durationDisplay = 0, | |||
isSeekTimeCountingDown, | |||
adjustVolume, | |||
volumeRef, | |||
handleAction, | |||
filenameRef, | |||
seekRef, | |||
startSeek, | |||
endSeek, | |||
setSeek, | |||
} = useMediaControls<HTMLAudioElement>({ | |||
controllerRef: 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 flex-col gap-4 w-full h-full relative"> | |||
<div className="h-2/5 flex-shrink-0 cursor-pointer relative"> | |||
<div | |||
ref={mediaContainerRef} | |||
className="relative h-full w-full" | |||
/> | |||
<div className="absolute bottom-0 left-0 z-[2]"> | |||
<button | |||
onClick={playMedia} | |||
> | |||
{isPlaying ? '⏸' : '▶'} | |||
</button> | |||
</div> | |||
</div> | |||
<dl className="h-3/5 flex-shrink-0 m-0 flex flex-col items-end" 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`} | |||
> | |||
{formatFileSize(f.size)} | |||
</dd> | |||
</div> | |||
<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?.duration === 'number' | |||
typeof augmentedFile.metadata?.previewUrl === 'string' | |||
&& ( | |||
<div className="w-full"> | |||
<dt className="sr-only"> | |||
Duration | |||
</dt> | |||
<dd | |||
className="m-0 w-full text-ellipsis overflow-hidden" | |||
title={`${formatNumeral(f.metadata.duration ?? 0)} seconds`} | |||
> | |||
{formatSecondsDurationPrecise(f.metadata.duration)} | |||
</dd> | |||
<div | |||
className="w-full h-full bg-black flex flex-col items-stretch" | |||
data-testid="preview" | |||
> | |||
<div className="w-full flex-auto relative"> | |||
<WaveSurferCanvas | |||
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={reset} | |||
onTimeUpdate={updateSeekFromPlayback} | |||
data-testid="preview" | |||
> | |||
<source | |||
src={augmentedFile.metadata.previewUrl} | |||
type={augmentedFile.type} | |||
/> | |||
</WaveSurferCanvas> | |||
</div> | |||
{enhanced && ( | |||
<div className="w-full flex-shrink-0 h-10 flex gap-4 relative"> | |||
<button | |||
className="w-10 h-full flex-shrink-0 text-primary" | |||
type="submit" | |||
name="action" | |||
value="togglePlayback" | |||
form={formId} | |||
> | |||
{isPlaying ? '⏸' : '▶'} | |||
</button> | |||
<div className="flex-auto w-full flex items-center gap-2 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="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" | |||
step="any" | |||
/> | |||
</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> | |||
)} | |||
</div> | |||
) | |||
} | |||
</dl> | |||
</div> | |||
<div | |||
className="flex-shrink-0 m-0 flex flex-col gap-4 justify-between" | |||
> | |||
<dl data-testid="infoBox"> | |||
<div className="w-full"> | |||
<dt className="sr-only"> | |||
Name | |||
</dt> | |||
<dd | |||
className="m-0 w-full text-ellipsis overflow-hidden font-bold" | |||
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?.duration === 'number' | |||
&& ( | |||
<div className="w-full"> | |||
<dt className="sr-only"> | |||
Duration | |||
</dt> | |||
<dd | |||
className="m-0 w-full text-ellipsis overflow-hidden" | |||
title={`${formatNumeral(augmentedFile.metadata.duration ?? 0)} seconds`} | |||
> | |||
{formatSecondsDurationPrecise(augmentedFile.metadata.duration)} | |||
</dd> | |||
</div> | |||
) | |||
} | |||
</dl> | |||
<form | |||
id={formId} | |||
onSubmit={handleAction} | |||
className="flex gap-4" | |||
> | |||
<fieldset | |||
disabled={disabled || typeof error !== 'undefined'} | |||
className="contents" | |||
> | |||
<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" | |||
> | |||
Download | |||
</span> | |||
</button> | |||
</fieldset> | |||
</form> | |||
</div> | |||
</div> | |||
); | |||
}; | |||
}); | |||
AudioFilePreview.displayName = 'AudioFilePreview'; |
@@ -11,7 +11,7 @@ export const AudioMiniFilePreview: React.FC<AudioMiniFilePreviewProps> = ({ | |||
file: f, | |||
}) => { | |||
const { | |||
mediaContainerRef, | |||
mountRef, | |||
playMedia, | |||
isPlaying, | |||
} = useAudioControls({ file: f }); | |||
@@ -19,7 +19,7 @@ export const AudioMiniFilePreview: React.FC<AudioMiniFilePreviewProps> = ({ | |||
return ( | |||
<div | |||
className="absolute top-0 left-0 w-full h-full cursor-pointer" | |||
ref={mediaContainerRef} | |||
ref={mountRef} | |||
onClick={playMedia} | |||
/> | |||
); | |||
@@ -1,5 +1,5 @@ | |||
import * as React from 'react'; | |||
import {augmentImageFile, getMimeTypeDescription, ImageFile} from '@/utils/blob'; | |||
import {augmentImageFile, getMimeTypeDescription} from '@/utils/blob'; | |||
import {formatFileSize, formatNumeral} from '@/utils/numeral'; | |||
import clsx from 'clsx'; | |||
import {useAugmentedFile, useImageControls} from '@/categories/blob/react'; | |||
@@ -18,7 +18,7 @@ export const ImageFilePreview = React.forwardRef<ImageFilePreviewDerivedComponen | |||
disabled = false, | |||
...etcProps | |||
}, forwardedRef) => { | |||
const { augmentedFile, error } = useAugmentedFile<ImageFile>({ | |||
const { augmentedFile, error } = useAugmentedFile({ | |||
file, | |||
augmentFunction: augmentImageFile, | |||
}); | |||
@@ -1,7 +1,7 @@ | |||
import * as React from 'react'; | |||
import {augmentVideoFile, getMimeTypeDescription, VideoFile} from '@/utils/blob'; | |||
import {augmentVideoFile, getMimeTypeDescription} from '@/utils/blob'; | |||
import {formatFileSize, formatNumeral, formatSecondsDurationConcise} from '@/utils/numeral'; | |||
import {useAugmentedFile, useVideoControls} from '@tesseract-design/web-blob-react'; | |||
import {useAugmentedFile, useMediaControls} from '@tesseract-design/web-blob-react'; | |||
import clsx from 'clsx'; | |||
type VideoFilePreviewDerivedComponent = HTMLVideoElement; | |||
@@ -12,7 +12,7 @@ export interface VideoFilePreviewProps extends Omit<React.HTMLProps<VideoFilePre | |||
enhanced?: boolean; | |||
} | |||
export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePreviewProps>(({ | |||
export const VideoFilePreview = React.forwardRef<VideoFilePreviewDerivedComponent, VideoFilePreviewProps>(({ | |||
file, | |||
className, | |||
style, | |||
@@ -20,7 +20,7 @@ export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePrev | |||
enhanced = false, | |||
...etcProps | |||
}, forwardedRef) => { | |||
const { augmentedFile, error } = useAugmentedFile<VideoFile>({ | |||
const { augmentedFile, error } = useAugmentedFile({ | |||
file, | |||
augmentFunction: augmentVideoFile, | |||
}); | |||
@@ -29,7 +29,7 @@ export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePrev | |||
volumeRef, | |||
isPlaying, | |||
adjustVolume, | |||
resetVideo, | |||
reset, | |||
startSeek, | |||
endSeek, | |||
setSeek, | |||
@@ -43,8 +43,8 @@ export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePrev | |||
mediaControllerRef, | |||
handleAction, | |||
filenameRef, | |||
} = useVideoControls({ | |||
mediaControllerRef: forwardedRef, | |||
} = useMediaControls<HTMLVideoElement>({ | |||
controllerRef: forwardedRef, | |||
}); | |||
const formId = React.useId(); | |||
@@ -78,7 +78,7 @@ export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePrev | |||
ref={mediaControllerRef} | |||
onLoadedMetadata={refreshControls} | |||
onDurationChange={refreshControls} | |||
onEnded={resetVideo} | |||
onEnded={reset} | |||
onTimeUpdate={updateSeekFromPlayback} | |||
data-testid="preview" | |||
controls={!enhanced} | |||
@@ -100,7 +100,7 @@ export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePrev | |||
> | |||
{isPlaying ? '⏸' : '▶'} | |||
</button> | |||
<div className="flex-auto w-full flex items-center gap-1 font-mono text-sm relative"> | |||
<div className="flex-auto w-full flex items-center gap-2 text-sm relative"> | |||
<button | |||
className="absolute overflow-hidden w-12 opacity-0 h-10" | |||
title="Toggle Seek Time Count Mode" | |||
@@ -112,7 +112,7 @@ export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePrev | |||
Toggle Seek Time Count Mode | |||
</button> | |||
<span | |||
className="font-mono before:block before:content-[attr(title)] contents tabular-nums" | |||
className="before:block before:content-[attr(title)] contents tabular-nums" | |||
title={ | |||
`${ | |||
isSeekTimeCountingDown ? '−' : '+' | |||
@@ -131,6 +131,7 @@ export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePrev | |||
onMouseUp={endSeek} | |||
onChange={setSeek} | |||
defaultValue="0" | |||
step="any" | |||
/> | |||
</span> | |||
<span> | |||
@@ -22,7 +22,7 @@ export const useAugmentedFile = <T extends Partial<File>>(options = {} as UseAug | |||
.catch((error) => { | |||
setError(error); | |||
}); | |||
}, [file]); | |||
}, [file, augmentFunction]); | |||
return React.useMemo(() => ({ | |||
augmentedFile: (augmentedFile ?? file) as T | undefined, | |||
@@ -1,87 +1,146 @@ | |||
import {AudioFile} from '@/utils/blob'; | |||
import * as React from 'react'; | |||
import WaveSurfer from 'wavesurfer.js'; | |||
export interface UseAudioFilePreviewControlsOptions { | |||
file: AudioFile; | |||
export interface UseAudioControlsOptions { | |||
actionFormKey?: string; | |||
forwardedRef?: React.Ref<any>; | |||
} | |||
export const useAudioControls = ({ file: f }: UseAudioFilePreviewControlsOptions) => { | |||
const mediaContainerRef = React.useRef<HTMLDivElement>(null); | |||
const mediaControllerRef = React.useRef<WaveSurfer>(null); | |||
export const useAudioControls = ({ | |||
forwardedRef, | |||
actionFormKey = 'action' as const, | |||
}: UseAudioControlsOptions) => { | |||
const mediaControllerRef = React.useRef<HTMLAudioElement>(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>(); | |||
const [seekTimeDisplay, setSeekTimeDisplay] = React.useState<number>(); | |||
const [durationDisplay, setDurationDisplay] = React.useState<number>(); | |||
const [isSeekTimeCountingDown, setIsSeekTimeCountingDown] = React.useState(false); | |||
React.useEffect(() => { | |||
if (!mediaControllerRef.current) { | |||
return; | |||
} | |||
const refreshControls: React.ReactEventHandler<HTMLAudioElement> = React.useCallback((e) => { | |||
const { currentTarget: mediaController } = e; | |||
if (isPlaying) { | |||
void mediaControllerRef.current.play(); | |||
return; | |||
} | |||
setCurrentTimeDisplay(mediaController.currentTime); | |||
setDurationDisplay(mediaController.duration); | |||
}, []); | |||
mediaControllerRef.current.pause(); | |||
}, [isPlaying]); | |||
const reset: React.ReactEventHandler<HTMLAudioElement> = React.useCallback((e) => { | |||
const videoElement = e.currentTarget; | |||
setIsPlaying(false); | |||
videoElement.currentTime = 0; | |||
}, []); | |||
React.useEffect(() => { | |||
if (!mediaControllerRef.current) { | |||
const updateSeekFromPlayback = React.useCallback((e) => { | |||
if (isSeeking) { | |||
return; | |||
} | |||
if (!mediaContainerRef.current) { | |||
const videoElement = e.currentTarget; | |||
const currentTime = videoElement.currentTime; | |||
setCurrentTimeDisplay(currentTime); | |||
}, [isSeeking]); | |||
const adjustVolume: React.ChangeEventHandler<HTMLInputElement> = React.useCallback((e) => { | |||
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) { | |||
return; | |||
} | |||
if (typeof f.metadata?.previewUrl !== 'string') { | |||
if (!mediaControllerRef.current) { | |||
return; | |||
} | |||
const { value } = e.currentTarget; | |||
mediaControllerRef.current.volume = Number(value); | |||
}, [mediaControllerRef]); | |||
const togglePlayback = React.useCallback(() => { | |||
setIsPlaying((p) => !p); | |||
}, []); | |||
mediaContainerRef.current.innerHTML = ''; | |||
const toggleSeekTimeCountMode = React.useCallback(() => { | |||
setIsSeekTimeCountingDown((b) => !b); | |||
}, []); | |||
if (f.type === 'audio/mid') { | |||
const download = React.useCallback(() => { | |||
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef?.current !== null)) { | |||
return; | |||
} | |||
const mediaControllerRefMutable = mediaControllerRef as React.MutableRefObject<WaveSurfer>; | |||
mediaControllerRefMutable.current = WaveSurfer.create({ | |||
container: mediaContainerRef.current, | |||
cursorWidth: 0, | |||
height: mediaContainerRef.current.offsetHeight, | |||
barWidth: 2, | |||
barGap: 2, | |||
barRadius: 1, | |||
}); | |||
if (!(typeof filenameRef === 'object' && filenameRef?.current !== null)) { | |||
return; | |||
} | |||
mediaControllerRefMutable.current.on('finish', () => { | |||
setIsPlaying(false); | |||
mediaControllerRefMutable.current.seekTo(0); | |||
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]); | |||
// TODO get the preview URL here | |||
mediaControllerRefMutable.current.load(f.metadata.previewUrl); | |||
React.useEffect(() => { | |||
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) { | |||
return; | |||
} | |||
return () => { | |||
if (!mediaControllerRefMutable.current) { | |||
return; | |||
} | |||
if (!mediaControllerRef.current) { | |||
return; | |||
} | |||
mediaControllerRefMutable.current.destroy(); | |||
if (isPlaying) { | |||
//console.log(mediaControllerRef.current); | |||
void mediaControllerRef.current.play(); | |||
return | |||
} | |||
}, [f, mediaContainerRef, mediaControllerRef]); | |||
const playMedia = React.useCallback(() => { | |||
setIsPlaying((p) => !p); | |||
}, []); | |||
mediaControllerRef.current.pause(); | |||
}, [isPlaying, mediaControllerRef]); | |||
return React.useMemo(() => ({ | |||
mediaContainerRef, | |||
playMedia, | |||
mediaControllerRef, | |||
refreshControls, | |||
reset: reset, | |||
updateSeekFromPlayback, | |||
isPlaying, | |||
isSeeking, | |||
currentTimeDisplay, | |||
seekTimeDisplay, | |||
durationDisplay, | |||
isSeekTimeCountingDown, | |||
volumeRef, | |||
adjustVolume, | |||
filenameRef, | |||
handleAction, | |||
}), [ | |||
mediaContainerRef, | |||
playMedia, | |||
mediaControllerRef, | |||
refreshControls, | |||
reset, | |||
updateSeekFromPlayback, | |||
isPlaying, | |||
isSeeking, | |||
currentTimeDisplay, | |||
seekTimeDisplay, | |||
durationDisplay, | |||
isSeekTimeCountingDown, | |||
volumeRef, | |||
adjustVolume, | |||
filenameRef, | |||
handleAction, | |||
]); | |||
}; |
@@ -1,16 +1,16 @@ | |||
import * as React from 'react'; | |||
export interface UseVideoControlsOptions { | |||
mediaControllerRef: React.Ref<HTMLVideoElement>; | |||
export interface UseMediaControlsOptions<T extends HTMLMediaElement> { | |||
controllerRef: React.Ref<T>; | |||
actionFormKey?: string; | |||
} | |||
export const useVideoControls = ({ | |||
mediaControllerRef: forwardedRef, | |||
export const useMediaControls = <T extends HTMLMediaElement>({ | |||
controllerRef: forwardedRef, | |||
actionFormKey = 'action' as const, | |||
}: UseVideoControlsOptions) => { | |||
const defaultRef = React.useRef<HTMLVideoElement>(null); | |||
const mediaControllerRef = forwardedRef ?? defaultRef; | |||
}: UseMediaControlsOptions<T>) => { | |||
const defaultRef = React.useRef<T>(null); | |||
const ref = forwardedRef ?? defaultRef; | |||
const seekRef = React.useRef<HTMLInputElement>(null); | |||
const volumeRef = React.useRef<HTMLInputElement>(null); | |||
const filenameRef = React.useRef<HTMLElement>(null); | |||
@@ -21,16 +21,12 @@ export const useVideoControls = ({ | |||
const [durationDisplay, setDurationDisplay] = React.useState<number>(); | |||
const [isSeekTimeCountingDown, setIsSeekTimeCountingDown] = React.useState(false); | |||
const refreshControls: React.ReactEventHandler<HTMLVideoElement> = (e) => { | |||
const refreshControls: React.ReactEventHandler<T> = React.useCallback((e) => { | |||
const { currentTarget: mediaController } = e; | |||
const { current: seek } = seekRef; | |||
if (!seek) { | |||
return; | |||
} | |||
setCurrentTimeDisplay(mediaController.currentTime); | |||
setDurationDisplay(mediaController.duration); | |||
}; | |||
}, []); | |||
const togglePlayback = React.useCallback(() => { | |||
setIsPlaying((p) => !p); | |||
@@ -40,16 +36,16 @@ export const useVideoControls = ({ | |||
setIsSeeking(true); | |||
}, []); | |||
const doSetSeek = (thisElement: HTMLInputElement, mediaController: HTMLVideoElement) => { | |||
const doSetSeek = React.useCallback((thisElement: HTMLInputElement, mediaController: HTMLMediaElement) => { | |||
mediaController.currentTime = thisElement.valueAsNumber; | |||
}; | |||
}, []); | |||
const setSeek: React.ChangeEventHandler<HTMLInputElement> = React.useCallback((e) => { | |||
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) { | |||
if (!(typeof ref === 'object' && ref !== null)) { | |||
return; | |||
} | |||
const { current: mediaController } = mediaControllerRef; | |||
const { current: mediaController } = ref; | |||
if (!mediaController) { | |||
return; | |||
} | |||
@@ -62,14 +58,14 @@ export const useVideoControls = ({ | |||
} | |||
doSetSeek(thisElement, mediaController); | |||
}, [mediaControllerRef, isSeeking]); | |||
}, [ref, isSeeking, doSetSeek]); | |||
const endSeek: React.MouseEventHandler<HTMLInputElement> = React.useCallback((e) => { | |||
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) { | |||
if (!(typeof ref === 'object' && ref !== null)) { | |||
return; | |||
} | |||
const { current: mediaController } = mediaControllerRef; | |||
const { current: mediaController } = ref; | |||
if (!mediaController) { | |||
return; | |||
@@ -78,32 +74,30 @@ export const useVideoControls = ({ | |||
const { currentTarget: thisElement } = e; | |||
setIsSeeking(false); | |||
doSetSeek(thisElement, mediaController); | |||
}, [mediaControllerRef]); | |||
}, [ref, doSetSeek]); | |||
const resetVideo: React.ReactEventHandler<HTMLVideoElement> = React.useCallback((e) => { | |||
const reset: React.ReactEventHandler<T> = React.useCallback((e) => { | |||
const videoElement = e.currentTarget; | |||
setIsPlaying(false); | |||
videoElement.currentTime = 0; | |||
}, []); | |||
const updateSeekFromPlayback: React.ReactEventHandler<HTMLVideoElement> = React.useCallback((e) => { | |||
if (!seekRef.current) { | |||
const updateSeekFromPlayback: React.ReactEventHandler<T> = React.useCallback((e) => { | |||
if (isSeeking) { | |||
return; | |||
} | |||
if (isSeeking) { | |||
const videoElement = e.currentTarget; | |||
const currentTime = videoElement.currentTime; | |||
setCurrentTimeDisplay(currentTime); | |||
if (!seekRef.current) { | |||
return; | |||
} | |||
const { current: seek } = seekRef; | |||
if (!seek) { | |||
return; | |||
} | |||
const videoElement = e.currentTarget; | |||
const currentTime = videoElement.currentTime; | |||
setCurrentTimeDisplay(currentTime); | |||
seek.value = String(currentTime); | |||
}, [isSeeking]); | |||
@@ -112,7 +106,7 @@ export const useVideoControls = ({ | |||
}, []); | |||
const download = React.useCallback(() => { | |||
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef?.current !== null)) { | |||
if (!(typeof ref === 'object' && ref?.current !== null)) { | |||
return; | |||
} | |||
@@ -122,12 +116,12 @@ export const useVideoControls = ({ | |||
const downloadLink = window.document.createElement('a'); | |||
downloadLink.download = filenameRef.current.textContent ?? 'image'; | |||
downloadLink.href = mediaControllerRef.current.currentSrc; | |||
downloadLink.href = ref.current.currentSrc; | |||
downloadLink.addEventListener('click', () => { | |||
downloadLink.remove(); | |||
}); | |||
downloadLink.click(); | |||
}, [mediaControllerRef, filenameRef]); | |||
}, [ref, filenameRef]); | |||
const actions = React.useMemo(() => ({ | |||
togglePlayback, | |||
@@ -144,33 +138,33 @@ export const useVideoControls = ({ | |||
}, [actions, actionFormKey]); | |||
const adjustVolume: React.ChangeEventHandler<HTMLInputElement> = React.useCallback((e) => { | |||
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) { | |||
if (!(typeof ref === 'object' && ref !== null)) { | |||
return; | |||
} | |||
if (!mediaControllerRef.current) { | |||
if (!ref.current) { | |||
return; | |||
} | |||
const { value } = e.currentTarget; | |||
mediaControllerRef.current.volume = Number(value); | |||
}, [mediaControllerRef]); | |||
ref.current.volume = Number(value); | |||
}, [ref]); | |||
React.useEffect(() => { | |||
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) { | |||
if (!(typeof ref === 'object' && ref !== null)) { | |||
return; | |||
} | |||
if (!mediaControllerRef.current) { | |||
if (!ref.current) { | |||
return; | |||
} | |||
if (isPlaying) { | |||
void mediaControllerRef.current.play(); | |||
void ref.current.play(); | |||
return | |||
} | |||
mediaControllerRef.current.pause(); | |||
}, [isPlaying, mediaControllerRef]); | |||
ref.current.pause(); | |||
}, [isPlaying, ref]); | |||
React.useEffect(() => { | |||
if (!seekRef.current) { | |||
@@ -203,18 +197,18 @@ export const useVideoControls = ({ | |||
return; | |||
} | |||
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) { | |||
if (!(typeof ref === 'object' && ref !== null)) { | |||
return; | |||
} | |||
if (!mediaControllerRef.current) { | |||
if (!ref.current) { | |||
return; | |||
} | |||
const { current: mediaController } = mediaControllerRef; | |||
const { current: mediaController } = ref; | |||
const { current: volume } = volumeRef; | |||
volume.value = String(mediaController.volume); | |||
}, [mediaControllerRef]); | |||
}, [ref]); | |||
return React.useMemo(() => ({ | |||
seekRef, | |||
@@ -222,7 +216,7 @@ export const useVideoControls = ({ | |||
isPlaying, | |||
refreshControls, | |||
adjustVolume, | |||
resetVideo, | |||
reset, | |||
startSeek, | |||
endSeek, | |||
setSeek, | |||
@@ -232,14 +226,15 @@ export const useVideoControls = ({ | |||
seekTimeDisplay, | |||
isSeeking, | |||
isSeekTimeCountingDown, | |||
mediaControllerRef, | |||
mediaControllerRef: ref, | |||
handleAction, | |||
filenameRef, | |||
}), [ | |||
refreshControls, | |||
isPlaying, | |||
isSeeking, | |||
adjustVolume, | |||
resetVideo, | |||
reset, | |||
startSeek, | |||
endSeek, | |||
setSeek, | |||
@@ -248,7 +243,7 @@ export const useVideoControls = ({ | |||
currentTimeDisplay, | |||
seekTimeDisplay, | |||
isSeekTimeCountingDown, | |||
mediaControllerRef, | |||
ref, | |||
handleAction, | |||
filenameRef, | |||
]); | |||
@@ -1,7 +1,6 @@ | |||
//export * from './components/AudioFilePreview'; | |||
export * from './components/AudioFilePreview'; | |||
//export * from './components/AudioMiniFilePreview'; | |||
//export * from './components/BinaryFilePreview'; | |||
//export * from './components/BinaryFilePreview'; | |||
//export * from './components/FileSelectBox'; | |||
export * from './components/ImageFilePreview'; | |||
//export * from './components/TextFilePreview'; | |||
@@ -0,0 +1,109 @@ | |||
import * as React from 'react'; | |||
type WaveSurferCanvasDerivedComponent = HTMLAudioElement; | |||
export interface WaveSurferCanvasProps extends React.HTMLProps<WaveSurferCanvasDerivedComponent> {} | |||
export const WaveSurferCanvas = React.forwardRef<WaveSurferCanvasDerivedComponent, WaveSurferCanvasProps>(({ | |||
className, | |||
children, | |||
controls, | |||
...etcProps | |||
}, forwardedRef) => { | |||
const [isPlaying, setIsPlaying] = React.useState(false); | |||
const defaultRef = React.useRef<HTMLAudioElement>(null); | |||
const ref = forwardedRef ?? defaultRef; | |||
const containerRef = React.useRef<HTMLDivElement>(null); | |||
const waveSurferRef = React.useRef<any>(null); | |||
const handleAction: React.FormEventHandler<HTMLFormElement> = (e) => { | |||
e.preventDefault(); | |||
const formData = new FormData(e.currentTarget, (e.nativeEvent as unknown as { submitter: HTMLElement }).submitter); | |||
const action = formData.get('action'); | |||
switch (action) { | |||
case 'togglePlayback': | |||
setIsPlaying((prev) => !prev); | |||
break; | |||
default: | |||
break; | |||
} | |||
}; | |||
React.useEffect(() => { | |||
const { current: container } = containerRef; | |||
const media = typeof ref === 'object' ? ref?.current : null; | |||
const { current: waveSurferCurrent } = waveSurferRef; | |||
const load = async (ref: React.Ref<HTMLAudioElement>) => { | |||
if (!(typeof ref === 'object' && ref?.current)) { | |||
return; | |||
} | |||
if (!(typeof containerRef === 'object' && containerRef?.current)) { | |||
return; | |||
} | |||
const { default: WaveSurfer } = await import('wavesurfer.js'); | |||
const waveSurferInstance = WaveSurfer.create({ | |||
container: containerRef.current, | |||
barWidth: 2, | |||
barGap: 2, | |||
height: containerRef.current.clientHeight, | |||
fillParent: true, | |||
media, | |||
} as any); | |||
waveSurferInstance.load(ref.current.currentSrc); | |||
waveSurferInstance.on('ready', () => { | |||
if (!container) { | |||
return; | |||
} | |||
while (container.children.length > 1) { | |||
container.removeChild(container.children[0]); | |||
} | |||
}); | |||
waveSurferRef.current = waveSurferInstance; | |||
}; | |||
void load(ref); | |||
return () => { | |||
if (waveSurferCurrent) { | |||
(waveSurferCurrent as unknown as Record<string, Function>).destroy(); | |||
} | |||
}; | |||
}, [ref]); | |||
return ( | |||
<div | |||
className="relative h-full w-full flex flex-col" | |||
> | |||
<audio | |||
{...etcProps} | |||
controls={controls} | |||
className={className} | |||
ref={ref} | |||
> | |||
{children} | |||
</audio> | |||
<div | |||
className="flex-auto relative" | |||
> | |||
<div className="absolute top-0 left-0 w-full h-full" | |||
ref={containerRef} | |||
/> | |||
</div> | |||
{controls && ( | |||
<form | |||
onSubmit={handleAction} | |||
> | |||
<button | |||
type="submit" | |||
name="action" | |||
value="togglePlayback" | |||
> | |||
{isPlaying ? '⏸' : '▶'} | |||
</button> | |||
</form> | |||
)} | |||
</div> | |||
); | |||
}); | |||
WaveSurferCanvas.displayName = 'WavesurferCanvas'; |
@@ -0,0 +1,10 @@ | |||
export * from './WaveSurferCanvas'; | |||
export interface WaveSurfer extends Omit<HTMLAudioElement, 'load'> { | |||
play: () => Promise<void>; | |||
pause: () => void; | |||
on: (event: string, callback: () => void) => void; | |||
seekTo: (time: number) => void; | |||
load: (url: string) => void; | |||
destroy: () => void; | |||
} |
@@ -27,6 +27,17 @@ const BlobPage: NextPage = () => { | |||
}); | |||
}, []); | |||
const [audioFile, setAudioFile] = React.useState<File>(); | |||
React.useEffect(() => { | |||
fetch('/audio.wav').then((response) => { | |||
response.blob().then((blob) => { | |||
setAudioFile(new File([blob], 'audio.wav', { | |||
type: 'audio/wav', | |||
})); | |||
}); | |||
}); | |||
}, []); | |||
return ( | |||
<DefaultLayout> | |||
<Section title="ImageFilePreview"> | |||
@@ -46,6 +57,15 @@ const BlobPage: NextPage = () => { | |||
/> | |||
</Subsection> | |||
</Section> | |||
<Section title="AudioFilePreview"> | |||
<Subsection title="Single File"> | |||
<BlobReact.AudioFilePreview | |||
file={audioFile} | |||
className="sm:h-64" | |||
enhanced | |||
/> | |||
</Subsection> | |||
</Section> | |||
<Section title="FileSelectBox"> | |||
<Subsection title="Single File"> | |||
{/*<BlobReact.FileSelectBox*/} | |||
@@ -29,6 +29,7 @@ const MIME_TYPE_DESCRIPTIONS = { | |||
'application/postscript': 'PostScript Document', | |||
'application/epub+zip': 'EPUB Document', | |||
'message/rfc822': 'Email Message', | |||
'video/mp4': 'MP4 Video', | |||
} as const; | |||
const EXTENSION_DESCRIPTIONS = { | |||
@@ -207,7 +208,7 @@ export interface AudioFile extends FileWithResolvedType<ContentType.AUDIO> { | |||
metadata?: AudioFileMetadata; | |||
} | |||
const augmentAudioFile = async (f: File): Promise<AudioFile> => { | |||
export const augmentAudioFile = async (f: File): Promise<AudioFile> => { | |||
const previewUrl = await readAsDataURL(f); | |||
const audioExtensions = await getAudioMetadata(previewUrl, f.type) as AudioFileMetadata; | |||
return { | |||