Unify audio and video interactivity.pull/1/head
@@ -6,7 +6,7 @@ import { | |||
formatSecondsDurationConcise, | |||
formatSecondsDurationPrecise, | |||
} from '@/utils/numeral'; | |||
import {useMediaControls} from '../../hooks/media'; | |||
import {useMediaControls} from '../../hooks/interactive'; | |||
import {useAugmentedFile} from '@/categories/blob/react'; | |||
import clsx from 'clsx'; | |||
@@ -26,6 +26,7 @@ export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedComponen | |||
className, | |||
enhanced = false, | |||
disabled = false, | |||
...etcProps | |||
}, forwardedRef) => { | |||
const { augmentedFile, error } = useAugmentedFile({ | |||
file, | |||
@@ -50,8 +51,11 @@ export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedComponen | |||
startSeek, | |||
endSeek, | |||
setSeek, | |||
visualizationMode, | |||
handleVisualizationModeChange, | |||
} = useMediaControls<HTMLAudioElement>({ | |||
controllerRef: forwardedRef, | |||
visualizationMode: 'waveform', | |||
}); | |||
const formId = React.useId(); | |||
@@ -79,27 +83,74 @@ export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedComponen | |||
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]" | |||
<audio | |||
{...etcProps} | |||
controls={!enhanced} | |||
ref={mediaControllerRef} | |||
onLoadedMetadata={refreshControls} | |||
onDurationChange={refreshControls} | |||
onEnded={reset} | |||
onTimeUpdate={updateSeekFromPlayback} | |||
data-testid="preview" | |||
barWidth={2} | |||
barGap={2} | |||
waveColor="rgb(199 138 179)" | |||
progressColor="rgb(238 238 238)" | |||
cursorWidth={2} | |||
cursorColor="rgb(255 153 0)" | |||
interact | |||
> | |||
<source | |||
src={augmentedFile.metadata.previewUrl} | |||
type={augmentedFile.type} | |||
/> | |||
</WaveSurferCanvas> | |||
</audio> | |||
{visualizationMode === 'waveform' && ( | |||
<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} | |||
data-testid="preview" | |||
barWidth={1} | |||
barGap={1} | |||
waveColor="rgb(199 138 179)" | |||
progressColor="rgb(255 153 0)" | |||
interact | |||
/> | |||
)} | |||
<div className="flex gap-4 absolute top-0 right-0 z-[2] px-4"> | |||
<label | |||
className={clsx( | |||
'h-12 flex 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', | |||
)} | |||
> | |||
<input | |||
type="radio" | |||
name="visualizationMode" | |||
value="waveform" | |||
className="sr-only" | |||
onChange={handleVisualizationModeChange} | |||
/> | |||
<span | |||
className="block uppercase font-bold h-[1.1em] w-full whitespace-nowrap overflow-hidden text-ellipsis font-semi-expanded" | |||
> | |||
Waveform | |||
</span> | |||
</label> | |||
<label | |||
className={clsx( | |||
'h-12 flex 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', | |||
)} | |||
> | |||
<input | |||
type="radio" | |||
name="visualizationMode" | |||
value="spectrum" | |||
className="sr-only" | |||
onChange={handleVisualizationModeChange} | |||
/> | |||
<span | |||
className="block uppercase font-bold h-[1.1em] w-full whitespace-nowrap overflow-hidden text-ellipsis font-semi-expanded" | |||
> | |||
Spectrum | |||
</span> | |||
</label> | |||
</div> | |||
</div> | |||
{enhanced && ( | |||
<div className="w-full flex-shrink-0 h-10 flex gap-4 relative"> | |||
@@ -39,7 +39,7 @@ export const ImageFilePreview = React.forwardRef<ImageFilePreviewDerivedComponen | |||
style={style} | |||
> | |||
<div className="h-full relative"> | |||
<div className="sm:absolute top-0 left-0 w-full sm:h-full z-[1]"> | |||
<div className="sm:absolute top-0 left-0 w-full sm:h-full z-[3]"> | |||
{ | |||
typeof augmentedFile.metadata?.previewUrl === 'string' | |||
&& ( | |||
@@ -144,7 +144,7 @@ export const ImageFilePreview = React.forwardRef<ImageFilePreviewDerivedComponen | |||
'focus:outline-0', | |||
'disabled:opacity-50 disabled:cursor-not-allowed', | |||
{ | |||
'fixed top-0 left-0 w-full h-full opacity-0 z-[1]': fullScreen, | |||
'fixed top-0 left-0 w-full h-full opacity-0 z-[3]': fullScreen, | |||
} | |||
)} | |||
> | |||
@@ -27,7 +27,7 @@ export const useImageControls = (options = {} as UseImageControlsOptions) => { | |||
const downloadLink = window.document.createElement('a'); | |||
downloadLink.download = filenameRef.current.textContent ?? 'image'; | |||
downloadLink.href = imageRef.current.src; | |||
downloadLink.href = imageRef.current.currentSrc; | |||
downloadLink.addEventListener('click', () => { | |||
downloadLink.remove(); | |||
}); |
@@ -0,0 +1,2 @@ | |||
export * from './media'; | |||
export * from './image'; |
@@ -3,17 +3,20 @@ import * as React from 'react'; | |||
export interface UseMediaControlsOptions<T extends HTMLMediaElement> { | |||
controllerRef: React.Ref<T>; | |||
actionFormKey?: string; | |||
visualizationMode?: string; | |||
} | |||
export const useMediaControls = <T extends HTMLMediaElement>({ | |||
controllerRef: forwardedRef, | |||
actionFormKey = 'action' as const, | |||
visualizationMode: initialVisualizationMode, | |||
}: 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); | |||
const [visualizationMode, setVisualizationMode] = React.useState(initialVisualizationMode); | |||
const [isPlaying, setIsPlaying] = React.useState(false); | |||
const [isSeeking, setIsSeeking] = React.useState(false); | |||
const [currentTimeDisplay, setCurrentTimeDisplay] = React.useState<number>(); | |||
@@ -123,6 +126,10 @@ export const useMediaControls = <T extends HTMLMediaElement>({ | |||
downloadLink.click(); | |||
}, [ref, filenameRef]); | |||
const handleVisualizationModeChange: React.ChangeEventHandler<HTMLInputElement> = React.useCallback((e) => { | |||
setVisualizationMode(e.currentTarget.value); | |||
}, []); | |||
const actions = React.useMemo(() => ({ | |||
togglePlayback, | |||
toggleSeekTimeCountMode, | |||
@@ -229,6 +236,8 @@ export const useMediaControls = <T extends HTMLMediaElement>({ | |||
mediaControllerRef: ref, | |||
handleAction, | |||
filenameRef, | |||
visualizationMode, | |||
handleVisualizationModeChange, | |||
}), [ | |||
refreshControls, | |||
isPlaying, | |||
@@ -246,5 +255,7 @@ export const useMediaControls = <T extends HTMLMediaElement>({ | |||
ref, | |||
handleAction, | |||
filenameRef, | |||
visualizationMode, | |||
handleVisualizationModeChange, | |||
]); | |||
}; |
@@ -1,146 +0,0 @@ | |||
import * as React from 'react'; | |||
export interface UseAudioControlsOptions { | |||
actionFormKey?: string; | |||
forwardedRef?: React.Ref<any>; | |||
} | |||
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); | |||
const refreshControls: React.ReactEventHandler<HTMLAudioElement> = React.useCallback((e) => { | |||
const { currentTarget: mediaController } = e; | |||
setCurrentTimeDisplay(mediaController.currentTime); | |||
setDurationDisplay(mediaController.duration); | |||
}, []); | |||
const reset: React.ReactEventHandler<HTMLAudioElement> = React.useCallback((e) => { | |||
const videoElement = e.currentTarget; | |||
setIsPlaying(false); | |||
videoElement.currentTime = 0; | |||
}, []); | |||
const updateSeekFromPlayback = React.useCallback((e) => { | |||
if (isSeeking) { | |||
return; | |||
} | |||
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 (!mediaControllerRef.current) { | |||
return; | |||
} | |||
const { value } = e.currentTarget; | |||
mediaControllerRef.current.volume = Number(value); | |||
}, [mediaControllerRef]); | |||
const togglePlayback = React.useCallback(() => { | |||
setIsPlaying((p) => !p); | |||
}, []); | |||
const toggleSeekTimeCountMode = React.useCallback(() => { | |||
setIsSeekTimeCountingDown((b) => !b); | |||
}, []); | |||
const download = React.useCallback(() => { | |||
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef?.current !== null)) { | |||
return; | |||
} | |||
if (!(typeof filenameRef === 'object' && filenameRef?.current !== null)) { | |||
return; | |||
} | |||
const downloadLink = window.document.createElement('a'); | |||
downloadLink.download = filenameRef.current.textContent ?? 'image'; | |||
downloadLink.href = mediaControllerRef.current.currentSrc; | |||
downloadLink.addEventListener('click', () => { | |||
downloadLink.remove(); | |||
}); | |||
downloadLink.click(); | |||
}, [mediaControllerRef, filenameRef]); | |||
const actions = React.useMemo(() => ({ | |||
togglePlayback, | |||
toggleSeekTimeCountMode, | |||
download, | |||
}), [togglePlayback, toggleSeekTimeCountMode, download]); | |||
const handleAction: React.FormEventHandler<HTMLFormElement> = React.useCallback((e) => { | |||
e.preventDefault(); | |||
const formData = new FormData(e.currentTarget, (e.nativeEvent as unknown as { submitter: HTMLElement }).submitter); | |||
const actionName = formData.get(actionFormKey) as keyof typeof actions; | |||
const { [actionName]: actionFunction } = actions; | |||
actionFunction?.(); | |||
}, [actions, actionFormKey]); | |||
React.useEffect(() => { | |||
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) { | |||
return; | |||
} | |||
if (!mediaControllerRef.current) { | |||
return; | |||
} | |||
if (isPlaying) { | |||
//console.log(mediaControllerRef.current); | |||
void mediaControllerRef.current.play(); | |||
return | |||
} | |||
mediaControllerRef.current.pause(); | |||
}, [isPlaying, mediaControllerRef]); | |||
return React.useMemo(() => ({ | |||
mediaControllerRef, | |||
refreshControls, | |||
reset: reset, | |||
updateSeekFromPlayback, | |||
isPlaying, | |||
isSeeking, | |||
currentTimeDisplay, | |||
seekTimeDisplay, | |||
durationDisplay, | |||
isSeekTimeCountingDown, | |||
volumeRef, | |||
adjustVolume, | |||
filenameRef, | |||
handleAction, | |||
}), [ | |||
mediaControllerRef, | |||
refreshControls, | |||
reset, | |||
updateSeekFromPlayback, | |||
isPlaying, | |||
isSeeking, | |||
currentTimeDisplay, | |||
seekTimeDisplay, | |||
durationDisplay, | |||
isSeekTimeCountingDown, | |||
volumeRef, | |||
adjustVolume, | |||
filenameRef, | |||
handleAction, | |||
]); | |||
}; |
@@ -1,3 +0,0 @@ | |||
export * from './audio'; | |||
export * from './video'; | |||
export * from './image'; |
@@ -7,4 +7,4 @@ export * from './components/ImageFilePreview'; | |||
export * from './components/VideoFilePreview'; | |||
export * from './hooks/blob'; | |||
export * from './hooks/media'; | |||
export * from './hooks/interactive'; |
@@ -69,6 +69,7 @@ export const WaveSurferCanvas = React.forwardRef<WaveSurferCanvasDerivedComponen | |||
return; | |||
} | |||
const { default: WaveSurfer } = await import('wavesurfer.js'); | |||
//const a = await import('wavesurfer.js/dist/plugins/spectrogram'); | |||
const waveSurferInstance = WaveSurfer.create({ | |||
container: containerRef.current, | |||
height: containerRef.current.clientHeight, | |||
@@ -97,8 +98,6 @@ export const WaveSurferCanvas = React.forwardRef<WaveSurferCanvasDerivedComponen | |||
cursorWidth, | |||
media: media ?? undefined, | |||
}); | |||
await waveSurferInstance.load(ref.current.currentSrc); | |||
waveSurferInstance.setTime(ref.current.currentTime); | |||
waveSurferInstance.on('ready', () => { | |||
if (!container) { | |||
return; | |||
@@ -107,6 +106,8 @@ export const WaveSurferCanvas = React.forwardRef<WaveSurferCanvasDerivedComponen | |||
container.removeChild(container.children[0]); | |||
} | |||
}); | |||
await waveSurferInstance.load(ref.current.currentSrc); | |||
waveSurferInstance.setTime(ref.current.currentTime); | |||
waveSurferRef.current = waveSurferInstance; | |||
}; | |||
void load(ref); | |||
@@ -147,19 +148,10 @@ export const WaveSurferCanvas = React.forwardRef<WaveSurferCanvasDerivedComponen | |||
return ( | |||
<div | |||
className={clsx( | |||
'relative h-full w-full flex flex-col', | |||
'relative flex flex-col', | |||
className, | |||
)} | |||
> | |||
<audio | |||
{...etcProps} | |||
controls={controls} | |||
className={className} | |||
ref={ref} | |||
autoPlay={autoPlay} | |||
> | |||
{children} | |||
</audio> | |||
<div | |||
className="flex-auto relative" | |||
> | |||