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",
"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"
}
}

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

@@ -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:


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

+ 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,
}) => {
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}
/>
);


+ 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 {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,
});


+ 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 {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>


+ 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) => {
setError(error);
});
}, [file]);
}, [file, augmentFunction]);

return React.useMemo(() => ({
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 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,
]);
};

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

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
- 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/BinaryFilePreview';
//export * from './components/BinaryFilePreview';
//export * from './components/FileSelectBox';
export * from './components/ImageFilePreview';
//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 (
<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*/}


+ 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/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 {


Loading…
Cancel
Save