Browse Source

Implement audio file preview

Use Wavesurfer for reimplementing audio file preview component.
pull/1/head
TheoryOfNekomata 1 year ago
parent
commit
fac2205e69
15 changed files with 572 additions and 198 deletions
  1. +3
    -2
      packages/web-kitchensink-reactnext/package.json
  2. +17
    -4
      packages/web-kitchensink-reactnext/pnpm-lock.yaml
  3. BIN
      packages/web-kitchensink-reactnext/public/audio.wav
  4. +238
    -72
      packages/web-kitchensink-reactnext/src/categories/blob/react/components/AudioFilePreview/index.tsx
  5. +2
    -2
      packages/web-kitchensink-reactnext/src/categories/blob/react/components/AudioMiniFilePreview/index.tsx
  6. +2
    -2
      packages/web-kitchensink-reactnext/src/categories/blob/react/components/ImageFilePreview/index.tsx
  7. +11
    -10
      packages/web-kitchensink-reactnext/src/categories/blob/react/components/VideoFilePreview/index.tsx
  8. +1
    -1
      packages/web-kitchensink-reactnext/src/categories/blob/react/hooks/blob/metadata.ts
  9. +110
    -51
      packages/web-kitchensink-reactnext/src/categories/blob/react/hooks/media/audio.ts
  10. +46
    -51
      packages/web-kitchensink-reactnext/src/categories/blob/react/hooks/media/video.ts
  11. +1
    -2
      packages/web-kitchensink-reactnext/src/categories/blob/react/index.ts
  12. +109
    -0
      packages/web-kitchensink-reactnext/src/packages/react-wavesurfer/WaveSurferCanvas/index.tsx
  13. +10
    -0
      packages/web-kitchensink-reactnext/src/packages/react-wavesurfer/index.ts
  14. +20
    -0
      packages/web-kitchensink-reactnext/src/pages/categories/blob/index.tsx
  15. +2
    -1
      packages/web-kitchensink-reactnext/src/utils/blob.ts

+ 3
- 2
packages/web-kitchensink-reactnext/package.json View File

@@ -26,10 +26,11 @@
"react-dom": "18.2.0", "react-dom": "18.2.0",
"tailwindcss": "3.3.2", "tailwindcss": "3.3.2",
"typescript": "5.1.3", "typescript": "5.1.3",
"wavesurfer.js": "7.0.0-beta.6"
"wavesurfer.js": "7.0.0-beta.11"
}, },
"devDependencies": { "devDependencies": {
"@types/mime-types": "^2.1.1", "@types/mime-types": "^2.1.1",
"@types/prismjs": "^1.26.0"
"@types/prismjs": "^1.26.0",
"@types/wavesurfer.js": "^6.0.6"
} }
} }

+ 17
- 4
packages/web-kitchensink-reactnext/pnpm-lock.yaml View File

@@ -57,8 +57,8 @@ dependencies:
specifier: 5.1.3 specifier: 5.1.3
version: 5.1.3 version: 5.1.3
wavesurfer.js: 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: devDependencies:
'@types/mime-types': '@types/mime-types':
@@ -67,6 +67,9 @@ devDependencies:
'@types/prismjs': '@types/prismjs':
specifier: ^1.26.0 specifier: ^1.26.0
version: 1.26.0 version: 1.26.0
'@types/wavesurfer.js':
specifier: ^6.0.6
version: 6.0.6


packages: packages:


@@ -349,6 +352,10 @@ packages:
tslib: 2.5.3 tslib: 2.5.3
dev: false dev: false


/@types/debounce@1.2.1:
resolution: {integrity: sha512-epMsEE85fi4lfmJUH/89/iV/LI+F5CvNIvmgs5g5jYFPfhO2S/ae8WSsLOKWdwtoaZw9Q2IhJ4tQ5tFCcS/4HA==}
dev: true

