diff --git a/README.md b/README.md index 406efb6..79109a4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # React Telebubble Player -A customizable, accessible, and modern circular video player React component. Built with TypeScript and SCSS modules, this library is designed for easy integration and flexible styling in your React projects. +A customizable, accessible, and modern circular video player React component. Built with TypeScript and BEM SCSS, this library is designed for easy integration and flexible styling in your React projects. [![NPM version](https://badge.fury.io/js/react-telebubble-player.svg)](http://badge.fury.io/js/react-telebubble-player) @@ -20,36 +20,42 @@ While it works and has some cool features, **it's not recommended for production - **๐Ÿ“ž State Callbacks** - `onPlay`, `onPause`, `onEnded` for state synchronization - **๐Ÿ”„ Single Source of Truth** - External state takes precedence when provided - **๐Ÿงน Simplified & Cleaner Code** - Better maintainability and performance +- **๐ŸŽฏ New Component API** - Composable sub-components for flexible styling +- **๐Ÿ“ฆ Zero Extra Dependencies** - Removed clsx, using custom utility functions +- **๐Ÿ”ง Path Aliases** - Clean imports with `@` alias +- **๐ŸŽจ BEM SCSS** - Converted from SCSS modules to BEM methodology -## ๐ŸŽ‰ What's New in v0.4.0 +## ๐Ÿš€ Quick Start -- **๐ŸŽฏ Fixed progress bar for percentage sizes** - Now works perfectly with 100%, 50%, etc. -- **๐Ÿ“ฑ Smart click tolerance** - New `progressClickTolerance` prop (default 5%) optimized for smaller players -- **๐Ÿซฅ Icon hiding support** - Set `playIcon="none"` or `pauseIcon="none"` to hide icons completely -- **๐Ÿ‘† Click anywhere to play** - Click anywhere on the player to play/pause with smart conflict detection -- **๐ŸŽจ Enhanced custom pause icons** - Full support for custom pause icons with comprehensive examples +1. Run the development server: + ```bash + npm run dev + ``` ---- +2. Open your browser to see both showcases with a toggle button in the top-right corner -## Features -- **๐ŸŽฎ External State Management** - Control play/pause state from outside the component with `playing` prop -- **๐Ÿ“ž State Change Callbacks** - `onPlay`, `onPause`, and `onEnded` callbacks for state synchronization -- **Circular video progress ring** with interactive seeking and proportional click tolerance -- **Click anywhere to play** - Click anywhere on the player to play/pause (smart detection prevents conflicts) -- **Custom play/pause icons** - Replace default icons with your own designs or hide them completely -- **Icon hiding support** - Set icons to `"none"` to hide them completely (no gray circles!) -- **Custom play buttons** - Complete control over play button appearance and behavior -- **State-based styling** - Different styles for play vs pause states -- **Progress click tolerance** - Control how much area around the progress ring is clickable -- **Responsive sizing** - Works perfectly with percentage-based sizes (100%, 50%, etc.) -- Customizable via props and class names -- Thumbnail support with smooth transitions -- Fully accessible (ARIA labels, keyboard navigation) -- Written in TypeScript +3. Click "New TelebubblePlayer Showcase" to explore the new component structure ---- +## ๐ŸŽฏ Component API Structure -## Installation +The new TelebubblePlayer uses a composable API with sub-components: + +```tsx + + + + + + } + pauseIcon={} + className="custom-toggle-btn" + /> + + +``` + +## ๐Ÿ“ฆ Installation ```bash npm install react-telebubble-player @@ -61,47 +67,75 @@ or with yarn: yarn add react-telebubble-player ``` ---- - -## Usage - -### Basic Usage +## ๐ŸŽฎ Basic Usage ```tsx -import { VideoPlayer } from 'react-telebubble-player'; +import { TelebubblePlayer } from 'react-telebubble-player'; export default function App() { return ( - + size="200px" + playing={false} + onPlay={() => console.log('Playing')} + onPause={() => console.log('Paused')} + onEnd={() => console.log('Ended')} + > + + + + + + + ); } ``` -### Size Examples +## ๐ŸŽจ Customization Options + +### Main TelebubblePlayer Props +- `src`: Video source URL (required) +- `size`: Player size (number or string, default: "100%") +- `className`: Custom class for the container +- `playing`: External control of play/pause state +- `onPlay`: Called when video starts playing +- `onPause`: Called when video is paused +- `onEnd`: Called when video ends + +### Track Props +- `strokeColor`: Background track color +- `strokeWidth`: Track thickness +- `fill`: Track fill color +- `strokeLinecap`: Track line cap style + +### Progress Props +- `strokeColor`: Progress indicator color +- `strokeWidth`: Progress thickness +- `fill`: Progress fill color +- `strokeLinecap`: Progress line cap style + +### Thumb Props +- `radius`: Thumb size +- `fill`: Thumb color +- `stroke`: Thumb border color +- `strokeWidth`: Thumb border thickness + +### ToggleButton Props +- `playIcon`: Custom play icon component +- `pauseIcon`: Custom pause icon component +- `className`: CSS class for styling +- Automatically shows/hides based on playing state + +### Overlay Props +- `className`: CSS class for overlay container +- `children`: Any custom elements to display + +## ๐ŸŽญ Custom Icons ```tsx -// Default responsive size (100%) - - -// Fixed pixel size - - -// CSS size values - - - -``` - -### Custom Play Icons - -```tsx -import { VideoPlayer } from 'react-telebubble-player'; +import { TelebubblePlayer } from 'react-telebubble-player'; const customPlayIcon = ( @@ -118,122 +152,43 @@ const customPauseIcon = ( export default function App() { return ( - + + + + + + + + ); } ``` -### Custom Play Button - -For complete control over the play button appearance: - -```tsx -import { VideoPlayer } from 'react-telebubble-player'; - -const customPlayButton = ({ isPlaying, onClick, onKeyDown, ariaLabel, className }) => ( - -); - -export default function App() { - return ( - - ); -} -``` - -### Hidden Icons - -You can hide play or pause icons completely by setting them to `"none"`: - -```tsx -// Hide only the play icon (shows pause icon when playing) - - -// Hide only the pause icon (shows play icon when paused) - - -// Hide both icons completely (clean, minimal look) - -``` - -### Progress Click Tolerance - -Control how much area around the progress ring is clickable for seeking vs clicking to play/pause: +## ๐ŸŽจ Size Examples ```tsx -// Tight tolerance (5% - default, optimized for smaller players) - - -// Medium tolerance (15% - good balance) - - -// Generous tolerance (30% - easier seeking, less play/pause area) - -``` - -**How it works:** -- **Progress ring area** (within tolerance %) โ†’ Seeks to that position -- **Anywhere else** โ†’ Toggles play/pause -- **Play/pause button** โ†’ Always toggles play/pause (isolated from other interactions) - -### Click Anywhere to Play +// Default responsive size (100%) + -The player now supports clicking anywhere to play/pause, with smart detection: +// Fixed pixel size + -```tsx - +// CSS size values + + + ``` -### External State Management +## ๐ŸŽฎ External State Management Control playback from external state with the `playing` prop and callbacks: ```tsx -import { VideoPlayer } from 'react-telebubble-player'; +import { TelebubblePlayer } from 'react-telebubble-player'; import { useState } from 'react'; function MyComponent() { @@ -245,13 +200,20 @@ function MyComponent() { {isPlaying ? 'Pause' : 'Play'} - setIsPlaying(true)} onPause={() => setIsPlaying(false)} - onEnded={() => setIsPlaying(false)} - /> + onEnd={() => setIsPlaying(false)} + > + + + + + + + ); } @@ -263,43 +225,60 @@ function MyComponent() { - Callbacks sync your external state with video events - Perfect for Redux, Zustand, Context, or any state management ---- - -## Props - -| Prop | Type | Default | Description | -|---------------------------|---------------------|-------------------|--------------------------------------------------| -| `src` | string | **required** | Video source URL | -| `size` | number \| string | "100%" | Size of the player (pixels if number, CSS value if string) | -| `thumbnailSrc` | string | - | Thumbnail image URL | -| `className` | string | - | Custom class for the container | -| `containerClassName` | string | - | Custom class for the container | -| `progressRingClassName` | string | - | Custom class for the SVG progress ring | -| `progressCircleClassName` | string | - | Custom class for the SVG progress circle | -| `videoWrapperClassName` | string | - | Custom class for the video wrapper | -| `videoClassName` | string | - | Custom class for the video element | -| `thumbnailClassName` | string | - | Custom class for the thumbnail | -| `playButtonClassName` | string | - | Custom class for the default play button | -| `playIcon` | React.ReactNode \| "none" | - | Custom play icon (replaces default triangle) or "none" to hide | -| `pauseIcon` | React.ReactNode \| "none" | - | Custom pause icon (replaces default bars) or "none" to hide | -| `progressClickTolerance` | number | 5 | Percentage of radius for progress ring click tolerance (5-30 recommended) | -| `customPlayButton` | function | - | Complete custom play button component | -| `customPlayButtonClassName` | string | - | Custom class for custom play button | -| `onPlayClassName` | string | - | Class applied when video is playing | -| `onPauseClassName` | string | - | Class applied when video is paused | -| `videoAriaLabel` | string | "Video player" | ARIA label for the video element | -| `thumbnailAlt` | string | "Video thumbnail" | Alt text for the thumbnail image | -| `playButtonAriaLabelPlay` | string | "Play" | ARIA label for play button (when paused) | -| `playButtonAriaLabelPause`| string | "Pause" | ARIA label for play button (when playing) | -| **External State Management** | | | | -| `playing` | boolean | - | External control of play/pause state | -| `onPlay` | () => void | - | Called when video starts playing | -| `onPause` | () => void | - | Called when video is paused | -| `onEnded` | () => void | - | Called when video ends | - ---- - -## Development +## ๐ŸŽจ CSS Styling + +The component uses BEM class names for easy styling: + +- `.telebubble-player` - Main container +- `.telebubble-player__video-wrapper` - Video container +- `.telebubble-player__video` - Video element +- `.telebubble-player__thumbnail` - Thumbnail image +- `.telebubble-player__thumbnail--hidden` - Hidden thumbnail state +- `.telebubble-player__play-button` - Play button +- `.progress-ring` - Progress ring container +- `.progress-ring__track` - Background track +- `.progress-ring__progress` - Progress indicator +- `.progress-ring__thumb` - Progress thumb +- `.telebubble-toggle-button` - Toggle button +- `.telebubble-overlay` - Overlay container + +## ๐Ÿ”„ Migration from Old API + +The new API provides a more declarative and composable structure: + +**Old API:** +```tsx + +``` + +**New API:** +```tsx + + + + + + + + +``` + +## ๐ŸŽฏ Benefits + +1. **Composability**: Mix and match components as needed +2. **Flexibility**: Custom styling per component +3. **Maintainability**: Clear component separation +4. **Extensibility**: Easy to add new sub-components +5. **Developer Experience**: Intuitive API structure +6. **Zero Dependencies**: No external CSS libraries required + +## ๐Ÿš€ Development ### Run Development Showcase @@ -310,12 +289,13 @@ npm run dev ``` This will start a development server with examples of: -- Basic video player with click-anywhere-to-play -- Custom play & pause icons (triangle, heart, star with matching pause styles) -- Hidden icons examples ("none" value demonstrations) -- Progress click tolerance demonstrations (5%, 15%, 30%) -- Different player sizes (120px, 200px, 50%) -- Players with and without thumbnails +- Basic TelebubblePlayer with default styling +- Track variations (thin/thick, different colors) +- Progress styles (gradient/solid, minimal/bold) +- Thumb styles (large/small, color coordination) +- Custom icons (heart, star, square shapes) +- Overlay customization (positioning, additional UI elements) +- Advanced combinations (complex layouts, multiple elements) ### Build for Production (Library Mode) @@ -325,12 +305,18 @@ npm run build This creates the distributable library files in the `dist/` directory. ---- +## ๐Ÿงน Technical Improvements + +- **Path Aliases**: Clean imports with `@` alias instead of relative paths +- **Custom clsx**: Replaced clsx dependency with lightweight utility function +- **BEM SCSS**: Converted from SCSS modules to BEM methodology for better maintainability +- **TypeScript**: Full type safety with proper interfaces and props +- **Zero Runtime Dependencies**: Only React and React DOM as peer dependencies + +## ๐Ÿค Contributing -## Contributing Pull requests and issues are welcome! Please open an issue to discuss your idea or bug before submitting a PR. ---- +## ๐Ÿ“„ License -## License -MIT +MIT \ No newline at end of file diff --git a/TELEBUBBLE_SHOWCASE.md b/TELEBUBBLE_SHOWCASE.md new file mode 100644 index 0000000..be50df2 --- /dev/null +++ b/TELEBUBBLE_SHOWCASE.md @@ -0,0 +1,154 @@ +# ๐ŸŒ€ TelebubblePlayer New Showcase + +This document describes the new TelebubblePlayer showcase that demonstrates the sub-component API structure. + +## ๐Ÿš€ Quick Start + +1. Run the development server: + ```bash + npm run dev + ``` + +2. Open your browser to see both showcases with a toggle button in the top-right corner + +3. Click "New TelebubblePlayer Showcase" to explore the new component structure + +## ๐Ÿ“‹ Showcase Features + +The new showcase demonstrates: + +### ๐ŸŽฏ **Basic Usage** +- Simple TelebubblePlayer with default styling +- Essential props: `src`, `size`, `playing`, `onPlay`, `onPause`, `onEnd` + +### ๐ŸŽจ **Track Variations** +- Thin and thick track configurations +- Different stroke colors and widths +- Customizable background tracks + +### โšก **Progress Styles** +- Gradient and solid progress indicators +- Minimal and bold styling options +- Color-matched thumbs + +### ๐ŸŽฏ **Thumb Styles** +- Large and small thumb variants +- Color coordination with progress +- Custom radius options + +### ๐ŸŽญ **Custom Icons** +- Heart-shaped play buttons +- Star-shaped play buttons +- Square pause buttons +- Custom SVG icons + +### ๐ŸŽจ **Overlay Customization** +- Custom overlay positioning +- Additional UI elements (settings, quality indicators) +- Advanced button placement + +### ๐Ÿš€ **Advanced Combinations** +- Complex overlay layouts +- Multiple custom elements +- Professional video player styling + +## ๐Ÿ”ง Component API Structure + +```tsx + + + + + + } + className="custom-play-btn" + /> + } + className="custom-pause-btn" + /> + + +``` + +## ๐ŸŽจ Customization Options + +### Track Props +- `strokeColor`: Background track color +- `strokeWidth`: Track thickness +- `fill`: Track fill color +- `strokeLinecap`: Track line cap style + +### Progress Props +- `strokeColor`: Progress indicator color +- `strokeWidth`: Progress thickness +- `fill`: Progress fill color +- `strokeLinecap`: Progress line cap style + +### Thumb Props +- `radius`: Thumb size +- `fill`: Thumb color +- `stroke`: Thumb border color +- `strokeWidth`: Thumb border thickness + +### PlayButton/PauseButton Props +- `icon`: Custom icon component +- `className`: CSS class for styling +- Automatically shows/hides based on playing state + +### Overlay Props +- `className`: CSS class for overlay container +- `children`: Any custom elements to display + +## ๐ŸŽจ CSS Styling + +The showcase includes custom CSS for various button styles: + +- `.basic-play-btn`, `.basic-pause-btn` - Simple circular buttons +- `.heart-play-btn`, `.heart-pause-btn` - Gradient heart-styled buttons +- `.star-play-btn`, `.star-pause-btn` - Star-themed buttons +- `.overlay-play-btn`, `.overlay-pause-btn` - Overlay-specific styling +- `.advanced-play-btn`, `.advanced-pause-btn` - Professional button styling + +## ๐Ÿ”„ Migration from Old API + +The new API provides a more declarative and composable structure: + +**Old API:** +```tsx + +``` + +**New API:** +```tsx + + + + + + + + +``` + +## ๐ŸŽฏ Benefits + +1. **Composability**: Mix and match components as needed +2. **Flexibility**: Custom styling per component +3. **Maintainability**: Clear component separation +4. **Extensibility**: Easy to add new sub-components +5. **Developer Experience**: Intuitive API structure + +## ๐Ÿš€ Next Steps + +- Explore the showcase by clicking different examples +- Customize the styling by modifying CSS classes +- Create your own custom icons and components +- Experiment with different combinations of sub-components diff --git a/package.json b/package.json index 1f453bb..44925e7 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,6 @@ "prepare": "npm run build" }, "dependencies": { - "clsx": "^2.1.1", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/src/components/Input/Input.tsx b/src/components/Input/Input.tsx new file mode 100644 index 0000000..02ad1a7 --- /dev/null +++ b/src/components/Input/Input.tsx @@ -0,0 +1,78 @@ +import React, { useCallback } from 'react'; +import { ProgressRingInput } from '@/components/ProgressRing/ProgressRingInput'; +import { Track } from '@/components/ProgressRing/Track'; +import { Progress } from '@/components/ProgressRing/Progress'; +import { Thumb } from '@/components/ProgressRing/Thumb'; + +export type InputProps = { + value: number; + onChange?: (value: number) => void; + clickTolerance?: number; + hasStarted?: boolean; + // Styling props + trackStrokeColor?: string; + trackStrokeWidth?: number; + trackFill?: string; + trackStrokeLinecap?: 'butt' | 'round' | 'square'; + progressStrokeColor?: string; + progressStrokeWidth?: number; + className?: string; +}; + +export const Input: React.FC = ({ + value = 25, + onChange = () => { }, + clickTolerance = 5, + hasStarted = false, + // Styling props with defaults + trackStrokeColor = "#ccc", + trackStrokeWidth = 8, + trackFill = "none", + trackStrokeLinecap = "round", + progressStrokeColor = "#0af", + progressStrokeWidth = 10, + className, +}) => { + // Handle seeking - just call onChange, let parent handle video seeking + const handleSeek = useCallback((newProgress: number) => { + onChange(newProgress); + }, [onChange]); + + return ( + + + + + + ); +}; diff --git a/src/components/ProgressRing/Overlay.tsx b/src/components/ProgressRing/Overlay.tsx new file mode 100644 index 0000000..f8983f6 --- /dev/null +++ b/src/components/ProgressRing/Overlay.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { clsx } from "@/utils"; + +export interface OverlayProps { + children?: React.ReactNode; + className?: string; + isPlaying?: boolean; + togglePlay?: () => void; + handleKeyPress?: (e: React.KeyboardEvent) => void; +} + +export const Overlay: React.FC = ({ + children, + className, + isPlaying, + togglePlay, + handleKeyPress, +}) => { + // Clone children and pass props if they're ToggleButton + const enhancedChildren = React.Children.map(children, (child) => { + if (React.isValidElement(child)) { + // Check if it's a ToggleButton by type + const childType = child.type as { name?: string }; + if (childType && childType.name === 'ToggleButton') { + return React.cloneElement(child as React.ReactElement>, { + isPlaying, + onToggle: togglePlay, + onKeyDown: handleKeyPress, + ...(child.props as Record), + }); + } + } + return child; + }); + + return ( +
+ {enhancedChildren} +
+ ); +}; diff --git a/src/components/ProgressRing/Progress.tsx b/src/components/ProgressRing/Progress.tsx new file mode 100644 index 0000000..84524be --- /dev/null +++ b/src/components/ProgressRing/Progress.tsx @@ -0,0 +1,48 @@ +import React, { useCallback } from 'react'; +import { useProgressRingInputContext } from '@/components/ProgressRing/ProgressRingInputContext'; +import { DEG_360_IN_RAD } from '@/utils/progressRing'; + +export interface ProgressProps { + strokeColor?: string; + strokeWidth?: number; + fill?: string; + strokeLinecap?: 'butt' | 'round' | 'square'; + className?: string; +} + +export const Progress: React.FC = ({ + strokeColor = "#0af", + strokeWidth = 10, + fill = "none", + strokeLinecap = "round", + className, +}) => { + const { value, radius, center, getValueFromPointerEvent, onChange } = useProgressRingInputContext(); + const innerCircumference = DEG_360_IN_RAD * radius; + + const handleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + if (onChange) { + const nearestValue = getValueFromPointerEvent(e.nativeEvent); + onChange(nearestValue); + } + }, [onChange, getValueFromPointerEvent]); + + return ( + + ); +}; diff --git a/src/components/ProgressRing/ProgressRingInput.tsx b/src/components/ProgressRing/ProgressRingInput.tsx new file mode 100644 index 0000000..b92bb6c --- /dev/null +++ b/src/components/ProgressRing/ProgressRingInput.tsx @@ -0,0 +1,275 @@ +import { + useRef, + useMemo, + useCallback, + useState, + useEffect, + KeyboardEvent, +} from 'react' +import { + polarToCartesian, + valueToAngle, + calculateNearestValueToPoint, +} from '@/utils/progressRing' +import { + ProgressRingInputContext, + ProgressRingInputProvider, +} from '@/components/ProgressRing/ProgressRingInputContext' +import { Track } from '@/components/ProgressRing/Track' +import { Progress } from '@/components/ProgressRing/Progress' +import { Thumb } from '@/components/ProgressRing/Thumb' + +type DefaultHTMLProps = React.JSX.IntrinsicElements['svg'] + +type Props = Omit & { + value: number + radius?: number + onChange?: (value: number) => void + clickTolerance?: number + hasStarted?: boolean + children?: React.ReactNode | ((context: ProgressRingInputContext) => React.ReactNode) + // disallow some props + ref?: undefined + width?: undefined + height?: undefined + viewBox?: undefined + onClick?: undefined +} + +export function ProgressRingInput({ + value = 25, + radius = 100, + onChange = () => { }, + clickTolerance = 5, + hasStarted = false, + tabIndex = 0, + children, + ...props +}: Props) { + const containerRef = useRef(null) + const [actualRadius, setActualRadius] = useState(radius) + const size = actualRadius * 2 + const center = useMemo(() => ({ x: actualRadius, y: actualRadius }), [actualRadius]) + + // Calculate actual radius based on container size + useEffect(() => { + const updateRadius = () => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect() + const containerSize = Math.min(rect.width, rect.height) + const newRadius = containerSize / 2 + setActualRadius(newRadius) + } + } + + updateRadius() + + const resizeObserver = new ResizeObserver(updateRadius) + if (containerRef.current) { + resizeObserver.observe(containerRef.current) + } + + return () => resizeObserver.disconnect() + }, []) + + // 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 { key } = e + + // arrow up, arrow right, page up, space + const isIncrement = + key === 'ArrowUp' || + key === 'ArrowRight' || + key === 'PageUp' || + key === ' ' + // arrow down, arrow left, page down + const isDecrement = + key === 'ArrowDown' || key === 'ArrowLeft' || key === 'PageDown' + + if (isIncrement) { + onChange(Math.min(100, value + 10)) + } + + if (isDecrement) { + onChange(Math.max(0, value - 10)) + } + + 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) / 100), + radius: actualRadius, + }), + [value, center, actualRadius] + ) + + const getValueFromPointerEvent = useCallback( + (e: Event) => { + // Get coordinates directly from native event + const nativeEvent = e as MouseEvent | TouchEvent; + let clientX: number, clientY: number; + + if ('touches' in nativeEvent && nativeEvent.touches.length > 0) { + clientX = nativeEvent.touches[0].clientX; + clientY = nativeEvent.touches[0].clientY; + } else if ('changedTouches' in nativeEvent && nativeEvent.changedTouches.length > 0) { + clientX = nativeEvent.changedTouches[0].clientX; + clientY = nativeEvent.changedTouches[0].clientY; + } else { + clientX = (nativeEvent as MouseEvent).clientX; + clientY = (nativeEvent as MouseEvent).clientY; + } + + // Get SVG element position and convert to SVG coordinates + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return value; + + // Convert client coordinates to SVG coordinates + const svgX = ((clientX - rect.left) / rect.width) * size; + const svgY = ((clientY - rect.top) / rect.height) * size; + + const point = { + x: svgX, + y: svgY, + }; + + // Use SVG coordinate system for calculation + return calculateNearestValueToPoint({ + point, + container: { x: 0, y: 0 }, // SVG origin + value: value / 100, + center, + radius: actualRadius, + }) * 100; + }, + [value, center, actualRadius, size] + ) + + // Context + + const context = useMemo( + (): ProgressRingInputContext => ({ + value, + radius: actualRadius, + center, + isFocused, + setFocused, + onChange, + getPointFromValue, + getValueFromPointerEvent, + clickTolerance, + hasStarted, + }), + [ + value, + actualRadius, + center, + onChange, + isFocused, + setFocused, + getPointFromValue, + getValueFromPointerEvent, + clickTolerance, + hasStarted, + ] + ) + + + const handleSvgClick = useCallback((e: React.MouseEvent) => { + // Only handle progress ring clicks if video has started + if (!hasStarted) return; + + // Use the same coordinate system as getValueFromPointerEvent + const rect = e.currentTarget.getBoundingClientRect(); + const clickX = ((e.clientX - rect.left) / rect.width) * size; + const clickY = ((e.clientY - rect.top) / rect.height) * size; + + // Calculate distance from the actual ring center (not container center) + const distanceFromCenter = Math.sqrt( + Math.pow(clickX - center.x, 2) + Math.pow(clickY - center.y, 2) + ); + + // Add tolerance around the ring based on clickTolerance prop + const tolerance = clickTolerance; + const minDistance = actualRadius - tolerance; + const maxDistance = actualRadius + tolerance; + + // Only handle clicks within the tolerance zone + if (distanceFromCenter >= minDistance && distanceFromCenter <= maxDistance) { + // Stop propagation only when clicking on the progress ring + e.stopPropagation(); + const nearestValue = getValueFromPointerEvent(e.nativeEvent); + onChange(nearestValue); + } + // If click is outside the ring area, let the event bubble up for play/pause + }, [center, actualRadius, clickTolerance, getValueFromPointerEvent, onChange, hasStarted, size]); + + const style = { + overflow: 'visible', + outline: 'none', + ...(props.style || {}), + touchAction: 'manipulation', + WebkitTapHighlightColor: 'rgba(0,0,0,0)', + } + + return ( + + + {children ? ( + typeof children === 'function' ? ( + children(context) + ) : ( + children + ) + ) : ( + <> + + + + + )} + + + ) +} diff --git a/src/components/ProgressRing/ProgressRingInputContext.tsx b/src/components/ProgressRing/ProgressRingInputContext.tsx new file mode 100644 index 0000000..2c7acde --- /dev/null +++ b/src/components/ProgressRing/ProgressRingInputContext.tsx @@ -0,0 +1,2 @@ +export type { ProgressRingInputContext } from './types' +export { ProgressRingInputProvider, useProgressRingInputContext } from './context' \ No newline at end of file diff --git a/src/components/ProgressRing/Thumb.tsx b/src/components/ProgressRing/Thumb.tsx new file mode 100644 index 0000000..caa0513 --- /dev/null +++ b/src/components/ProgressRing/Thumb.tsx @@ -0,0 +1,58 @@ +import React, { useRef, useCallback } from 'react'; +import { useProgressRingInputContext } from '@/components/ProgressRing/ProgressRingInputContext'; +import { useCircularDrag } from '@/hooks/useCircularDrag'; + +export interface ThumbProps { + radius?: number; + fill?: string; + stroke?: string; + strokeWidth?: number; + className?: string; +} + +export const Thumb: React.FC = ({ + radius = 6, + fill = "#fff", + stroke, + strokeWidth = 3, + className, +}) => { + const { getPointFromValue, isFocused, getValueFromPointerEvent, onChange } = useProgressRingInputContext(); + const ref = useRef(null); + const { isDragging } = useCircularDrag(ref); + + const handleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + if (onChange) { + const nearestValue = getValueFromPointerEvent(e.nativeEvent); + onChange(nearestValue); + } + }, [onChange, getValueFromPointerEvent]); + + const point = getPointFromValue(); + + // Check conditions after all hooks are called + if (!point) return null; + + const { x, y } = point; + + const style = { + transition: 'r 150ms cubic-bezier(0.215, 0.61, 0.355, 1)', + cursor: 'pointer', + }; + + return ( + + ); +}; diff --git a/src/components/ProgressRing/Track.tsx b/src/components/ProgressRing/Track.tsx new file mode 100644 index 0000000..cc08a23 --- /dev/null +++ b/src/components/ProgressRing/Track.tsx @@ -0,0 +1,47 @@ +import React, { useRef, useCallback } from 'react'; +import { useProgressRingInputContext } from '@/components/ProgressRing/ProgressRingInputContext'; +import { useCircularDrag } from '@/hooks/useCircularDrag'; + +export interface TrackProps { + stroke?: string; + strokeWidth?: number; + fill?: string; + strokeLinecap?: 'butt' | 'round' | 'square'; + className?: string; +} + +export const Track: React.FC = ({ + stroke = "#ccc", + strokeWidth = 8, + fill = "none", + strokeLinecap = "round", + className, +}) => { + const { radius, center, getValueFromPointerEvent, onChange } = useProgressRingInputContext(); + const ref = useRef(null); + useCircularDrag(ref); + + const handleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + if (onChange) { + const nearestValue = getValueFromPointerEvent(e.nativeEvent); + onChange(nearestValue); + } + }, [onChange, getValueFromPointerEvent]); + + return ( + + ); +}; diff --git a/src/components/ProgressRing/context.tsx b/src/components/ProgressRing/context.tsx new file mode 100644 index 0000000..b4738a1 --- /dev/null +++ b/src/components/ProgressRing/context.tsx @@ -0,0 +1,22 @@ +import { + createContext, + useContext, + Context as ReactContext, +} from 'react' +import type { ProgressRingInputContext } from './types' + +const Context: ReactContext = createContext( + {} as ProgressRingInputContext +) + +export const ProgressRingInputProvider = Context.Provider + +export function useProgressRingInputContext(): ProgressRingInputContext { + const context = useContext(Context) + if (!context) { + throw new Error( + `ProgressRingInput components cannot be rendered outside the ProgressRingInput component` + ) + } + return context as ProgressRingInputContext +} \ No newline at end of file diff --git a/src/components/ProgressRing/index.ts b/src/components/ProgressRing/index.ts new file mode 100644 index 0000000..086ea60 --- /dev/null +++ b/src/components/ProgressRing/index.ts @@ -0,0 +1,4 @@ +export { Overlay, type OverlayProps } from './Overlay'; +export { Progress, type ProgressProps } from './Progress'; +export { ProgressRingInput } from './ProgressRingInput'; +export { type ProgressRingInputContext, ProgressRingInputProvider, useProgressRingInputContext, } from './ProgressRingInputContext'; \ 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..36a9064 --- /dev/null +++ b/src/components/ProgressRing/types.ts @@ -0,0 +1,14 @@ +import { Coordinates } from '@/utils/progressRing' + +export type ProgressRingInputContext = { + value: number + radius: number + center: Coordinates + isFocused: boolean + setFocused: React.Dispatch> + onChange: (value: number) => void + getPointFromValue: (v?: number) => Coordinates | null + getValueFromPointerEvent: (e: Event) => number + clickTolerance: number + hasStarted: boolean +} \ No newline at end of file diff --git a/src/components/TelebubblePlayer/TelebubblePlayer.tsx b/src/components/TelebubblePlayer/TelebubblePlayer.tsx new file mode 100644 index 0000000..648c9f3 --- /dev/null +++ b/src/components/TelebubblePlayer/TelebubblePlayer.tsx @@ -0,0 +1,154 @@ +import React, { memo, useMemo, useState, useCallback, useEffect } from "react"; +import { clsx } from "@/utils"; +import "@/styles/telebubble-player.scss"; +import { useVideoPlayerState } from "@/hooks"; +import { calculateSize } from "@/utils"; +import { Video } from "@/components/Video"; +import { Thumbnail } from "@/components/Thumbnail"; +import { Input } from "@/components/Input/Input"; + +// Import sub-components +import * as Components from "@/components/VideoPlayer/components"; + +// Types for the new API +export interface TelebubblePlayerProps { + src: string; + className?: string; + size?: number | string; + thumbnailSrc?: string; + playing?: boolean; + onPlay?: () => void; + onPause?: () => void; + onEnd?: () => void; + children?: React.ReactNode; +} + +// Main TelebubblePlayer component +const TelebubblePlayer = memo(({ + className, + src, + size = "100%", + thumbnailSrc, + playing, + onPlay, + onPause, + onEnd, + children, +}) => { + // Use custom hook for video player state management + const { + videoRef, + hasStarted, + currentPlayingState, + togglePlay, + handleKeyPress, + handleContainerClick, + } = useVideoPlayerState({ + playing, + onPlay, + onPause, + onEnded: onEnd, + clickVideoToPlay: true, + }); + + // Progress state + const [progress, setProgress] = useState(0); + + // Calculate size-related values + const { cssVariables } = useMemo(() => calculateSize(size), [size]); + + // Update progress from video + useEffect(() => { + const video = videoRef?.current; + if (!video) return; + + const updateProgress = () => { + if (video.duration) { + const newProgress = (video.currentTime / video.duration) * 100; + setProgress(newProgress); + } + }; + + video.addEventListener("timeupdate", updateProgress); + return () => video.removeEventListener("timeupdate", updateProgress); + }, [videoRef]); + + // Handle seeking - update both progress state and video time + const handleSeek = useCallback((newProgress: number) => { + setProgress(newProgress); + + // Also seek the video + const video = videoRef?.current; + if (video) { + const time = (newProgress / 100) * video.duration; + video.currentTime = time; + } + }, [videoRef]); + + const classNames = clsx("telebubble-player", className); + + // Extract overlay component from children + const overlayComponent = React.Children.toArray(children).find( + (child) => React.isValidElement(child) && child.type === Components.Overlay + ) as React.ReactElement | undefined; + + return ( +
+ +
+ {thumbnailSrc && ( + + )} +
+ + {/* Render overlay component with its children */} + {overlayComponent && React.cloneElement(overlayComponent as React.ReactElement>, { + isPlaying: currentPlayingState, + togglePlay: () => togglePlay(), + handleKeyPress, + })} +
+ ); +}); + +// Define the component with proper typing +interface TelebubblePlayerComponent extends React.MemoExoticComponent> { + Track: typeof Components.Track; + Progress: typeof Components.Progress; + Thumb: typeof Components.Thumb; + Overlay: typeof Components.Overlay; + ToggleButton: typeof Components.ToggleButton; +} + +// Attach sub-components to main component +const TelebubblePlayerWithComponents = TelebubblePlayer as TelebubblePlayerComponent; +TelebubblePlayerWithComponents.Track = Components.Track; +TelebubblePlayerWithComponents.Progress = Components.Progress; +TelebubblePlayerWithComponents.Thumb = Components.Thumb; +TelebubblePlayerWithComponents.Overlay = Components.Overlay; +TelebubblePlayerWithComponents.ToggleButton = Components.ToggleButton; + +export default TelebubblePlayerWithComponents; diff --git a/src/components/TelebubblePlayer/index.ts b/src/components/TelebubblePlayer/index.ts new file mode 100644 index 0000000..348fcb4 --- /dev/null +++ b/src/components/TelebubblePlayer/index.ts @@ -0,0 +1,2 @@ +export { default as TelebubblePlayer } from './TelebubblePlayer'; +export type { TelebubblePlayerProps } from './TelebubblePlayer'; \ No newline at end of file diff --git a/src/components/Thumbnail/Thumbnail.tsx b/src/components/Thumbnail/Thumbnail.tsx new file mode 100644 index 0000000..bbae383 --- /dev/null +++ b/src/components/Thumbnail/Thumbnail.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { clsx } from "@/utils"; +import "@/styles/telebubble-player.scss"; +import { ThumbnailProps } from "@/components/VideoPlayer/types"; + +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..dec4784 --- /dev/null +++ b/src/components/Video/Video.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { clsx } from "@/utils"; +import "@/styles/telebubble-player.scss"; +import { VideoProps } from "@/components/VideoPlayer/types"; + +export const Video: React.FC = ({ + src, + videoRef, + className, + videoAriaLabel = "Video player", + onKeyDown, +}) => { + return ( +