|
|
@@ -1,109 +1,256 @@ |
|
|
|
import * as React from 'react'; |
|
|
|
import {getMimeTypeDescription, VideoFile} from '../../utils/blob'; |
|
|
|
import {formatFileSize, formatNumeral} from '../../utils/numeral'; |
|
|
|
import {formatFileSize, formatNumeral, formatSecondsDurationConcise} from '../../utils/numeral'; |
|
|
|
|
|
|
|
export interface VideoFilePreviewProps { |
|
|
|
file: VideoFile; |
|
|
|
} |
|
|
|
|
|
|
|
export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePreviewProps>(({ |
|
|
|
file: f, |
|
|
|
}, forwardedRef) => { |
|
|
|
const defaultRef = React.useRef<HTMLVideoElement>(null); |
|
|
|
const mediaControllerRef = forwardedRef ?? defaultRef; |
|
|
|
interface UseVideoControlsOptions { |
|
|
|
mediaControllerRef: React.Ref<HTMLVideoElement>; |
|
|
|
} |
|
|
|
|
|
|
|
const useVideoControls = ({ |
|
|
|
mediaControllerRef, |
|
|
|
}: UseVideoControlsOptions) => { |
|
|
|
const seekRef = React.useRef<HTMLInputElement>(null); |
|
|
|
const volumeRef = React.useRef<HTMLInputElement>(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 (typeof mediaControllerRef !== 'object') { |
|
|
|
const refreshControls: React.ReactEventHandler<HTMLVideoElement> = (e) => { |
|
|
|
const { currentTarget: mediaController } = e; |
|
|
|
const { current: seek } = seekRef; |
|
|
|
if (!seek) { |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
if (!mediaControllerRef.current) { |
|
|
|
setCurrentTimeDisplay(mediaController.currentTime); |
|
|
|
setDurationDisplay(mediaController.duration); |
|
|
|
}; |
|
|
|
|
|
|
|
const playMedia = React.useCallback(() => { |
|
|
|
setIsPlaying((p) => !p); |
|
|
|
}, []); |
|
|
|
|
|
|
|
const startSeek: React.MouseEventHandler<HTMLInputElement> = React.useCallback(() => { |
|
|
|
setIsSeeking(true); |
|
|
|
}, []); |
|
|
|
|
|
|
|
const doSetSeek = (thisElement: HTMLInputElement, mediaController: HTMLVideoElement) => { |
|
|
|
mediaController.currentTime = thisElement.valueAsNumber; |
|
|
|
}; |
|
|
|
|
|
|
|
const setSeek: React.ChangeEventHandler<HTMLInputElement> = React.useCallback((e) => { |
|
|
|
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) { |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
if (isPlaying) { |
|
|
|
void mediaControllerRef.current.play(); |
|
|
|
return |
|
|
|
const { current: mediaController } = mediaControllerRef; |
|
|
|
if (!mediaController) { |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
mediaControllerRef.current.pause(); |
|
|
|
}, [isPlaying, mediaControllerRef]); |
|
|
|
const { currentTarget: thisElement } = e; |
|
|
|
setSeekTimeDisplay(thisElement.valueAsNumber); |
|
|
|
|
|
|
|
React.useEffect(() => { |
|
|
|
if (typeof mediaControllerRef !== 'object') { |
|
|
|
if (isSeeking) { |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
if (!mediaControllerRef.current) { |
|
|
|
doSetSeek(thisElement, mediaController); |
|
|
|
}, [mediaControllerRef, isSeeking]); |
|
|
|
|
|
|
|
const endSeek: React.MouseEventHandler<HTMLInputElement> = React.useCallback((e) => { |
|
|
|
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) { |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
const { current: mediaController } = mediaControllerRef; |
|
|
|
|
|
|
|
if (!mediaController) { |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
const { currentTarget: thisElement } = e; |
|
|
|
setIsSeeking(false); |
|
|
|
doSetSeek(thisElement, mediaController); |
|
|
|
}, [mediaControllerRef]); |
|
|
|
|
|
|
|
const resetVideo: React.ReactEventHandler<HTMLVideoElement> = React.useCallback((e) => { |
|
|
|
const videoElement = e.currentTarget; |
|
|
|
setIsPlaying(false); |
|
|
|
videoElement.currentTime = 0; |
|
|
|
}, []); |
|
|
|
|
|
|
|
const updateSeekFromPlayback: React.ReactEventHandler<HTMLVideoElement> = React.useCallback((e) => { |
|
|
|
if (!seekRef.current) { |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
const { current: mediaController } = mediaControllerRef; |
|
|
|
if (isSeeking) { |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
const { current: seek } = seekRef; |
|
|
|
seek.max = String(mediaController.duration); |
|
|
|
seek.value = String(mediaController.currentTime); |
|
|
|
}, [f, mediaControllerRef]); |
|
|
|
|
|
|
|
React.useEffect(() => { |
|
|
|
if (!volumeRef.current) { |
|
|
|
if (!seek) { |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
if (typeof mediaControllerRef !== 'object') { |
|
|
|
const videoElement = e.currentTarget; |
|
|
|
const currentTime = videoElement.currentTime; |
|
|
|
setCurrentTimeDisplay(currentTime); |
|
|
|
seek.value = String(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 { current: mediaController } = mediaControllerRef; |
|
|
|
const { current: volume } = volumeRef; |
|
|
|
volume.value = String(mediaController.volume); |
|
|
|
}, [f, mediaControllerRef]); |
|
|
|
const toggleSeekTimeCountMode: React.MouseEventHandler<HTMLButtonElement> = React.useCallback((e) => { |
|
|
|
e.preventDefault(); |
|
|
|
setIsSeekTimeCountingDown((b) => !b); |
|
|
|
}, []); |
|
|
|
|
|
|
|
const playMedia = () => { |
|
|
|
setIsPlaying((p) => !p); |
|
|
|
}; |
|
|
|
React.useEffect(() => { |
|
|
|
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) { |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
const seekMedia = () => { |
|
|
|
if (!mediaControllerRef.current) { |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
}; |
|
|
|
if (isPlaying) { |
|
|
|
void mediaControllerRef.current.play(); |
|
|
|
return |
|
|
|
} |
|
|
|
|
|
|
|
const resetVideo: React.ReactEventHandler<HTMLVideoElement> = (e) => { |
|
|
|
const videoElement = e.currentTarget; |
|
|
|
setIsPlaying(false); |
|
|
|
videoElement.currentTime = 0; |
|
|
|
}; |
|
|
|
mediaControllerRef.current.pause(); |
|
|
|
}, [isPlaying, mediaControllerRef]); |
|
|
|
|
|
|
|
const updateSeekFromPlayback: React.ReactEventHandler<HTMLVideoElement> = (e) => { |
|
|
|
React.useEffect(() => { |
|
|
|
if (!seekRef.current) { |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
const { current: seek } = seekRef; |
|
|
|
const videoElement = e.currentTarget; |
|
|
|
seek.value = String(videoElement.currentTime); |
|
|
|
}; |
|
|
|
if (!seek) { |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
seek.value = String(currentTimeDisplay); |
|
|
|
}, [currentTimeDisplay]); |
|
|
|
|
|
|
|
React.useEffect(() => { |
|
|
|
if (!seekRef.current) { |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
const { current: seek } = seekRef; |
|
|
|
if (!seek) { |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
seek.max = String(durationDisplay); |
|
|
|
}, [durationDisplay]); |
|
|
|
|
|
|
|
React.useEffect(() => { |
|
|
|
if (!volumeRef.current) { |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
const adjustVolume: React.ChangeEventHandler<HTMLInputElement> = (e) => { |
|
|
|
if (typeof mediaControllerRef !== 'object') { |
|
|
|
if (!(typeof mediaControllerRef === 'object' && mediaControllerRef !== null)) { |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
if (!mediaControllerRef.current) { |
|
|
|
return; |
|
|
|
} |
|
|
|
const { value } = e.currentTarget; |
|
|
|
mediaControllerRef.current.volume = Number(value); |
|
|
|
}; |
|
|
|
|
|
|
|
const { current: mediaController } = mediaControllerRef; |
|
|
|
const { current: volume } = volumeRef; |
|
|
|
volume.value = String(mediaController.volume); |
|
|
|
}, [mediaControllerRef]); |
|
|
|
|
|
|
|
return React.useMemo(() => ({ |
|
|
|
seekRef, |
|
|
|
volumeRef, |
|
|
|
isPlaying, |
|
|
|
refreshControls, |
|
|
|
adjustVolume, |
|
|
|
resetVideo, |
|
|
|
startSeek, |
|
|
|
endSeek, |
|
|
|
setSeek, |
|
|
|
updateSeekFromPlayback, |
|
|
|
playMedia, |
|
|
|
durationDisplay, |
|
|
|
currentTimeDisplay, |
|
|
|
seekTimeDisplay, |
|
|
|
isSeeking, |
|
|
|
isSeekTimeCountingDown, |
|
|
|
toggleSeekTimeCountMode, |
|
|
|
}), [ |
|
|
|
isPlaying, |
|
|
|
isSeeking, |
|
|
|
adjustVolume, |
|
|
|
playMedia, |
|
|
|
resetVideo, |
|
|
|
startSeek, |
|
|
|
endSeek, |
|
|
|
setSeek, |
|
|
|
updateSeekFromPlayback, |
|
|
|
durationDisplay, |
|
|
|
currentTimeDisplay, |
|
|
|
seekTimeDisplay, |
|
|
|
isSeekTimeCountingDown, |
|
|
|
toggleSeekTimeCountMode, |
|
|
|
]); |
|
|
|
}; |
|
|
|
|
|
|
|
export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePreviewProps>(({ |
|
|
|
file: f, |
|
|
|
}, forwardedRef) => { |
|
|
|
const defaultRef = React.useRef<HTMLVideoElement>(null); |
|
|
|
const mediaControllerRef = forwardedRef ?? defaultRef; |
|
|
|
const { |
|
|
|
seekRef, |
|
|
|
volumeRef, |
|
|
|
isPlaying, |
|
|
|
adjustVolume, |
|
|
|
playMedia, |
|
|
|
resetVideo, |
|
|
|
startSeek, |
|
|
|
endSeek, |
|
|
|
setSeek, |
|
|
|
updateSeekFromPlayback, |
|
|
|
refreshControls, |
|
|
|
durationDisplay = 0, |
|
|
|
currentTimeDisplay = 0, |
|
|
|
seekTimeDisplay = 0, |
|
|
|
isSeeking, |
|
|
|
isSeekTimeCountingDown, |
|
|
|
toggleSeekTimeCountMode, |
|
|
|
} = useVideoControls({ |
|
|
|
mediaControllerRef, |
|
|
|
}); |
|
|
|
|
|
|
|
const finalSeekTimeDisplay = isSeekTimeCountingDown ? (durationDisplay - seekTimeDisplay) : seekTimeDisplay; |
|
|
|
const finalCurrentTimeDisplay = isSeekTimeCountingDown ? (durationDisplay - currentTimeDisplay) : currentTimeDisplay; |
|
|
|
|
|
|
|
return ( |
|
|
|
<div className="flex gap-4 w-full h-full relative"> |
|
|
@@ -117,8 +264,10 @@ export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePrev |
|
|
|
> |
|
|
|
<div className="w-full flex-auto relative"> |
|
|
|
<video |
|
|
|
className="absolute w-full h-full top-0 left-0 block w-full h-full object-center object-contain flex-auto" |
|
|
|
className="absolute w-full h-full top-0 left-0 block object-center object-contain flex-auto" |
|
|
|
ref={mediaControllerRef as React.RefObject<HTMLVideoElement>} |
|
|
|
onLoadedMetadata={refreshControls} |
|
|
|
onDurationChange={refreshControls} |
|
|
|
onEnded={resetVideo} |
|
|
|
onTimeUpdate={updateSeekFromPlayback} |
|
|
|
> |
|
|
@@ -128,20 +277,50 @@ export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePrev |
|
|
|
/> |
|
|
|
</video> |
|
|
|
</div> |
|
|
|
<div className="w-full flex-shrink-0 h-10 flex"> |
|
|
|
<div className="w-full flex-shrink-0 h-10 flex gap-4"> |
|
|
|
<button |
|
|
|
onClick={playMedia} |
|
|
|
className="w-10 h-full" |
|
|
|
className="w-10 h-full flex-shrink-0" |
|
|
|
type="button" |
|
|
|
> |
|
|
|
{isPlaying ? '⏸' : '▶'} |
|
|
|
</button> |
|
|
|
<input |
|
|
|
type="range" |
|
|
|
className="flex-auto" |
|
|
|
ref={seekRef} |
|
|
|
onChange={seekMedia} |
|
|
|
defaultValue="0" |
|
|
|
/> |
|
|
|
<div className="flex-auto w-full flex items-center gap-1 font-mono text-sm relative"> |
|
|
|
<button |
|
|
|
className="absolute overflow-hidden w-12 opacity-0 h-10" |
|
|
|
onClick={toggleSeekTimeCountMode} |
|
|
|
title="Toggle Seek Time Count Mode" |
|
|
|
type="button" |
|
|
|
> |
|
|
|
Toggle Seek Time Count Mode |
|
|
|
</button> |
|
|
|
<span |
|
|
|
className="font-mono before:block before:content-[attr(title)] contents" |
|
|
|
title={ |
|
|
|
`${ |
|
|
|
isSeekTimeCountingDown ? '-' : '+' |
|
|
|
}${ |
|
|
|
isSeeking |
|
|
|
? formatSecondsDurationConcise(finalSeekTimeDisplay) |
|
|
|
: formatSecondsDurationConcise(finalCurrentTimeDisplay) |
|
|
|
}` |
|
|
|
} |
|
|
|
> |
|
|
|
<input |
|
|
|
type="range" |
|
|
|
className="flex-auto w-full" |
|
|
|
ref={seekRef} |
|
|
|
onMouseDown={startSeek} |
|
|
|
onMouseUp={endSeek} |
|
|
|
onChange={setSeek} |
|
|
|
defaultValue="0" |
|
|
|
|
|
|
|
/> |
|
|
|
</span> |
|
|
|
<span> |
|
|
|
{formatSecondsDurationConcise(durationDisplay)} |
|
|
|
</span> |
|
|
|
</div> |
|
|
|
<input |
|
|
|
type="range" |
|
|
|
ref={volumeRef} |
|
|
@@ -149,7 +328,9 @@ export const VideoFilePreview = React.forwardRef<HTMLVideoElement, VideoFilePrev |
|
|
|
min={0} |
|
|
|
onChange={adjustVolume} |
|
|
|
step="any" |
|
|
|
className="flex-shrink-0 w-12" |
|
|
|
defaultValue="1" |
|
|
|
title="Volume" |
|
|
|
/> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|