/@types/json5@0.0.29: /@types/json5@0.0.29:
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
dev: false dev: false
@@ -387,6 +394,12 @@ packages:
resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==} resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==}
dev: false 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): /@typescript-eslint/parser@5.60.0(eslint@8.43.0)(typescript@5.1.3):
resolution: {integrity: sha512-jBONcBsDJ9UoTWrARkRRCgDz6wUggmH5RpQVlt7BimSwaTkTjwypGzKORXbR4/2Hqjk9hgwlon2rVQAjWNpkyQ==} resolution: {integrity: sha512-jBONcBsDJ9UoTWrARkRRCgDz6wUggmH5RpQVlt7BimSwaTkTjwypGzKORXbR4/2Hqjk9hgwlon2rVQAjWNpkyQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -2702,8 +2715,8 @@ packages:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
dev: false 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 dev: false


/which-boxed-primitive@1.0.2: /which-boxed-primitive@1.0.2:


BIN
packages/web-kitchensink-reactnext/public/audio.wav View File


+ 238
- 72
packages/web-kitchensink-reactnext/src/categories/blob/react/components/AudioFilePreview/index.tsx View File

@@ -1,87 +1,253 @@
import * as React from 'react'; 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 { const {
mediaContainerRef,
playMedia,
mediaControllerRef,
refreshControls,
reset,
updateSeekFromPlayback,
isPlaying, 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 ( 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> </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> </div>
); );
};
});

AudioFilePreview.displayName = 'AudioFilePreview';

+ 2
- 2
packages/web-kitchensink-reactnext/src/categories/blob/react/components/AudioMiniFilePreview/index.tsx View File

@@ -11,7 +11,7 @@ export const AudioMiniFilePreview: React.FC<AudioMiniFilePreviewProps> = ({
file: f, file: f,
}) => { }) => {
const { const {
mediaContainerRef,
mountRef,
playMedia, playMedia,
isPlaying, isPlaying,
} = useAudioControls({ file: f }); } = useAudioControls({ file: f });
@@ -19,7 +19,7 @@ export const AudioMiniFilePreview: React.FC<AudioMiniFilePreviewProps> = ({
return ( return (
<div <div
className="absolute top-0 left-0 w-full h-full cursor-pointer" className="absolute top-0 left-0 w-full h-full cursor-pointer"
ref={mediaContainerRef}
ref={mountRef}
onClick={playMedia} onClick={playMedia}
/> />
); );


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

@@ -1,5 +1,5 @@
import * as React from 'react'; 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 {formatFileSize, formatNumeral} from '@/utils/numeral';
import clsx from 'clsx'; import clsx from 'clsx';
import {useAugmentedFile, useImageControls} from '@/categories/blob/react'; import {useAugmentedFile, useImageControls} from '@/categories/blob/react';
@@ -18,7 +18,7 @@ export const ImageFilePreview = React.forwardRef<ImageFilePreviewDerivedComponen
disabled = false, disabled = false,
...etcProps ...etcProps
}, forwardedRef) => { }, forwardedRef) => {
const { augmentedFile, error } = useAugmentedFile<ImageFile>({
const { augmentedFile, error } = useAugmentedFile({
file, file,
augmentFunction: augmentImageFile, augmentFunction: augmentImageFile,
}); });


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

@@ -1,7 +1,7 @@
import * as React from 'react'; 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 {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'; import clsx from 'clsx';


type VideoFilePreviewDerivedComponent = HTMLVideoElement; type VideoFilePreviewDerivedComponent = HTMLVideoElement;
@@ -12,7 +12,7 @@ export interface VideoFilePreviewProps extends Omit<React.HTMLProps<VideoFilePre
enhanced?: boolean; enhanced?: boolean;
} }


