Skip to content

Instantly share code, notes, and snippets.

@vincentriemer
Created July 21, 2019 18:29
Show Gist options
  • Star 7 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save vincentriemer/0a1f12a9ec1443f1bb14c01c311ea22e to your computer and use it in GitHub Desktop.
Save vincentriemer/0a1f12a9ec1443f1bb14c01c311ea22e to your computer and use it in GitHub Desktop.
The current (WIP) implementation of chonkit's video progress slider.
/**
* @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