@@ -6,7 +6,7 @@ import { | |||||
formatSecondsDurationConcise, | formatSecondsDurationConcise, | ||||
formatSecondsDurationPrecise, | formatSecondsDurationPrecise, | ||||
} from '@/utils/numeral'; | } from '@/utils/numeral'; | ||||
import {useMediaControls} from '../../hooks/media'; | |||||
import {useMediaControls} from '../../hooks/interactive'; | |||||
import {useAugmentedFile} from '@/categories/blob/react'; | import {useAugmentedFile} from '@/categories/blob/react'; | ||||
import clsx from 'clsx'; | import clsx from 'clsx'; | ||||
@@ -26,6 +26,7 @@ export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedComponen | |||||
className, | className, | ||||
enhanced = false, | enhanced = false, | ||||
disabled = false, | disabled = false, | ||||
...etcProps | |||||
}, forwardedRef) => { | }, forwardedRef) => { | ||||
const { augmentedFile, error } = useAugmentedFile({ | const { augmentedFile, error } = useAugmentedFile({ | ||||
file, | file, | ||||
@@ -50,8 +51,11 @@ export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedComponen | |||||
startSeek, | startSeek, | ||||
endSeek, | endSeek, | ||||
setSeek, | setSeek, | ||||
visualizationMode, | |||||
handleVisualizationModeChange, | |||||
} = useMediaControls<HTMLAudioElement>({ | } = useMediaControls<HTMLAudioElement>({ | ||||
controllerRef: forwardedRef, | controllerRef: forwardedRef, | ||||
visualizationMode: 'waveform', | |||||
}); | }); | ||||
const formId = React.useId(); | const formId = React.useId(); | ||||
@@ -79,27 +83,74 @@ export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedComponen | |||||
data-testid="preview" | data-testid="preview" | ||||
> | > | ||||
<div className="w-full flex-auto relative"> | <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} | ref={mediaControllerRef} | ||||
onLoadedMetadata={refreshControls} | onLoadedMetadata={refreshControls} | ||||
onDurationChange={refreshControls} | onDurationChange={refreshControls} | ||||
onEnded={reset} | onEnded={reset} | ||||
onTimeUpdate={updateSeekFromPlayback} | 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 | <source | ||||
src={augmentedFile.metadata.previewUrl} | src={augmentedFile.metadata.previewUrl} | ||||
type={augmentedFile.type} | 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> | </div> | ||||
{enhanced && ( | {enhanced && ( | ||||
<div className="w-full flex-shrink-0 h-10 flex gap-4 relative"> | <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} | style={style} | ||||
> | > | ||||
<div className="h-full relative"> | <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' | typeof augmentedFile.metadata?.previewUrl === 'string' | ||||
&& ( | && ( | ||||
@@ -144,7 +144,7 @@ export const ImageFilePreview = React.forwardRef<ImageFilePreviewDerivedComponen | |||||
'focus:outline-0', | 'focus:outline-0', | ||||
'disabled:opacity-50 disabled:cursor-not-allowed', | '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'); | const downloadLink = window.document.createElement('a'); | ||||
downloadLink.download = filenameRef.current.textContent ?? 'image'; | downloadLink.download = filenameRef.current.textContent ?? 'image'; | ||||
downloadLink.href = imageRef.current.src; | |||||
downloadLink.href = imageRef.current.currentSrc; | |||||
downloadLink.addEventListener('click', () => { | downloadLink.addEventListener('click', () => { | ||||
downloadLink.remove(); | 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> { | export interface UseMediaControlsOptions<T extends HTMLMediaElement> { | ||||
controllerRef: React.Ref<T>; | controllerRef: React.Ref<T>; | ||||
actionFormKey?: string; | actionFormKey?: string; | ||||
visualizationMode?: string; | |||||
} | } | ||||
export const useMediaControls = <T extends HTMLMediaElement>({ | export const useMediaControls = <T extends HTMLMediaElement>({ | ||||
controllerRef: forwardedRef, | controllerRef: forwardedRef, | ||||
actionFormKey = 'action' as const, | actionFormKey = 'action' as const, | ||||
visualizationMode: initialVisualizationMode, | |||||
}: UseMediaControlsOptions<T>) => { | }: UseMediaControlsOptions<T>) => { | ||||
const defaultRef = React.useRef<T>(null); | const defaultRef = React.useRef<T>(null); | ||||
const ref = forwardedRef ?? defaultRef; | 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); | ||||
const [visualizationMode, setVisualizationMode] = React.useState(initialVisualizationMode); | |||||
const [isPlaying, setIsPlaying] = React.useState(false); | const [isPlaying, setIsPlaying] = React.useState(false); | ||||
const [isSeeking, setIsSeeking] = React.useState(false); | const [isSeeking, setIsSeeking] = React.useState(false); | ||||
const [currentTimeDisplay, setCurrentTimeDisplay] = React.useState<number>(); | const [currentTimeDisplay, setCurrentTimeDisplay] = React.useState<number>(); | ||||
@@ -123,6 +126,10 @@ export const useMediaControls = <T extends HTMLMediaElement>({ | |||||
downloadLink.click(); | downloadLink.click(); | ||||
}, [ref, filenameRef]); | }, [ref, filenameRef]); | ||||
const handleVisualizationModeChange: React.ChangeEventHandler<HTMLInputElement> = React.useCallback((e) => { | |||||
setVisualizationMode(e.currentTarget.value); | |||||
}, []); | |||||
const actions = React.useMemo(() => ({ | const actions = React.useMemo(() => ({ | ||||
togglePlayback, | togglePlayback, | ||||
toggleSeekTimeCountMode, | toggleSeekTimeCountMode, | ||||
@@ -229,6 +236,8 @@ export const useMediaControls = <T extends HTMLMediaElement>({ | |||||
mediaControllerRef: ref, | mediaControllerRef: ref, | ||||
handleAction, | handleAction, | ||||
filenameRef, | filenameRef, | ||||
visualizationMode, | |||||
handleVisualizationModeChange, | |||||
}), [ | }), [ | ||||
refreshControls, | refreshControls, | ||||
isPlaying, | isPlaying, | ||||
@@ -246,5 +255,7 @@ export const useMediaControls = <T extends HTMLMediaElement>({ | |||||
ref, | ref, | ||||
handleAction, | handleAction, | ||||
filenameRef, | 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 './components/VideoFilePreview'; | ||||
export * from './hooks/blob'; | export * from './hooks/blob'; | ||||
export * from './hooks/media'; | |||||
export * from './hooks/interactive'; |
@@ -69,6 +69,7 @@ export const WaveSurferCanvas = React.forwardRef<WaveSurferCanvasDerivedComponen | |||||
return; | return; | ||||
} | } | ||||
const { default: WaveSurfer } = await import('wavesurfer.js'); | const { default: WaveSurfer } = await import('wavesurfer.js'); | ||||
//const a = await import('wavesurfer.js/dist/plugins/spectrogram'); | |||||
const waveSurferInstance = WaveSurfer.create({ | const waveSurferInstance = WaveSurfer.create({ | ||||
container: containerRef.current, | container: containerRef.current, | ||||
height: containerRef.current.clientHeight, | height: containerRef.current.clientHeight, | ||||
@@ -97,8 +98,6 @@ export const WaveSurferCanvas = React.forwardRef<WaveSurferCanvasDerivedComponen | |||||
cursorWidth, | cursorWidth, | ||||
media: media ?? undefined, | media: media ?? undefined, | ||||
}); | }); | ||||
await waveSurferInstance.load(ref.current.currentSrc); | |||||
waveSurferInstance.setTime(ref.current.currentTime); | |||||
waveSurferInstance.on('ready', () => { | waveSurferInstance.on('ready', () => { | ||||
if (!container) { | if (!container) { | ||||
return; | return; | ||||
@@ -107,6 +106,8 @@ export const WaveSurferCanvas = React.forwardRef<WaveSurferCanvasDerivedComponen | |||||
container.removeChild(container.children[0]); | container.removeChild(container.children[0]); | ||||
} | } | ||||
}); | }); | ||||
await waveSurferInstance.load(ref.current.currentSrc); | |||||
waveSurferInstance.setTime(ref.current.currentTime); | |||||
waveSurferRef.current = waveSurferInstance; | waveSurferRef.current = waveSurferInstance; | ||||
}; | }; | ||||
void load(ref); | void load(ref); | ||||
@@ -147,19 +148,10 @@ export const WaveSurferCanvas = React.forwardRef<WaveSurferCanvasDerivedComponen | |||||
return ( | return ( | ||||
<div | <div | ||||
className={clsx( | className={clsx( | ||||
'relative h-full w-full flex flex-col', | |||||
'relative flex flex-col', | |||||
className, | className, | ||||
)} | )} | ||||
> | > | ||||
<audio | |||||
{...etcProps} | |||||
controls={controls} | |||||
className={className} | |||||
ref={ref} | |||||
autoPlay={autoPlay} | |||||
> | |||||
{children} | |||||
</audio> | |||||
<div | <div | ||||
className="flex-auto relative" | className="flex-auto relative" | ||||
> | > | ||||