export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePreviewProps>(({
export const VideoFilePreview = React.forwardRef<VideoFilePreviewDerivedComponent, VideoFilePreviewProps>(({
file, file,
className, className,
style, style,
@@ -20,7 +20,7 @@ export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePrev
enhanced = false, enhanced = false,
...etcProps ...etcProps
}, forwardedRef) => { }, forwardedRef) => {
const { augmentedFile, error } = useAugmentedFile<VideoFile>({
const { augmentedFile, error } = useAugmentedFile({
file, file,
augmentFunction: augmentVideoFile, augmentFunction: augmentVideoFile,
}); });
@@ -29,7 +29,7 @@ export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePrev
volumeRef, volumeRef,
isPlaying, isPlaying,
adjustVolume, adjustVolume,
resetVideo,
reset,
startSeek, startSeek,
endSeek, endSeek,
setSeek, setSeek,
@@ -43,8 +43,8 @@ export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePrev
mediaControllerRef, mediaControllerRef,
handleAction, handleAction,
filenameRef, filenameRef,
} = useVideoControls({
mediaControllerRef: forwardedRef,
} = useMediaControls<HTMLVideoElement>({
controllerRef: forwardedRef,
}); });
const formId = React.useId(); const formId = React.useId();


@@ -78,7 +78,7 @@ export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePrev
ref={mediaControllerRef} ref={mediaControllerRef}
onLoadedMetadata={refreshControls} onLoadedMetadata={refreshControls}
onDurationChange={refreshControls} onDurationChange={refreshControls}
onEnded={resetVideo}
onEnded={reset}
onTimeUpdate={updateSeekFromPlayback} onTimeUpdate={updateSeekFromPlayback}
data-testid="preview" data-testid="preview"
controls={!enhanced} controls={!enhanced}
@@ -100,7 +100,7 @@ export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePrev
> >
{isPlaying ? '⏸' : '▶'} {isPlaying ? '⏸' : '▶'}
</button> </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 <button
className="absolute overflow-hidden w-12 opacity-0 h-10" className="absolute overflow-hidden w-12 opacity-0 h-10"
title="Toggle Seek Time Count Mode" title="Toggle Seek Time Count Mode"
@@ -112,7 +112,7 @@ export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePrev
Toggle Seek Time Count Mode Toggle Seek Time Count Mode
</button> </button>
<span <span
className="font-mono before:block before:content-[attr(title)] contents tabular-nums"
className="before:block before:content-[attr(title)] contents tabular-nums"
title={ title={
`${ `${
isSeekTimeCountingDown ? '−' : '+' isSeekTimeCountingDown ? '−' : '+'
@@ -131,6 +131,7 @@ export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePrev
onMouseUp={endSeek} onMouseUp={endSeek}
onChange={setSeek} onChange={setSeek}
defaultValue="0" defaultValue="0"
step="any"
/> />
</span> </span>
<span> <span>


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

@@ -22,7 +22,7 @@ export const useAugmentedFile = <T extends Partial<File>>(options = {} as UseAug
.catch((error) => { .catch((error) => {
setError(error); setError(error);
}); });
}, [file]);
}, [file, augmentFunction]);


return React.useMemo(() => ({ return React.useMemo(() => ({
augmentedFile: (augmentedFile ?? file) as T | undefined, augmentedFile: (augmentedFile ?? file) as T | undefined,


+ 110
- 51
packages/web-kitchensink-reactnext/src/categories/blob/react/hooks/media/audio.ts View File

@@ -1,87 +1,146 @@
import {AudioFile} from '@/utils/blob';
import * as React from 'react'; 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 [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; 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; return;
} }


if (typeof f.metadata?.previewUrl !== 'string') {
if (!mediaControllerRef.current) {
return; 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; 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(() => ({ return React.useMemo(() => ({
mediaContainerRef,
playMedia,
mediaControllerRef,
refreshControls,
reset: reset,
updateSeekFromPlayback,
isPlaying, isPlaying,
isSeeking,
currentTimeDisplay,
seekTimeDisplay,
durationDisplay,
isSeekTimeCountingDown,
volumeRef,
adjustVolume,
filenameRef,
handleAction,
}), [ }), [
mediaContainerRef,
playMedia,
mediaControllerRef,
refreshControls,
reset,
updateSeekFromPlayback,
isPlaying, isPlaying,
isSeeking,
currentTimeDisplay,
seekTimeDisplay,
durationDisplay,
isSeekTimeCountingDown,
volumeRef,
adjustVolume,
filenameRef,
handleAction,
]); ]);
}; };

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

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


export interface UseVideoControlsOptions {
mediaControllerRef: React.Ref<HTMLVideoElement>;
export interface UseMediaControlsOptions<T extends HTMLMediaElement> {
controllerRef: React.Ref<T>;
actionFormKey?: string; actionFormKey?: string;
} }


export const useVideoControls = ({
mediaControllerRef: forwardedRef,
export const useMediaControls = <T extends HTMLMediaElement>({
controllerRef: forwardedRef,
actionFormKey = 'action' as const, 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 seekRef = React.useRef<HTMLInputElement>(null);
const volumeRef = React.useRef<HTMLInputElement>(null); const volumeRef = React.useRef<HTMLInputElement>(null);
const filenameRef = React.useRef<HTMLElement>(null); const filenameRef = React.useRef<HTMLElement>(null);
@@ -21,16 +21,12 @@ export const useVideoControls = ({
const [durationDisplay, setDurationDisplay] = React.useState<number>(); const [durationDisplay, setDurationDisplay] = React.useState<number>();
const [isSeekTimeCountingDown, setIsSeekTimeCountingDown] = React.useState(false); 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 { currentTarget: mediaController } = e;
const { current: seek } = seekRef;
if (!seek) {
return;
}


setCurrentTimeDisplay(mediaController.currentTime); setCurrentTimeDisplay(mediaController.currentTime);
setDurationDisplay(mediaController.duration); setDurationDisplay(mediaController.duration);
};
}, []);


const togglePlayback = React.useCallback(() => { const togglePlayback = React.useCallback(() => {
setIsPlaying((p) => !p); setIsPlaying((p) => !p);
@@ -40,16 +36,16 @@ export const useVideoControls = ({
setIsSeeking(true); setIsSeeking(true);
}, []); }, []);


const doSetSeek = (thisElement: HTMLInputElement, mediaController: HTMLVideoElement) => {
const doSetSeek = React.useCallback((thisElement: HTMLInputElement, mediaController: HTMLMediaElement) => {
mediaController.currentTime = thisElement.valueAsNumber; mediaController.currentTime = thisElement.valueAsNumber;
};
}, []);


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


const { current: mediaController } = mediaControllerRef;
const { current: mediaController } = ref;
if (!mediaController) { if (!mediaController) {
return; return;
} }
@@ -62,14 +58,14 @@ export const useVideoControls = ({
} }


doSetSeek(thisElement, mediaController); doSetSeek(thisElement, mediaController);
}, [mediaControllerRef, isSeeking]);
}, [ref, isSeeking, doSetSeek]);


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


const { current: mediaController } = mediaControllerRef;
const { current: mediaController } = ref;


if (!mediaController) { if (!mediaController) {
return; return;
@@ -78,32 +74,30 @@ export const useVideoControls = ({
const { currentTarget: thisElement } = e; const { currentTarget: thisElement } = e;
setIsSeeking(false); setIsSeeking(false);
doSetSeek(thisElement, mediaController); 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; const videoElement = e.currentTarget;
setIsPlaying(false); setIsPlaying(false);
videoElement.currentTime = 0; 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; return;
} }


if (isSeeking) {
const videoElement = e.currentTarget;
const currentTime = videoElement.currentTime;
setCurrentTimeDisplay(currentTime);

if (!seekRef.current) {
return; return;
} }

const { current: seek } = seekRef; const { current: seek } = seekRef;

if (!seek) { if (!seek) {
return; return;
} }

const videoElement = e.currentTarget;
const currentTime = videoElement.currentTime;
setCurrentTimeDisplay(currentTime);
seek.value = String(currentTime); seek.value = String(currentTime);
}, [isSeeking]); }, [isSeeking]);


@@ -112,7 +106,7 @@ export const useVideoControls = ({
}, []); }, []);


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


@@ -122,12 +116,12 @@ export const useVideoControls = ({


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


const actions = React.useMemo(() => ({ const actions = React.useMemo(() => ({
togglePlayback, togglePlayback,
@@ -144,33 +138,33 @@ export const useVideoControls = ({
}, [actions, actionFormKey]); }, [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 ref === 'object' && ref !== null)) {
return; return;
} }


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


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


if (!mediaControllerRef.current) {
if (!ref.current) {
return; return;
} }


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


mediaControllerRef.current.pause();
}, [isPlaying, mediaControllerRef]);
ref.current.pause();
}, [isPlaying, ref]);


React.useEffect(() => { React.useEffect(() => {
if (!seekRef.current) { if (!seekRef.current) {
@@ -203,18 +197,18 @@ export const useVideoControls = ({
return; return;
} }


if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) {
if (!(typeof ref === 'object' && ref !== null)) {
return; return;
} }


if (!mediaControllerRef.current) {
if (!ref.current) {
return; return;
} }


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


return React.useMemo(() => ({ return React.useMemo(() => ({
seekRef, seekRef,
@@ -222,7 +216,7 @@ export const useVideoControls = ({
isPlaying, isPlaying,
refreshControls, refreshControls,
adjustVolume, adjustVolume,
resetVideo,
reset,
startSeek, startSeek,
endSeek, endSeek,
setSeek, setSeek,
@@ -232,14 +226,15 @@ export const useVideoControls = ({
seekTimeDisplay, seekTimeDisplay,
isSeeking, isSeeking,
isSeekTimeCountingDown, isSeekTimeCountingDown,
mediaControllerRef,
mediaControllerRef: ref,
handleAction, handleAction,
filenameRef, filenameRef,
}), [ }), [
refreshControls,
isPlaying, isPlaying,
isSeeking, isSeeking,
adjustVolume, adjustVolume,
resetVideo,
reset,
startSeek, startSeek,
endSeek, endSeek,
setSeek, setSeek,
@@ -248,7 +243,7 @@ export const useVideoControls = ({
currentTimeDisplay, currentTimeDisplay,
seekTimeDisplay, seekTimeDisplay,
isSeekTimeCountingDown, isSeekTimeCountingDown,
mediaControllerRef,
ref,
handleAction, handleAction,
filenameRef, filenameRef,
]); ]);


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

@@ -1,7 +1,6 @@
//export * from './components/AudioFilePreview';
export * from './components/AudioFilePreview';
//export * from './components/AudioMiniFilePreview'; //export * from './components/AudioMiniFilePreview';
//export * from './components/BinaryFilePreview'; //export * from './components/BinaryFilePreview';
//export * from './components/BinaryFilePreview';
//export * from './components/FileSelectBox'; //export * from './components/FileSelectBox';
export * from './components/ImageFilePreview'; export * from './components/ImageFilePreview';
//export * from './components/TextFilePreview'; //export * from './components/TextFilePreview';


+ 109
- 0
packages/web-kitchensink-reactnext/src/packages/react-wavesurfer/WaveSurferCanvas/index.tsx View File

@@ -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';

+ 10
- 0
packages/web-kitchensink-reactnext/src/packages/react-wavesurfer/index.ts View File

@@ -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;
}

+ 20
- 0
packages/web-kitchensink-reactnext/src/pages/categories/blob/index.tsx View File

@@ -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 ( return (
<DefaultLayout> <DefaultLayout>
<Section title="ImageFilePreview"> <Section title="ImageFilePreview">
@@ -46,6 +57,15 @@ const BlobPage: NextPage = () => {
/> />
</Subsection> </Subsection>
</Section> </Section>
<Section title="AudioFilePreview">
<Subsection title="Single File">
<BlobReact.AudioFilePreview
file={audioFile}
className="sm:h-64"
enhanced
/>
</Subsection>
</Section>
<Section title="FileSelectBox"> <Section title="FileSelectBox">
<Subsection title="Single File"> <Subsection title="Single File">
{/*<BlobReact.FileSelectBox*/} {/*<BlobReact.FileSelectBox*/}


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

@@ -29,6 +29,7 @@ const MIME_TYPE_DESCRIPTIONS = {
'application/postscript': 'PostScript Document', 'application/postscript': 'PostScript Document',
'application/epub+zip': 'EPUB Document', 'application/epub+zip': 'EPUB Document',
'message/rfc822': 'Email Message', 'message/rfc822': 'Email Message',
'video/mp4': 'MP4 Video',
} as const; } as const;


const EXTENSION_DESCRIPTIONS = { const EXTENSION_DESCRIPTIONS = {
@@ -207,7 +208,7 @@ export interface AudioFile extends FileWithResolvedType<ContentType.AUDIO> {
metadata?: AudioFileMetadata; metadata?: AudioFileMetadata;
} }


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


Loading…
Cancel
Save