Created
July 21, 2019 18:29
-
-
Save vincentriemer/0a1f12a9ec1443f1bb14c01c311ea22e to your computer and use it in GitHub Desktop.
The current (WIP) implementation of chonkit's video progress slider.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* @flow | |
*/ | |
import VisuallyHidden from "@reach/visually-hidden"; | |
import instyle from "instyle"; | |
import * as React from "react"; | |
import { Focus } from "react-events/focus"; | |
import { Drag } from "react-events/drag"; | |
import { Press } from "react-events/press"; | |
import { Input } from "react-events/input"; | |
import { useElementSize } from "~/Hooks/useElementSize"; | |
import { useTheme } from "~/Hooks/useTheme"; | |
import { focusOutline } from "~/Styles/Presets"; | |
import { useLatestValueRef } from "~/Hooks/useLatestValueRef"; | |
const styles = instyle.create({ | |
root: { | |
webkitUserSelect: "none", | |
userSelect: "none", | |
touchAction: "pan-x" | |
}, | |
railContainer: { | |
position: "absolute", | |
top: "50%", | |
left: 20, | |
right: 20, | |
height: 2, | |
borderRadius: 999, | |
transform: "translateY(-50%)", | |
overflow: "hidden", | |
contain: "paint" | |
}, | |
railLayer: { | |
position: "absolute", | |
top: 0, | |
left: 0, | |
width: "100%", | |
transformOrigin: "center left", | |
height: "100%" | |
}, | |
handle: { | |
width: 10, | |
height: 10, | |
borderRadius: "50%", | |
overflow: "hidden", | |
transitionProperty: "transform", | |
transitionDuration: "200ms", | |
transitionTimingFunction: "ease-out" | |
}, | |
handleContainer: { | |
position: "absolute", | |
top: 0, | |
left: 0, | |
bottom: 0, | |
width: 40, | |
display: "flex", | |
flexDirection: "column", | |
alignItems: "center", | |
justifyContent: "center" | |
}, | |
focused: focusOutline | |
}); | |
function getProgressFromDrag(x, railWidth, duration) { | |
const clampedX = Math.min(Math.max(0, x), railWidth); | |
const newValuePercentage = clampedX / railWidth; | |
const progress = newValuePercentage * duration; | |
return progress; | |
} | |
export type VideoBufferedRange = $ReadOnly<{| | |
start: number, | |
stop: number | |
|}>; | |
type Props = $ReadOnly<{| | |
style: CSSProperties, | |
duration: number, | |
progress: number, | |
bufferedRange: VideoBufferedRange, | |
onChange: (value: number) => mixed | |
|}>; | |
const VideoProgressSlider = (props: Props): React.MixedElement => { | |
const { duration, progress, bufferedRange, style, onChange } = props; | |
const theme = useTheme(); | |
const [rootRef, containerSize] = useElementSize(); | |
const { width: containerWidth } = containerSize; | |
const initialPressPositionRef = React.useRef({ x: 0, y: 0 }); | |
const railWidthRef = React.useRef(0); | |
const railRef: {| current: HTMLDivElement | null |} = React.useRef(null); | |
const inputRef: {| current: HTMLInputElement | null |} = React.useRef(null); | |
const durationRef = useLatestValueRef(duration); | |
const [rangeFocused, setRangeFocused] = React.useState(false); | |
const [isDragging, setIsDragging] = React.useState(false); | |
const handleAccessibleChange = React.useCallback( | |
(rawValue: string) => { | |
const value = parseInt(rawValue, 10); | |
if (!Number.isNaN(value)) { | |
onChange(Math.min(durationRef.current, value)); | |
} | |
}, | |
[durationRef, onChange] | |
); | |
const progressPercent = (progress / duration) * 100; | |
const bufferedRangePercent = { | |
start: (bufferedRange.start / duration) * 100, | |
stop: (bufferedRange.stop / duration) * 100 | |
}; | |
let bufferedRangeScale = | |
(bufferedRangePercent.stop - bufferedRangePercent.start) / 100; | |
// HTMLMediaElement's buffered range doesn't seem to ever report 100% | |
// buffered so if it's close enough, just round it up to 100% | |
if (bufferedRangeScale > 0.95) { | |
bufferedRangeScale = 1.0; | |
} | |
const handleRailPressDown = React.useCallback( | |
(event: React$PressEvent) => { | |
const railElem = railRef.current; | |
const { clientX, clientY } = event; | |
if (railElem != null && clientX != null && clientY != null) { | |
const { left, top, width } = railElem.getBoundingClientRect(); | |
const initialX = clientX - left; | |
const initialY = clientY - top; | |
const progress = getProgressFromDrag( | |
initialX, | |
width, | |
durationRef.current | |
); | |
onChange(progress); | |
initialPressPositionRef.current = { x: initialX, y: initialY }; | |
railWidthRef.current = width; | |
setIsDragging(true); | |
} | |
}, | |
[durationRef, onChange] | |
); | |
const handleDrag = React.useCallback( | |
(event: React$DragEvent) => { | |
const { diffX } = event; | |
const { x: initialX } = initialPressPositionRef.current; | |
const duration = durationRef.current; | |
const railWidth = railWidthRef.current; | |
if (diffX != null) { | |
const progress = getProgressFromDrag( | |
initialX + diffX, | |
railWidth, | |
duration | |
); | |
onChange(progress); | |
} | |
}, | |
[durationRef, onChange] | |
); | |
const shouldClaimOwnership = React.useCallback(() => true, []); | |
return ( | |
<Press onPressStart={handleRailPressDown}> | |
<Drag | |
onDragMove={handleDrag} | |
onDragChange={setIsDragging} | |
onShouldClaimOwnership={shouldClaimOwnership} | |
> | |
<div | |
ref={rootRef} | |
style={instyle(styles.root, style, { | |
cursor: isDragging ? "grabbing" : "grab" | |
})} | |
> | |
{/* | |
Semantic slider for screen-reader & keyboard accessibility, whereas the presentational elements below | |
are hidden from screen readers. It recieves keyboard focus *but* the component will visually highlight | |
the equivalent presentational element below. | |
*/} | |
<VisuallyHidden> | |
<Focus onFocusVisibleChange={setRangeFocused}> | |
<Input onValueChange={handleAccessibleChange}> | |
<input | |
ref={inputRef} | |
type="range" | |
name="playback progress" | |
min={0} | |
max={duration} | |
step={1000} | |
value={progress} | |
/> | |
</Input> | |
</Focus> | |
</VisuallyHidden> | |
{/* slider rail */} | |
<div | |
aria-hidden="true" | |
ref={railRef} | |
style={instyle(styles.railContainer, { | |
backgroundColor: theme.touchHighlight | |
})} | |
> | |
{/* buffered range display */} | |
<div | |
style={instyle(styles.railLayer, { | |
backgroundColor: theme.touchHighlight, | |
transform: ` | |
translateX(${bufferedRangePercent.start}%) | |
scaleX(${bufferedRangeScale})` | |
})} | |
/> | |
{/* rail progress display */} | |
<div | |
style={instyle(styles.railLayer, { | |
backgroundColor: theme.foregroundBlue, | |
transform: `translateX(${-(100 - progressPercent)}%)` | |
})} | |
/> | |
</div> | |
{/* slider handle */} | |
<div | |
aria-hidden="true" | |
style={instyle(styles.handleContainer, { | |
transform: `translateX(${(containerWidth - 40) * | |
(progressPercent / 100)}px)` | |
})} | |
> | |
<div | |
style={instyle( | |
styles.handle, | |
rangeFocused ? styles.focused : null, | |
{ | |
transform: isDragging ? "scale(2.5)" : "scale(1)", | |
backgroundColor: theme.foregroundBlue | |
} | |
)} | |
/> | |
</div> | |
</div> | |
</Drag> | |
</Press> | |
); | |
}; | |
export { VideoProgressSlider }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment