├── src ├── video │ ├── hooks │ │ ├── webrtc.ts │ │ └── useControls.ts │ ├── styles │ │ ├── mixins.scss │ │ └── video.module.scss │ ├── components │ │ ├── SettingsMenu.tsx │ │ ├── VideoTime.tsx │ │ ├── ContextMenu.tsx │ │ ├── VolumeControl.tsx │ │ ├── SeekingCanvas.tsx │ │ └── ProgressBar.tsx │ ├── element.tsx │ ├── context.tsx │ ├── types.ts │ ├── index.tsx │ └── controls.tsx ├── typings.d.ts ├── index.ts ├── audio │ ├── types.ts │ └── index.tsx ├── hooks │ └── useIntersectionObserver.tsx ├── demo.tsx └── image │ └── index.tsx ├── .github ├── dependabot.yml └── workflows │ └── publish.yml ├── tailwind.config.js ├── index.html ├── .gitpod.yml ├── .editorconfig ├── eslint.config.mjs ├── .npmignore ├── SECURITY.md ├── .gitignore ├── tsconfig.json ├── LICENSE ├── rollup.config.js ├── README.md ├── package.json └── CONTRIBUTING.MD /src/video/hooks/webrtc.ts: -------------------------------------------------------------------------------- 1 | export function useWebRTC() {} 2 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.module.scss" { 2 | const content: Record; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | time: "02:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./src/**/*.{tsx,jsx,ts,js}" 5 | ], 6 | theme: { 7 | extend: {}, 8 | }, 9 | plugins: [], 10 | } 11 | 12 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | # This configuration file was automatically generated by Gitpod. 2 | # Please adjust to your needs (see https://www.gitpod.io/docs/config-gitpod-file) 3 | # and commit this file to your remote git repository to share the goodness with others. 4 | 5 | tasks: 6 | - init: npm install && npm run build 7 | command: npm run dev 8 | 9 | 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.coffee] 12 | indent_style = space 13 | 14 | [{package.json,*.yml}] 15 | indent_style = space 16 | indent_size = 2 17 | 18 | [*.md] 19 | trim_trailing_whitespace = false 20 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | import tseslint from "typescript-eslint"; 4 | import pluginReactConfig from "eslint-plugin-react/configs/recommended.js"; 5 | 6 | 7 | export default [ 8 | {languageOptions: { globals: globals.browser }}, 9 | pluginJs.configs.recommended, 10 | ...tseslint.configs.recommended, 11 | pluginReactConfig, 12 | ]; -------------------------------------------------------------------------------- /src/video/styles/mixins.scss: -------------------------------------------------------------------------------- 1 | // desktop-screen-size varable 2 | $desktop-screen-size: 1024px; 3 | $tablet-screen-size: 768px; 4 | $mobile-screen-size: 425px; 5 | 6 | @mixin desktop { 7 | @media (min-width: $desktop-screen-size) { 8 | @content; 9 | } 10 | } 11 | 12 | @mixin tablet { 13 | @media (max-width: $desktop-screen-size) and (min-width: $tablet-screen-size) { 14 | @content; 15 | } 16 | } 17 | 18 | @mixin mobile { 19 | @media (max-width: $tablet-screen-size) { 20 | @content; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | src 3 | rollup.config.js 4 | .babelrc 5 | .eslintrc.js 6 | comand.txt 7 | players.txt 8 | .git 9 | CVS 10 | .svn 11 | .hg 12 | .lock-wscript 13 | .wafpickle-N 14 | .DS_Store 15 | .npmrc 16 | config.gypi 17 | .vs 18 | .vscode 19 | README.html 20 | test 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | server.js 25 | .trix 26 | .editorconfig 27 | demo 28 | webpack.config.js 29 | .github 30 | SECURITY.md 31 | public 32 | .gitpod.yml 33 | index.html 34 | eslint.config.js 35 | tailwind.config.js 36 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | The following are the version that are being suported with security updates. 6 | 7 | | Version | Supported | 8 | | ------- | ------------------ | 9 | | 2.x.x | :white_check_mark: | 10 | | 1.5.x | :white_check_mark: | 11 | | <1.3.x | :x: | 12 | 13 | ## Reporting a Vulnerability 14 | 15 | 16 | 17 | Incase of a vulnerability open a security issue and state the problem in the most detailed way possible. It will be attended to as soon as possible 18 | 19 | 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | # testing 7 | /coverage 8 | /test 9 | # production 10 | /build 11 | /dist 12 | 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | # editor 22 | .vs 23 | .vscode 24 | 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | comand.txt 29 | players.txt 30 | 31 | # media files used in development 32 | demo/video.* 33 | demo/image.* 34 | demo/audio.* 35 | public/video.* 36 | public/image.* 37 | public/audio.* 38 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | }, 19 | "include": ["src"], 20 | } 21 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { useControls } from "./video/hooks/useControls"; 2 | import Video, { VideoProvider } from "./video"; 3 | import { VideoContext } from "./video/context"; 4 | import { VideoControls, VideoPoster } from "./video/controls"; 5 | import { VideoElement } from "./video/element"; 6 | import Audio from "./audio"; 7 | import Img from "./image"; 8 | 9 | export * from "./video/types"; 10 | export * from "./audio/types"; 11 | export { 12 | Video, 13 | VideoContext, 14 | VideoControls, 15 | VideoElement, 16 | VideoProvider, 17 | VideoPoster, 18 | useControls, 19 | Audio, 20 | Img, 21 | }; 22 | -------------------------------------------------------------------------------- /src/video/components/SettingsMenu.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/react-in-jsx-scope */ 2 | import styles from "../styles/video.module.scss"; 3 | 4 | export const Settings = ({}) => { 5 | return ( 6 |
7 |
8 |
9 |

Settings

10 |
11 |
12 |
13 |

14 |
15 |
16 |
17 |
18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package to npmjs 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | contents: read 10 | id-token: write 11 | steps: 12 | - uses: actions/checkout@v4 13 | # Setup .npmrc file to publish to npm 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: "20.x" 17 | registry-url: "https://registry.npmjs.org" 18 | - run: npm install 19 | - run: npm run build 20 | - run: npm publish --provenance --access public 21 | env: 22 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 23 | -------------------------------------------------------------------------------- /src/audio/types.ts: -------------------------------------------------------------------------------- 1 | export interface AudioProps { 2 | src: string; 3 | controls?: boolean; 4 | customControls?: boolean; 5 | autoplay?: boolean; 6 | loop?: boolean; 7 | volume?: number; 8 | playbackRate?: number; 9 | onPlay?: () => void; 10 | onPause?: () => void; 11 | onEnd?: () => void; 12 | onError?: (error: Error) => void; 13 | onVolumeChange?: (volume: number) => void; 14 | onSeek?: (time: number) => void; 15 | className?: string; 16 | } 17 | 18 | export interface AudioState { 19 | isPlaying: boolean; 20 | currentTime: number; 21 | duration: number; 22 | volume: number; 23 | playbackRate: number; 24 | isMuted: boolean; 25 | isLoading: boolean; 26 | error: Error | null; 27 | } 28 | -------------------------------------------------------------------------------- /src/video/element.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect } from "react"; 2 | import { VideoContext } from "./context"; 3 | import styles from "./styles/video.module.scss"; 4 | import { VideoElementProps } from "./types"; 5 | 6 | export const VideoElement = ({ controls = true, src }: VideoElementProps) => { 7 | const { videoRef } = useContext(VideoContext); 8 | 9 | useEffect(() => { 10 | if (videoRef.current) { 11 | if (src) { 12 | if (typeof src === "string") { 13 | videoRef.current.src = src; 14 | } else { 15 | videoRef.current.autoplay = true; 16 | videoRef.current.srcObject = src; 17 | } 18 | } 19 | videoRef.current.preload = "metadata"; 20 | } 21 | }, [videoRef.current, src]); 22 | 23 | return ( 24 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src/hooks/useIntersectionObserver.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, RefObject } from "react"; 2 | 3 | interface IntersectionObserverOptions { 4 | threshold?: number; 5 | rootMargin?: string; 6 | triggerOnce?: boolean; 7 | } 8 | 9 | export const useIntersectionObserver = ( 10 | ref: RefObject, 11 | { 12 | threshold = 0, 13 | rootMargin = "0px", 14 | triggerOnce = false, 15 | }: IntersectionObserverOptions = {} 16 | ) => { 17 | const [isIntersecting, setIsIntersecting] = useState(false); 18 | 19 | useEffect(() => { 20 | const element = ref.current; 21 | if (!element) return; 22 | 23 | const observer = new IntersectionObserver( 24 | ([entry]) => { 25 | setIsIntersecting(entry.isIntersecting); 26 | if (entry.isIntersecting && triggerOnce) { 27 | observer.unobserve(element); 28 | } 29 | }, 30 | { threshold, rootMargin } 31 | ); 32 | 33 | observer.observe(element); 34 | return () => observer.disconnect(); 35 | }, [ref, threshold, rootMargin, triggerOnce]); 36 | 37 | return { isIntersecting }; 38 | }; 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Beingana Jim Junior 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from "@rollup/plugin-node-resolve"; 2 | import commonjs from "@rollup/plugin-commonjs"; 3 | import typescript from "@rollup/plugin-typescript"; 4 | import dts from "rollup-plugin-dts"; 5 | import terser from "@rollup/plugin-terser"; 6 | import peerDepsExternal from "rollup-plugin-peer-deps-external"; 7 | import postcss from "rollup-plugin-postcss"; 8 | 9 | const packageJson = require("./package.json"); 10 | 11 | export default [ 12 | { 13 | input: "src/index.ts", 14 | output: [ 15 | { 16 | file: packageJson.main, 17 | format: "cjs", 18 | sourcemap: false, 19 | }, 20 | { 21 | file: packageJson.module, 22 | format: "esm", 23 | sourcemap: false, 24 | }, 25 | ], 26 | plugins: [ 27 | peerDepsExternal(), 28 | resolve(), 29 | commonjs(), 30 | typescript({ tsconfig: "./tsconfig.json" }), 31 | terser(), 32 | postcss({ 33 | extract: false, 34 | modules: true, 35 | use: ["sass"], 36 | }), 37 | ], 38 | external: ["react", "react-dom"], 39 | }, 40 | { 41 | input: "src/index.ts", 42 | output: [{ file: "dist/types.d.ts", format: "es" }], 43 | plugins: [dts.default()], 44 | }, 45 | ]; 46 | -------------------------------------------------------------------------------- /src/video/components/VideoTime.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/react-in-jsx-scope */ 2 | import { useContext, useState, useEffect } from "react"; 3 | import { VideoContext } from "../context"; 4 | import styles from "../styles/video.module.scss"; 5 | 6 | export const VideoTime = () => { 7 | const { videoRef } = useContext(VideoContext); 8 | const [time, setTime] = useState("00:00"); 9 | 10 | function formatTime(time: number) { 11 | const minutes = Math.floor(time / 60); 12 | const seconds = Math.floor(time % 60); 13 | const secondsString = seconds < 10 ? `0${seconds}` : `${seconds}`; 14 | const minutesString = minutes < 10 ? `0${minutes}` : `${minutes}`; 15 | return `${minutesString}:${secondsString}`; 16 | } 17 | 18 | useEffect(() => { 19 | if (videoRef.current) { 20 | const handleTimeUpdate = () => { 21 | if (videoRef.current) { 22 | setTime(formatTime(videoRef.current.currentTime)); 23 | } 24 | }; 25 | 26 | videoRef.current.addEventListener("timeupdate", handleTimeUpdate); 27 | 28 | return () => { 29 | videoRef.current?.removeEventListener("timeupdate", handleTimeUpdate); 30 | }; 31 | } 32 | }, [videoRef.current]); 33 | return ( 34 |
35 | {time} 36 | 37 | {formatTime(videoRef.current?.duration || 0)} 38 | 39 |
40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /src/demo.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import { Video, VideoPlayerRef } from "./index"; 4 | import Audio from "./audio"; 5 | import Img from "./image"; 6 | 7 | const App = () => { 8 | const ref = React.useRef(null); 9 | const [src, setSrc] = React.useState( 10 | "/video.mkv" 11 | ); 12 | const constraints = (window.constraints = { 13 | audio: false, 14 | video: true, 15 | }); 16 | 17 | async function init(e) { 18 | try { 19 | const stream = await navigator.mediaDevices.getUserMedia(constraints); 20 | setSrc(stream); 21 | e.target.disabled = true; 22 | } catch (e) { 23 | // pass 24 | } 25 | } 26 | return ( 27 |
28 |
54 | ); 55 | }; 56 | 57 | export default App; 58 | 59 | const rootElement = document.getElementById("app"); 60 | if (rootElement) { 61 | const root = createRoot(rootElement); 62 | root.render(); 63 | } 64 | -------------------------------------------------------------------------------- /src/video/components/ContextMenu.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/react-in-jsx-scope */ 2 | import { useContext } from "react"; 3 | import { VideoContext } from "../context"; 4 | import styles from "../styles/video.module.scss"; 5 | import { ContextMenuItem } from "../types"; 6 | 7 | export const ContextMenu = ({ 8 | renderCustomMenu, 9 | }: { 10 | renderCustomMenu?: ( 11 | contextMenuItems: Array 12 | ) => React.ReactNode | null; 13 | }) => { 14 | const { contextMenuItems, menuOpen, setMenuOpen, menuClientX, menuClientY } = 15 | useContext(VideoContext); 16 | if (!menuOpen) { 17 | return null; 18 | } 19 | 20 | return ( 21 |
setMenuOpen(false)} 24 | > 25 |
29 | {renderCustomMenu ? ( 30 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 31 | // @ts-ignore 32 | renderCustomMenu(contextMenuItems) 33 | ) : ( 34 |
35 | {contextMenuItems?.map((item: ContextMenuItem, index: number) => ( 36 | 46 | ))} 47 |
48 | )} 49 |
50 |
51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Reactjs Media 2 | 3 | #####
[![License: MIT](https://flat.badgen.net/npm/license/reactjs-media)](https://opensource.org/licenses/MIT) [![Npm package total downloads](https://flat.badgen.net/npm/dt/reactjs-media)](https://npmjs.com/package/reactjs-media) [![version](https://flat.badgen.net/npm/v/reactjs-media)](https://npmjs.com/package/reactjs-media) [![](https://flat.badgen.net/badge/icon/github?icon=github&label&color=black)](https://github.com/jim-junior/reactjs-media)
4 | 5 | Interactive media in React. This library is a collection of media components that can be used to display media on the web. 6 | 7 | Try it out on [CodeSandbox](https://codesandbox.io/p/sandbox/reactjs-media-c5x795) 8 | 9 | It includes currently only has a video and audio component. 10 | 11 | Available components: 12 | 13 | - Video 14 | - Audio 15 | 16 | #### Installation 17 | 18 | To install go to your terminal and run this script 19 | 20 | ```bash 21 | # npm 22 | $ npm install reactjs-media 23 | # yarn 24 | $ yarn add reactjs-media 25 | ``` 26 | 27 | #### Setup 28 | 29 | In here we shall show a small demo on how to setup a simple video component. We shall create the default component. 30 | 31 | 32 | ```jsx 33 | import React from 'react'; 34 | import { Video } from 'reactjs-media'; 35 | 36 | const App = () => { 37 | return ( 38 |
39 |
44 | ) 45 | } 46 | 47 | 48 | 49 | ``` 50 | 51 | If you want to learn more, Checkout the offical [Documentation](https://open.cranom.tech/reactjs-media/intro "Documentation") 52 | 53 | ___ 54 | 55 | The source code can be found on [Github](https://github.com/jim-junior/reactjs-media). 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reactjs-media", 3 | "version": "3.1.8", 4 | "description": "Awesome Multimedia Components for React", 5 | "main": "./dist/index.js", 6 | "module": "./dist/index.esm.js", 7 | "types": "./dist/types.d.ts", 8 | "scripts": { 9 | "build": "rollup -c --bundleConfigAsCjs", 10 | "dev": "vite" 11 | }, 12 | "peerDependencies": { 13 | "react": ">=18.3.1 <=19.0.0", 14 | "react-dom": ">=18.3.1 <=19.0.0" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/jim-junior/reactjs-media.git" 19 | }, 20 | "keywords": [ 21 | "react", 22 | "media", 23 | "reactjs-media", 24 | "video", 25 | "video-component", 26 | "audio", 27 | "reactjs-audio" 28 | ], 29 | "author": "Beingana Jim Junior", 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/jim-junior/reactjs-media/issues" 33 | }, 34 | "homepage": "https://orbiton.js.org/open-ug/reactjs-media/intro/", 35 | "devDependencies": { 36 | "@eslint/js": "^9.1.1", 37 | "@rollup/plugin-commonjs": "^26.0.1", 38 | "@rollup/plugin-node-resolve": "^15.2.3", 39 | "@rollup/plugin-terser": "^0.4.4", 40 | "@rollup/plugin-typescript": "^11.1.6", 41 | "@types/react": "^18.3.1", 42 | "@types/react-dom": "^18.3.0", 43 | "@vitejs/plugin-react": "^4.2.1", 44 | "autoprefixer": "^10.4.19", 45 | "eslint": "^8.57.0", 46 | "eslint-plugin-react": "^7.34.1", 47 | "globals": "^15.1.0", 48 | "node-sass": "^9.0.0", 49 | "postcss": "^8.4.38", 50 | "rollup": "^4.17.0", 51 | "rollup-plugin-dts": "^6.1.0", 52 | "rollup-plugin-peer-deps-external": "^2.2.4", 53 | "rollup-plugin-postcss": "^4.0.2", 54 | "sass": "^1.75.0", 55 | "tailwindcss": "^3.4.3", 56 | "tslib": "^2.6.2", 57 | "typescript": "^5.4.5", 58 | "typescript-eslint": "^8.0.0", 59 | "vite": "^5.2.10" 60 | }, 61 | "dependencies": { 62 | "react-icons": "^5.1.0" 63 | }, 64 | "eslintConfig": { 65 | "extends": [ 66 | "plugin:storybook/recommended" 67 | ] 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/video/context.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import React, { useEffect } from "react"; 3 | import { Context, createContext, useRef, useState } from "react"; 4 | import { ContextMenuItem, VideoCTX } from "./types"; 5 | 6 | export const VideoContext: VideoCTX = createContext({ 7 | videoRef: { current: null }, 8 | containerRef: { current: null }, 9 | overlayRef: { current: null }, 10 | seekPreview: false, 11 | setSeekPreview: (seekPreview: boolean) => {}, 12 | menuClientX: 0, 13 | setMenuClientX: (menuClientX: number) => {}, 14 | menuClientY: 0, 15 | setMenuClientY: (menuClientY: number) => {}, 16 | contextMenuItems: [], 17 | setContextMenuItems: (contextMenuItems: Array) => {}, 18 | menuOpen: false, 19 | setMenuOpen: (menuOpen: boolean) => {}, 20 | setSrc: (src: string | MediaStream | null) => {}, 21 | src: null, 22 | }) as VideoCTX; 23 | 24 | export const VideoCTXProvider = ({ 25 | children, 26 | src, 27 | }: { 28 | children: React.ReactNode; 29 | src: string | MediaStream | null; 30 | }) => { 31 | const videoRef = useRef(null); 32 | const containerRef = useRef(null); 33 | const overlayRef = useRef(null); 34 | const [seekPreview, setSeekPreview] = useState(false); 35 | const [menuClientX, setMenuClientX] = useState(0); 36 | const [menuClientY, setMenuClientY] = useState(0); 37 | const [contextMenuItems, setContextMenuItems] = useState< 38 | Array 39 | >([]); 40 | const [menuOpen, setMenuOpen] = useState(false); 41 | const [videoSrc, setSrc] = useState(src); 42 | 43 | useEffect(() => { 44 | setSrc(src); 45 | }, [src]); 46 | 47 | return ( 48 | 67 | {children} 68 | 69 | ); 70 | }; 71 | -------------------------------------------------------------------------------- /CONTRIBUTING.MD: -------------------------------------------------------------------------------- 1 | 2 | # Contributing to Reactjs Media 3 | 4 | First off, thank you for considering contributing to Reactjs Media! Your support helps us improve and grow. 5 | 6 | ## Code of Conduct 7 | 8 | Please note that this project is released with a [Contributor Code of Conduct](https://www.contributor-covenant.org/version/2/0/code_of_conduct/). By participating in this project, you agree to abide by its terms. 9 | 10 | ## How Can I Contribute? 11 | 12 | ### Reporting Bugs 13 | 14 | If you've encountered a bug, please help us by submitting an issue. Before you create an issue, please check if a similar issue already exists. If it does, add any additional information as a comment. 15 | 16 | **To report a bug:** 17 | 18 | 1. **Search Existing Issues:** Check if the issue has already been reported. 19 | 2. **Create a New Issue:** If it hasn't been reported, [open a new issue](https://github.com/jim-junior/reactjs-media/issues/new) and include: 20 | - A clear and descriptive title. 21 | - A detailed description of the problem. 22 | - Steps to reproduce the issue. 23 | - Any relevant logs or screenshots. 24 | 25 | ### Suggesting Features 26 | 27 | We welcome new ideas to enhance the project. 28 | 29 | **To suggest a feature:** 30 | 31 | 1. **Check Existing Issues:** Ensure the feature hasn't been suggested already. 32 | 2. **Create a New Issue:** [Open a new issue](https://github.com/jim-junior/reactjs-media/issues/new) and provide: 33 | - A clear and descriptive title. 34 | - A detailed description of the proposed feature. 35 | - Rationale for why the feature is needed. 36 | 37 | ### Submitting Pull Requests 38 | 39 | We appreciate your contributions! To ensure a smooth process: 40 | 41 | 1. **Fork the Repository:** Create your own fork of the project. 42 | 2. **Clone the Fork:** Clone your fork to your local machine. 43 | 3. **Create a Branch:** Create a new branch for your feature or bugfix. 44 | 4. **Make Changes:** Implement your changes in this branch. 45 | 5. **Commit Changes:** Write clear and concise commit messages. 46 | 6. **Push to GitHub:** Push your changes to your fork. 47 | 7. **Open a Pull Request:** From your fork, open a pull request to the main repository. 48 | 49 | **Pull Request Guidelines:** 50 | 51 | - **Describe Your Changes:** Provide a clear description of what your pull request does. 52 | - **Reference Issues:** If your pull request addresses an issue, include a reference (e.g., `Closes #123`). 53 | - **Be Responsive:** Participate in the review process and address any feedback. 54 | 55 | ## Development Setup 56 | 57 | To set up the project locally: 58 | 59 | 1. **Clone the Repository:** 60 | 61 | ```bash 62 | git clone https://github.com/jim-junior/reactjs-media.git 63 | cd reactjs-media 64 | ``` 65 | 66 | 2. **Install Dependencies:** 67 | 68 | ```bash 69 | npm install 70 | ``` 71 | 72 | 3. **Run the Development Server:** 73 | 74 | ```bash 75 | npm run dev 76 | ``` 77 | 78 | All code is located in the `src` directory. and to test your component import it in the `test/demo.tsx` file. 79 | 80 | ## Coding Style 81 | 82 | To maintain consistency, we follow the following Code Style: 83 | 84 | - **Code Formatting:** Use [Prettier](https://prettier.io/) for code formatting. 85 | - **Linting:** Ensure your code passes all linting rules. We utilise eslint 86 | -------------------------------------------------------------------------------- /src/video/types.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "react"; 2 | 3 | export interface VideoProps { 4 | /** 5 | * Indicates whether the video should have controls. If `false`, wont render the controls 6 | */ 7 | controls?: boolean; 8 | /** 9 | * The source of the video 10 | * @example "https://www.example.com/video.mp4" 11 | * @example "/video.mp4" 12 | **/ 13 | src: string | MediaStream | null; 14 | height: string | number; 15 | width: string | number; 16 | poster?: string; 17 | /** 18 | * Indicates whether the video should show a preview when seeking 19 | */ 20 | seekPreview?: boolean; 21 | /** 22 | * Event Listener for when the video time updates 23 | * @param time - The current time of the video 24 | */ 25 | onTimeUpdate?: (time: number) => void; 26 | /** 27 | * Event Listener for when the video plays 28 | */ 29 | onPlay?: () => void; 30 | /** 31 | * Event Listener for when the video pauses 32 | */ 33 | onPause?: () => void; 34 | /** 35 | * Event Listener for when the video ends 36 | */ 37 | onEnded?: () => void; 38 | /** 39 | * Event Listener for when the video volume changes 40 | * @param volume - The current volume of the video 41 | */ 42 | onVolumeChange?: (volume: number) => void; 43 | 44 | onSeeking?: () => void; 45 | onSeeked?: () => void; 46 | onLoadedMetadata?: () => void; 47 | onLoadedData?: () => void; 48 | onCanPlay?: () => void; 49 | contextMenu?: boolean; 50 | contextMenuItems?: Array; 51 | settings?: boolean; 52 | settingsGroups?: Array; 53 | } 54 | 55 | export type ContextMenuItem = { 56 | label: string; 57 | onClick: () => void; 58 | icon?: React.ReactNode; 59 | }; 60 | 61 | export type SettingsGroup = { 62 | title: string; 63 | options: Array; 64 | }; 65 | 66 | export type SettingsItem = { 67 | label: string; 68 | onClick: () => void; 69 | }; 70 | 71 | export interface VideoElementProps { 72 | controls?: boolean; 73 | src: string | MediaStream | null; 74 | } 75 | 76 | export type VideoCTX = Context<{ 77 | videoRef: React.RefObject; 78 | containerRef: React.RefObject; 79 | overlayRef: React.RefObject; 80 | seekPreview: boolean; 81 | setSeekPreview: 82 | | React.Dispatch> 83 | | ((boolean: boolean) => void); 84 | menuClientY: number; 85 | setMenuClientY: React.Dispatch>; 86 | menuClientX: number; 87 | setMenuClientX: React.Dispatch>; 88 | contextMenuItems?: Array | []; 89 | setContextMenuItems: React.Dispatch< 90 | React.SetStateAction> 91 | >; 92 | menuOpen: boolean; 93 | setMenuOpen: React.Dispatch>; 94 | src: string | MediaStream | null; 95 | setSrc: React.Dispatch>; 96 | }>; 97 | 98 | export type VideoPlayerRef = { 99 | play: () => void; 100 | pause: () => void; 101 | seek: (time: number) => void; 102 | volume: (volume: number) => void; 103 | playbackRate: (rate: number) => void; 104 | toggleFullscreen: () => void; 105 | togglePip: () => void; 106 | toggleMute: () => void; 107 | togglePlay: () => void; 108 | setLoop: (loop: boolean) => void; 109 | }; 110 | -------------------------------------------------------------------------------- /src/video/hooks/useControls.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { VideoContext } from "../context"; 3 | 4 | export const useControls = () => { 5 | const { videoRef, containerRef } = useContext(VideoContext); 6 | 7 | const play = async () => { 8 | if (videoRef.current) { 9 | await videoRef.current.play(); 10 | } 11 | }; 12 | 13 | const pause = () => { 14 | if (videoRef.current) { 15 | videoRef.current.pause(); 16 | } 17 | }; 18 | 19 | const togglePlay = async () => { 20 | if (videoRef.current) { 21 | if (videoRef.current.paused) { 22 | await play(); 23 | } else { 24 | pause(); 25 | } 26 | } 27 | }; 28 | 29 | const forward = () => { 30 | if (videoRef.current) { 31 | videoRef.current.currentTime += 10; 32 | } 33 | }; 34 | 35 | const rewind = () => { 36 | if (videoRef.current) { 37 | videoRef.current.currentTime -= 10; 38 | } 39 | }; 40 | 41 | const updateVolume = (volume: number) => { 42 | if (videoRef.current) { 43 | videoRef.current.volume = volume; 44 | } 45 | }; 46 | 47 | const updatePlaybackRate = (rate: number) => { 48 | if (videoRef.current) { 49 | videoRef.current.playbackRate = rate; 50 | } 51 | }; 52 | 53 | const increasePlaybackRate = () => { 54 | if (videoRef.current) { 55 | videoRef.current.playbackRate += 0.25; 56 | } 57 | }; 58 | 59 | const decreasePlaybackRate = () => { 60 | if (videoRef.current) { 61 | videoRef.current.playbackRate -= 0.25; 62 | } 63 | }; 64 | 65 | const toggleMute = () => { 66 | if (videoRef.current) { 67 | videoRef.current.muted = !videoRef.current.muted; 68 | } 69 | }; 70 | 71 | const increaseVolume = () => { 72 | if (videoRef.current) { 73 | videoRef.current.volume += 0.1; 74 | } 75 | }; 76 | 77 | const decreaseVolume = () => { 78 | if (videoRef.current) { 79 | videoRef.current.volume -= 0.1; 80 | } 81 | }; 82 | 83 | const toggleFullscreen = () => { 84 | if (containerRef.current) { 85 | if (document.fullscreenElement) { 86 | document.exitFullscreen(); 87 | } else { 88 | containerRef.current.requestFullscreen(); 89 | } 90 | } 91 | }; 92 | 93 | const togglePip = async () => { 94 | if (videoRef.current && "requestPictureInPicture" in videoRef.current) { 95 | try { 96 | if (document.pictureInPictureElement) { 97 | await document.exitPictureInPicture(); 98 | } else { 99 | await videoRef.current.requestPictureInPicture(); 100 | } 101 | } catch (error) { 102 | console.error(error); 103 | } 104 | } 105 | }; 106 | 107 | const seek = (time: number) => { 108 | if (videoRef.current) { 109 | videoRef.current.currentTime = time; 110 | } 111 | }; 112 | 113 | const setLoop = (loop: boolean) => { 114 | if (videoRef.current) { 115 | videoRef.current.loop = loop; 116 | } 117 | }; 118 | 119 | return { 120 | play, 121 | pause, 122 | togglePlay, 123 | forward, 124 | rewind, 125 | updateVolume, 126 | updatePlaybackRate, 127 | increasePlaybackRate, 128 | decreasePlaybackRate, 129 | toggleMute, 130 | increaseVolume, 131 | decreaseVolume, 132 | toggleFullscreen, 133 | togglePip, 134 | seek, 135 | setLoop, 136 | }; 137 | }; 138 | -------------------------------------------------------------------------------- /src/video/components/VolumeControl.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/react-in-jsx-scope */ 2 | import { useContext, useState, useEffect, useRef } from "react"; 3 | import { VideoContext } from "../context"; 4 | import styles from "../styles/video.module.scss"; 5 | import { FaVolumeMute, FaVolumeOff, FaVolumeUp } from "react-icons/fa"; 6 | import { FaVolumeLow } from "react-icons/fa6"; 7 | import { useControls } from "../hooks/useControls"; 8 | 9 | export const VideoVolumeControlBar = () => { 10 | const { updateVolume, toggleMute } = useControls(); 11 | const { videoRef } = useContext(VideoContext); 12 | const [volume, setVolume] = useState(1); 13 | const slider = useRef(null); 14 | const [isMuted, setIsMuted] = useState(false); 15 | 16 | useEffect(() => { 17 | if (slider.current) { 18 | const handleMouseDown = (e: MouseEvent) => { 19 | if (slider.current && videoRef.current) { 20 | const rect = slider.current.getBoundingClientRect(); 21 | const percentage = ((e.clientX - rect.left) / rect.width) * 100; 22 | updateVolume(percentage / 100); 23 | setVolume(percentage / 100); 24 | } 25 | }; 26 | 27 | slider.current.addEventListener("mousedown", handleMouseDown); 28 | 29 | return () => { 30 | slider.current?.removeEventListener("mousedown", handleMouseDown); 31 | }; 32 | } 33 | }, [slider.current]); 34 | 35 | useEffect(() => { 36 | if (videoRef.current) { 37 | setVolume(videoRef.current.volume); 38 | } 39 | }, [videoRef.current]); 40 | 41 | function renderVolumeIcon() { 42 | if (isMuted) { 43 | return ; 44 | } else if (volume === 0) { 45 | return ; 46 | } else if (volume <= 0.3) { 47 | return ; 48 | } else if (volume < 0.5) { 49 | return ; 50 | } else { 51 | return ; 52 | } 53 | } 54 | 55 | function handleMute() { 56 | if (isMuted) { 57 | toggleMute(); 58 | setIsMuted(false); 59 | } else { 60 | toggleMute(); 61 | setIsMuted(true); 62 | } 63 | } 64 | 65 | useEffect(() => { 66 | if (videoRef.current) { 67 | const handleVolumeChange = () => { 68 | if (videoRef.current) { 69 | setVolume(videoRef.current.volume); 70 | } 71 | }; 72 | 73 | const handleMute = () => { 74 | if (videoRef.current) { 75 | setIsMuted(videoRef.current.muted); 76 | } 77 | }; 78 | 79 | videoRef.current.addEventListener("volumechange", handleVolumeChange); 80 | videoRef.current.addEventListener("muted", handleMute); 81 | 82 | return () => { 83 | videoRef.current?.removeEventListener( 84 | "volumechange", 85 | handleVolumeChange 86 | ); 87 | 88 | videoRef.current?.removeEventListener("muted", handleMute); 89 | }; 90 | } 91 | }, [videoRef.current]); 92 | 93 | return ( 94 |
95 | 98 |
99 |
103 |
107 |
108 |
109 | ); 110 | }; 111 | -------------------------------------------------------------------------------- /src/video/components/SeekingCanvas.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useRef, 3 | useEffect, 4 | //useState, 5 | //useCallback, 6 | useContext, 7 | } from "react"; 8 | import styles from "../styles/video.module.scss"; 9 | import { VideoContext } from "../context"; 10 | 11 | interface SeekingCanvasProps { 12 | src: string; 13 | time: number; 14 | percentage: number; 15 | width?: number; 16 | height?: number; 17 | } 18 | 19 | export const SeekingCanvas: React.FC = ({ 20 | time, 21 | percentage, 22 | width = 160, 23 | height = 90, 24 | }) => { 25 | const canvasRef = useRef(null); 26 | const workerRef = useRef(null); 27 | //const [frameData, setFrameData] = useState(null); 28 | const { src } = useContext(VideoContext); 29 | 30 | // Initialize the frame extraction worker 31 | useEffect(() => { 32 | // Create a worker for frame extraction 33 | const workerCode = ` 34 | let decoder = null; 35 | let frameCount = 0; 36 | 37 | async function initDecoder(videoUrl) { 38 | try { 39 | const response = await fetch(videoUrl); 40 | const videoData = await response.arrayBuffer(); 41 | 42 | decoder = new VideoDecoder({ 43 | output: frame => { 44 | const bitmap = frame.createImageBitmap(); 45 | self.postMessage({ type: 'frame', bitmap }); 46 | frame.close(); 47 | }, 48 | error: e => self.postMessage({ type: 'error', error: e.message }) 49 | }); 50 | 51 | const demuxer = new MP4Demuxer(videoData); 52 | await decoder.configure(demuxer.getConfig()); 53 | 54 | return demuxer; 55 | } catch (e) { 56 | self.postMessage({ type: 'error', error: e.message }); 57 | } 58 | } 59 | 60 | self.onmessage = async function(e) { 61 | const { type, videoUrl, timestamp } = e.data; 62 | 63 | if (type === 'init') { 64 | const demuxer = await initDecoder(videoUrl); 65 | if (demuxer) { 66 | self.postMessage({ type: 'ready' }); 67 | } 68 | } else if (type === 'seek') { 69 | try { 70 | const frame = await decoder.decode(timestamp); 71 | frameCount++; 72 | if (frameCount > 30) { 73 | decoder.flush(); 74 | frameCount = 0; 75 | } 76 | } catch (e) { 77 | self.postMessage({ type: 'error', error: e.message }); 78 | } 79 | } 80 | }; 81 | `; 82 | 83 | const blob = new Blob([workerCode], { type: "application/javascript" }); 84 | workerRef.current = new Worker(URL.createObjectURL(blob)); 85 | 86 | // Initialize the worker with the video source 87 | workerRef.current.postMessage({ type: "init", videoUrl: src }); 88 | 89 | // Handle worker messages 90 | workerRef.current.onmessage = (e) => { 91 | const { type, bitmap, error } = e.data; 92 | 93 | if (type === "frame" && bitmap) { 94 | createImageBitmap(bitmap).then((img) => { 95 | if (canvasRef.current) { 96 | const ctx = canvasRef.current.getContext("2d"); 97 | if (ctx) { 98 | ctx.drawImage(img, 0, 0, width, height); 99 | } 100 | } 101 | }); 102 | } else if (type === "error") { 103 | console.error("Frame extraction error:", error); 104 | } 105 | }; 106 | 107 | return () => { 108 | if (workerRef.current) { 109 | workerRef.current.terminate(); 110 | workerRef.current = null; 111 | } 112 | }; 113 | }, [src]); 114 | 115 | // Handle time updates 116 | useEffect(() => { 117 | if (workerRef.current) { 118 | workerRef.current.postMessage({ type: "seek", timestamp: time }); 119 | } 120 | }, [time]); 121 | 122 | /* // Frame cache management 123 | const frameCache = useRef(new Map()); 124 | 125 | const getFrameFromCache = useCallback((timestamp: number) => { 126 | const roundedTime = Math.round(timestamp * 10) / 10; // Round to 0.1s 127 | return frameCache.current.get(roundedTime); 128 | }, []); 129 | 130 | const cacheFrame = useCallback((timestamp: number, frameData: ImageData) => { 131 | const roundedTime = Math.round(timestamp * 10) / 10; 132 | frameCache.current.set(roundedTime, frameData); 133 | 134 | // Limit cache size 135 | if (frameCache.current.size > 100) { 136 | const firstKey = frameCache.current.keys().next().value; 137 | frameCache.current.delete(firstKey); 138 | } 139 | }, []); */ 140 | 141 | return ( 142 |
143 | 156 |
160 | {new Date(time * 1000).toISOString().substr(11, 8)} 161 |
162 |
163 | ); 164 | }; 165 | 166 | /* // Optional helper class for MP4 demuxing 167 | class MP4Demuxer { 168 | private data: ArrayBuffer; 169 | 170 | constructor(data: ArrayBuffer) { 171 | this.data = data; 172 | } 173 | 174 | getConfig() { 175 | // Extract video config from MP4 container 176 | // This is a simplified version - you'd want to properly parse the MP4 header 177 | return { 178 | codec: "avc1.42E01E", // Basic H.264 codec 179 | codedHeight: 720, 180 | codedWidth: 1280, 181 | description: new Uint8Array([]), 182 | }; 183 | } 184 | 185 | // Additional methods for frame extraction would go here 186 | } 187 | */ 188 | -------------------------------------------------------------------------------- /src/video/components/ProgressBar.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/react-in-jsx-scope */ 2 | import { useContext, useState, useRef, useEffect } from "react"; 3 | import { VideoContext } from "../context"; 4 | import styles from "../styles/video.module.scss"; 5 | 6 | export const VideoProgressBar = () => { 7 | const { videoRef, seekPreview } = useContext(VideoContext); 8 | const [videoPercentagePlayed, setVideoPercentagePlayed] = useState(0); 9 | const progressBar = useRef(null); 10 | const [hoveringPrecentage, setHoveringPercentage] = useState(0); 11 | const [isHovering, setIsHovering] = useState(false); 12 | const [hoverTime, setHoverTime] = useState("00:00"); 13 | const [hoverTimeStamp, setHoverTimeStamp] = useState(0); 14 | 15 | function seek(e: MouseEvent) { 16 | if (progressBar.current && videoRef.current) { 17 | const rect = progressBar.current.getBoundingClientRect(); 18 | const percentage = ((e.clientX - rect.left) / rect.width) * 100; 19 | videoRef.current.currentTime = 20 | (percentage / 100) * videoRef.current.duration; 21 | } 22 | } 23 | 24 | useEffect(() => { 25 | if (progressBar.current) { 26 | const handleMouseDown = (e: MouseEvent) => { 27 | seek(e); 28 | }; 29 | 30 | progressBar.current.addEventListener("mousedown", handleMouseDown); 31 | 32 | return () => { 33 | progressBar.current?.removeEventListener("mousedown", handleMouseDown); 34 | }; 35 | } 36 | }, [progressBar.current]); 37 | 38 | function formatTime(time: number) { 39 | const minutes = Math.floor(time / 60); 40 | const seconds = Math.floor(time % 60); 41 | const secondsString = seconds < 10 ? `0${seconds}` : `${seconds}`; 42 | const minutesString = minutes < 10 ? `0${minutes}` : `${minutes}`; 43 | return `${minutesString}:${secondsString}`; 44 | } 45 | 46 | useEffect(() => { 47 | if (progressBar.current) { 48 | const handleMouseMove = (e: MouseEvent) => { 49 | if (progressBar.current && videoRef.current) { 50 | const rect = progressBar.current.getBoundingClientRect(); 51 | const percentage = ((e.clientX - rect.left) / rect.width) * 100; 52 | setHoveringPercentage(percentage); 53 | 54 | const time = (percentage / 100) * videoRef.current.duration; 55 | setHoverTimeStamp(time); 56 | setHoverTime(formatTime(time)); 57 | } 58 | }; 59 | 60 | const handleMouseEnter = () => { 61 | setIsHovering(true); 62 | }; 63 | 64 | const handleMouseLeave = () => { 65 | setIsHovering(false); 66 | }; 67 | 68 | progressBar.current.addEventListener("mousemove", handleMouseMove); 69 | progressBar.current.addEventListener("mouseenter", handleMouseEnter); 70 | progressBar.current.addEventListener("mouseleave", handleMouseLeave); 71 | 72 | return () => { 73 | progressBar.current?.removeEventListener("mousemove", handleMouseMove); 74 | progressBar.current?.removeEventListener( 75 | "mouseenter", 76 | handleMouseEnter 77 | ); 78 | progressBar.current?.removeEventListener( 79 | "mouseleave", 80 | handleMouseLeave 81 | ); 82 | }; 83 | } 84 | }, [progressBar.current]); 85 | 86 | useEffect(() => { 87 | if (videoRef.current) { 88 | const handleTimeUpdate = () => { 89 | if (videoRef.current) { 90 | setVideoPercentagePlayed( 91 | (videoRef.current.currentTime / videoRef.current.duration) * 100 92 | ); 93 | } 94 | }; 95 | 96 | videoRef.current.addEventListener("timeupdate", handleTimeUpdate); 97 | 98 | return () => { 99 | videoRef.current?.removeEventListener("timeupdate", handleTimeUpdate); 100 | }; 101 | } 102 | }, [videoRef.current]); 103 | return ( 104 |
105 | {seekPreview && isHovering && ( 106 | 107 | )} 108 |
112 | 117 |
118 | ); 119 | }; 120 | 121 | const VideoToolTip = ({ 122 | text, 123 | percentage, 124 | open, 125 | }: { 126 | text: string; 127 | percentage: number; 128 | open: boolean; 129 | }) => { 130 | if (!open) { 131 | return null; 132 | } 133 | return ( 134 |
135 | {text} 136 |
137 | ); 138 | }; 139 | 140 | const SeekingCanvas = ({ 141 | time, 142 | percentage, 143 | }: { 144 | time: number; 145 | percentage: number; 146 | }) => { 147 | const canvasRef = useRef(null); 148 | const { videoRef } = useContext(VideoContext); 149 | const [referenceElement, setReferenceElement] = 150 | useState(null); 151 | 152 | useEffect(() => { 153 | if (referenceElement === null && videoRef.current) { 154 | const element = document.createElement("video"); 155 | element.src = videoRef.current.src; 156 | element.volume = 0; 157 | setReferenceElement(element); 158 | } 159 | }, [referenceElement, videoRef.current]); 160 | 161 | // get frame at time from reference video and draw it on canvas 162 | useEffect(() => { 163 | async function getFrameAtTime(time: number) { 164 | if (canvasRef.current && referenceElement) { 165 | const context = canvasRef.current.getContext("2d"); 166 | if (context) { 167 | referenceElement.currentTime = time; 168 | // Fix: Error: The play() request was interrupted by a call to pause(). https://goo.gl/LdLk22 169 | // See: https://github.com/jim-junior/reactjs-media/issues/261 170 | try { 171 | await referenceElement.play(); 172 | } catch (error) { 173 | console.warn("Reference Video Inturrupted"); 174 | } 175 | try { 176 | referenceElement.pause(); 177 | } catch (error) { 178 | // Do Nothing, Just Catch the Error Since the browser automatically pauses the video 179 | } 180 | 181 | context.drawImage(referenceElement, 0, 0, 80, 40); 182 | } 183 | } 184 | } 185 | getFrameAtTime(time); 186 | }, [canvasRef.current, time, referenceElement]); 187 | 188 | return ( 189 | 196 | ); 197 | }; 198 | -------------------------------------------------------------------------------- /src/video/styles/video.module.scss: -------------------------------------------------------------------------------- 1 | @import "./mixins.scss"; 2 | 3 | .videoRoot { 4 | position: relative; 5 | width: 100%; 6 | height: 100%; 7 | overflow: hidden; 8 | } 9 | .videoOverlay { 10 | position: absolute; 11 | top: 0; 12 | left: 0; 13 | width: 100%; 14 | height: 100%; 15 | background-color: rgba(0, 0, 0, 0.5); 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | color: white; 20 | font-size: 2rem; 21 | font-weight: bold; 22 | text-align: center; 23 | } 24 | 25 | .videoLoader { 26 | // video loader is inside the video-overlay 27 | position: absolute; 28 | top: 0; 29 | left: 0; 30 | width: 100%; 31 | height: 100%; 32 | background-color: rgba(0, 0, 0, 0.5); 33 | display: flex; 34 | justify-content: center; 35 | align-items: center; 36 | } 37 | 38 | .videoError { 39 | position: absolute; 40 | top: 50%; 41 | left: 50%; 42 | transform: translate(-50%, -50%); 43 | color: white; 44 | font-size: 2rem; 45 | font-weight: bold; 46 | } 47 | 48 | .videoLoaderSpinner { 49 | display: inline-block; 50 | width: 50px; 51 | height: 50px; 52 | border: 3px solid rgba(255, 255, 255, 0.3); 53 | border-radius: 50%; 54 | border-top-color: white; 55 | animation: spin 1s linear infinite; 56 | } 57 | 58 | @keyframes spin { 59 | to { 60 | transform: rotate(360deg); 61 | } 62 | } 63 | 64 | .videoTooltip { 65 | position: absolute; 66 | top: -4.5rem; 67 | left: 0; 68 | background-color: rgba(0, 0, 0, 0.5); 69 | color: white; 70 | padding: 0.2rem 0.5rem; 71 | border-radius: 0.2rem; 72 | font-size: 0.8rem; 73 | z-index: 2; 74 | } 75 | 76 | .videoPoster { 77 | // inside the videoRoot 78 | position: absolute; 79 | top: 0; 80 | left: 0; 81 | width: 100%; 82 | height: 100%; 83 | object-fit: cover; 84 | cursor: pointer; 85 | z-index: 1; 86 | background-position: center; 87 | background-size: cover; 88 | &:hover { 89 | filter: brightness(0.8); 90 | } 91 | } 92 | 93 | .videoPosterPlayButton { 94 | position: absolute; 95 | top: 50%; 96 | left: 50%; 97 | transform: translate(-50%, -50%); 98 | width: 50px; 99 | height: 50px; 100 | border: 3px solid white; 101 | border-radius: 50%; 102 | display: flex; 103 | justify-content: center; 104 | align-items: center; 105 | cursor: pointer; 106 | } 107 | 108 | .videoControlsBar { 109 | display: flex; 110 | justify-content: space-between; 111 | align-items: center; 112 | padding: 5px 0px; 113 | width: 96%; 114 | margin: 0 auto; 115 | } 116 | 117 | .videoControlsBarLeft { 118 | display: flex; 119 | align-items: center; 120 | } 121 | 122 | .videoControlsBarRight { 123 | display: flex; 124 | align-items: center; 125 | } 126 | 127 | .videoControlsButton { 128 | background-color: transparent; 129 | border: none; 130 | color: white; 131 | font-size: 1.2rem; 132 | cursor: pointer; 133 | margin: 0 10px; 134 | } 135 | 136 | .videoControlsContainer { 137 | position: absolute; 138 | bottom: 0; 139 | left: 0; 140 | width: 100%; 141 | background: linear-gradient(transparent, rgb(0, 0, 0)); 142 | display: flex; 143 | flex-direction: column; 144 | height: fit-content; 145 | } 146 | 147 | .videoTime { 148 | color: white; 149 | text-align: left; 150 | width: 96%; 151 | margin: 0 auto; 152 | display: flex; 153 | margin-bottom: 10px; 154 | @include desktop { 155 | font-size: 0.8rem; 156 | } 157 | 158 | @include tablet { 159 | font-size: 0.7rem; 160 | } 161 | 162 | @include mobile { 163 | font-size: 0.5rem; 164 | margin-bottom: 10px; 165 | } 166 | } 167 | 168 | .videoTimeCurrent { 169 | } 170 | 171 | .videoTimeDuration { 172 | margin-left: auto; 173 | } 174 | 175 | .videoProgressBar { 176 | width: 96%; 177 | margin: 0 auto; 178 | height: 3px; 179 | background-color: rgba(255, 255, 255, 0.5); 180 | position: relative; 181 | cursor: pointer; 182 | transition: height 0.3s; 183 | &:hover { 184 | .videoProgressBarFill { 185 | background-color: white; 186 | } 187 | height: 5px; 188 | } 189 | } 190 | 191 | .videoProgressBarFill { 192 | height: 100%; 193 | background-color: rgba(255, 255, 255, 0.5); 194 | transition: background-color 0.3s; 195 | } 196 | 197 | .seekingCanvas { 198 | position: absolute; 199 | top: -3rem; 200 | left: 0; 201 | width: 80px; 202 | height: 40px; 203 | background-color: rgba(255, 255, 255, 0.5); 204 | z-index: 2; 205 | border-radius: 0.2rem; 206 | border: 1px solid white; 207 | } 208 | 209 | .videoVolumeControlContainer { 210 | display: flex; 211 | align-items: center; 212 | margin: 0 10px; 213 | @include mobile { 214 | display: none; 215 | } 216 | } 217 | 218 | .volumeControlsButton { 219 | background-color: transparent; 220 | border: none; 221 | color: white; 222 | font-size: 1rem; 223 | cursor: pointer; 224 | margin: 0 10px; 225 | } 226 | 227 | .volumeControlsBar { 228 | width: 70px; 229 | height: 3px; 230 | background-color: rgba(255, 255, 255, 0.5); 231 | position: relative; 232 | cursor: pointer; 233 | transition: height 0.3s; 234 | &:hover { 235 | .volumeControlsBarFill { 236 | background-color: rgba(255, 255, 255, 0.702); 237 | } 238 | } 239 | } 240 | 241 | .volumeControlsBarFill { 242 | height: 100%; 243 | background-color: white; 244 | transition: background-color 0.3s; 245 | } 246 | 247 | .volumeControlsBarHandle { 248 | position: absolute; 249 | top: -3.5px; 250 | left: 0; 251 | width: 10px; 252 | height: 10px; 253 | background-color: white; 254 | border-radius: 50%; 255 | cursor: pointer; 256 | } 257 | 258 | .contextPageOverlay { 259 | position: fixed; 260 | top: 0; 261 | bottom: 0; 262 | left: 0; 263 | right: 0; 264 | } 265 | 266 | .contextMenuCard { 267 | position: absolute; 268 | } 269 | 270 | .contextMenu { 271 | background: rgba(0, 0, 0, 0.507); 272 | display: flex; 273 | flex-direction: column; 274 | color: white; 275 | padding: 5px; 276 | } 277 | 278 | .contextMenuItem { 279 | display: flex; 280 | align-items: center; 281 | padding: 10px; 282 | outline: none; 283 | border: none; 284 | border-radius: 5px; 285 | background: transparent; 286 | color: white; 287 | cursor: pointer; 288 | &:hover { 289 | background: rgba(245, 245, 245, 0.325); 290 | } 291 | } 292 | 293 | .contextMenuItemIcon { 294 | color: white; 295 | margin-right: 10px; 296 | } 297 | 298 | .contextMenuItemLabel { 299 | color: white; 300 | font-size: 0.7rem; 301 | } 302 | 303 | // Settings Modal 304 | 305 | .settingsOverlay { 306 | position: absolute; 307 | top: 0; 308 | bottom: 0; 309 | left: 0; 310 | right: 0; 311 | background-color: rgba(0, 0, 0, 0.5); 312 | display: flex; 313 | justify-content: center; 314 | align-items: center; 315 | z-index: 2; 316 | } 317 | 318 | .settingRoot { 319 | background-color: white; 320 | width: 50%; 321 | height: 50%; 322 | border-radius: 0.2rem; 323 | padding: 1rem; 324 | } 325 | 326 | .settingHeader { 327 | display: flex; 328 | justify-content: space-between; 329 | align-items: center; 330 | margin-bottom: 1rem; 331 | } 332 | 333 | .settingTitle { 334 | font-size: 1.5rem; 335 | } 336 | -------------------------------------------------------------------------------- /src/image/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useCallback, useEffect } from "react"; 2 | import { useIntersectionObserver } from "../hooks/useIntersectionObserver"; 3 | 4 | interface ImageProps { 5 | src: string; 6 | alt: string; 7 | width?: string | number; 8 | height?: string | number; 9 | lazy?: boolean; 10 | placeholder?: string; 11 | srcSet?: string; 12 | sizes?: string; 13 | className?: string; 14 | style?: React.CSSProperties; 15 | filter?: string; 16 | onLoad?: () => void; 17 | onError?: (error: Error) => void; 18 | onClick?: (event: React.MouseEvent) => void; 19 | caption?: string; 20 | overlay?: React.ReactNode; 21 | zoomable?: boolean; 22 | fallbackSrc?: string; 23 | } 24 | 25 | interface ImageState { 26 | isLoaded: boolean; 27 | hasError: boolean; 28 | isZoomed: boolean; 29 | zoomPosition: { x: number; y: number }; 30 | } 31 | 32 | export const Img: React.FC = ({ 33 | src, 34 | alt, 35 | width = "auto", 36 | height = "auto", 37 | lazy = true, 38 | placeholder, 39 | srcSet, 40 | sizes, 41 | className = "", 42 | style = {}, 43 | filter, 44 | onLoad, 45 | onError, 46 | onClick, 47 | caption, 48 | overlay, 49 | zoomable = false, 50 | fallbackSrc, 51 | }) => { 52 | const [state, setState] = useState({ 53 | isLoaded: false, 54 | hasError: false, 55 | isZoomed: false, 56 | zoomPosition: { x: 0, y: 0 }, 57 | }); 58 | 59 | const imageRef = useRef(null); 60 | const containerRef = useRef(null); 61 | 62 | // Intersection observer for lazy loading 63 | const { isIntersecting } = useIntersectionObserver(containerRef, { 64 | threshold: 0.1, 65 | triggerOnce: true, 66 | }); 67 | 68 | // Handle image loading 69 | const handleLoad = useCallback(() => { 70 | setState((prev) => ({ ...prev, isLoaded: true })); 71 | onLoad?.(); 72 | }, [onLoad]); 73 | 74 | // Handle image error 75 | const handleError = useCallback(() => { 76 | const error = new Error("Image failed to load"); 77 | setState((prev) => ({ ...prev, hasError: true })); 78 | onError?.(error); 79 | }, [onError]); 80 | 81 | // Handle zoom functionality 82 | const handleZoom = useCallback( 83 | (event: React.MouseEvent) => { 84 | if (!zoomable || !imageRef.current || !containerRef.current) return; 85 | 86 | const rect = containerRef.current.getBoundingClientRect(); 87 | const x = (event.clientX - rect.left) / rect.width; 88 | const y = (event.clientY - rect.top) / rect.height; 89 | 90 | setState((prev) => ({ 91 | ...prev, 92 | isZoomed: !prev.isZoomed, 93 | zoomPosition: { x, y }, 94 | })); 95 | }, 96 | [zoomable] 97 | ); 98 | 99 | // Apply CSS filters 100 | const filterStyle = filter ? { filter } : {}; 101 | 102 | // Combine custom styles with filter 103 | const combinedStyle: React.CSSProperties = { 104 | ...style, 105 | ...filterStyle, 106 | width: width, 107 | height: height, 108 | objectFit: "cover", 109 | transition: "opacity 0.3s ease-in-out", 110 | }; 111 | 112 | // Handle WebP support detection 113 | useEffect(() => { 114 | const checkWebPSupport = async () => { 115 | const webPImage = new Image(); 116 | webPImage.src = 117 | "data:image/webp;base64,UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA=="; 118 | return new Promise((resolve) => { 119 | webPImage.onload = () => resolve(true); 120 | webPImage.onerror = () => resolve(false); 121 | }); 122 | }; 123 | 124 | checkWebPSupport().then((hasWebP) => { 125 | if (hasWebP && srcSet && !srcSet.includes(".webp")) { 126 | console.warn("WebP format is supported but not included in srcSet"); 127 | } 128 | }); 129 | }, [srcSet]); 130 | 131 | // Render placeholder while loading 132 | const renderPlaceholder = () => ( 133 |
143 | {placeholder ? ( 144 | 154 | ) : ( 155 |
Loading...
156 | )} 157 |
158 | ); 159 | 160 | // Render error state 161 | const renderError = () => ( 162 |
172 | {fallbackSrc ? ( 173 | {alt} 178 | ) : ( 179 |
Failed to load image
180 | )} 181 |
182 | ); 183 | 184 | // Main render 185 | return ( 186 |
195 | {(!lazy || isIntersecting) && ( 196 | {alt} { 213 | handleZoom(e); 214 | onClick?.(e); 215 | }} 216 | loading={lazy ? "lazy" : undefined} 217 | /> 218 | )} 219 | 220 | {!state.isLoaded && !state.hasError && renderPlaceholder()} 221 | {state.hasError && renderError()} 222 | 223 | {overlay && state.isLoaded && !state.hasError && ( 224 |
237 | {overlay} 238 |
239 | )} 240 | 241 | {caption && state.isLoaded && !state.hasError && ( 242 |
255 | {caption} 256 |
257 | )} 258 |
259 | ); 260 | }; 261 | 262 | export default Img; 263 | -------------------------------------------------------------------------------- /src/video/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useContext, 3 | useEffect, 4 | forwardRef, 5 | MutableRefObject, 6 | } from "react"; 7 | import { VideoContext, VideoCTXProvider } from "./context"; 8 | import { VideoControls, VideoPoster } from "./controls"; 9 | import { VideoElement } from "./element"; 10 | import styles from "./styles/video.module.scss"; 11 | import { VideoProps, VideoPlayerRef } from "./types"; 12 | import { useControls } from "./hooks/useControls"; 13 | import { FaExpand, FaPlay, FaVolumeMute } from "react-icons/fa"; 14 | import { FaPause } from "react-icons/fa6"; 15 | import { MdPictureInPicture } from "react-icons/md"; 16 | import { ContextMenu } from "./components/ContextMenu"; 17 | 18 | /** 19 | * A Video Component 20 | * @param props - VideoProps 21 | * @returns A Video Component 22 | * @example 23 | * ```tsx 24 | *