From 3eb6a3ef81560bca05a1a48e42c91e9ffd72630d Mon Sep 17 00:00:00 2001 From: Sanny Date: Sun, 7 Sep 2025 01:50:56 +0300 Subject: [PATCH 1/3] WIP: BREAKING CHANGE - Add interactive circular progress ring with drag functionality - Implemented complete ProgressRing component system with: - CircularInput: Main interactive component with click and drag handling - CircularTrack: Background circle - CircularProgress: Progress arc showing current position - CircularThumb: Draggable handle for seeking - useCircularDrag: Custom hook for mouse/touch drag events - Context system for sharing state between components - Features: - Click anywhere in ring to play/pause video - Drag thumb to seek through video - Touch support for mobile devices - Keyboard accessibility (arrow keys, space, page up/down) - Accurate positioning for both fixed and percentage-based sizes - Real-time progress updates as video plays - Refactored VideoPlayer into modular architecture: - Split logic into separate hooks, utils, and components - Created reusable Video, Thumbnail, PlayButton components - Added proper TypeScript types and interfaces - Implemented UI library conventions with proper folder structure - Fixed issues: - Drag only triggers on thumb, not anywhere in ring - Accurate positioning for percentage-based sizes using ResizeObserver - Proper event handling to allow play/pause while maintaining drag functionality - HMR compatibility by removing defaultProps exports - Visual improvements: - Bright red progress ring for visibility - White thumb with red stroke for better contrast - Larger thumb (radius 8) for easier grabbing - Smooth animations and proper z-indexing BREAKING CHANGE: VideoPlayer component structure has been completely refactored. Existing implementations may need updates to import paths and prop handling. --- .../CustomPlayButtonWrapper.tsx | 23 + .../CustomPlayButtonWrapper/index.ts | 1 + src/components/PlayButton/PlayButton.tsx | 31 ++ src/components/PlayButton/index.ts | 1 + src/components/ProgressRing/CircularInput.tsx | 213 +++++++++ .../ProgressRing/CircularProgress.tsx | 34 ++ src/components/ProgressRing/CircularThumb.tsx | 44 ++ src/components/ProgressRing/CircularTrack.tsx | 35 ++ .../ProgressRing/ProgressRing.module.scss | 17 + src/components/ProgressRing/ProgressRing.tsx | 116 +++++ src/components/ProgressRing/context.ts | 36 ++ src/components/ProgressRing/index.ts | 8 + src/components/ProgressRing/reference/1.tsx | 36 ++ src/components/ProgressRing/reference/2.tsx | 31 ++ src/components/ProgressRing/reference/3.tsx | 203 ++++++++ .../ProgressRing/reference/context.ts | 36 ++ src/components/ProgressRing/reference/hook.ts | 100 ++++ .../ProgressRing/reference/utils.ts | 121 +++++ src/components/ProgressRing/types.ts | 10 + .../ProgressRing/useCircularDrag.ts | 100 ++++ src/components/ProgressRing/utils.ts | 119 +++++ src/components/Thumbnail/Thumbnail.tsx | 22 + src/components/Thumbnail/index.ts | 1 + src/components/Video/Video.tsx | 24 + src/components/Video/index.ts | 1 + .../VideoPlayer/VideoPlayer.module.scss | 20 - src/components/VideoPlayer/VideoPlayer.tsx | 432 ++++-------------- src/components/VideoPlayer/index.ts | 15 +- src/components/VideoPlayer/types.ts | 82 ++++ src/components/index.ts | 24 +- src/dev/DevShowcase.tsx | 15 + src/hooks/index.ts | 1 + src/hooks/useVideoPlayerState.ts | 110 +++++ src/icons/PauseIcon.tsx | 14 + src/icons/PlayIcon.tsx | 13 + src/icons/index.ts | 2 + src/index.ts | 12 +- src/utils/index.ts | 1 + src/utils/videoPlayer.ts | 38 ++ 39 files changed, 1771 insertions(+), 371 deletions(-) create mode 100644 src/components/CustomPlayButtonWrapper/CustomPlayButtonWrapper.tsx create mode 100644 src/components/CustomPlayButtonWrapper/index.ts create mode 100644 src/components/PlayButton/PlayButton.tsx create mode 100644 src/components/PlayButton/index.ts create mode 100644 src/components/ProgressRing/CircularInput.tsx create mode 100644 src/components/ProgressRing/CircularProgress.tsx create mode 100644 src/components/ProgressRing/CircularThumb.tsx create mode 100644 src/components/ProgressRing/CircularTrack.tsx create mode 100644 src/components/ProgressRing/ProgressRing.module.scss create mode 100644 src/components/ProgressRing/ProgressRing.tsx create mode 100644 src/components/ProgressRing/context.ts create mode 100644 src/components/ProgressRing/index.ts create mode 100644 src/components/ProgressRing/reference/1.tsx create mode 100644 src/components/ProgressRing/reference/2.tsx create mode 100644 src/components/ProgressRing/reference/3.tsx create mode 100644 src/components/ProgressRing/reference/context.ts create mode 100644 src/components/ProgressRing/reference/hook.ts create mode 100644 src/components/ProgressRing/reference/utils.ts create mode 100644 src/components/ProgressRing/types.ts create mode 100644 src/components/ProgressRing/useCircularDrag.ts create mode 100644 src/components/ProgressRing/utils.ts create mode 100644 src/components/Thumbnail/Thumbnail.tsx create mode 100644 src/components/Thumbnail/index.ts create mode 100644 src/components/Video/Video.tsx create mode 100644 src/components/Video/index.ts create mode 100644 src/components/VideoPlayer/types.ts create mode 100644 src/hooks/index.ts create mode 100644 src/hooks/useVideoPlayerState.ts create mode 100644 src/icons/PauseIcon.tsx create mode 100644 src/icons/PlayIcon.tsx create mode 100644 src/icons/index.ts create mode 100644 src/utils/index.ts create mode 100644 src/utils/videoPlayer.ts diff --git a/src/components/CustomPlayButtonWrapper/CustomPlayButtonWrapper.tsx b/src/components/CustomPlayButtonWrapper/CustomPlayButtonWrapper.tsx new file mode 100644 index 0000000..9f51471 --- /dev/null +++ b/src/components/CustomPlayButtonWrapper/CustomPlayButtonWrapper.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { CustomPlayButtonWrapperProps } from "../VideoPlayer/types"; + +export const CustomPlayButtonWrapper: React.FC = ({ + isPlaying, + onClick, + onKeyDown, + ariaLabel, + className, + onPlayClassName, + onPauseClassName, + customPlayButton, +}) => { + return customPlayButton({ + isPlaying, + onClick, + onKeyDown, + ariaLabel, + className, + onPlayClassName, + onPauseClassName, + }); +}; diff --git a/src/components/CustomPlayButtonWrapper/index.ts b/src/components/CustomPlayButtonWrapper/index.ts new file mode 100644 index 0000000..257ee08 --- /dev/null +++ b/src/components/CustomPlayButtonWrapper/index.ts @@ -0,0 +1 @@ +export { CustomPlayButtonWrapper } from './CustomPlayButtonWrapper'; diff --git a/src/components/PlayButton/PlayButton.tsx b/src/components/PlayButton/PlayButton.tsx new file mode 100644 index 0000000..09476d0 --- /dev/null +++ b/src/components/PlayButton/PlayButton.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import clsx from "clsx"; +import { PlayButtonProps } from "../VideoPlayer/types"; +import { PlayIcon, PauseIcon } from "../../icons"; +import styles from "../VideoPlayer/VideoPlayer.module.scss"; + +export const PlayButton: React.FC = ({ + isPlaying, + onClick, + onKeyDown, + ariaLabel, + className, + playIcon, + pauseIcon, +}) => { + return ( + + ); +}; diff --git a/src/components/PlayButton/index.ts b/src/components/PlayButton/index.ts new file mode 100644 index 0000000..9a91428 --- /dev/null +++ b/src/components/PlayButton/index.ts @@ -0,0 +1 @@ +export { PlayButton } from './PlayButton'; diff --git a/src/components/ProgressRing/CircularInput.tsx b/src/components/ProgressRing/CircularInput.tsx new file mode 100644 index 0000000..21f78b9 --- /dev/null +++ b/src/components/ProgressRing/CircularInput.tsx @@ -0,0 +1,213 @@ +import React, { + useRef, + useMemo, + useCallback, + useState, + KeyboardEvent, +} from 'react'; +import { + Coordinates, + polarToCartesian, + valueToAngle, + calculateNearestValueToPoint, + getElementPosition, + absPos, +} from './utils'; +import { + CircularInputContext, + CircularInputProvider, +} from './context'; +import { CircularTrack } from './CircularTrack'; +import { CircularProgress } from './CircularProgress'; +import { CircularThumb } from './CircularThumb'; + +type DefaultHTMLProps = React.SVGProps; + +type CircularInputProps = Omit & { + value: number; + radius?: number; + onChange?: (value: number) => void; + onChangeEnd?: (value: number) => void; + clickTolerance?: number; + // disallow some props + ref?: undefined; + width?: undefined; + height?: undefined; + viewBox?: undefined; + onClick?: undefined; +}; + +export function CircularInput({ + value = 0.25, + radius = 100, + onChange = () => { }, + onChangeEnd = () => { }, + clickTolerance = 5, + tabIndex = 0, + children, + ...props +}: CircularInputProps) { + const containerRef = useRef(null); + const size = radius * 2; + const center = useMemo(() => ({ x: radius, y: radius }), [radius]); + + // Accessibility + const [isFocused, setFocused] = useState(false); + + const isReadonly = !onChange; + + const handleFocus = useCallback(() => { + setFocused(true); + }, []); + + const handleBlur = useCallback(() => { + setFocused(false); + }, []); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!isFocused) return; + const { keyCode } = e; + + // arrow up, arrow right, page up, space + const isIncrement = + keyCode === 38 || + keyCode === 39 || + keyCode === 33 || + keyCode === 32; + // arrow down, arrow left, page down + const isDecrement = + keyCode === 40 || keyCode === 37 || keyCode === 34; + + if (isIncrement) { + onChange(Math.min(1, value + 0.1)); + } + + if (isDecrement) { + onChange(Math.max(0, value - 0.1)); + } + + if (isIncrement || isDecrement) { + e.preventDefault(); + } + }, + [isFocused, onChange, value] + ); + + const accessibilityProps = { + tabIndex, + role: 'slider', + onFocus: handleFocus, + onBlur: handleBlur, + onKeyDown: handleKeyDown, + }; + + // Geometry utilities + + const getPointFromValue = useCallback( + (v?: number) => + polarToCartesian({ + center, + angle: valueToAngle(v || value), + radius, + }), + [value, center, radius] + ); + + const getValueFromPointerEvent = useCallback( + (e: Event) => + calculateNearestValueToPoint({ + point: absPos(e as any), + container: getElementPosition( + containerRef.current + ) as Coordinates, + value, + center, + radius, + }), + [value, center, radius] + ); + + // Context + + const context = useMemo( + (): CircularInputContext => ({ + value, + radius, + center, + isFocused, + setFocused, + onChange, + onChangeEnd, + getPointFromValue, + getValueFromPointerEvent, + }), + [ + value, + radius, + center, + onChange, + onChangeEnd, + isFocused, + setFocused, + getPointFromValue, + getValueFromPointerEvent, + ] + ); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (isReadonly) return; + + // Check if click is on the thumb - if so, don't handle it here + const target = e.target as SVGElement; + if (target.tagName === 'circle' && target.getAttribute('r') === '8') { + // This is a click on the thumb, let the drag handler deal with it + return; + } + + // For all other clicks, allow them to bubble up to the video player + // This will trigger play/pause functionality + // Don't prevent default or stop propagation + }, + [isReadonly] + ); + + const style = { + overflow: 'visible', + outline: 'none', + ...(props.style || {}), + touchAction: 'manipulation', + WebkitTapHighlightColor: 'rgba(0,0,0,0)', + pointerEvents: 'auto', // Allow SVG to receive events + }; + + return ( + + + {children ? ( + typeof children === 'function' ? ( + (children as any)(context) + ) : ( + children + ) + ) : ( + <> + + + + + )} + + + ); +} diff --git a/src/components/ProgressRing/CircularProgress.tsx b/src/components/ProgressRing/CircularProgress.tsx new file mode 100644 index 0000000..434617b --- /dev/null +++ b/src/components/ProgressRing/CircularProgress.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { useCircularInputContext } from './context'; +import { CircularTrack } from './CircularTrack'; +import { CircularTrackProps } from './CircularTrack'; +import { DEG_360_IN_RAD } from './utils'; + +type CircularProgressProps = React.SVGProps & + CircularTrackProps & { + // disallow some props + strokeDasharray?: undefined; + strokeDashoffset?: undefined; + transform?: undefined; + }; + +// const defaultProps = { +// stroke: '#3D99FF', +// }; + +export const CircularProgress: React.FC = (props) => { + const { value, radius, center } = useCircularInputContext(); + const innerCircumference = DEG_360_IN_RAD * radius; + + return ( + + ); +}; + +// CircularProgress.defaultProps = defaultProps; diff --git a/src/components/ProgressRing/CircularThumb.tsx b/src/components/ProgressRing/CircularThumb.tsx new file mode 100644 index 0000000..7882c20 --- /dev/null +++ b/src/components/ProgressRing/CircularThumb.tsx @@ -0,0 +1,44 @@ +import React, { useRef } from 'react'; +import { useCircularInputContext } from './context'; +import { useCircularDrag } from './useCircularDrag'; +import { polarToCartesian, valueToAngle } from './utils'; + +export type CircularThumbProps = React.SVGProps & { + // disallow some props + ref?: undefined; + cx?: undefined; + cy?: undefined; +}; + +export const CircularThumb: React.FC = ({ + fill = '#3D99FF', + stroke = '#ffffff', + strokeWidth = 3, + r = 8, + ...props +}) => { + const { value, radius, center } = useCircularInputContext(); + const ref = useRef(null); + useCircularDrag(ref); + + const thumbPosition = polarToCartesian({ + center, + angle: valueToAngle(value), + radius, + }); + + return ( + + ); +}; + diff --git a/src/components/ProgressRing/CircularTrack.tsx b/src/components/ProgressRing/CircularTrack.tsx new file mode 100644 index 0000000..c171c8e --- /dev/null +++ b/src/components/ProgressRing/CircularTrack.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { useCircularInputContext } from './context'; + +export type CircularTrackProps = React.SVGProps & { + // disallow some props + ref?: undefined; + cx?: undefined; + cy?: undefined; + r?: undefined; +}; + +export const CircularTrack: React.FC = ({ + strokeWidth = 20, + stroke = '#CEE0F5', + fill = 'none', + strokeLinecap = 'round', + ...props +}) => { + const { radius, center } = useCircularInputContext(); + + return ( + + ); +}; + diff --git a/src/components/ProgressRing/ProgressRing.module.scss b/src/components/ProgressRing/ProgressRing.module.scss new file mode 100644 index 0000000..813aabe --- /dev/null +++ b/src/components/ProgressRing/ProgressRing.module.scss @@ -0,0 +1,17 @@ +.progressRing { + opacity: 1; + position: absolute; + top: 0; + left: 0; + z-index: 2; + cursor: pointer; + outline: none; + pointer-events: none; + /* Allow clicks to pass through to video player */ + width: 100%; + height: 100%; + + &:hover { + opacity: 1; + } +} \ No newline at end of file diff --git a/src/components/ProgressRing/ProgressRing.tsx b/src/components/ProgressRing/ProgressRing.tsx new file mode 100644 index 0000000..fb0b451 --- /dev/null +++ b/src/components/ProgressRing/ProgressRing.tsx @@ -0,0 +1,116 @@ +import React, { useRef, useEffect, useState } from "react"; +import clsx from "clsx"; +import { ProgressRingProps } from "./types"; +import { CircularInput } from "./CircularInput"; +import { CircularTrack } from "./CircularTrack"; +import { CircularProgress } from "./CircularProgress"; +import { CircularThumb } from "./CircularThumb"; +import styles from "./ProgressRing.module.scss"; + +export const ProgressRing: React.FC = ({ + progress, + size = 300, + strokeWidth = 4, + className, + strokeColor = "#ffffff", + backgroundColor = "transparent", + onSeek, + clickTolerance = 5, +}) => { + // Convert progress from 0-100 to 0-1 for circular input + const value = progress / 100; + + const containerRef = useRef(null); + const [actualSize, setActualSize] = useState(() => { + // Use numeric size as fallback + return typeof size === 'number' ? size : 300; + }); + + // Get actual rendered size for percentage-based sizes + useEffect(() => { + const updateSize = () => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + const actualSize = Math.min(rect.width, rect.height); + setActualSize(actualSize); + } + }; + + // Small delay to ensure DOM is ready + const timeoutId = setTimeout(updateSize, 0); + + // Update on resize + const resizeObserver = new ResizeObserver(updateSize); + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } + + return () => { + clearTimeout(timeoutId); + resizeObserver.disconnect(); + }; + }, [size]); + + // Use actual size for radius calculation + const radius = actualSize / 2; + + const handleChange = (newValue: number) => { + if (onSeek) { + // Convert back to 0-100 range + onSeek(newValue * 100); + } + }; + + const handleChangeEnd = (newValue: number) => { + if (onSeek) { + // Convert back to 0-100 range + onSeek(newValue * 100); + } + }; + + return ( +
+ + + + + +
+ ); +}; diff --git a/src/components/ProgressRing/context.ts b/src/components/ProgressRing/context.ts new file mode 100644 index 0000000..36e5069 --- /dev/null +++ b/src/components/ProgressRing/context.ts @@ -0,0 +1,36 @@ +import { + createContext, + useContext, + Context as ReactContext, + SetStateAction, + Dispatch, +} from 'react'; +import { Coordinates } from './utils'; + +export type CircularInputContext = { + value: number; + radius: number; + center: Coordinates; + isFocused: boolean; + setFocused: Dispatch>; + onChange: (value: number) => void; + onChangeEnd: (value: number) => void; + getPointFromValue: (v?: number) => Coordinates | null; + getValueFromPointerEvent: (...args: Parameters) => number; +}; + +const Context: ReactContext = createContext( + {} as CircularInputContext +); + +export const CircularInputProvider = Context.Provider; + +export function useCircularInputContext(): CircularInputContext { + const context = useContext(Context); + if (!context) { + throw new Error( + `CircularInput components cannot be rendered outside the CircularInput component` + ); + } + return context as CircularInputContext; +} diff --git a/src/components/ProgressRing/index.ts b/src/components/ProgressRing/index.ts new file mode 100644 index 0000000..59dbc22 --- /dev/null +++ b/src/components/ProgressRing/index.ts @@ -0,0 +1,8 @@ +export { ProgressRing } from './ProgressRing'; +export { CircularInput } from './CircularInput'; +export { CircularTrack } from './CircularTrack'; +export { CircularProgress } from './CircularProgress'; +export { CircularThumb } from './CircularThumb'; +export { useCircularDrag } from './useCircularDrag'; +export type { ProgressRingProps } from './types'; +export type { CircularInputContext } from './context'; diff --git a/src/components/ProgressRing/reference/1.tsx b/src/components/ProgressRing/reference/1.tsx new file mode 100644 index 0000000..2bf188a --- /dev/null +++ b/src/components/ProgressRing/reference/1.tsx @@ -0,0 +1,36 @@ +import React, { useRef } from 'react' +import { useCircularInputContext } from './' +import { useCircularDrag } from './useCircularDrag' + +export type Props = JSX.IntrinsicElements['circle'] & { + // disallow some props + ref?: undefined + cx?: undefined + cy?: undefined + r?: undefined +} + +export const defaultProps = { + stroke: '#CEE0F5', + fill: 'none', + strokeWidth: 20, + strokeLinecap: 'round', +} + +export const CircularTrack = ({ strokeWidth, ...props }: Props) => { + const { radius, center } = useCircularInputContext() + const ref = useRef(null) + useCircularDrag(ref) + return ( + + ) +} + +CircularTrack.defaultProps = defaultProps \ No newline at end of file diff --git a/src/components/ProgressRing/reference/2.tsx b/src/components/ProgressRing/reference/2.tsx new file mode 100644 index 0000000..0d2ef0e --- /dev/null +++ b/src/components/ProgressRing/reference/2.tsx @@ -0,0 +1,31 @@ +import React from 'react' +import { useCircularInputContext, CircularTrack } from '.' +import { Props as CircularTrackProps } from './CircularTrack' +import { DEG_360_IN_RAD } from './utils' + +type Props = JSX.IntrinsicElements['circle'] & + CircularTrackProps & { + // disallow some props + strokeDasharray?: undefined + strokeDashoffset?: undefined + transform?: undefined + } + +const defaultProps = { + stroke: '#3D99FF', +} + +export const CircularProgress = (props: Props) => { + const { value, radius, center } = useCircularInputContext() + const innerCircumference = DEG_360_IN_RAD * radius + return ( + + ) +} + +CircularProgress.defaultProps = defaultProps \ No newline at end of file diff --git a/src/components/ProgressRing/reference/3.tsx b/src/components/ProgressRing/reference/3.tsx new file mode 100644 index 0000000..b50dd8b --- /dev/null +++ b/src/components/ProgressRing/reference/3.tsx @@ -0,0 +1,203 @@ +import React, { + useRef, + RefObject, + useMemo, + useCallback, + useState, + KeyboardEvent, +} from 'react' +import { + Coordinates, + polarToCartesian, + valueToAngle, + calculateNearestValueToPoint, + getElementPosition, + absPos, +} from './utils' +import { + CircularInputContext, + CircularInputProvider, +} from './CircularInputContext' +import { CircularTrack } from './CircularTrack' +import { CircularProgress } from './CircularProgress' +import { CircularThumb } from './CircularThumb' + +type DefaultHTMLProps = JSX.IntrinsicElements['svg'] + +type Props = Omit & { + value: number + radius?: number + onChange?: (value: number) => void + onChangeEnd?: (value: number) => void + // disallow some props + ref?: undefined + width?: undefined + height?: undefined + viewBox?: undefined + onClick?: undefined +} + +export function CircularInput({ + value = 0.25, + radius = 100, + onChange = () => { }, + onChangeEnd = () => { }, + tabIndex = 0, + children, + ...props +}: Props) { + const containerRef: RefObject = useRef(null) + const size = radius * 2 + const center = useMemo(() => ({ x: radius, y: radius }), [radius]) + + // Accessibility + const [isFocused, setFocused] = useState(false) + + const isReadonly = !onChange + + const handleFocus = useCallback(() => { + setFocused(true) + }, []) + + const handleBlur = useCallback(() => { + setFocused(false) + }, []) + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!isFocused) return + const { keyCode } = e + + // arrow up, arrow right, page up, space + const isIncrement = + keyCode === 38 || + keyCode === 39 || + keyCode === 33 || + keyCode === 32 + // arrow down, arrow left, page down + const isDecrement = + keyCode === 40 || keyCode === 37 || keyCode === 34 + + if (isIncrement) { + onChange(Math.min(1, value + 0.1)) + } + + if (isDecrement) { + onChange(Math.max(0, value - 0.1)) + } + + if (isIncrement || isDecrement) { + e.preventDefault() + } + }, + [isFocused, onChange, value] + ) + + const accessibilityProps = { + tabIndex, + role: 'slider', + onFocus: handleFocus, + onBlur: handleBlur, + onKeyDown: handleKeyDown, + } + + // Geometry utilities + + const getPointFromValue = useCallback( + (v) => + polarToCartesian({ + center, + angle: valueToAngle(v || value), + radius, + }), + [value, center, radius] + ) + + const getValueFromPointerEvent = useCallback( + (e) => + calculateNearestValueToPoint({ + point: absPos(e), + container: getElementPosition( + containerRef.current + ) as Coordinates, + value, + center, + radius, + }), + [value, center, radius] + ) + + // Context + + const context = useMemo( + (): CircularInputContext => ({ + value, + radius, + center, + isFocused, + setFocused, + onChange, + onChangeEnd, + getPointFromValue, + getValueFromPointerEvent, + }), + [ + value, + radius, + center, + onChange, + onChangeEnd, + isFocused, + setFocused, + getPointFromValue, + getValueFromPointerEvent, + ] + ) + + const handleClick = useCallback( + (e) => { + if (isReadonly) return + const nearestValue = getValueFromPointerEvent(e) + onChange(nearestValue) + onChangeEnd(nearestValue) + }, + [onChange, onChangeEnd, getValueFromPointerEvent, isReadonly] + ) + + const style = { + overflow: 'visible', + outline: 'none', + ...(props.style || {}), + touchAction: 'manipulation', + WebkitTapHighlightColor: 'rgba(0,0,0,0)', + } + + return ( + + + {children ? ( + typeof children === 'function' ? ( + children(context) + ) : ( + children + ) + ) : ( + <> + + + + + )} + + + ) +} \ No newline at end of file diff --git a/src/components/ProgressRing/reference/context.ts b/src/components/ProgressRing/reference/context.ts new file mode 100644 index 0000000..e4b7f93 --- /dev/null +++ b/src/components/ProgressRing/reference/context.ts @@ -0,0 +1,36 @@ +import { + createContext, + useContext, + Context as ReactContext, + SetStateAction, + Dispatch, +} from 'react' +import { Coordinates } from './utils' + +export type CircularInputContext = { + value: number + radius: number + center: Coordinates + isFocused: boolean + setFocused: Dispatch> + onChange: (value: number) => void + onChangeEnd: (value: number) => void + getPointFromValue: (v?: number) => Coordinates | null + getValueFromPointerEvent: (...args: Parameters) => number +} + +const Context: ReactContext = createContext( + {} as CircularInputContext +) + +export const CircularInputProvider = Context.Provider + +export function useCircularInputContext(): CircularInputContext { + const context = useContext(Context) + if (!context) { + throw new Error( + `CircularInput components cannot be rendered outside the CircularInput component` + ) + } + return context as CircularInputContext +} \ No newline at end of file diff --git a/src/components/ProgressRing/reference/hook.ts b/src/components/ProgressRing/reference/hook.ts new file mode 100644 index 0000000..951b6ac --- /dev/null +++ b/src/components/ProgressRing/reference/hook.ts @@ -0,0 +1,100 @@ +import { useEffect, RefObject, useState, useCallback } from 'react' +import { useCircularInputContext } from './' + +export function useCircularDrag(ref: RefObject) { + const { + onChange, + onChangeEnd, + getValueFromPointerEvent, + } = useCircularInputContext() + const [isDragging, setDragging] = useState(false) + + const handleStart: EventListener = useCallback( + (e) => { + if (!onChange) return + stopEvent(e) + setDragging(true) + const nearestValue = getValueFromPointerEvent(e) + onChange(nearestValue) + }, + [onChange, setDragging, getValueFromPointerEvent] + ) + + const handleMove: EventListener = useCallback( + (e) => { + stopEvent(e) + const nearestValue = getValueFromPointerEvent(e) + onChange(nearestValue) + }, + [onChange, getValueFromPointerEvent] + ) + + const handleEnd: EventListener = useCallback( + (e) => { + stopEvent(e) + setDragging(false) + if (!onChangeEnd) return + const nearestValue = getValueFromPointerEvent(e) + onChangeEnd(nearestValue) + }, + [onChangeEnd, getValueFromPointerEvent] + ) + + // we can't just use React for this due to needing { passive: false } to prevent touch devices scrolling + useEffect(() => { + const node = ref.current + if (!node) return + addStartListeners(node, handleStart) + return () => { + if (!node) return + removeStartListeners(node, handleStart) + } + }, [ref, handleStart]) + + useEffect(() => { + if (!isDragging) return + addListeners(handleMove, handleEnd) + return () => { + removeListeners(handleMove, handleEnd) + } + }, [isDragging, handleMove, handleEnd]) + + return { isDragging } +} + +function addStartListeners( + element: SVGElement | HTMLElement, + onStart: EventListener +) { + element.addEventListener('mousedown', onStart, { passive: false }) + element.addEventListener('touchstart', onStart, { passive: false }) +} + +function removeStartListeners( + element: SVGElement | HTMLElement, + onStart: EventListener +) { + element.removeEventListener('mousedown', onStart) + element.removeEventListener('touchstart', onStart) +} + +function addListeners(onMove: EventListener, onEnd: EventListener) { + document.addEventListener('mousemove', onMove, { passive: false }) + document.addEventListener('touchmove', onMove, { passive: false }) + document.addEventListener('mouseup', onEnd, { passive: false }) + document.addEventListener('touchend', onEnd, { passive: false }) +} + +function removeListeners(onMove: EventListener, onEnd: EventListener) { + document.removeEventListener('mousemove', onMove) + document.removeEventListener('touchmove', onMove) + document.removeEventListener('mouseup', onEnd) + document.removeEventListener('touchend', onEnd) +} + +const stopEvent: EventListener = (e) => { + e.stopPropagation() + if (e.cancelable) { + e.preventDefault() + } +} \ No newline at end of file diff --git a/src/components/ProgressRing/reference/utils.ts b/src/components/ProgressRing/reference/utils.ts new file mode 100644 index 0000000..eb5afc0 --- /dev/null +++ b/src/components/ProgressRing/reference/utils.ts @@ -0,0 +1,121 @@ +import { MouseEvent, TouchEvent } from 'react' + +export const DEG_360_IN_RAD = radians(360) +export const ANGLE_OFFSET = Math.PI + +export type Coordinates = { + x: number + y: number +} + +export function polarToCartesian({ + center, + angle, + radius, +}: { + center: Coordinates + angle: number + radius: number +}): Coordinates { + return { + x: center.x + Math.sin(angle) * radius, + y: center.y + Math.cos(angle) * radius, + } +} + +export function radians(deg: number) { + return (deg * Math.PI) / 180 +} + +export function degrees(rad: number) { + return (rad * 180) / Math.PI +} + +export function matrixScale(scale: number, x: number, y: number) { + return `matrix(${scale}, 0, 0, ${scale}, ${x - scale * x}, ${y - scale * y + })` +} + +export function clamp(min: number, max: number, value: number) { + return Math.min(Math.max(value, min), max) +} + +export function calculateNearestValueToPoint({ + center: { x: centerX, y: centerY }, + container: { x: containerX, y: containerY }, + point: { x: pointX, y: pointY }, + radius, + value, +}: { + center: Coordinates + container: Coordinates + point: Coordinates + radius: number + value: number +}) { + const radialPosition = { + x: pointX - containerX - centerX, + y: -(pointY - containerY - centerY), + } + const valuePosition = polarToCartesian({ + center: { x: 0, y: 0 }, + angle: valueToAngle(value), + radius, + }) + const deltaTheta = calcAngleDiff( + radialPosition.x, + radialPosition.y, + valuePosition.x, + -valuePosition.y + ) + const deltaValue = value + deltaTheta / 360 + const nearestValue = deltaValue > 1 ? deltaValue - 1 : deltaValue + return nearestValue +} + +export function calcAngleDiff(x1: number, y1: number, x2: number, y2: number) { + let arcTangent = Math.atan2(x1 * y2 - y1 * x2, x1 * x2 + y1 * y2) + if (arcTangent < 0) { + arcTangent += 2 * Math.PI + } + return (arcTangent * 180) / Math.PI +} + +export function valueToAngle(value: number) { + return -value * DEG_360_IN_RAD + ANGLE_OFFSET +} + +export function absPos(e: TouchEvent | MouseEvent) { + const touchEvent = (e as TouchEvent).touches && (e as TouchEvent) + if (touchEvent) { + return { + x: + touchEvent.changedTouches[0].pageX - + (window.scrollX || window.pageXOffset), + y: + touchEvent.changedTouches[0].pageY - + (window.scrollY || window.pageYOffset), + } + } + const mouseEvent = (e as MouseEvent).pageX && (e as MouseEvent) + if (mouseEvent) { + return { + x: mouseEvent.pageX - (window.scrollX || window.pageXOffset), + y: mouseEvent.pageY - (window.scrollY || window.pageYOffset), + } + } + throw new Error( + 'Unknown event type received (expected: MouseEvent | TouchEvent)' + ) +} + +export function stopEvent(e: Event | MouseEvent | TouchEvent) { + e.stopPropagation() + e.preventDefault() +} + +export function getElementPosition(el?: Element | null) { + if (!el) return + const { left: x, top: y } = el.getBoundingClientRect() + return { x, y } +} \ No newline at end of file diff --git a/src/components/ProgressRing/types.ts b/src/components/ProgressRing/types.ts new file mode 100644 index 0000000..00440e0 --- /dev/null +++ b/src/components/ProgressRing/types.ts @@ -0,0 +1,10 @@ +export interface ProgressRingProps { + progress: number; // 0-100 + size?: number | string; + strokeWidth?: number; + className?: string; + strokeColor?: string; + backgroundColor?: string; + onSeek?: (progress: number) => void; + clickTolerance?: number; +} diff --git a/src/components/ProgressRing/useCircularDrag.ts b/src/components/ProgressRing/useCircularDrag.ts new file mode 100644 index 0000000..e2247f8 --- /dev/null +++ b/src/components/ProgressRing/useCircularDrag.ts @@ -0,0 +1,100 @@ +import { useEffect, RefObject, useState, useCallback } from 'react'; +import { useCircularInputContext } from './context'; + +export function useCircularDrag(ref: RefObject) { + const { + onChange, + onChangeEnd, + getValueFromPointerEvent, + } = useCircularInputContext(); + const [isDragging, setDragging] = useState(false); + + const handleStart: EventListener = useCallback( + (e) => { + if (!onChange) return; + stopEvent(e); + setDragging(true); + const nearestValue = getValueFromPointerEvent(e); + onChange(nearestValue); + }, + [onChange, setDragging, getValueFromPointerEvent] + ); + + const handleMove: EventListener = useCallback( + (e) => { + stopEvent(e); + const nearestValue = getValueFromPointerEvent(e); + onChange(nearestValue); + }, + [onChange, getValueFromPointerEvent] + ); + + const handleEnd: EventListener = useCallback( + (e) => { + stopEvent(e); + setDragging(false); + if (!onChangeEnd) return; + const nearestValue = getValueFromPointerEvent(e); + onChangeEnd(nearestValue); + }, + [onChangeEnd, getValueFromPointerEvent] + ); + + // we can't just use React for this due to needing { passive: false } to prevent touch devices scrolling + useEffect(() => { + const node = ref.current; + if (!node) return; + addStartListeners(node, handleStart); + return () => { + if (!node) return; + removeStartListeners(node, handleStart); + }; + }, [ref, handleStart]); + + useEffect(() => { + if (!isDragging) return; + addListeners(handleMove, handleEnd); + return () => { + removeListeners(handleMove, handleEnd); + }; + }, [isDragging, handleMove, handleEnd]); + + return { isDragging }; +} + +function addStartListeners( + element: SVGElement | HTMLElement, + onStart: EventListener +) { + element.addEventListener('mousedown', onStart, { passive: false }); + element.addEventListener('touchstart', onStart, { passive: false }); +} + +function removeStartListeners( + element: SVGElement | HTMLElement, + onStart: EventListener +) { + element.removeEventListener('mousedown', onStart); + element.removeEventListener('touchstart', onStart); +} + +function addListeners(onMove: EventListener, onEnd: EventListener) { + document.addEventListener('mousemove', onMove, { passive: false }); + document.addEventListener('touchmove', onMove, { passive: false }); + document.addEventListener('mouseup', onEnd, { passive: false }); + document.addEventListener('touchend', onEnd, { passive: false }); +} + +function removeListeners(onMove: EventListener, onEnd: EventListener) { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('touchmove', onMove); + document.removeEventListener('mouseup', onEnd); + document.removeEventListener('touchend', onEnd); +} + +const stopEvent: EventListener = (e) => { + e.stopPropagation(); + if (e.cancelable) { + e.preventDefault(); + } +}; diff --git a/src/components/ProgressRing/utils.ts b/src/components/ProgressRing/utils.ts new file mode 100644 index 0000000..2620bb1 --- /dev/null +++ b/src/components/ProgressRing/utils.ts @@ -0,0 +1,119 @@ +import { MouseEvent, TouchEvent } from 'react'; + +export const DEG_360_IN_RAD = radians(360); +export const ANGLE_OFFSET = Math.PI; + +export type Coordinates = { + x: number; + y: number; +}; + +export function polarToCartesian({ + center, + angle, + radius, +}: { + center: Coordinates; + angle: number; + radius: number; +}): Coordinates { + return { + x: center.x + Math.sin(angle) * radius, + y: center.y + Math.cos(angle) * radius, + }; +} + +export function radians(deg: number) { + return (deg * Math.PI) / 180; +} + +export function degrees(rad: number) { + return (rad * 180) / Math.PI; +} + +export function matrixScale(scale: number, x: number, y: number) { + return `matrix(${scale}, 0, 0, ${scale}, ${x - scale * x}, ${y - scale * y})`; +} + +export function clamp(min: number, max: number, value: number) { + return Math.min(Math.max(value, min), max); +} + +export function calculateNearestValueToPoint({ + center: { x: centerX, y: centerY }, + container: { x: containerX, y: containerY }, + point: { x: pointX, y: pointY }, + radius, + value, +}: { + center: Coordinates; + container: Coordinates; + point: Coordinates; + radius: number; + value: number; +}) { + // Calculate the angle from center to the click point + const clickX = pointX - containerX - centerX; + const clickY = pointY - containerY - centerY; + + // Calculate angle in radians (0 to 2π) + let angle = Math.atan2(clickX, -clickY); + + // Convert to 0-1 range (0 = top, 0.25 = right, 0.5 = bottom, 0.75 = left) + if (angle < 0) { + angle += 2 * Math.PI; + } + + // Convert to 0-1 value + const newValue = angle / (2 * Math.PI); + + // Clamp to 0-1 range + return Math.max(0, Math.min(1, newValue)); +} + +export function calcAngleDiff(x1: number, y1: number, x2: number, y2: number) { + let arcTangent = Math.atan2(x1 * y2 - y1 * x2, x1 * x2 + y1 * y2); + if (arcTangent < 0) { + arcTangent += 2 * Math.PI; + } + return (arcTangent * 180) / Math.PI; +} + +export function valueToAngle(value: number) { + return -value * DEG_360_IN_RAD + ANGLE_OFFSET; +} + +export function absPos(e: TouchEvent | MouseEvent) { + const touchEvent = (e as TouchEvent).touches && (e as TouchEvent); + if (touchEvent) { + return { + x: + touchEvent.changedTouches[0].pageX - + (window.scrollX || window.pageXOffset), + y: + touchEvent.changedTouches[0].pageY - + (window.scrollY || window.pageYOffset), + }; + } + const mouseEvent = (e as MouseEvent).pageX && (e as MouseEvent); + if (mouseEvent) { + return { + x: mouseEvent.pageX - (window.scrollX || window.pageXOffset), + y: mouseEvent.pageY - (window.scrollY || window.pageYOffset), + }; + } + throw new Error( + 'Unknown event type received (expected: MouseEvent | TouchEvent)' + ); +} + +export function stopEvent(e: Event | MouseEvent | TouchEvent) { + e.stopPropagation(); + e.preventDefault(); +} + +export function getElementPosition(el?: Element | null) { + if (!el) return; + const { left: x, top: y } = el.getBoundingClientRect(); + return { x, y }; +} diff --git a/src/components/Thumbnail/Thumbnail.tsx b/src/components/Thumbnail/Thumbnail.tsx new file mode 100644 index 0000000..07cb42c --- /dev/null +++ b/src/components/Thumbnail/Thumbnail.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import clsx from "clsx"; +import { ThumbnailProps } from "../VideoPlayer/types"; +import styles from "../VideoPlayer/VideoPlayer.module.scss"; + +export const Thumbnail: React.FC = ({ + src, + alt, + className, + isHidden, +}) => { + return ( + {alt} + ); +}; diff --git a/src/components/Thumbnail/index.ts b/src/components/Thumbnail/index.ts new file mode 100644 index 0000000..9a19e45 --- /dev/null +++ b/src/components/Thumbnail/index.ts @@ -0,0 +1 @@ +export { Thumbnail } from './Thumbnail'; diff --git a/src/components/Video/Video.tsx b/src/components/Video/Video.tsx new file mode 100644 index 0000000..edf6162 --- /dev/null +++ b/src/components/Video/Video.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import clsx from "clsx"; +import { VideoProps } from "../VideoPlayer/types"; +import styles from "../VideoPlayer/VideoPlayer.module.scss"; + +export const Video: React.FC = ({ + src, + videoRef, + className, + videoAriaLabel = "Video player", + onKeyDown, +}) => { + return ( +