diff --git a/src/routes/player/PreviewBar.tsx b/src/routes/player/PreviewBar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3d1710aede0abf814374463e9c057389be821751 --- /dev/null +++ b/src/routes/player/PreviewBar.tsx @@ -0,0 +1,55 @@ +import React, {useMemo} from "react"; +import {createUseStyles} from "react-jss"; +import {useOffsetRef} from "../../util/offset/useOffsetRef"; +import {PreviewViewer} from "./PreviewViewer"; + +interface Props { + previewTrack: HTMLTrackElement | null, + duration: number, + position: number, +} + +export function PreviewBar({previewTrack, duration, position}: Props) { + const classes = useStyles(); + + const [previewBarRef, previewHeadRef, offset] = useOffsetRef(position); + + return useMemo(() => ( + <div + ref={previewBarRef} + className={classes.previewBar} + > + <div + ref={previewHeadRef} + className={classes.previewHead} + style={{ + transform: `translate3d(${offset}px, 0, 0)` + }} + > + <PreviewViewer + previewTrack={previewTrack} + position={position * duration} + /> + </div> + </div> + ), [classes.previewHead, classes.previewBar, duration, offset, position, previewBarRef, previewHeadRef, previewTrack]); +} + +const useStyles = createUseStyles({ + previewBar: { + position: "relative", + width: "40rem", + height: "6rem", + background: "#7c7", + }, + previewHead: { + position: "absolute", + top: 0, + bottom: 0, + left: "-5rem", + width: "10rem", + display: "flex", + flexDirection: "column-reverse", + background: "#f00", + } +}); diff --git a/src/routes/player/PreviewViewer.tsx b/src/routes/player/PreviewViewer.tsx index 3ba98292639a73a600964c19443fd6988c07217f..550508a8d0a1d6fbb60db6f9b5fb2f147ff2e75a 100644 --- a/src/routes/player/PreviewViewer.tsx +++ b/src/routes/player/PreviewViewer.tsx @@ -1,22 +1,32 @@ import React, {useMemo} from "react"; +import {createUseStyles} from "react-jss"; import {useImage} from "../../util/media/useImage"; import {useTextTrackCues} from "../../util/media/useTextTrackCues"; import {parseImageSprite} from "../../util/sprite/parseImageSprite"; import {useImageSprite} from "../../util/sprite/useImageSprite"; interface Props { - previewTrack: HTMLTrackElement, - position: number, + previewTrack: HTMLTrackElement | null, + position: number | null, } export function PreviewViewer({previewTrack, position}: Props) { + const classes = useStyles(); + const cues = useTextTrackCues(previewTrack); - const activeCue = cues.find(it => it.startTime <= position && it.endTime >= position) - const activeUrl = activeCue ? new URL(activeCue.text, previewTrack.src).toString() : null; + const activeCue = position === null ? null : cues.find(it => it.startTime <= position && it.endTime >= position); + const activeUrl = previewTrack && activeCue ? new URL(activeCue.text, previewTrack.src).toString() : null; const imageSprite = useMemo(() => parseImageSprite(activeUrl), [activeUrl]); const image = useImage(imageSprite?.src || null); const sprite = useImageSprite(imageSprite, image); return useMemo(() => ( - <img alt="" src={sprite || undefined}/> + <img alt="" className={classes.preview} src={sprite || undefined}/> ), [sprite]); } + +const useStyles = createUseStyles({ + preview: { + maxWidth: "100%", + maxHeight: "100%", + } +}); diff --git a/src/routes/player/SeekBar.tsx b/src/routes/player/SeekBar.tsx index 4efb327550c82b777533cc755ceda7a249681c33..00b6db0cdb0b0f974ebe237fb891f7d0f2b2111c 100644 --- a/src/routes/player/SeekBar.tsx +++ b/src/routes/player/SeekBar.tsx @@ -1,6 +1,7 @@ -import React, {MouseEvent, useCallback, useMemo, useRef, useState} from "react"; +import React, {MouseEvent, useCallback, useMemo, useState} from "react"; import {createUseStyles} from "react-jss"; -import {PreviewViewer} from "./PreviewViewer"; +import {useOffsetRef} from "../../util/offset/useOffsetRef"; +import {PreviewBar} from "./PreviewBar"; interface Props { previewTrack: HTMLTrackElement | null, @@ -27,10 +28,11 @@ function getMousePosition(event: MouseEvent<HTMLDivElement>): MousePosition | nu export function SeekBar({video, previewTrack, duration, position}: Props) { const classes = useStyles(); - const seekHeadRef = useRef<HTMLDivElement | null>(null); const [seekPosition, setSeekPosition] = useState<number | null>(null); const isVisible = seekPosition !== null; + const [seekBarRef, seekHeadRef, offset] = useOffsetRef(seekPosition || 0); + const onMouseLeave = useCallback(() => { setSeekPosition(null) }, [setSeekPosition]); @@ -50,38 +52,35 @@ export function SeekBar({video, previewTrack, duration, position}: Props) { if (position === null) { return; } - if (seekHeadRef.current) { - seekHeadRef.current.style.transform = `translate3d(${position.absolute}px, 0, 0)` - } window.requestAnimationFrame(() => { - setSeekPosition(position.relative * duration) + setSeekPosition(position.relative) }) - }, [duration]); + }, []); const seekBar = useMemo(() => ( <div + ref={seekBarRef} className={classes.seekBar} onMouseLeave={onMouseLeave} onClick={onClick} onMouseMove={onMouseMove} > <div - className={classes.seekHead} ref={seekHeadRef} + className={classes.seekHead} style={{ - opacity: isVisible ? 1 : 0 + opacity: isVisible ? 1 : 0, + transform: `translate3d(${offset}px, 0, 0)` }} /> </div> - ), [classes.seekBar, classes.seekHead, isVisible, onClick, onMouseLeave, onMouseMove]); + ), [classes.seekBar, classes.seekHead, isVisible, offset, onClick, onMouseLeave, onMouseMove, seekBarRef, seekHeadRef]); return ( - <div> + <React.Fragment> + <PreviewBar previewTrack={previewTrack} duration={duration} position={seekPosition || 0}/> {seekBar} - {seekPosition !== null && previewTrack && ( - <PreviewViewer previewTrack={previewTrack} position={seekPosition}/> - )} - </div> + </React.Fragment> ) } diff --git a/src/util/media/useTextTrackCues.ts b/src/util/media/useTextTrackCues.ts index 1309d104f1ba5fafef51cbf46d4ae2f0d6aa4922..557a77431b3e0642b4b429f49dcbeffbe1087707 100644 --- a/src/util/media/useTextTrackCues.ts +++ b/src/util/media/useTextTrackCues.ts @@ -1,23 +1,25 @@ import {useEffect, useState} from "react"; -export const useTextTrackCues = (track: HTMLTrackElement) => { +export const useTextTrackCues = (track: HTMLTrackElement | null) => { const [cues, setCues] = useState<TextTrackCue[]>([]); useEffect(() => { - track.track.mode = "hidden"; - if (track.readyState >= 1) { - setCues(Array.from(track.track.cues || [])) - } else { - let animationFrame: number | null = null; - const listener = () => { - animationFrame = window.requestAnimationFrame(() => { - setCues(Array.from(track.track.cues || [])) - }) - }; - track.addEventListener("load", listener) - return () => { - track.removeEventListener("load", listener); - if (animationFrame) { - window.cancelAnimationFrame(animationFrame); + if (track !== null) { + track.track.mode = "hidden"; + if (track.readyState >= 1) { + setCues(Array.from(track.track.cues || [])) + } else { + let animationFrame: number | null = null; + const listener = () => { + animationFrame = window.requestAnimationFrame(() => { + setCues(Array.from(track.track.cues || [])) + }) + }; + track.addEventListener("load", listener) + return () => { + track.removeEventListener("load", listener); + if (animationFrame) { + window.cancelAnimationFrame(animationFrame); + } } } } diff --git a/src/util/offset/useOffset.ts b/src/util/offset/useOffset.ts new file mode 100644 index 0000000000000000000000000000000000000000..05cdc5720992c343ef5374e897e8c8eb53207953 --- /dev/null +++ b/src/util/offset/useOffset.ts @@ -0,0 +1,11 @@ +import {useWidth} from "./useWidth"; + +export const useOffset = <T extends HTMLElement, U extends HTMLElement>(parent: T | null, child: U | null, position: number) => { + const parentWidth = useWidth(parent); + const childWidth = useWidth(child); + + const offset = parentWidth * position; + const minOffset = childWidth / 2; + const maxOffset = parentWidth - minOffset; + return Math.max(minOffset, Math.min(offset, maxOffset)); +} diff --git a/src/util/offset/useOffsetRef.ts b/src/util/offset/useOffsetRef.ts new file mode 100644 index 0000000000000000000000000000000000000000..cad16f6bb4ad1acf39f8dc03bf858d3b43acbfba --- /dev/null +++ b/src/util/offset/useOffsetRef.ts @@ -0,0 +1,10 @@ +import {useState} from "react"; +import {useOffset} from "./useOffset"; + +export const useOffsetRef = <T extends HTMLElement, U extends HTMLElement>(position: number): + [(it: T | null) => void, (it: U | null) => void, number] => { + const [parent, setParent] = useState<T | null>(null); + const [child, setChild] = useState<U | null>(null); + + return [setParent, setChild, useOffset(parent, child, position)]; +} diff --git a/src/util/offset/useWidth.ts b/src/util/offset/useWidth.ts new file mode 100644 index 0000000000000000000000000000000000000000..6d53d44abb2b308c23a3d6da8a26f2be7a24049d --- /dev/null +++ b/src/util/offset/useWidth.ts @@ -0,0 +1,23 @@ +import {useEffect, useState} from "react"; + +export const useWidth = <T extends HTMLElement>(it: T | null) => { + const [width, setWidth] = useState<number>(0); + useEffect(() => { + let animationFrame: number | null = null; + const listener = () => { + animationFrame = window.requestAnimationFrame(() => { + animationFrame = null; + setWidth(it?.offsetWidth || 0); + }); + }; + listener(); + window.addEventListener("resize", listener); + return () => { + window.removeEventListener("resize", listener); + if (animationFrame !== null) { + window.cancelAnimationFrame(animationFrame); + } + } + }, [it]); + return width; +}