├── .editorconfig ├── .eslintrc ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── test.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .nvmrc ├── LICENSE ├── build.mjs ├── dev.mjs ├── dev ├── TestComponent.tsx ├── constants │ └── testVideos.ts ├── index.html ├── index.tsx └── utils │ ├── ComponentProfiler.tsx │ └── LoadingSpinnerOverlay.tsx ├── docs ├── .vuepress │ ├── config.ts │ └── public │ │ └── CNAME ├── BREAKING.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── README.md └── assets │ └── images │ ├── heading_demo.gif │ ├── hover_overlay_prop_demo.gif │ ├── loading_overlay_prop_demo.gif │ ├── paused_overlay_prop_demo.gif │ └── playback_range_demo.gif ├── package-lock.json ├── package.json ├── scripts ├── .eslintrc ├── copyDocsReadmeToRoot.ts ├── tsconfig.json └── validateReadme.ts ├── src ├── HoverVideoPlayer.styles.ts ├── HoverVideoPlayer.tsx ├── HoverVideoPlayer.types.ts └── index.ts ├── tests ├── assets │ ├── captions.vtt │ ├── subtitles-ga.vtt │ ├── video.mp4 │ └── video.webm ├── constants.ts ├── index.html ├── index.tsx ├── playwright.config.ts ├── serveTests.mjs └── specs │ ├── hoverTarget │ ├── hoverTarget.spec.ts │ └── index.tsx │ ├── loadingStateTimeout │ ├── index.tsx │ └── loadingStateTimeout.spec.ts │ ├── overlays │ ├── index.tsx │ └── overlays.spec.ts │ ├── playback │ ├── index.tsx │ ├── playback-focus.spec.ts │ ├── playback-mouse.spec.ts │ └── playback-touch.spec.ts │ ├── playbackRange │ ├── index.tsx │ └── playbackRange.spec.ts │ ├── playbackStartDelay │ ├── index.tsx │ └── playbackStartDelay.spec.ts │ ├── sizingMode │ ├── index.tsx │ └── sizingMode.spec.ts │ ├── unloadVideoOnPause │ ├── index.tsx │ └── unloadVideoOnPause.spec.ts │ ├── videoCaptions │ ├── index.tsx │ └── videoCaptions.spec.ts │ ├── videoSrc │ ├── index.tsx │ └── videoSrc.spec.ts │ └── videoSrcChange │ ├── index.tsx │ └── videoSrcChange.spec.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "ignorePatterns": ["node_modules"], 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "ecmaVersion": 2020, 6 | "sourceType": "module", 7 | "ecmaFeatures": { 8 | "jsx": true 9 | } 10 | }, 11 | "settings": { 12 | "react": { 13 | "version": "detect" 14 | }, 15 | "import/resolver": { 16 | "node": { 17 | "paths": ["src"], 18 | "extensions": [".js", ".jsx", ".ts", ".tsx"] 19 | } 20 | } 21 | }, 22 | "extends": [ 23 | "plugin:react/recommended", 24 | "plugin:@typescript-eslint/eslint-recommended", 25 | "plugin:@typescript-eslint/recommended", 26 | "plugin:import/errors", 27 | "plugin:import/warnings" 28 | ], 29 | "plugins": ["@typescript-eslint", "jsx-a11y", "react-hooks"], 30 | "rules": { 31 | "react/prop-types": 0, 32 | "no-underscore-dangle": 0, 33 | "import/imports-first": ["error", "absolute-first"], 34 | "import/newline-after-import": "error", 35 | "import/prefer-default-export": 0, 36 | "import/no-extraneous-dependencies": "off", 37 | "semi": "error", 38 | "react-hooks/rules-of-hooks": "error", 39 | "react-hooks/exhaustive-deps": "warn", 40 | "no-console": [ 41 | "error", 42 | { 43 | "allow": ["warn", "error"] 44 | } 45 | ] 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: "Let's squash some bugs \U0001F41B" 4 | title: "[BUG]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: "Got an idea? \U0001F4A1" 4 | title: "[FEAT]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What problem do you want to solve? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Checklist 2 | 3 | - [ ] I have read the [CONTRIBUTING](https://github.com/Gyanreyer/react-hover-video-player/blob/main/CONTRIBUTING.md) doc. 4 | - [ ] I have added/updated unit tests to cover my changes. 5 | - [ ] Unit tests pass locally. 6 | - [ ] I have added appropriate documentation for my changes. 7 | - [ ] I have updated the README to reflect any changes that will affect the component's public API (if applicable). 8 | 9 | ## Problem 10 | 11 | Describe the problem that these changes are solving. If this is addressing an open issue, please link to it here. 12 | 13 | ## Solution 14 | 15 | Describe your code changes in detail and how they solve the problem described above. 16 | 17 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | jobs: 9 | test: 10 | timeout-minutes: 60 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-node@v3 15 | with: 16 | node-version: 16 17 | - name: Install dependencies 18 | run: npm ci 19 | - name: Install Playwright Browsers 20 | run: npx playwright install --with-deps 21 | - name: Run Playwright tests 22 | run: npm run test 23 | - uses: actions/upload-artifact@v3 24 | if: always() 25 | with: 26 | name: playwright-report 27 | path: playwright-report/ 28 | retention-days: 30 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | .nyc_output 3 | /dist 4 | /node_modules 5 | /dev/build 6 | npm-debug.log* 7 | .DS_Store 8 | /README.md 9 | /docs/.vuepress/dist 10 | /docs/.vuepress/.temp 11 | /docs/.vuepress/.cache 12 | /test-results/ 13 | /playwright-report/ 14 | /playwright/.cache/ 15 | /tests/index.js -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v16.13.2 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Ryan Geyer 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 | -------------------------------------------------------------------------------- /build.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import fs from "fs"; 3 | 4 | const outputDir = "dist"; 5 | 6 | const shouldClean = process.argv.includes("--clean"); 7 | 8 | if (shouldClean) { 9 | // Clean up any files in the output directory, or make sure the output directory exists 10 | if (fs.existsSync(outputDir)) { 11 | fs.rmSync(outputDir, { 12 | recursive: true, 13 | force: true, 14 | }); 15 | } 16 | 17 | fs.mkdirSync(outputDir); 18 | } 19 | 20 | const sharedOptions = { 21 | entryPoints: ["src/index.ts"], 22 | sourcemap: true, 23 | bundle: true, 24 | packages: "external", 25 | target: "es6", 26 | logLevel: "info", 27 | }; 28 | 29 | const esmOptions = { 30 | ...sharedOptions, 31 | outfile: `${outputDir}/index.mjs`, 32 | format: "esm", 33 | }; 34 | 35 | const cjsOptions = { 36 | ...sharedOptions, 37 | outfile: `${outputDir}/index.cjs`, 38 | format: "cjs", 39 | }; 40 | 41 | const shouldWatch = process.argv.includes("--watch"); 42 | 43 | if (shouldWatch) { 44 | const context = await esbuild.context(esmOptions); 45 | 46 | await context.watch(); 47 | await context.serve(); 48 | } else { 49 | let buildTargets = ["all"]; 50 | const buildsArgIndex = process.argv.indexOf("--builds"); 51 | if (buildsArgIndex >= 0) { 52 | buildTargets = process.argv[buildsArgIndex + 1].split(","); 53 | } 54 | 55 | const builds = []; 56 | 57 | buildTargets.forEach((buildTarget) => { 58 | if (buildTarget === "esm" || buildTarget === "all") { 59 | builds.push(esbuild.build(esmOptions)); 60 | } 61 | 62 | if (buildTarget === "cjs" || buildTarget === "all") { 63 | builds.push(esbuild.build(cjsOptions)); 64 | } 65 | }); 66 | 67 | if (builds.length === 0) { 68 | console.error("No valid builds specified for the --builds arg."); 69 | process.exit(1); 70 | } 71 | 72 | await Promise.all(builds); 73 | } 74 | -------------------------------------------------------------------------------- /dev.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild'; 2 | 3 | const context = await esbuild.context({ 4 | entryPoints: ['dev/index.tsx'], 5 | outfile: 'dev/build/index.js', 6 | sourcemap: true, 7 | bundle: true, 8 | target: 'es6', 9 | logLevel: 'info', 10 | }); 11 | 12 | await context.watch(); 13 | await context.serve({ 14 | servedir: 'dev', 15 | port: 8080, 16 | }); 17 | -------------------------------------------------------------------------------- /dev/TestComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { css } from "emotion"; 3 | 4 | import HoverVideoPlayer from "../src"; 5 | import LoadingSpinnerOverlay from "./utils/LoadingSpinnerOverlay"; 6 | import ComponentProfiler from "./utils/ComponentProfiler"; 7 | 8 | interface TestComponentProps { 9 | videoSrc: string; 10 | thumbnailImageSrc: string; 11 | } 12 | 13 | /** 14 | * Do all of your testing on this component! 15 | * It is wrapped with a ComponentProfiler component by default, 16 | * which will log out render times each time the component re-renders. 17 | * 18 | * You may modify this file however you want for testing, 19 | * but your changes should not be committed. If you think your changes should be committed, 20 | * please contact the maintainer. 21 | */ 22 | const TestComponent = ({ 23 | videoSrc, 24 | thumbnailImageSrc, 25 | }: TestComponentProps): JSX.Element => { 26 | return ( 27 | 28 | {/* TEST COMPONENT HERE */} 29 | } 31 | pausedOverlay={ 32 | 41 | } 42 | loadingOverlay={} 43 | className={css` 44 | padding-top: 75%; 45 | `} 46 | sizingMode="container" 47 | preload="none" 48 | unloadVideoOnPaused 49 | restartOnPaused 50 | /> 51 | 52 | ); 53 | }; 54 | 55 | export default TestComponent; 56 | -------------------------------------------------------------------------------- /dev/constants/testVideos.ts: -------------------------------------------------------------------------------- 1 | // Public test videos courtesy of https://gist.github.com/jsturgis/ 2 | interface Video { 3 | videoSrc: string; 4 | thumbnailImageSrc: string; 5 | } 6 | 7 | const testVideos: Video[] = [ 8 | { 9 | videoSrc: 10 | 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4', 11 | thumbnailImageSrc: 12 | 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/images/BigBuckBunny.jpg', 13 | }, 14 | { 15 | videoSrc: 16 | 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4', 17 | thumbnailImageSrc: 18 | 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/images/ElephantsDream.jpg', 19 | }, 20 | { 21 | videoSrc: 22 | 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4', 23 | thumbnailImageSrc: 24 | 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/images/Sintel.jpg', 25 | }, 26 | { 27 | videoSrc: 28 | 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/TearsOfSteel.mp4', 29 | thumbnailImageSrc: 30 | 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/images/TearsOfSteel.jpg', 31 | }, 32 | { 33 | videoSrc: 34 | 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4', 35 | thumbnailImageSrc: 36 | 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/images/ForBiggerBlazes.jpg', 37 | }, 38 | { 39 | videoSrc: 40 | 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4', 41 | thumbnailImageSrc: 42 | 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/images/ForBiggerEscapes.jpg', 43 | }, 44 | { 45 | videoSrc: 46 | 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4', 47 | thumbnailImageSrc: 48 | 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/images/ForBiggerJoyrides.jpg', 49 | }, 50 | { 51 | videoSrc: 52 | 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerMeltdowns.mp4', 53 | thumbnailImageSrc: 54 | 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/images/ForBiggerMeltdowns.jpg', 55 | }, 56 | ]; 57 | 58 | export default testVideos; 59 | -------------------------------------------------------------------------------- /dev/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Dev Page 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /dev/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { css } from "emotion"; 4 | 5 | import TestComponent from "./TestComponent"; 6 | import testVideos from "./constants/testVideos"; 7 | 8 | const DevPage = (): JSX.Element => ( 9 |
14 |

REACT HOVER VIDEO PLAYER

15 |
22 | {testVideos.map(({ videoSrc, thumbnailImageSrc }) => ( 23 | 28 | ))} 29 |
30 |
31 | ); 32 | 33 | const rootElement = document.createElement("div"); 34 | document.body.appendChild(rootElement); 35 | 36 | ReactDOM.render(, rootElement); 37 | 38 | export default DevPage; 39 | -------------------------------------------------------------------------------- /dev/utils/ComponentProfiler.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import React from 'react'; 3 | 4 | interface RenderTiming { 5 | averageRenderTime: number; 6 | renderCount: number; 7 | } 8 | 9 | interface ComponentProfilerProps { 10 | profilerID: string; 11 | children: React.ReactChild; 12 | } 13 | 14 | /** 15 | * Util component logs out render timings for whatever component it's wrapped aroud 16 | */ 17 | const ComponentProfiler = ({ 18 | profilerID, 19 | children, 20 | }: ComponentProfilerProps): JSX.Element => { 21 | // Use a ref to track render timing for this component in a persistent store 22 | const renderTiming = React.useRef(); 23 | if (!renderTiming.current) { 24 | renderTiming.current = { 25 | averageRenderTime: 0, 26 | renderCount: 0, 27 | }; 28 | } 29 | 30 | // Logs out helpful render timing info for performance measurements 31 | const onProfilerRender = React.useCallback( 32 | ( 33 | id, // the "id" prop of the Profiler tree that has just committed 34 | phase: string, // either "mount" (if the tree just mounted) or "update" (if it re-rendered) 35 | actualDuration: number // time spent rendering the committed update 36 | ) => { 37 | if (phase === 'mount') { 38 | console.log(`${profilerID} | MOUNT: ${actualDuration}ms`); 39 | } else { 40 | renderTiming.current.renderCount += 1; 41 | renderTiming.current.averageRenderTime += 42 | (actualDuration - renderTiming.current.averageRenderTime) / 43 | renderTiming.current.renderCount; 44 | console.log( 45 | `${profilerID} | UPDATE: ${actualDuration}ms | New average: ${renderTiming.current.averageRenderTime}ms` 46 | ); 47 | } 48 | }, 49 | [profilerID] 50 | ); 51 | return ( 52 | 53 | {children} 54 | 55 | ); 56 | }; 57 | 58 | export default ComponentProfiler; 59 | -------------------------------------------------------------------------------- /dev/utils/LoadingSpinnerOverlay.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { css, cx } from 'emotion'; 3 | 4 | // Shared styles 5 | const loadingOverlayWrapper = css` 6 | width: 100%; 7 | height: 100%; 8 | 9 | display: flex; 10 | justify-content: center; 11 | align-items: center; 12 | `; 13 | 14 | const darkenedBackground = css` 15 | background-color: rgba(0, 0, 0, 0.7); 16 | `; 17 | 18 | // LoadingSpinnerOverlay styles 19 | const animateStroke = css` 20 | animation-name: spinner-stroke-animation; 21 | animation-timing-function: ease-in-out; 22 | animation-iteration-count: infinite; 23 | 24 | @keyframes spinner-stroke-animation { 25 | 0%, 26 | 20% { 27 | stroke-dashoffset: 54; 28 | transform: rotate(0); 29 | } 30 | 31 | 60%, 32 | 80% { 33 | stroke-dashoffset: 14; 34 | transform: rotate(45deg); 35 | } 36 | 37 | 100% { 38 | stroke-dashoffset: 54; 39 | transform: rotate(360deg); 40 | } 41 | } 42 | `; 43 | 44 | interface LoadingSpinnerOverlayProps { 45 | spinnerDiameter?: number; 46 | animationDuration?: number; 47 | shouldAnimateStroke?: boolean; 48 | shouldShowDarkenedBackground?: boolean; 49 | shouldShowSemiTransparentRing?: boolean; 50 | strokeColor?: string; 51 | className?: string; 52 | } 53 | 54 | /** 55 | * @component LoadingSpinnerOverlay 56 | * 57 | * Renders a loading overlay for the HoverVideoPlayer which shows an animated rotating semi-circle spinner 58 | * 59 | * @param {number} [spinnerDiameter=60] - The pixel width that the spinner circle should display at 60 | * @param {number} [animationDuration=1000] - The duration in ms that it should take for the spinner circle to complete a single rotation 61 | * @param {bool} [shouldAnimateStroke=true] - Whether the circle's outline stroke should be animated so that it appears to expand and contract 62 | * @param {bool} [shouldShowDarkenedBackground=true] - Whether the loading overlay should have a semi-transparent background which darkens the contents behind it 63 | * @param {bool} [shouldShowSemiTransparentRing=false] - Whether the spinner should have a semi-transparent circle behind the main animated stroke 64 | * @param {string} [strokeColor="#ffffff"] - The color to apply to the spinner circle's stroke 65 | * @param {string} [className] - Custom className to apply to the loading overlay wrapper 66 | */ 67 | const LoadingSpinnerOverlay = ({ 68 | spinnerDiameter = 60, 69 | animationDuration = 1000, 70 | shouldAnimateStroke = true, 71 | shouldShowDarkenedBackground = true, 72 | shouldShowSemiTransparentRing = false, 73 | strokeColor = '#ffffff', 74 | className = '', 75 | }: LoadingSpinnerOverlayProps): JSX.Element => ( 76 |
85 | 108 | 131 | {shouldShowSemiTransparentRing && ( 132 | 148 | )} 149 | 150 |
151 | ); 152 | 153 | export default LoadingSpinnerOverlay; 154 | -------------------------------------------------------------------------------- /docs/.vuepress/config.ts: -------------------------------------------------------------------------------- 1 | import { defineUserConfig } from 'vuepress'; 2 | import type { DefaultThemeOptions } from 'vuepress'; 3 | 4 | export default defineUserConfig({ 5 | description: 6 | 'A React component for setting up a video that plays on hover. Supports both desktop mouse events and mobile touch events, and provides an easy interface for adding thumbnails and loading states.', 7 | head: [ 8 | // Set the favicon to the film strip emoji 9 | [ 10 | 'link', 11 | { 12 | rel: 'icon', 13 | href: 'data:image/svg+xml,🎞️', 14 | }, 15 | ], 16 | // Add GA tags to the of the page 17 | [ 18 | 'script', 19 | { 20 | async: true, 21 | src: 'https://www.googletagmanager.com/gtag/js?id=G-D7PD421E1W', 22 | }, 23 | ], 24 | [ 25 | 'script', 26 | {}, 27 | `window.dataLayer = window.dataLayer || [];function gtag(){dataLayer.push(arguments);}gtag('js', new Date());gtag('config', 'G-D7PD421E1W');`, 28 | ], 29 | ], 30 | themeConfig: { 31 | docsDir: 'docs', 32 | navbar: [ 33 | { text: 'Home', link: '/' }, 34 | { text: 'Contributing', link: '/CONTRIBUTING.md' }, 35 | ], 36 | repo: 'gyanreyer/react-hover-video-player', 37 | sidebar: 'auto', 38 | sidebarDepth: 2, 39 | }, 40 | markdown: { 41 | links: { 42 | externalIcon: false, 43 | }, 44 | }, 45 | plugins: ['@vuepress/plugin-search'], 46 | }); 47 | -------------------------------------------------------------------------------- /docs/.vuepress/public/CNAME: -------------------------------------------------------------------------------- 1 | react-hover-video-player.dev -------------------------------------------------------------------------------- /docs/BREAKING.md: -------------------------------------------------------------------------------- 1 | # Breaking changes 2 | 3 | ## 10.0.0 breaking changes 4 | 5 | - [Deprecates passing config objects to `videoSrc` prop](#deprecates-passing-config-objects-to-videosrc-prop) 6 | - [Deprecates passing config objects to `videoCaptions` prop](#deprecates-passing-config-objects-to-videocaptions-prop) 7 | - [Removes `shouldSuppressPlaybackInterruptedErrors` prop](#removes-shouldsuppressplaybackinterruptederrors-prop) 8 | 9 | ### Deprecates passing config objects to `videoSrc` prop 10 | 11 | The `videoSrc` prop still supports URL strings, 12 | but no longer accepts config objects or arrays of config objects for video sources. 13 | Instead, sources like this must now be defined as `` elements. 14 | 15 | Migration for a single source config object is fairly straightforward; the `src` and `type` properties 16 | on the config objects map directly to the attributes that need to be set on the `` elements: 17 | 18 | ```diff 19 | 29 | + )} 30 | /> 31 | ``` 32 | 33 | For an array of source config objects, simply wrap your `` elements in a fragment: 34 | 35 | ```diff 36 | 49 | + 50 | + 51 | + 52 | + )} 53 | /> 54 | ``` 55 | 56 | ### Deprecates passing config objects to `videoCaptions` prop 57 | 58 | The `videoCaptions` prop also no longer accepts config objects for caption tracks. 59 | Instead, caption tracks must be defined as `` elements. 60 | 61 | Like with the changes to `videoSrc` above, all of the properties on the caption track config objects should map directly to 62 | attributes on the `` elements: 63 | 64 | ```diff 65 | 84 | + 85 | + 86 | + 87 | + )} 88 | /> 89 | ``` 90 | 91 | ### Removes `shouldSuppressPlaybackInterruptedErrors` prop 92 | 93 | The `shouldSuppressPlaybackInterruptedErrors` prop is being removed, as it is not really needed anymore. 94 | 95 | This prop defaulted to `true` and was not commonly used, so will likely not impact many people. You can simply remove the prop and things will continue functioning as normal. 96 | 97 | ```diff 98 | 102 | ``` 103 | -------------------------------------------------------------------------------- /docs/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at ryan@geyer.dev. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## How to contribute to an open source project 4 | 5 | 1. Click the "Fork" button on this library's [GitHub page](https://github.com/gyanreyer/react-hover-video-player) to make a copy of the repository in your account which you can work on. 6 | 2. Clone your forked repo to your computer. 7 | 3. Move into the root of this project's directory and run `npm install` to get all dependencies installed; this will also set up Husky commit hooks automatically. 8 | 4. Start coding! Check out the "I want to..." sections below for guidance on where to start. 9 | 5. Once you are confident that your work is done, push your changes up to your repository and click "Contribute" > "Open pull request". Fill out a description of your changes and then create the pull request. Please be detailed about the what and why of your changes if you can! 10 | 6. A maintainer will review your code and may give feedback on anything that should be changed. Once tests are passing and your changes are approved, they will be merged into the main repository and deployed in the next release. 11 | 12 | Thank you so much for your contribution ❤️ 13 | 14 | ## I want to update documentation 15 | 16 | All documentation can be found in the `docs` directory. 17 | 18 | The documentation site at uses [VuePress](https://vuepress.vuejs.org/) to automatically construct a site with pages based on the `README.md` and `CONTRIBUTING.md` files' contents. 19 | 20 | To preview the documentation site locally, run `npm run docs:dev` to serve it at . 21 | 22 | ## I want to fix a bug or add a feature 23 | 24 | All component code can be found in the `src` directory. 25 | 26 | The HoverVideoPlayer component is defined in the `src/HoverVideoPlayer.tsx` file, so it is recommended that you start there. 27 | 28 | ### Automated testing 29 | 30 | This library uses [Playwright](https://playwright.dev/) for automated testing; all tests can be found in the `tests/` directory. Your changes will not be accepted unless all existing tests are passing. If you add new functionality, it is highly suggested that you add a test to cover it. 31 | 32 | - `npm run test` will run all tests once 33 | - To run a specific test file, you can provide the test file's name as an arg, like `npm run test -- videoSrc.spec.ts`; this will only run tests in the `tests/specs/videoSrc/videoSrc.spec.ts` file 34 | - To only run a single test within a specific test file, change `test()` to `test.only()` for the desired test; all others will be skipped. Just make sure you revert that before committing! 35 | 36 | ### Development playground 37 | 38 | Along with automated tests, you can also test your changes in a live demo playground environment located in the `dev` directory. 39 | 40 | Running `npm run dev` will serve the contents of `dev/index.tsx` at . 41 | 42 | For the most part, you will likely want to focus on editing the `TestComponent.tsx` file when testing your changes. 43 | You may modify playground files however you want for testing purposes, but please refrain from committing your changes unless you have a strong case for why they should be! 44 | 45 | ## Releases 46 | 47 | The process for publishing new releases to npm is fairly standard: 48 | 49 | 1. `npm run test` - Make sure all tests are passing first! 50 | 1. Update the version number in the `package.json` as is appropriate. 51 | 1. `npm run build:prod` 52 | 1. `npm publish` 53 | 1. Create a release in the GitHub repo describing the new changes. 54 | 55 | ## Other miscellaneous things 56 | 57 | ### Recommended process for creating an example gif for documentation 58 | 59 | 1. Make a new sandbox in CodeSandbox (for an easy head start, [fork this sandbox](https://codesandbox.io/s/hovervideoplayer-example-6y0fn)). 60 | 2. Once the example is set up, make a screen recording. 61 | - On Macs, QuickTime Player is a decent option for making quick screen recordings 62 | - Try to keep the recording short and loop-friendly if possible (ie, start and end with the mouse cursor off the screen) 63 | 3. Convert the recording to a .gif file 64 | 65 | - To guarantee the gif comes out with a good balance between file size and quality, it's recommended to use the following ffmpeg command for conversion: 66 | 67 | ```sh 68 | ffmpeg -i path/to/original-recording.mp4 -vf "fps=10,scale=1024:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" output/demo.gif 69 | ``` 70 | 71 | (special thanks to [llogan's very helpful answer](https://superuser.com/a/556031) which this command is based on) 72 | 73 | 4. Add the .gif file to the README 74 | - Put the .gif file in the `docs/assets/images` directory. 75 | - Add `![alt text](./assets/images/your_file_name.gif)]` to the appropriate place in the README. 76 | - If you feel comfortable, please wrap the gif with a link to your CodeSandbox! 77 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # React Hover Video Player 2 | 3 | [![npm version](https://badgen.net/npm/v/react-hover-video-player)](https://www.npmjs.com/package/react-hover-video-player) 4 | [![minzipped size](https://badgen.net/bundlephobia/minzip/react-hover-video-player)](https://bundlephobia.com/result?p=react-hover-video-player) 5 | [![license](https://img.shields.io/npm/l/react-hover-video-player)](https://github.com/Gyanreyer/react-hover-video-player/blob/main/LICENSE) 6 | 7 | ![demo](./assets/images/heading_demo.gif?raw=true) 8 | 9 | ## What It Is 10 | 11 | A React component that makes it simple to set up a video that will play when the user hovers over it. This is particularly useful for setting up a thumbnail that will play a video preview on hover. 12 | 13 | **Want to play around with a real working example? [Check it out on CodeSandbox!](https://codesandbox.io/s/hovervideoplayer-example-6y0fn?file=/src/App.js)** 14 | 15 | ## Features 16 | 17 | - Out-of-the-box support for both mouse and touchscreen interactions 18 | - Easily add custom thumbnails and loading states 19 | - Lightweight and fast 20 | - No dependencies 21 | - Gracefully uses fallback behavior if browser policies block a video from playing with sound on 22 | 23 | ## How It Works 24 | 25 | This component will render a video element which will start playing when an `onMouseEnter`, `onTouchStart`, or `onFocus` event is fired on the [hover target](#hovertarget) and will accordingly be paused when an `onMouseLeave` or `onBlur` event is fired on the target, or an `onTouchStart` event is fired outside of the target. This default behavior can be [disabled, overridden, and customized](#hover-event-handling) as needed. 26 | 27 | Everything is written with extra care to cleanly handle the video element's state as it asynchronously loads and plays. 28 | 29 | ## Contributing 30 | 31 | Want to help? Contributions are welcome! Check out the [contributing guide for details](./CONTRIBUTING.md) 32 | 33 | ## Upgrading to v10 34 | 35 | **react-hover-video-player 10.0.0 includes some breaking API changes. [See here](./BREAKING.md) for a breakdown of the changes and how to migrate.** 36 | 37 | ## Get Started 38 | 39 | ### Installation 40 | 41 | ```bash 42 | npm install react-hover-video-player 43 | ``` 44 | 45 | ### Basic Usage 46 | 47 | ```jsx 48 | import HoverVideoPlayer from 'react-hover-video-player'; 49 | 50 | function MyComponent() { 51 | return ( 52 | 65 | } 66 | loadingOverlay={ 67 |
68 |
69 |
70 | } 71 | /> 72 | ); 73 | } 74 | ``` 75 | 76 | ## Sources 77 | 78 | ### videoSrc 79 | 80 | **Type**: `string` or a React node | **This prop is required** 81 | 82 | `videoSrc` accepts a string or a React node containing a set of `` elements for the video source file(s) which should be used for the video player. 83 | 84 | If you only have **one video source**, you can simply provide a single string for the URL path to the video file like so: 85 | 86 | ```jsx 87 | 88 | ``` 89 | 90 | If you have **multiple video sources**, you can provide all of them as `` elements wrapped in a React fragment: 91 | 92 | ```jsx 93 | 96 | 97 | 98 | 99 | )} 100 | /> 101 | ``` 102 | 103 | If you have multiple video sources, make sure you order them by ascending file size so that the smallest video file is first; browsers will always simply pick the first source that they support. 104 | 105 | ### videoCaptions 106 | 107 | **Type**: `object` or `array` of objects | **Default**: `null` 108 | 109 | `videoCaptions` accepts a React node containing one or more `` elements descibing the caption track sources to use in order to apply closed captions to the video for accessibility. 110 | 111 | If you have a single caption track, you can apply it like so: 112 | 113 | ```tsx 114 | 129 | )} 130 | /> 131 | ``` 132 | 133 | Note that if you do not set `default` or have more than one track, it is recommended that you set the [controls](#controls) prop to `true` so that the user may enable the captions or choose the correct captions for their desired language. 134 | 135 | If you have more than one track, you can wrap them in a React fragment like so: 136 | 137 | ```jsx 138 | 142 | 149 | 155 | 156 | )} 157 | // Enable the video's controls so that the user can select the caption track they want or toggle captions on and off 158 | controls 159 | /> 160 | ``` 161 | 162 | ### crossOrigin 163 | 164 | **Type**: `string` | **Default**: `null` 165 | 166 | The `crossOrigin` prop maps directly to the [HTML Video element's crossorigin attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/video#attr-crossorigin) and allows us to define how the video element should handle CORS requests. For most purposes, you should not need to worry about setting this, but if you are having trouble with CORS a good first step may be to try setting it to `"anonymous"`. 167 | 168 | The acceptable values are: 169 | 170 | - `"anonymous"`: The video element will send cross-origin requests with no credentials. 171 | - `"use-credentials"`: The video element will send cross-origin requests with credentials. 172 | 173 | ```jsx 174 | 175 | ``` 176 | 177 | ## Overlays 178 | 179 | ### pausedOverlay 180 | 181 | **Type**: `node` | **Default**: `null` 182 | 183 | This optional prop accepts any renderable content that you would like to be displayed over the video while it is in a paused or loading state. When the video starts playing, this content will be faded out. 184 | 185 | [![Demo of the pausedOverlay prop being used](./assets/images/paused_overlay_prop_demo.gif?raw=true)](https://codesandbox.io/s/hovervideoplayer-examples-pausedoverlay-uo2oh?file=/src/App.js) 186 | 187 | A common use case for this would be displaying a thumbnail image over the video while it is paused. 188 | 189 | ```jsx 190 | 204 | } 205 | /> 206 | ``` 207 | 208 | The [overlayTransitionDuration](#overlaytransitionduration) prop allows you to set how long it should take for the overlay to fade out when the video starts playing and fade back in when it stops playing. 209 | 210 | ### loadingOverlay 211 | 212 | **Type**: `node` | **Default**: `null` 213 | 214 | `loadingOverlay` is an optional prop that accepts any renderable content that you would like to be displayed over the video if it takes too long to start after the user attempts to play it. 215 | 216 | [![Demo of the loadingOverlay prop being used](./assets/images/loading_overlay_prop_demo.gif?raw=true)](https://codesandbox.io/s/hovervideoplayer-examples-loadingoverlay-kc8lz?file=/src/App.js) 217 | 218 | This allows you to provide a better user experience for users with slower internet connections, particularly if you are using larger video assets. 219 | 220 | Note that the [pausedOverlay](#pausedoverlay) will still be rendered while the video is in a loading state, so this overlay will simply be displayed on top of that one. 221 | 222 | ```jsx 223 | Loading...
} 228 | /> 229 | ``` 230 | 231 | This overlay will only be displayed if it takes more than a certain amount of time for the video to start after we attempt to play it. You can set the precise timeout duration with the [loadingStateTimeout](#loadingstatetimeout) prop. 232 | 233 | ### overlayTransitionDuration 234 | 235 | **Type**: `number` | **Default**: `400` 236 | 237 | `overlayTransitionDuration` accepts the number of milliseconds that it should take for the [paused overlay](#pausedoverlay) and [loading overlay](#loadingoverlay) to fade in and out as the player's state changes. 238 | 239 | After the user stops hovering on the player, the video will continue playing until the overlay has fully faded back in to provide the most seamless user experience possible. 240 | 241 | ```jsx 242 | Paused!} 245 | // It should take 500ms for the pausedOverlay to fade out when 246 | // the video plays and fade back in when the video pauses 247 | overlayTransitionDuration={500} 248 | /> 249 | ``` 250 | 251 | ### loadingStateTimeout 252 | 253 | **Type**: `number` | **Default**: `200` 254 | 255 | `loadingStateTimeout` accepts the number of milliseconds that the player should wait before showing a loading state if the video is not able to play immediately. 256 | 257 | ```jsx 258 | Loading...} 261 | // We should only show the loading state if the video takes 262 | // more than 1 full second to start after attempting to play 263 | loadingStateTimeout={1000} 264 | /> 265 | ``` 266 | 267 | ### hoverOverlay 268 | 269 | **Type**: `node` | **Default**: `null` 270 | 271 | `hoverOverlay` is an optional prop that accepts any renderable content that you would like to be displayed over the video while the player is active from a hover/touch event or when the [focused](#focused) prop is `true`. 272 | 273 | [![Demo of the hoverOverlay prop being used](./assets/images/hover_overlay_prop_demo.gif?raw=true)](https://codesandbox.io/s/hovervideoplayer-examples-hover-overlay-8wnq0?file=/src/App.js) 274 | 275 | This can be useful if you wish to reveal content to the user when they hover while the video still plays underneath it. 276 | 277 | Note that this overlay takes highest ordering priority and will be displayed on top of both the [pausedOverlay](#pausedoverlay) and [loadingOverlay](#loadingoverlay) if they are set. 278 | 279 | ```jsx 280 | 284 |

Video Title

285 |

286 | Here is a short description of the video. You can still see the video 287 | playing underneath this overlay. 288 | Click here to read more 289 |

290 | 291 | } 292 | /> 293 | ``` 294 | 295 | ## Hover Event Handling 296 | 297 | ### hoverTarget 298 | 299 | **Type**: `Node`, a function that returns a `Node`, or a `React.RefObject` | **Default**: `null` 300 | 301 | `hoverTarget` accepts a DOM node, a function that returns a DOM node, or a React ref to an element. The component will apply [default event handling](#how-it-works) to the received target element so the video will play when a user hovers over it with a mouse or touch interaction. If no `hoverTarget` is provided, HoverVideoPlayer will use the component's container `
` as the hover target. 302 | 303 | ```jsx 304 | // Passing a function that returns a DOM node 305 | document.getElementById("hover-target")} 309 | /> 310 | 311 | ... 312 | 313 | // Using a React ref 314 | const wrapperLinkRef = useRef(); 315 | 316 | 317 | 322 | 323 | 324 | ... 325 | 326 | // Passing a DOM node 327 | // PLEASE BEWARE THAT THIS CAN BE UNSAFE: Only do this if you are confident that the element 328 | // will always already exist on the DOM before this component is rendered. 329 | 334 | ``` 335 | 336 | ### focused 337 | 338 | **Type**: `boolean` | **Default**: `false` 339 | 340 | `focused` accepts a boolean value which, if true, will force the video player to play regardless of any other user interactions it receives. This can be useful for scenarios where you may wish to implement custom behavior outside of standard mouse/touch interactions with the video player. 341 | 342 | ```jsx 343 | const [isVideoPlaying, setIsVideoPlaying] = useState(false); 344 | 345 | ... 346 | 347 | 353 | 357 | ``` 358 | 359 | If you wish to set up a a fully custom implementation that overrides the hover player's default mouse and touch event handling, you can use the [disableDefaultEventHandling](#disabledefaulteventhandling) prop. 360 | 361 | ### disableDefaultEventHandling 362 | 363 | **Type**: `boolean` | **Default**: `false` 364 | 365 | `disableDefaultEventHandling` accepts a boolean value which, if true, will disable the player's default built-in event handling where `onMouseEnter` and `onTouchStart` events play the video and `onMouseLeave` events and `touchStart` events outside of the player pause the video. This can be useful if you want to build a fully custom implementation of the player's behavior using the [focused](#focused) prop. 366 | 367 | ```jsx 368 | const [isVideoPlaying, setIsVideoPlaying] = useState(false); 369 | 370 | ... 371 | 372 |
setIsVideoPlaying(!isVideoPlaying)} 375 | > 376 | 382 |
383 | ``` 384 | 385 | ### onHoverStart 386 | 387 | **Type**: `function` | **Default**: `null` 388 | 389 | `onHoverStart` accepts a callback function which will be fired when the user hovers on the player's [hover target](#hovertarget). 390 | 391 | ```jsx 392 | { 395 | console.log('User just moused over or touched hover target.'); 396 | console.log('The video will now attempt to play.'); 397 | }} 398 | /> 399 | ``` 400 | 401 | ### onHoverEnd 402 | 403 | **Type**: `function` | **Default**: `null` 404 | 405 | `onHoverStart` accepts a callback function which will be fired when the user stops hovering on the player's [hover target](#hovertarget). 406 | 407 | ```jsx 408 | { 411 | console.log('User just moused out of or touched outside of hover target.'); 412 | console.log('The video will now stop playing.'); 413 | }} 414 | /> 415 | ``` 416 | 417 | ## Video Behavior 418 | 419 | ### restartOnPaused 420 | 421 | **Type**: `boolean` | **Default**: `false` 422 | 423 | `restartOnPaused` accepts a boolean value which will toggle whether the video should be reset to the start every time it is paused or resume from the previous time it was at. 424 | 425 | ```jsx 426 | 430 | ``` 431 | 432 | ### muted 433 | 434 | **Type**: `boolean` | **Default**: `true` 435 | 436 | `muted` accepts a boolean value which toggles whether or not the video should be muted. 437 | 438 | Note that if the video is unmuted, you may encounter issues with [browser autoplay policies](https://developer.chrome.com/blog/autoplay/) blocking the video 439 | from playing with sound. This is an unfortunate limitation stemming from the fact that modern browsers will block playing 440 | audio until the user has "interacted" with the page by doing something like clicking or tapping anywhere at least once. 441 | 442 | If playback is initially blocked for an unmuted video, the component will fall back by muting the video and attempting to play again without audio; 443 | if the user clicks on the page, the video will be unmuted again and continue playing. 444 | 445 | ```jsx 446 | 451 | ``` 452 | 453 | ### volume 454 | 455 | **Type**: `number` | **Default**: 1 456 | 457 | `volume` accepts a number on a scale from 0-1 for the volume that the video's audio should play at. 458 | 459 | Note that this will only work if the [muted](#muted) prop is also set to `false`. 460 | 461 | ```jsx 462 | 468 | ``` 469 | 470 | ### loop 471 | 472 | **Type**: `boolean` | **Default**: `true` 473 | 474 | `loop` accepts a boolean value which toggles whether or not the video should loop when it reaches the end. 475 | 476 | ```jsx 477 | 482 | ``` 483 | 484 | ### videoRef 485 | 486 | **Type**: `React.Ref` | **Default**: null 487 | 488 | `videoRef` can be used to expose a ref to the video element rendered by HoverVideoPlayer. This is useful if you need to directly access the video element to extend its behavior in some way, but beware that any changes you make could produce unexpected behavior from the component. 489 | 490 | ```jsx 491 | const hoverVideoRef = useRef(); 492 | 493 | useEffect(() => { 494 | const videoElement = hoverVideoRef.current; 495 | 496 | videoElement.playbackRate = 2; 497 | }, []); 498 | 499 | return ; 500 | ``` 501 | 502 | ## Setting a Playback Range 503 | 504 | Setting a playback range on `HoverVideoPlayer` allows you to set the times in the video that it should start from and/or play to. 505 | This can be useful if you want to show a smaller preview of a longer video without having to manually edit the file, 506 | perhaps because you wish to still use the full video file elsewhere on the site. 507 | 508 | [![Demo of the playback range props being used](./assets/images/playback_range_demo.gif?raw=true)](https://codesandbox.io/s/hovervideoplayer-examples-playbackrange-unggy?file=/src/App.js) 509 | 510 | If a playback range is set, the component will add a [media fragment identifier to the video's URL](https://developer.mozilla.org/en-US/docs/Web/Guide/Audio_and_video_delivery#specifying_playback_range) to tell browsers to only load the portion 511 | of the video within the desired playback range. Note that support for media fragments is not entirely consistent across all browsers, but regardless the component will still be able to play within the desired range, just without the added performance benefit of avoiding downloading the full video file. 512 | 513 | ### playbackRangeStart 514 | 515 | **Type**: `number` | **Default**: null 516 | 517 | `playbackRangeStart` accepts a number in seconds for what time video playback should start from. If not set, playback will start from the beginning of the video file. 518 | 519 | ```jsx 520 | 525 | ``` 526 | 527 | ### playbackRangeEnd 528 | 529 | **Type**: `number` | **Default**: null 530 | 531 | `playbackRangeEnd` accepts a number in seconds for what time video playback should end at. If not set, the video will play all the way to the end of the video file. 532 | 533 | ```jsx 534 | 539 | ``` 540 | 541 | ## Custom Styling 542 | 543 | ### Applying classNames and styles 544 | 545 | The base styling for this component's contents are set using inline styling. You can customize or override this styling using various props that accept classNames and style objects. 546 | 547 | ```jsx 548 | 588 | ``` 589 | 590 | ### sizingMode 591 | 592 | **Type**: `string` | **Default**: `"video"` 593 | 594 | The `sizingMode` prop can be used to apply one of four available styling presets which define how the player's contents should be sized. These presets are: 595 | 596 | - `"video"`: **This is the default sizing mode.** Everything should be sized based on the video element's dimensions and the overlays will expand to cover the video. 597 | - Note that this mode comes with a caveat: The video element may briefly display with different dimensions until it finishes loading the metadata containing the video's actual dimensions. This is usually fine when the metadata is loaded immediately, so it is recommended that you avoid using this mode in combination with the [unloadVideoOnPaused](#unloadvideoonpaused) optimization prop described below as it will cause the video's metadata to be unloaded frequently. 598 | - `"overlay"`: Everything should be sized based on the [paused overlay](#pausedoverlay)'s dimensions and the video element will expand to fill that space. 599 | - Note that the [paused overlay](#pausedoverlay) contents will need to have a `display: block` style in order for this mode to work correctly. 600 | - `"container"`: All of the video's contents should expand to fill the component's outer container div. 601 | - `"manual"`: Removes all preset sizing-related styling if you wish to use your own fully custom styling implementation. 602 | 603 | ```jsx 604 | 615 | ``` 616 | 617 | ## Optimization 618 | 619 | ### preload 620 | 621 | **Type**: `string` | **Default**: `null` 622 | 623 | The `preload` prop maps directly to the [HTML Video element's preload attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/video#attr-preload) and allows us to define how much data a video element should preload before it is played. This prop defaults to null, which will use whatever the browser's default setting is. 624 | The acceptable values are: 625 | 626 | - `"auto"`: We can load the whole video file before playing, even if it never gets used. If you have a large number of videos on the page, beware that this can create performance problems as the browser will attempt to load them all up front at once. 627 | - `"metadata"`: We should only load the video's metadata (video dimensions, duration, etc) before playing. This helps us avoid loading large amounts of data unless it is absolutely needed. 628 | - Note that in Safari, video elements with `preload="metadata"` applied will just appear empty rather than displaying the first frame of the video like other browsers do. As a result, it is recommended that if you use this setting, you should have [paused overlay](#pausedoverlay) contents set that will hide the video element until it is playing. 629 | - `"none"`: We should not preload any part of the video before playing, including metadata. 630 | - Note that this means that the video's dimensions will not be loaded until the video is played. This can potentially cause a content jump when the video starts loading if you are using the `"video"` [sizing mode](#sizingmode). 631 | - Additionally, nothing will be displayed for the video element until it starts playing, so you should make sure you provide [paused overlay](#pausedoverlay) contents to hide the video element. 632 | 633 | The official specs recommend that browsers should use `metadata` as the default, but implementations differ between browsers. As of writing, the defaults for each browser seem to be: 634 | | Browser | Default `preload` value | 635 | | ------- | ----------------------- | 636 | | Chrome | `metadata` | 637 | | Firefox | `metadata` | 638 | | Safari | `auto` | 639 | | Edge | `metadata` | 640 | 641 | ```jsx 642 | 647 | ``` 648 | 649 | ### unloadVideoOnPaused 650 | 651 | **Type**: `boolean` | **Default**: `false` 652 | 653 | Having a large number of videos with large file sizes on the page at the same time can cause severe performance problems in some cases, especially in Google Chrome. This is because after you play a video for the first time, it will continue loading in the background even after it is paused, taking up bandwidth and memory even though it is not in use. If you have too many large videos loading in the background at once, this can gum up the works very quickly and cause significant performance degredation to the point where other assets may stop loading entirely as well. This is where the `unloadVideoOnPaused` prop comes in: when set to true, it will ensure that video assets will be kept completely unloaded whenever the video is not playing. This may result in the video being slighly slower to start on repeat play attempts, but assuming the browser is caching the video assets correctly, the difference should not be too significant. 654 | 655 | Note that this will also keep the video's metadata unloaded when it is not playing, causing content jump issues with the `"video"` [sizing mode](#sizingmode). 656 | 657 | Additionally, nothing will be displayed for the video element when it is unloaded, so it is highly recommended that you provide [paused overlay](#pausedoverlay) contents to hide the video when it is paused. 658 | 659 | ```jsx 660 | 665 | ``` 666 | 667 | ### playbackStartDelay 668 | 669 | **Type**: `number` | **Default**: `0` 670 | 671 | Although `unloadVideoOnPaused` is usually the best solution for front-end performance issues, some may be concerned about back-end issues; especially if you are self-hosting the video files and you are displaying a lot of videos at once on a page, your server may get barraged by requests for video files as the user moves their mouse around the page even if they don't actually stop to watch any of the videos they hovered over. 672 | 673 | This can be solved by using the `playbackStartDelay` prop; this prop takes a number for the time in milliseconds that the component should wait to actually start loading the video after the user has started hovering over it. Using this prop, you can feel more confident that you are only loading video files that your users actually want to watch. 674 | 675 | Note that from a user experience perspective, it is highly recommended that you use a [loading overlay](#loadingoverlay) if you use this prop; otherwise the user may have to wait for the duration of the delay you set without getting any visual feedback that that their hover action is actually doing something. 676 | 677 | ```jsx 678 | 684 | ``` 685 | 686 | ## Video Controls 687 | 688 | ### controls 689 | 690 | **Type**: `boolean` | **Default**: `false` 691 | 692 | `controls` accepts a boolean value which toggles whether the video element should have the browser's video playback controls enabled. 693 | 694 | ```jsx 695 | 700 | ``` 701 | 702 | ### controlsList 703 | 704 | **Type**: `string` | **Default**: `null` 705 | 706 | `controlsList` accepts a string describing buttons that should be excluded from the video's playback controls. The string can include the following possible values, with spaces separating each one: 707 | 708 | - `"nodownload"`: Removes the download button from the video's controls 709 | - `"nofullscreen"`: Removes the fullscreen button from the video's controls 710 | 711 | Be aware that this feature [is not currently supported across all major browsers.](https://caniuse.com/mdn-api_htmlmediaelement_controlslist) 712 | 713 | ```jsx 714 | 721 | ``` 722 | 723 | ### disableRemotePlayback 724 | 725 | **Type**: `boolean` | **Default**: `true` 726 | 727 | `disableRemotePlayback` toggles whether the browser should show a remote playback UI on the video, which allows the user to cast the video to other devices. 728 | 729 | ```jsx 730 | 735 | ``` 736 | 737 | ### disablePictureInPicture 738 | 739 | **Type**: `boolean` | **Default**: `true` 740 | 741 | `disablePictureInPicture` toggles whether the browser should show a picture-in-picture UI on the video, which allows the user to pop the video out into a floating window that persists over other tabs or apps. 742 | 743 | ```jsx 744 | 749 | ``` 750 | -------------------------------------------------------------------------------- /docs/assets/images/heading_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gyanreyer/react-hover-video-player/5cbca8947aeca7f7b26c40b20301db7425adbeb7/docs/assets/images/heading_demo.gif -------------------------------------------------------------------------------- /docs/assets/images/hover_overlay_prop_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gyanreyer/react-hover-video-player/5cbca8947aeca7f7b26c40b20301db7425adbeb7/docs/assets/images/hover_overlay_prop_demo.gif -------------------------------------------------------------------------------- /docs/assets/images/loading_overlay_prop_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gyanreyer/react-hover-video-player/5cbca8947aeca7f7b26c40b20301db7425adbeb7/docs/assets/images/loading_overlay_prop_demo.gif -------------------------------------------------------------------------------- /docs/assets/images/paused_overlay_prop_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gyanreyer/react-hover-video-player/5cbca8947aeca7f7b26c40b20301db7425adbeb7/docs/assets/images/paused_overlay_prop_demo.gif -------------------------------------------------------------------------------- /docs/assets/images/playback_range_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gyanreyer/react-hover-video-player/5cbca8947aeca7f7b26c40b20301db7425adbeb7/docs/assets/images/playback_range_demo.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-hover-video-player", 3 | "version": "10.0.2", 4 | "description": "React component which manages playing a video when the user hovers over it and pausing when they stop.", 5 | "main": "dist/index.cjs", 6 | "module": "dist/index.mjs", 7 | "types": "dist/index.d.ts", 8 | "exports": { 9 | "require": "./dist/index.cjs", 10 | "import": "./dist/index.mjs", 11 | "types": "./dist/index.d.ts" 12 | }, 13 | "files": [ 14 | "dist" 15 | ], 16 | "scripts": { 17 | "build": "node build.mjs", 18 | "build:prod": "node build.mjs --clean --builds=all && npx tsc", 19 | "dev": "node dev.mjs", 20 | "docs:dev": "vuepress dev docs", 21 | "docs:build": "vuepress build docs", 22 | "validate-docs": "npx ts-node scripts/validateReadme.ts", 23 | "commit": "git-cz", 24 | "prepare": "husky install", 25 | "prepack": "npx ts-node scripts/copyDocsReadmeToRoot.ts", 26 | "test": "playwright test -c tests/playwright.config.ts" 27 | }, 28 | "peerDependencies": { 29 | "react": ">=16.8.0", 30 | "react-dom": ">=16.8.0" 31 | }, 32 | "devDependencies": { 33 | "@playwright/test": "^1.31.0", 34 | "@types/node": "^20.2.5", 35 | "@types/react": "^17.0.3", 36 | "@typescript-eslint/eslint-plugin": "^4.22.0", 37 | "@typescript-eslint/parser": "^4.22.0", 38 | "@vuepress/plugin-search": "^2.0.0-beta.61", 39 | "emotion": "^10.0.27", 40 | "esbuild": "^0.17.10", 41 | "eslint": "^6.8.0", 42 | "eslint-import-resolver-typescript": "^2.4.0", 43 | "eslint-plugin-import": "^2.20.2", 44 | "eslint-plugin-jsx-a11y": "^6.4.1", 45 | "eslint-plugin-react": "^7.23.2", 46 | "eslint-plugin-react-hooks": "^3.0.0", 47 | "gh-pages": "^3.2.3", 48 | "husky": "^5.1.3", 49 | "lint-staged": "^10.2.6", 50 | "react": "^17.0.2", 51 | "react-dom": "^17.0.2", 52 | "react-router-dom": "^6.8.1", 53 | "ts-node": "^10.2.1", 54 | "typescript": "^5.1.3", 55 | "vuepress": "^2.0.0-beta.61" 56 | }, 57 | "author": "Ryan Geyer", 58 | "homepage": "https://react-hover-video-player.dev", 59 | "license": "MIT", 60 | "repository": { 61 | "type": "git", 62 | "url": "https://github.com/Gyanreyer/react-hover-video-player.git" 63 | }, 64 | "keywords": [ 65 | "react", 66 | "component", 67 | "image", 68 | "thumbnail", 69 | "hover", 70 | "play", 71 | "mouse", 72 | "touch", 73 | "loading", 74 | "video", 75 | "player" 76 | ], 77 | "config": { 78 | "commitizen": { 79 | "path": "./node_modules/cz-conventional-changelog" 80 | } 81 | }, 82 | "lint-staged": { 83 | "*.js": [ 84 | "eslint", 85 | "prettier --write" 86 | ] 87 | }, 88 | "release": { 89 | "branches": [ 90 | "main" 91 | ], 92 | "plugins": [ 93 | [ 94 | "@semantic-release/commit-analyzer", 95 | { 96 | "preset": "angular", 97 | "releaseRules": [ 98 | { 99 | "type": "docs", 100 | "scope": "readme", 101 | "release": "patch" 102 | }, 103 | { 104 | "type": "refactor", 105 | "release": "minor" 106 | }, 107 | { 108 | "type": "perf", 109 | "release": "minor" 110 | } 111 | ], 112 | "parserOpts": { 113 | "noteKeywords": [ 114 | "BREAKING CHANGE", 115 | "BREAKING CHANGES" 116 | ] 117 | } 118 | } 119 | ], 120 | "@semantic-release/release-notes-generator", 121 | "@semantic-release/npm", 122 | "@semantic-release/github" 123 | ] 124 | } 125 | } -------------------------------------------------------------------------------- /scripts/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-console": "off" 4 | } 5 | } -------------------------------------------------------------------------------- /scripts/copyDocsReadmeToRoot.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | const docsReadmeFilePath = path.resolve(__dirname, '../docs/README.md'); 5 | const rootReadmeFilePath = path.resolve(__dirname, '../README.md'); 6 | 7 | // Script copies the README file from /docs into the root directory so it can be displayed correctly 8 | // on the npm package page. 9 | // This should be run in the `prepack` script which is run before the npm package is published 10 | fs.readFile(docsReadmeFilePath, 'utf8', (err, data) => { 11 | if (err) throw err; 12 | 13 | // Since this README file will be in the root rather than the /docs directory, 14 | // modify any relative file paths starting with "./" so they start with "/docs/" instead 15 | const rootReadmeFileContents = data.replace(/(\.\/)/g, '/docs/'); 16 | 17 | // Write our modified file contents to a new README file in the root directory 18 | fs.writeFile(rootReadmeFilePath, rootReadmeFileContents, (err) => { 19 | if (err) throw err; 20 | 21 | console.log('README.md succcessfully copied to the root directory.'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /scripts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "ts-node/node12/tsconfig.json", 3 | "ts-node": { 4 | "transpileOnly": true, 5 | "files": true, 6 | }, 7 | } -------------------------------------------------------------------------------- /scripts/validateReadme.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | const readmeFilePath = path.resolve(__dirname, '../docs/README.md'); 5 | 6 | // Script reads the README's contents and ensures all of its internal links point to valid sections 7 | // (ie, a [...](#heading-text) link should match a heading that says "Heading Text") 8 | fs.readFile(readmeFilePath, 'utf8', (err, data) => { 9 | if (err) throw err; 10 | 11 | // Convert to lowercase so we can account for the fact that markdown section heading ids 12 | // get transformed to all lower case (ie, "# This is a Heading" -> "this-is-a-heading") 13 | const lowerCaseFileContents = data.toLowerCase(); 14 | 15 | // Regex matches all markdown links to page sections; ie, [link text](#section) 16 | const regexMdLinks = /\[.+?\]\(#.+?\)/gm; 17 | 18 | const markdownInternalLinks = data.match(regexMdLinks); 19 | if (!markdownInternalLinks) { 20 | throw new Error('No internal links found in README.'); 21 | } 22 | 23 | // Regex to use on each individual match found from the first regex pattern 24 | // This will split the string into capturing groups so we can just easily get the 25 | // string that is supposed to target an existing section heading 26 | const singleLinkRegex = /\[(?.+?)\]\(#(?.+?)\)/; 27 | 28 | for ( 29 | let i = 0, numInternalLinks = markdownInternalLinks.length; 30 | i < numInternalLinks; 31 | i += 1 32 | ) { 33 | const targetSectionHeading = markdownInternalLinks[i].match(singleLinkRegex) 34 | ?.groups?.targetSectionHeading; 35 | 36 | if (!targetSectionHeading) { 37 | throw new Error( 38 | `Could not parse link target from ${markdownInternalLinks[i]}` 39 | ); 40 | } 41 | 42 | // Convert any dashes from the link to spaces so we can match against the 43 | // actual section heading 44 | const formattedTargetHeading = targetSectionHeading.replace(/-/g, ' '); 45 | 46 | if (!lowerCaseFileContents.includes(`# ${formattedTargetHeading}`)) { 47 | // If we can't find a matching section heading, throw an error! 48 | throw new Error( 49 | `README file contains link to #${targetSectionHeading}, but no section with that heading exists.` 50 | ); 51 | } 52 | } 53 | 54 | console.log('README file is valid!'); 55 | }); 56 | -------------------------------------------------------------------------------- /src/HoverVideoPlayer.styles.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface SizingModeStyle { 4 | video: React.CSSProperties | null; 5 | overlay: React.CSSProperties | null; 6 | container: React.CSSProperties | null; 7 | manual: React.CSSProperties | null; 8 | } 9 | 10 | // CSS styles to make some contents in the player expand to fill the container 11 | export const expandToFillContainerStyle: React.CSSProperties = { 12 | position: 'absolute', 13 | width: '100%', 14 | height: '100%', 15 | top: 0, 16 | bottom: 0, 17 | left: 0, 18 | right: 0, 19 | }; 20 | 21 | const containerMatchContentDimensionsStyle: React.CSSProperties = { 22 | display: 'inline-block', 23 | }; 24 | 25 | export const containerSizingStyles: SizingModeStyle = { 26 | video: containerMatchContentDimensionsStyle, 27 | overlay: containerMatchContentDimensionsStyle, 28 | container: null, 29 | manual: null, 30 | }; 31 | 32 | // Styles to apply to the paused overlay wrapper for each sizing mode 33 | export const pausedOverlayWrapperSizingStyles: SizingModeStyle = { 34 | // Sizing should be based on the video element, so make the overlay 35 | // expand to cover the player's container element 36 | video: expandToFillContainerStyle, 37 | // Sizing should be based on the paused overlay, so set position: relative 38 | // to make it occupy space in the document flow 39 | overlay: { 40 | position: 'relative', 41 | }, 42 | // Sizing should be based on the player's container element, so make the overlay 43 | // expand to cover it 44 | container: expandToFillContainerStyle, 45 | // Don't apply any preset styling to the overlay 46 | manual: null, 47 | }; 48 | 49 | // Styles to apply to the video element for each sizing mode 50 | export const videoSizingStyles: SizingModeStyle = { 51 | // Sizing should be based on the video element, so set display: block 52 | // to make sure it occupies space in the document flow 53 | video: { 54 | display: 'block', 55 | // Ensure the video is sized relative to the container's width 56 | // rather than the video asset's native width 57 | width: '100%', 58 | }, 59 | // Make the video element expand to cover the container if we're sizing 60 | // based on the overlay or container 61 | overlay: expandToFillContainerStyle, 62 | container: expandToFillContainerStyle, 63 | // Don't apply any preset styling to the video 64 | manual: null, 65 | }; 66 | 67 | export const overlayTransitionDurationVar = "--hvp-overlay-transition-duration"; 68 | 69 | export const visibleOverlayStyles: React.CSSProperties = { 70 | visibility: 'visible', 71 | opacity: 1, 72 | transitionProperty: 'opacity', 73 | transitionDuration: `var(${overlayTransitionDurationVar})`, 74 | }; 75 | 76 | export const hiddenOverlayStyles: React.CSSProperties = { 77 | visibility: 'hidden', 78 | opacity: 0, 79 | transitionProperty: 'opacity, visibility', 80 | transitionDuration: `var(${overlayTransitionDurationVar}), 0s`, 81 | transitionDelay: `0s, var(${overlayTransitionDurationVar})`, 82 | }; 83 | -------------------------------------------------------------------------------- /src/HoverVideoPlayer.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useRef, 3 | useImperativeHandle, 4 | useEffect, 5 | useState, 6 | useCallback, 7 | } from "react"; 8 | 9 | import { 10 | expandToFillContainerStyle, 11 | containerSizingStyles, 12 | pausedOverlayWrapperSizingStyles, 13 | videoSizingStyles, 14 | visibleOverlayStyles, 15 | hiddenOverlayStyles, 16 | overlayTransitionDurationVar, 17 | } from "./HoverVideoPlayer.styles"; 18 | 19 | import { HoverVideoPlayerProps } from "./HoverVideoPlayer.types"; 20 | 21 | /** 22 | * @component HoverVideoPlayer 23 | * @license MIT 24 | * 25 | * @param {HoverVideoPlayerProps} props 26 | */ 27 | export default function HoverVideoPlayer({ 28 | videoSrc, 29 | videoCaptions = null, 30 | focused = false, 31 | disableDefaultEventHandling = false, 32 | hoverTarget = null, 33 | onHoverStart = null, 34 | onHoverEnd = null, 35 | hoverOverlay = null, 36 | pausedOverlay = null, 37 | loadingOverlay = null, 38 | loadingStateTimeout = 200, 39 | overlayTransitionDuration = 400, 40 | playbackStartDelay = 0, 41 | restartOnPaused = false, 42 | unloadVideoOnPaused = false, 43 | playbackRangeStart = null, 44 | playbackRangeEnd = null, 45 | muted = true, 46 | volume = 1, 47 | loop = true, 48 | preload = undefined, 49 | crossOrigin = undefined, 50 | controls = false, 51 | controlsList = undefined, 52 | disableRemotePlayback = true, 53 | disablePictureInPicture = true, 54 | style = undefined, 55 | hoverOverlayWrapperClassName = undefined, 56 | hoverOverlayWrapperStyle = undefined, 57 | pausedOverlayWrapperClassName = undefined, 58 | pausedOverlayWrapperStyle = undefined, 59 | loadingOverlayWrapperClassName = undefined, 60 | loadingOverlayWrapperStyle = undefined, 61 | videoId = undefined, 62 | videoClassName = undefined, 63 | videoRef: forwardedVideoRef = null, 64 | videoStyle = undefined, 65 | sizingMode = "video", 66 | ...spreadableProps 67 | }: HoverVideoPlayerProps): JSX.Element { 68 | // Element refs 69 | const containerRef = useRef(null); 70 | const videoRef = useRef(null); 71 | // Forward out local videoRef along to the videoRef prop 72 | useImperativeHandle( 73 | forwardedVideoRef, 74 | () => videoRef.current as HTMLVideoElement 75 | ); 76 | 77 | // Effects set attributes on the video which can't be done via props 78 | useEffect(() => { 79 | // Manually setting the `muted` attribute on the video element via an effect in order 80 | // to avoid a know React issue with the `muted` prop not applying correctly on initial render 81 | // https://github.com/facebook/react/issues/10389 82 | if (videoRef.current) videoRef.current.muted = muted; 83 | }, [muted]); 84 | useEffect(() => { 85 | // Set the video's volume to match the `volume` prop 86 | // Note that this will have no effect if the `muted` prop is set to true 87 | if (videoRef.current) videoRef.current.volume = volume; 88 | }, [volume]); 89 | // React does not support directly setting disableRemotePlayback or disablePictureInPicture directly 90 | // via the video element's props, so we have to manually set them in an effect 91 | useEffect(() => { 92 | if (videoRef.current) 93 | videoRef.current.disableRemotePlayback = disableRemotePlayback; 94 | }, [disableRemotePlayback]); 95 | useEffect(() => { 96 | if (videoRef.current) 97 | videoRef.current.disablePictureInPicture = disablePictureInPicture; 98 | }, [disablePictureInPicture]); 99 | 100 | useEffect(() => { 101 | const videoElement = videoRef.current; 102 | 103 | if (videoElement && playbackRangeStart) { 104 | videoElement.currentTime = playbackRangeStart; 105 | } 106 | }, [playbackRangeStart]); 107 | 108 | const [hoverTargetElement, setHoverTargetElement] = useState( 109 | null 110 | ); 111 | 112 | useEffect(() => { 113 | // Default to the container element unless a hoverTarget prop is provided 114 | let element: Node | null = containerRef.current; 115 | 116 | if (hoverTarget) { 117 | // Get the hover target element from the hoverTarget prop, or default to the component's container div 118 | // A `hoverTarget` value could be a function, a DOM element, or a React ref, so 119 | // figure out which one it is and get the hover target element out of it accordingly 120 | if (typeof hoverTarget === "function") { 121 | element = hoverTarget(); 122 | } else if (hoverTarget instanceof Node) { 123 | element = hoverTarget; 124 | } else if (hoverTarget && hoverTarget.hasOwnProperty("current")) { 125 | element = hoverTarget.current; 126 | } else { 127 | console.error( 128 | "HoverVideoPlayer was unable to get a usable hover target element. Please check your usage of the `hoverTarget` prop." 129 | ); 130 | } 131 | } 132 | 133 | setHoverTargetElement(element); 134 | }, [hoverTarget]); 135 | 136 | // Keep a ref for the time which the video should be started from next time it is played 137 | // This is useful if the video gets unloaded and we want to restore it to the time it was 138 | // at before if the user tries playing it again 139 | const nextVideoStartTimeRef = useRef(null); 140 | 141 | // Whether the user is hovering over the hover target, meaning we should be trying to play the video 142 | const [isHovering, setIsHovering] = useState(false); 143 | // Whether the video is currently in a loading state, meaning it's not ready to be played yet 144 | const [isLoading, setIsLoading] = useState(false); 145 | // Whether the video is currently playing or not 146 | const [isPlaying, setIsPlaying] = useState(false); 147 | 148 | const isHoveringRef = useRef(); 149 | isHoveringRef.current = isHovering; 150 | 151 | const playTimeoutRef = useRef(); 152 | const pauseTimeoutRef = useRef(); 153 | 154 | const cancelTimeouts = useCallback(() => { 155 | // Cancel any previously active pause or playback attempts 156 | window.clearTimeout(playTimeoutRef.current); 157 | window.clearTimeout(pauseTimeoutRef.current); 158 | }, []); 159 | 160 | const hasPausedOverlay = Boolean(pausedOverlay); 161 | const hasHoverOverlay = Boolean(hoverOverlay); 162 | 163 | // If we have a paused or hover overlay, the player should wait 164 | // for the overlay(s) to finish transitioning back in before we 165 | // pause the video 166 | const shouldWaitForOverlayTransitionBeforePausing = 167 | hasPausedOverlay || hasHoverOverlay; 168 | 169 | useEffect(() => { 170 | const videoElement = videoRef.current; 171 | 172 | if (!hoverTargetElement || !videoElement) return undefined; 173 | 174 | const onHoverStart = () => { 175 | // Bail out if we're already hovering 176 | if (isHoveringRef.current) return; 177 | 178 | // Cancel any previously active pause or playback attempts 179 | cancelTimeouts(); 180 | 181 | setIsHovering(true); 182 | }; 183 | const onHoverEnd = () => { 184 | cancelTimeouts(); 185 | 186 | setIsHovering(false); 187 | }; 188 | 189 | hoverTargetElement.addEventListener("hvp:hoverStart", onHoverStart); 190 | hoverTargetElement.addEventListener("hvp:hoverEnd", onHoverEnd); 191 | 192 | return () => { 193 | hoverTargetElement.removeEventListener("hvp:hoverStart", onHoverStart); 194 | hoverTargetElement.removeEventListener("hvp:hoverEnd", onHoverEnd); 195 | }; 196 | }, [ 197 | cancelTimeouts, 198 | hoverTargetElement, 199 | overlayTransitionDuration, 200 | playbackRangeStart, 201 | restartOnPaused, 202 | shouldWaitForOverlayTransitionBeforePausing, 203 | ]); 204 | 205 | const playVideo = useCallback(() => { 206 | const videoElement = videoRef.current; 207 | if (!videoElement) return; 208 | 209 | videoElement.play().catch((error: DOMException) => { 210 | // Suppress logging for "AbortError" errors, which are thrown when the video is paused while it was trying to play. 211 | // These errors are expected and happen often, so they can be safely ignored. 212 | if (error.name === "AbortError") { 213 | return; 214 | } 215 | 216 | // Additional handling for when browsers block playback for unmuted videos. 217 | // This is unfortunately necessary because most modern browsers do not allow playing videos with audio 218 | // until the user has "interacted" with the page by clicking somewhere at least once; mouseenter events 219 | // don't count. 220 | // If the video isn't muted and playback failed with a `NotAllowedError`, this means the browser blocked 221 | // playing the video because the user hasn't clicked anywhere on the page yet. 222 | if (!videoElement.muted && error.name === "NotAllowedError") { 223 | console.warn( 224 | "HoverVideoPlayer: Playback with sound was blocked by the browser. Attempting to play again with the video muted; audio will be restored if the user clicks on the page." 225 | ); 226 | // Mute the video and attempt to play again 227 | videoElement.muted = true; 228 | playVideo(); 229 | 230 | // When the user clicks on the document, unmute the video since we should now 231 | // be free to play audio 232 | const onClickDocument = () => { 233 | videoElement.muted = false; 234 | 235 | // Clean up the event listener so it is only fired once 236 | document.removeEventListener("click", onClickDocument); 237 | }; 238 | document.addEventListener("click", onClickDocument); 239 | } else { 240 | // Log any other playback errors with console.error 241 | console.error(`HoverVideoPlayer: ${error.message}`); 242 | } 243 | }); 244 | }, []); 245 | 246 | // Effect attempts to start playing the video if the user is hovering over the hover target 247 | // and the video is loaded enough to be played 248 | useEffect(() => { 249 | const videoElement = videoRef.current; 250 | if (!videoElement) return; 251 | 252 | if (isHovering && !isLoading && !isPlaying) { 253 | if ( 254 | nextVideoStartTimeRef.current !== null && 255 | videoElement.currentTime !== nextVideoStartTimeRef.current 256 | ) { 257 | videoElement.currentTime = nextVideoStartTimeRef.current; 258 | } 259 | 260 | if (playbackStartDelay) { 261 | playTimeoutRef.current = window.setTimeout( 262 | playVideo, 263 | playbackStartDelay 264 | ); 265 | } else { 266 | playVideo(); 267 | } 268 | } 269 | }, [isHovering, isLoading, isPlaying, playVideo, playbackStartDelay]); 270 | 271 | // Effect pauses the video if the user is no longer hovering over the hover target 272 | // and the video is currently playing 273 | useEffect(() => { 274 | const videoElement = videoRef.current; 275 | if (!videoElement) return; 276 | 277 | if (!isHovering && (isPlaying || isLoading)) { 278 | const pauseVideo = () => { 279 | videoElement.pause(); 280 | 281 | // Performing post-save cleanup tasks in here rather than the onPause listener 282 | // because onPause can also be called when the video reaches the end of a playback range 283 | // and it's just simpler to deal with that separately 284 | if (restartOnPaused) { 285 | videoElement.currentTime = playbackRangeStart || 0; 286 | } 287 | nextVideoStartTimeRef.current = videoElement.currentTime; 288 | }; 289 | 290 | if (shouldWaitForOverlayTransitionBeforePausing) { 291 | // If we have a paused overlay, the player should wait 292 | // for the overlay(s) to finish transitioning back in before we 293 | // pause the video 294 | pauseTimeoutRef.current = window.setTimeout( 295 | pauseVideo, 296 | overlayTransitionDuration 297 | ); 298 | } else { 299 | pauseVideo(); 300 | } 301 | } 302 | }, [ 303 | isHovering, 304 | isLoading, 305 | isPlaying, 306 | overlayTransitionDuration, 307 | playbackRangeStart, 308 | restartOnPaused, 309 | shouldWaitForOverlayTransitionBeforePausing, 310 | ]); 311 | 312 | // Effect cancels any pending timeouts when the component unmounts 313 | useEffect(() => () => cancelTimeouts(), [cancelTimeouts]); 314 | 315 | // Keeping hover callbacks as refs because we want to be able to access them from within our 316 | // onHoverStart and onHoverEnd event listeners without needing to re-run the 317 | // event setup effect every time they change 318 | const onHoverStartCallbackRef = useRef(); 319 | onHoverStartCallbackRef.current = onHoverStart; 320 | 321 | const onHoverEndCallbackRef = useRef(); 322 | onHoverEndCallbackRef.current = onHoverEnd; 323 | 324 | // Effect sets up event listeners for hover events on hover target 325 | useEffect(() => { 326 | // If default event handling is disabled, we shouldn't check for touch events outside of the player 327 | if (disableDefaultEventHandling || !hoverTargetElement) return undefined; 328 | 329 | const onHoverStart = () => { 330 | hoverTargetElement.dispatchEvent(new Event("hvp:hoverStart")); 331 | onHoverStartCallbackRef.current?.(); 332 | }; 333 | const onHoverEnd = () => { 334 | hoverTargetElement.dispatchEvent(new Event("hvp:hoverEnd")); 335 | onHoverEndCallbackRef.current?.(); 336 | }; 337 | 338 | // Mouse events 339 | hoverTargetElement.addEventListener("mouseenter", onHoverStart); 340 | hoverTargetElement.addEventListener("mouseleave", onHoverEnd); 341 | 342 | // Focus/blur 343 | hoverTargetElement.addEventListener("focus", onHoverStart); 344 | hoverTargetElement.addEventListener("blur", onHoverEnd); 345 | 346 | // Touch events 347 | const touchStartListenerOptions = { passive: true }; 348 | 349 | hoverTargetElement.addEventListener( 350 | "touchstart", 351 | onHoverStart, 352 | touchStartListenerOptions 353 | ); 354 | // Event listener pauses the video when the user touches somewhere outside of the player 355 | const onWindowTouchStart = (event: TouchEvent) => { 356 | if ( 357 | !(event.target instanceof Node) || 358 | !hoverTargetElement.contains(event.target) 359 | ) { 360 | onHoverEnd(); 361 | } 362 | }; 363 | 364 | window.addEventListener( 365 | "touchstart", 366 | onWindowTouchStart, 367 | touchStartListenerOptions 368 | ); 369 | 370 | // Return a cleanup function that removes all event listeners 371 | return () => { 372 | hoverTargetElement.removeEventListener("mouseenter", onHoverStart); 373 | hoverTargetElement.removeEventListener("mouseleave", onHoverEnd); 374 | hoverTargetElement.removeEventListener("focus", onHoverStart); 375 | hoverTargetElement.removeEventListener("blur", onHoverEnd); 376 | hoverTargetElement.removeEventListener("touchstart", onHoverStart); 377 | window.removeEventListener("touchstart", onWindowTouchStart); 378 | }; 379 | }, [disableDefaultEventHandling, hoverTargetElement]); 380 | 381 | // Defaulting the ref to false rather than the initial value of the focused prop because 382 | // if focused is true initially, we want to run the effect, but if it's false, we don't 383 | const previousFocusedRef = useRef(false); 384 | 385 | // Effect dispatches hover start/end events on the target element when the focused prop changes 386 | useEffect(() => { 387 | if (!hoverTargetElement) return; 388 | 389 | if (previousFocusedRef.current !== focused) { 390 | previousFocusedRef.current = focused; 391 | 392 | if (focused) { 393 | hoverTargetElement.dispatchEvent(new Event("hvp:hoverStart")); 394 | } else { 395 | hoverTargetElement.dispatchEvent(new Event("hvp:hoverEnd")); 396 | } 397 | } 398 | }, [hoverTargetElement, focused]); 399 | 400 | const currentVideoSrc = useRef(videoSrc); 401 | let shouldReloadVideoSrc = false; 402 | if (videoSrc !== currentVideoSrc.current && !isHovering && !isPlaying) { 403 | currentVideoSrc.current = videoSrc; 404 | shouldReloadVideoSrc = true; 405 | } 406 | 407 | const hasStringSrc = typeof currentVideoSrc.current === "string"; 408 | 409 | useEffect(() => { 410 | const videoElement = videoRef.current; 411 | if (!videoElement) return; 412 | 413 | if (shouldReloadVideoSrc) { 414 | // If the video element doesn't have a loaded source or the source has changed since the 415 | // last time we played the video, make sure to force the video to load the most up-to-date sources 416 | videoElement.load(); 417 | // Reset the next start time to the start of the video 418 | nextVideoStartTimeRef.current = playbackRangeStart || 0; 419 | } 420 | }, [playbackRangeStart, shouldReloadVideoSrc]); 421 | 422 | // If the video's sources should be unloaded when it's paused and the video is not currently active, we can unload the video's sources. 423 | // We will remove the video's tags in this render and then call video.load() in an effect to 424 | // fully unload the video 425 | const shouldUnloadVideo = unloadVideoOnPaused && !isHovering && !isPlaying; 426 | 427 | useEffect(() => { 428 | if (shouldUnloadVideo) { 429 | // Re-load the video with the sources removed so we unload everything from memory 430 | videoRef.current?.load(); 431 | } 432 | }, [shouldUnloadVideo]); 433 | 434 | const shouldShowLoadingOverlay = isHovering && !isPlaying; 435 | // Show a paused overlay when the user isn't hovering or when the user is hovering 436 | // but the video is still loading 437 | const shouldShowPausedOverlay = !isHovering || (isHovering && !isPlaying); 438 | 439 | const isUsingPlaybackRange = 440 | playbackRangeStart !== null || playbackRangeEnd !== null; 441 | 442 | const hasLoadingOverlay = Boolean(loadingOverlay); 443 | 444 | return ( 445 |
455 | {hasPausedOverlay ? ( 456 |
467 | {pausedOverlay} 468 |
469 | ) : null} 470 | {hasLoadingOverlay ? ( 471 |
485 | {loadingOverlay} 486 |
487 | ) : null} 488 | {hasHoverOverlay ? ( 489 |
499 | {hoverOverlay} 500 |
501 | ) : null} 502 | {/* eslint-disable-next-line jsx-a11y/media-has-caption */} 503 | 590 |
591 | ); 592 | } 593 | -------------------------------------------------------------------------------- /src/HoverVideoPlayer.types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Node, callback function that returns a node, or a React ref which can resolve to the desired 3 | * DOM Node to use as the target to attach hover interaction events to 4 | */ 5 | export type HoverTarget = Node | (() => Node) | React.RefObject; 6 | 7 | type VideoProps = React.ComponentPropsWithoutRef<'video'>; 8 | 9 | export interface HoverVideoPlayerProps 10 | extends React.ComponentPropsWithoutRef<'div'> { 11 | /** 12 | * Source(s) to load from and play in the video player. 13 | */ 14 | videoSrc: string | React.ReactNode; 15 | /** 16 | * Captions track(s) to load and display on the video player for accessibility. 17 | * @defaultValue null 18 | */ 19 | videoCaptions?: React.ReactNode; 20 | /** 21 | * Offers a prop interface for forcing the video to start/stop without DOM events. 22 | * When set to true, the video will begin playing and any events that would normally 23 | * stop it will be ignored. 24 | * @defaultValue false 25 | */ 26 | focused?: boolean; 27 | /** 28 | * Whether the video player's default mouse and touch event handling should be disabled in favor of a fully 29 | * custom solution using the `focused` prop 30 | * @defaultValue false 31 | */ 32 | disableDefaultEventHandling?: boolean; 33 | /** 34 | * Provides a custom element that should be used as the target for hover events to start/stop the video. 35 | * Accepts a DOM Node, a function which returns a DOM Node, or a React ref. 36 | * The component's container div element will be used by default if no hover target is provided. 37 | * @defaultValue null 38 | */ 39 | hoverTarget?: Node | (() => Node | null) | React.RefObject | null; 40 | /** 41 | * Callback fired when the user starts hovering on the player's hover target 42 | * @defaultValue null 43 | */ 44 | onHoverStart?: (() => void) | null; 45 | /** 46 | * Callback fired when the user stops hovering on the player's hover target 47 | * @defaultValue null 48 | */ 49 | onHoverEnd?: (() => void) | null; 50 | /** 51 | * Contents to render over the video while the user is hovering over the player. 52 | * @defaultValue null 53 | */ 54 | hoverOverlay?: JSX.Element | null; 55 | /** 56 | * Contents to render over the video while it's not playing. 57 | * @defaultValue null 58 | */ 59 | pausedOverlay?: JSX.Element | null; 60 | /** 61 | * Contents to render over the video while it's loading. 62 | * If a `pausedOverlay` was provided, this will be overlaid on top of that. 63 | * @defaultValue null 64 | */ 65 | loadingOverlay?: JSX.Element | null; 66 | /** 67 | * Duration in ms to wait after attempting to start the video before showing the loading overlay. 68 | * @defaultValue 200 69 | */ 70 | loadingStateTimeout?: number; 71 | /** 72 | * The transition duration in ms for how long it should take for 73 | * the `pausedOverlay` and `loadingOverlay` to fade in/out. 74 | * @defaultValue 400 75 | */ 76 | overlayTransitionDuration?: number; 77 | /** 78 | * The duration in ms for how long of a delay there should be between when the 79 | * user starts hovering over the player and when the video will actually attempt 80 | * to start playing. 81 | * This prop may help with performance if you are concerned about your server getting 82 | * hit with too many requests as the user moves their mouse over a large number of videos. 83 | * @defaultValue 0 84 | */ 85 | playbackStartDelay?: number; 86 | /** 87 | * Whether the video should reset to the beginning every time it is paused. 88 | * @defaultValue false 89 | */ 90 | restartOnPaused?: boolean; 91 | /** 92 | * Whether we should unload the video's sources when it is not playing 93 | * in order to free up memory and bandwidth for performance purposes. 94 | * @defaultValue false 95 | */ 96 | unloadVideoOnPaused?: boolean; 97 | /** 98 | * The time in seconds that we should start loading and playing the video from using the 99 | * playback range media fragment identifier. If not specified, the video will 100 | * be played from the start. 101 | * @defaultValue null 102 | */ 103 | playbackRangeStart?: number | null; 104 | /** 105 | * The maximum time in seconds that we can load/play the video to using the 106 | * playback range media fragment identifier. If not specified, the video 107 | * will play through to the end. 108 | * @defaultValue null 109 | */ 110 | playbackRangeEnd?: number | null; 111 | /** 112 | * Whether the video's audio should be muted. 113 | * @defaultValue true 114 | */ 115 | muted?: VideoProps['muted']; 116 | /** 117 | * The volume that the video's audio should play at, on a scale from 0-1. 118 | * This will only work if the muted prop is also set to false. 119 | * @defaultValue 1 120 | */ 121 | volume?: number; 122 | /** 123 | * Whether the video player should loop when it reaches the end. 124 | * @defaultValue true 125 | */ 126 | loop?: VideoProps['loop']; 127 | /** 128 | * Sets how much information the video element should preload before being played. 129 | * Accepts one of the following values: 130 | * - **"none"**: Nothing should be preloaded before the video is played 131 | * - **"metadata"**: Only the video's metadata (ie length, dimensions) should be preloaded 132 | * - **"auto"**: The whole video file should be preloaded even if it won't be played 133 | * 134 | * By default, the video's preload behavior will be left up to the browser; the official spec recommends 135 | * defaulting to "metadata", but many browsers don't follow that standard. 136 | * @defaultValue undefined 137 | */ 138 | preload?: VideoProps['preload']; 139 | /** 140 | * Sets how the video element should handle CORS requests. 141 | * Accepts one of the following values: 142 | * - **"anonymous"**: The video element will send cross-origin requests with no credentials. 143 | * This is the browser default and usually all you need for most purposes. 144 | * - **"use-credentials"**: The video element will send cross-origin requests with credentials. 145 | * 146 | * @defaultValue undefined 147 | */ 148 | crossOrigin?: VideoProps['crossOrigin']; 149 | /** 150 | * Sets whether the video element should have the browser's video playback controls enabled. 151 | * @defaultValue false 152 | */ 153 | controls?: boolean; 154 | /** 155 | * Allows finer control over which controls the browser should exclude from the video playback controls. 156 | * Be aware that this feature is not currently supported across all major browsers. 157 | * Accepts a string with the following values, separated by spaces if using more than one: 158 | * - **"nodownload"**: Removes the download button from the video's controls 159 | * - **"nofullscreen"**: Removes the fullscreen button from the video's controls 160 | * @defaultValue undefined 161 | */ 162 | controlsList?: VideoProps['controlsList']; 163 | /** 164 | * Prevents the browser from showing controls to cast the video. 165 | * @defaultValue true 166 | */ 167 | disableRemotePlayback?: boolean; 168 | /** 169 | * Prevents the browser from showing picture-in-picture controls on the video. 170 | * @defaultValue true 171 | */ 172 | disablePictureInPicture?: boolean; 173 | /** 174 | * Style object to apply custom inlined styles to the component's container div element. 175 | * @defaultValue undefined 176 | */ 177 | style?: React.CSSProperties; 178 | /** 179 | * Optional className to apply custom styling to the div element wrapping the `hoverOverlay` contents. 180 | * @defaultValue undefined 181 | */ 182 | hoverOverlayWrapperClassName?: string; 183 | /** 184 | * Style object to apply custom inlined styles to the div element wrapping the `hoverOverlay` contents. 185 | * @defaultValue undefined 186 | */ 187 | hoverOverlayWrapperStyle?: React.CSSProperties; 188 | /** 189 | * Optional className to apply custom styling to the div element wrapping the `pausedOverlay` contents. 190 | * @defaultValue undefined 191 | */ 192 | pausedOverlayWrapperClassName?: string; 193 | /** 194 | * Style object to apply custom inlined styles to the div element wrapping the `pausedOverlay` contents. 195 | * @defaultValue undefined 196 | */ 197 | pausedOverlayWrapperStyle?: React.CSSProperties; 198 | /** 199 | * Optional className to apply custom styling to the div element wrapping the `loadingOverlay` contents. 200 | * @defaultValue undefined 201 | */ 202 | loadingOverlayWrapperClassName?: string; 203 | /** 204 | * Style object to apply custom inlined styles to the div element wrapping the `loadingOverlay` contents. 205 | * @defaultValue undefined 206 | */ 207 | loadingOverlayWrapperStyle?: React.CSSProperties; 208 | /** 209 | * React ref to forward to the video element rendered by HoverVideoPlayer. 210 | * @defaultValue null 211 | */ 212 | videoRef?: React.Ref | null; 213 | /** 214 | * Optional unique ID to apply to the video element. 215 | * This can be useful for scenarios where you need to manually access 216 | * and manipulate the video element via `getElementById`. 217 | * @defaultValue undefined 218 | */ 219 | videoId?: string; 220 | /** 221 | * Optional className to apply custom styling to the video element. 222 | * @defaultValue undefined 223 | */ 224 | videoClassName?: string; 225 | /** 226 | * Style object to apply custom inlined styles to the video element. 227 | * @defaultValue undefined 228 | */ 229 | videoStyle?: React.CSSProperties; 230 | /** 231 | * Describes which styling preset to apply to determine how the player's contents should be sized. 232 | * Accepts 4 possible values: 233 | * - **"video"**: Everything should be sized based on the video element's dimensions; the overlays will expand to cover the video. 234 | * - **"overlay"**: Everything should be sized based on the paused overlay's dimensions; the video element will expand to fit inside those dimensions. 235 | * - **"container"**: Everything should be sized based on the component's outer container div element; the overlays and video will all expand to cover the container. 236 | * - **"manual"**: Manual mode disables preset styling and allows the developer to exercise full control over how everything should be sized. 237 | * This means you will likely need to provide your own custom styling for both the paused overlay and the video element. 238 | * 239 | * @defaultValue "video" 240 | */ 241 | sizingMode?: 'video' | 'overlay' | 'container' | 'manual'; 242 | } 243 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './HoverVideoPlayer'; 2 | -------------------------------------------------------------------------------- /tests/assets/captions.vtt: -------------------------------------------------------------------------------- 1 | WEBVTT 2 | 3 | 00:00:00.500 --> 00:00:03.000 4 | This is some caption text 5 | 6 | 00:00:04.000 --> 00:00:06.500 7 | I hope you can read it... 8 | 9 | 00:00:06.700 --> 00:00:09.000 10 | ...because otherwise that means this doesn't work 11 | -------------------------------------------------------------------------------- /tests/assets/subtitles-ga.vtt: -------------------------------------------------------------------------------- 1 | WEBVTT 2 | 3 | 00:00:00.500 --> 00:00:03.000 4 | Seo roinnt téacs fotheidil 5 | 6 | 00:00:04.000 --> 00:00:06.500 7 | Tá súil agam gur féidir leat é a léamh ... 8 | 9 | 00:00:06.700 --> 00:00:09.000 10 | ... mar gheall ar shlí eile ciallaíonn sé sin nach n-oibríonn sé seo 11 | -------------------------------------------------------------------------------- /tests/assets/video.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gyanreyer/react-hover-video-player/5cbca8947aeca7f7b26c40b20301db7425adbeb7/tests/assets/video.mp4 -------------------------------------------------------------------------------- /tests/assets/video.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gyanreyer/react-hover-video-player/5cbca8947aeca7f7b26c40b20301db7425adbeb7/tests/assets/video.webm -------------------------------------------------------------------------------- /tests/constants.ts: -------------------------------------------------------------------------------- 1 | export const port = 8080; 2 | export const baseURL = `http://localhost:${port}`; 3 | 4 | export const mp4VideoSrc = '/assets/video.mp4'; 5 | export const mp4VideoSrcURL = new URL(mp4VideoSrc, baseURL).toString(); 6 | export const webmVideoSrc = '/assets/video.webm'; 7 | export const webmVideoSrcURL = new URL(webmVideoSrc, baseURL).toString(); 8 | export const thumbnailSrc = '/assets/thumbnail.jpg'; 9 | 10 | export const captionsSrc = '/assets/captions.vtt'; 11 | export const captionsSrcURL = new URL(captionsSrc, baseURL).toString(); 12 | export const gaelicSubtitlesSrc = '/assets/subtitles-ga.vtt'; 13 | export const gaelicSubtitlesSrcURL = new URL(gaelicSubtitlesSrc, baseURL).toString(); 14 | -------------------------------------------------------------------------------- /tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Testing Page 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /tests/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | 4 | import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; 5 | 6 | import HoverTargetTestPage from "./specs/hoverTarget"; 7 | import LoadingStateTimeoutTestPage from "./specs/loadingStateTimeout"; 8 | import OverlaysTestPage from "./specs/overlays"; 9 | import PlaybackTestPage from "./specs/playback"; 10 | import PlaybackRangeTestPage from "./specs/playbackRange"; 11 | import PlaybackStartDelayTestPage from "./specs/playbackStartDelay"; 12 | import SizingModeTestPage from "./specs/sizingMode"; 13 | import UnloadVideoOnPauseTestPage from "./specs/unloadVideoOnPause"; 14 | import VideoCaptionsTextPage from "./specs/videoCaptions"; 15 | import VideoSrcTestPage from "./specs/videoSrc"; 16 | import VideoSrcChangeTestPage from "./specs/videoSrcChange"; 17 | 18 | function App(): JSX.Element { 19 | return ( 20 | 21 | 22 | } /> 23 | } 26 | /> 27 | } /> 28 | } /> 29 | } /> 30 | } 33 | /> 34 | } /> 35 | } 38 | /> 39 | } /> 40 | } /> 41 | } /> 42 | 43 | 44 | ); 45 | } 46 | 47 | ReactDOM.render(, document.getElementById("root")); 48 | -------------------------------------------------------------------------------- /tests/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from '@playwright/test'; 2 | 3 | /** 4 | * See https://playwright.dev/docs/test-configuration. 5 | */ 6 | export default defineConfig({ 7 | testDir: './', 8 | /* The base directory, relative to the config file, for snapshot files created with toMatchSnapshot and toHaveScreenshot. */ 9 | snapshotDir: './__snapshots__', 10 | /* Maximum time one test can run for. */ 11 | timeout: 10 * 1000, 12 | /* Run tests in files in parallel */ 13 | fullyParallel: true, 14 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 15 | forbidOnly: !!process.env.CI, 16 | /* Retry on CI only */ 17 | retries: process.env.CI ? 2 : 0, 18 | /* Opt out of parallel tests on CI. */ 19 | workers: process.env.CI ? 1 : undefined, 20 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 21 | reporter: 'html', 22 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 23 | use: { 24 | baseURL: `http://localhost:8080`, 25 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 26 | trace: 'on-first-retry', 27 | ignoreHTTPSErrors: true, 28 | headless: true, 29 | }, 30 | /* Configure projects for major browsers */ 31 | projects: [ 32 | { 33 | name: 'Google Chrome', 34 | use: { 35 | channel: 'chrome', 36 | }, 37 | }, 38 | ], 39 | /* Run your local dev server before starting the tests */ 40 | webServer: { 41 | command: 'node ./serveTests.mjs', 42 | port: 8080, 43 | }, 44 | }); 45 | -------------------------------------------------------------------------------- /tests/serveTests.mjs: -------------------------------------------------------------------------------- 1 | import http from "node:http"; 2 | import { existsSync, readFileSync, statSync } from "node:fs"; 3 | import path from "node:path"; 4 | import url from "node:url"; 5 | 6 | import esbuild from "esbuild"; 7 | 8 | const port = 8080; 9 | 10 | const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); 11 | 12 | const extensionContentTypeMap = { 13 | mp4: "video/mp4", 14 | webm: "video/webm", 15 | jpg: "image/jpeg", 16 | jpeg: "image/jpeg", 17 | png: "image/png", 18 | js: "text/javascript", 19 | css: "text/css", 20 | html: "text/html", 21 | vtt: "text/vtt", 22 | }; 23 | 24 | // Build the test page 25 | await esbuild.build({ 26 | entryPoints: [path.resolve(__dirname, "index.tsx")], 27 | outfile: path.resolve(__dirname, "./index.js"), 28 | bundle: true, 29 | format: "esm", 30 | target: "es6", 31 | logLevel: "info", 32 | }); 33 | 34 | const filePathRegex = /\.[a-z0-9]{2,4}$/; 35 | 36 | http 37 | .createServer((request, response) => { 38 | // If a request path ends with a file extension, just serve the file for that path; 39 | // otherwise, we'll assume it's a request for a page so we should just serve the index.html file. 40 | const requestPath = request.url.match(filePathRegex) 41 | ? request.url 42 | : "/index.html"; 43 | 44 | const pathname = `${__dirname}${requestPath}`; 45 | 46 | const exists = existsSync(pathname); 47 | 48 | if (!exists) { 49 | // if the file is not found, return 404 50 | response.statusCode = 404; 51 | response.end(`File ${pathname} not found`); 52 | return; 53 | } 54 | 55 | response.statusCode = 200; 56 | 57 | // Website you wish to allow to connect 58 | response.setHeader("Access-Control-Allow-Origin", "*"); 59 | 60 | // Request methods you wish to allow 61 | response.setHeader("Access-Control-Allow-Methods", "GET"); 62 | 63 | // Request headers you wish to allow 64 | response.setHeader( 65 | "Access-Control-Allow-Headers", 66 | "X-Requested-With,content-type" 67 | ); 68 | 69 | const contentType = extensionContentTypeMap[pathname.split(".").pop()]; 70 | 71 | response.setHeader("Content-Type", contentType); 72 | 73 | const fileData = readFileSync(pathname); 74 | 75 | if (contentType.startsWith("video")) { 76 | const { size } = statSync(pathname); 77 | 78 | response.statusCode = 206; 79 | response.setHeader("Accept-Ranges", "bytes"); 80 | response.setHeader("Content-Length", size); 81 | response.setHeader("Content-Range", `bytes 0-${size - 1}/${size}`); 82 | } 83 | 84 | response.end(fileData); 85 | }) 86 | .listen(port); 87 | 88 | // eslint-disable-next-line no-console 89 | console.log(`Serving test assets at http://localhost:${port}/`); 90 | -------------------------------------------------------------------------------- /tests/specs/hoverTarget/hoverTarget.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test.beforeEach(async ({ page }) => { 4 | await page.goto('/hoverTarget'); 5 | }); 6 | 7 | test('the player container div is used as the default hover target if no hoverTarget prop is set', async ({ page }) => { 8 | const hoverVideoPlayer = page.locator('[data-testid="hvp:no-hover-target"]'); 9 | const video = hoverVideoPlayer.locator('video'); 10 | 11 | const currentHoveringTestID = page.locator('[data-testid="current-hovering-testid"]'); 12 | 13 | await Promise.all([ 14 | expect(video).toHaveJSProperty('paused', true), 15 | expect(currentHoveringTestID).toBeEmpty(), 16 | ]); 17 | 18 | await hoverVideoPlayer.hover(); 19 | 20 | await Promise.all([ 21 | expect(video).toHaveJSProperty('paused', false), 22 | expect(currentHoveringTestID).toHaveText('hvp:no-hover-target'), 23 | ]); 24 | 25 | // Move the mouse so we're no longer hovering 26 | await page.mouse.move(0, 0); 27 | 28 | await Promise.all([ 29 | expect(video).toHaveJSProperty('paused', true), 30 | expect(currentHoveringTestID).toBeEmpty(), 31 | ]); 32 | }); 33 | 34 | test('setting an external hover target via ref works as expected', async ({ page }) => { 35 | const hoverVideoPlayer = page.locator('[data-testid="hvp:hover-target-ref"]'); 36 | const video = hoverVideoPlayer.locator('video'); 37 | 38 | const hoverTarget = page.locator('[data-testid="hover-target-ref"]'); 39 | 40 | const currentHoveringTestID = page.locator('[data-testid="current-hovering-testid"]'); 41 | 42 | await Promise.all([ 43 | expect(video).toHaveJSProperty('paused', true), 44 | expect(currentHoveringTestID).toBeEmpty(), 45 | ]); 46 | 47 | // Nothing should happen when we hover on the player container 48 | await hoverVideoPlayer.hover(); 49 | await Promise.all([ 50 | expect(video).toHaveJSProperty('paused', true), 51 | expect(currentHoveringTestID).toBeEmpty(), 52 | ]); 53 | 54 | // Hovering on the hoverTarget should trigger playback 55 | await hoverTarget.hover(); 56 | 57 | await Promise.all([ 58 | expect(video).toHaveJSProperty('paused', false), 59 | expect(currentHoveringTestID).toHaveText('hvp:hover-target-ref'), 60 | ]); 61 | 62 | // Move the mouse so we're no longer hovering 63 | await page.mouse.move(0, 0); 64 | 65 | await Promise.all([ 66 | expect(video).toHaveJSProperty('paused', true), 67 | expect(currentHoveringTestID).toBeEmpty(), 68 | ]); 69 | }); 70 | 71 | test('setting an external hover target via callback works as expected', async ({ page }) => { 72 | const hoverVideoPlayer = page.locator('[data-testid="hvp:hover-target-fn"]'); 73 | const video = hoverVideoPlayer.locator('video'); 74 | 75 | const hoverTarget = page.locator('[data-testid="hover-target-fn"]'); 76 | 77 | const currentHoveringTestID = page.locator('[data-testid="current-hovering-testid"]'); 78 | 79 | await Promise.all([ 80 | expect(video).toHaveJSProperty('paused', true), 81 | expect(currentHoveringTestID).toBeEmpty(), 82 | ]); 83 | 84 | // Nothing should happen when we hover on the player container 85 | await hoverVideoPlayer.hover(); 86 | await Promise.all([ 87 | expect(video).toHaveJSProperty('paused', true), 88 | expect(currentHoveringTestID).toBeEmpty(), 89 | ]); 90 | 91 | // Hovering on the hoverTarget should trigger playback 92 | await hoverTarget.hover(); 93 | 94 | await Promise.all([ 95 | expect(video).toHaveJSProperty('paused', false), 96 | expect(currentHoveringTestID).toHaveText('hvp:hover-target-fn'), 97 | ]); 98 | 99 | // Move the mouse so we're no longer hovering 100 | await page.mouse.move(0, 0); 101 | 102 | await Promise.all([ 103 | expect(video).toHaveJSProperty('paused', true), 104 | expect(currentHoveringTestID).toBeEmpty(), 105 | ]); 106 | }); 107 | -------------------------------------------------------------------------------- /tests/specs/hoverTarget/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState } from "react"; 2 | import HoverVideoPlayer from "../../../src/HoverVideoPlayer"; 3 | 4 | import { mp4VideoSrc } from "../../constants"; 5 | 6 | export default function HoverTargetTestPage(): JSX.Element { 7 | const hoverTargetRef = useRef(null); 8 | 9 | const [hoveringTestID, setHoveringTestID] = useState(null); 10 | 11 | return ( 12 |
13 |

hoverTarget

14 |

{hoveringTestID}

15 | setHoveringTestID("hvp:no-hover-target")} 18 | onHoverEnd={() => setHoveringTestID(null)} 19 | data-testid="hvp:no-hover-target" 20 | /> 21 |
22 | Hover on me! 23 |
24 | setHoveringTestID("hvp:hover-target-ref")} 28 | onHoverEnd={() => setHoveringTestID(null)} 29 | data-testid="hvp:hover-target-ref" 30 | /> 31 |
No, hover on me!
32 | 35 | document.querySelector("[data-testid=hover-target-fn]") 36 | } 37 | onHoverStart={() => setHoveringTestID("hvp:hover-target-fn")} 38 | onHoverEnd={() => setHoveringTestID(null)} 39 | data-testid="hvp:hover-target-fn" 40 | /> 41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /tests/specs/loadingStateTimeout/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import HoverVideoPlayer from "../../../src/HoverVideoPlayer"; 3 | 4 | import { mp4VideoSrc, webmVideoSrc } from "../../constants"; 5 | 6 | export default function LoadingStateTimeoutTestPage(): JSX.Element { 7 | return ( 8 |
9 |

loadingStateTimeout

10 | 20 | Loading 21 |
22 | } 23 | style={{ 24 | aspectRatio: "16/9", 25 | background: "red", 26 | }} 27 | preload="none" 28 | data-testid="hvp:lst-0" 29 | /> 30 | 39 | Loading 40 |
41 | } 42 | style={{ 43 | aspectRatio: "16/9", 44 | background: "red", 45 | }} 46 | preload="none" 47 | data-testid="hvp:lst-200" 48 | /> 49 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /tests/specs/loadingStateTimeout/loadingStateTimeout.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test.beforeEach(async ({ page }) => { 4 | // Apply an artificial delay to all video requests 5 | // to make sure the loading timeout has time to 6 | // kick in 7 | await page.route("**/*.{mp4,webm}", (route) => { 8 | setTimeout(() => { 9 | route.continue(); 10 | }, 300); 11 | }); 12 | 13 | await page.goto('/loadingStateTimeout'); 14 | }); 15 | 16 | test('a loadingStateTimeout of 0 will cause the loading overlay to start fading in immediately', async ({ page }) => { 17 | const hoverVideoPlayer = page.locator('[data-testid="hvp:lst-0"]'); 18 | const loadingOverlayWrapper = hoverVideoPlayer.locator(".loading-overlay-wrapper"); 19 | const video = hoverVideoPlayer.locator('video'); 20 | 21 | await expect(loadingOverlayWrapper).not.toBeVisible(); 22 | await expect(loadingOverlayWrapper).toHaveCSS("transition", "opacity 0.4s ease 0s, visibility 0s ease 0.4s"); 23 | 24 | await hoverVideoPlayer.hover(); 25 | 26 | // Wait for a frame to allow our state to update to start fading the loading overlay in 27 | await page.waitForTimeout(20); 28 | 29 | // The loading overlay should be visible and fading in immediately 30 | const loadingOverlayComputedStyle = await loadingOverlayWrapper.evaluate((el) => getComputedStyle(el)); 31 | expect(loadingOverlayComputedStyle.visibility).toBe("visible"); 32 | expect(Number(loadingOverlayComputedStyle.opacity)).toBeGreaterThan(0); 33 | expect(loadingOverlayComputedStyle.transition).toBe("opacity 0.4s ease 0s"); 34 | 35 | await expect(video).toHaveJSProperty('readyState', 4); 36 | 37 | await expect(loadingOverlayWrapper).not.toBeVisible(); 38 | 39 | // Move the mouse so we're no longer hovering 40 | await page.mouse.move(0, 0); 41 | 42 | await Promise.all([ 43 | expect(video).toHaveJSProperty('paused', true), 44 | expect(loadingOverlayWrapper).not.toBeVisible(), 45 | ]); 46 | }); 47 | 48 | test("a loadingStateTimeout of 300 will cause the loading overlay to start fading in after .3 seconds", async ({ page }) => { 49 | const hoverVideoPlayer = page.locator('[data-testid="hvp:lst-200"]'); 50 | const loadingOverlayWrapper = hoverVideoPlayer.locator(".loading-overlay-wrapper"); 51 | const video = hoverVideoPlayer.locator('video'); 52 | 53 | await expect(loadingOverlayWrapper).not.toBeVisible(); 54 | await expect(loadingOverlayWrapper).toHaveCSS("transition", "opacity 0.4s ease 0s, visibility 0s ease 0.4s"); 55 | 56 | await hoverVideoPlayer.hover(); 57 | 58 | // The loading overlay should have visibility: visible set, but shouldn't fade in immediately 59 | const loadingOverlayComputedStyle = await loadingOverlayWrapper.evaluate((el) => getComputedStyle(el)); 60 | expect(loadingOverlayComputedStyle.visibility).toBe("visible"); 61 | expect(loadingOverlayComputedStyle.opacity).toBe("0"); 62 | expect(loadingOverlayComputedStyle.transition).toBe("opacity 0.4s ease 0.2s"); 63 | 64 | await page.waitForTimeout(200); 65 | 66 | expect(await loadingOverlayWrapper.evaluate((el) => getComputedStyle(el).opacity)).not.toBe("0"); 67 | 68 | await expect(video).toHaveJSProperty('readyState', 4); 69 | 70 | await expect(loadingOverlayWrapper).not.toBeVisible(); 71 | 72 | // Move the mouse so we're no longer hovering 73 | await page.mouse.move(0, 0); 74 | 75 | await Promise.all([ 76 | expect(video).toHaveJSProperty('paused', true), 77 | expect(loadingOverlayWrapper).not.toBeVisible(), 78 | ]); 79 | }); 80 | -------------------------------------------------------------------------------- /tests/specs/overlays/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import HoverVideoPlayer from "../../../src/HoverVideoPlayer"; 3 | 4 | import { mp4VideoSrc, webmVideoSrc } from "../../constants"; 5 | 6 | const PausedOverlay = () => ( 7 |
13 | Paused 14 |
15 | ); 16 | 17 | const LoadingOverlay = () => ( 18 |
24 | Loading 25 |
26 | ); 27 | 28 | const HoverOverlay = () => ( 29 |
35 | Hovering 36 |
37 | ); 38 | 39 | export default function OverlaysTestPage(): JSX.Element { 40 | return ( 41 |
42 |

overlays

43 | } 46 | data-testid="paused-overlay-only" 47 | /> 48 | } 51 | preload="none" 52 | style={{ 53 | aspectRatio: "16/9", 54 | width: 500, 55 | }} 56 | data-testid="loading-overlay-only" 57 | /> 58 | } 61 | data-testid="hover-overlay-only" 62 | /> 63 | } 66 | loadingOverlay={} 67 | hoverOverlay={} 68 | preload="none" 69 | style={{ 70 | aspectRatio: "16/9", 71 | width: 500, 72 | }} 73 | data-testid="all-overlays" 74 | /> 75 |
76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /tests/specs/overlays/overlays.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, Locator } from '@playwright/test'; 2 | 3 | test.beforeEach(async ({ page }) => { 4 | await page.goto('/overlays'); 5 | }); 6 | 7 | async function expectOverlayToBeVisible( 8 | overlayLocator: Locator, 9 | { 10 | message = 'the overlay should be visible', 11 | shouldPoll = false, 12 | transitionDelay = '0s', 13 | } = {} 14 | ) { 15 | const evaluateOverlayTransitionStyles = () => { 16 | return overlayLocator.evaluate((element) => { 17 | if (!element.parentElement) { 18 | throw new Error('No parent element'); 19 | } 20 | return getComputedStyle(element.parentElement).transition; 21 | }); 22 | }; 23 | 24 | return Promise.all([ 25 | expect( 26 | overlayLocator, 27 | `${message}; the overlay should have visibility: visible` 28 | ).toBeVisible(), 29 | (shouldPoll 30 | ? expect.poll( 31 | evaluateOverlayTransitionStyles, 32 | `${message}; timed out waiting for the parent element to get the correct visible transition styles` 33 | ) : expect( 34 | await evaluateOverlayTransitionStyles(), 35 | `${message}; the parent element does not have the correct visible transition styles` 36 | ) 37 | ).toBe(`opacity 0.4s ease ${transitionDelay}`), 38 | ]); 39 | } 40 | 41 | async function expectOverlayToBeHidden( 42 | overlayLocator: Locator, 43 | { 44 | message = 'the overlay should be hidden', 45 | shouldPoll = false, 46 | } = {}, 47 | ) { 48 | const evaluateOverlayTransitionStyles = () => { 49 | return overlayLocator.evaluate((element) => { 50 | if (!element.parentElement) { 51 | throw new Error('No parent element'); 52 | } 53 | return getComputedStyle(element.parentElement).transition; 54 | }); 55 | }; 56 | 57 | return Promise.all([ 58 | expect( 59 | overlayLocator, 60 | `${message}; the overlay should have visibility: hidden` 61 | ).toBeHidden(), 62 | (shouldPoll 63 | ? expect.poll( 64 | evaluateOverlayTransitionStyles, 65 | `${message}; timed out waiting for the parent element to get the correct hidden transition styles` 66 | ) : expect( 67 | await evaluateOverlayTransitionStyles(), 68 | `${message}; the parent element does not have the correct hidden transition styles` 69 | ) 70 | ) 71 | .toBe('opacity 0.4s ease 0s, visibility 0s ease 0.4s'), 72 | ]); 73 | } 74 | 75 | test('pausedOverlay works as expected', async ({ page }) => { 76 | const hoverVideoPlayer = page.locator('[data-testid="paused-overlay-only"]'); 77 | const video = hoverVideoPlayer.locator('video'); 78 | const pausedOverlay = hoverVideoPlayer.locator( 79 | '[data-testid="paused-overlay"]' 80 | ); 81 | 82 | await Promise.all([ 83 | expect(video).toHaveJSProperty('paused', true), 84 | expectOverlayToBeVisible(pausedOverlay), 85 | ]); 86 | 87 | await hoverVideoPlayer.hover(); 88 | 89 | await Promise.all([ 90 | expect(video).toHaveJSProperty('paused', false), 91 | expectOverlayToBeHidden(pausedOverlay), 92 | ]); 93 | 94 | // Move the mouse so we're no longer hovering 95 | await page.mouse.move(0, 0); 96 | 97 | await Promise.all([ 98 | expect(video).toHaveJSProperty('paused', true), 99 | expectOverlayToBeVisible(pausedOverlay), 100 | ]); 101 | }); 102 | 103 | test('loadingOverlay works as expected', async ({ page }) => { 104 | // Apply an artificial delay to all video requests 105 | // to make sure the loading timeout has time to 106 | // kick in 107 | await page.route("**/*.{mp4,webm}", (route) => { 108 | setTimeout(() => { 109 | route.continue(); 110 | }, 300); 111 | }); 112 | 113 | const hoverVideoPlayer = page.locator('[data-testid="loading-overlay-only"]'); 114 | const video = hoverVideoPlayer.locator('video'); 115 | const loadingOverlay = hoverVideoPlayer.locator( 116 | '[data-testid="loading-overlay"]' 117 | ); 118 | 119 | await Promise.all([ 120 | // The video should be paused 121 | expect(video).toHaveJSProperty('paused', true), 122 | expect( 123 | await video.evaluate((element: HTMLVideoElement) => element.readyState), 124 | 'the video should not be loaded yet' 125 | ).toBeLessThan(4), 126 | // The loading overlay should be hidden initially 127 | expectOverlayToBeHidden(loadingOverlay), 128 | ]); 129 | 130 | await hoverVideoPlayer.hover(); 131 | 132 | await Promise.all([ 133 | // The video is loading 134 | expect(video).toHaveJSProperty('paused', false), 135 | expect( 136 | await video.evaluate((element: HTMLVideoElement) => element.readyState), 137 | 'the video should be loading' 138 | ).toBeLessThan(4), 139 | // The loading overlay should be visible 140 | expectOverlayToBeVisible(loadingOverlay, { 141 | transitionDelay: '0.2s', 142 | shouldPoll: true, 143 | }), 144 | ]); 145 | 146 | await Promise.all([ 147 | // The video is now playing 148 | expect(video).toHaveJSProperty('paused', false), 149 | expect(video).toHaveJSProperty('readyState', 4), 150 | ]); 151 | 152 | // The loading overlay should be hidden again 153 | await expectOverlayToBeHidden(loadingOverlay); 154 | 155 | // Move the mouse so we're no longer hovering 156 | await page.mouse.move(0, 0); 157 | 158 | await Promise.all([ 159 | expect(video).toHaveJSProperty('paused', true), 160 | expectOverlayToBeHidden(loadingOverlay), 161 | ]); 162 | }); 163 | 164 | test('hoverOverlay works as expected', async ({ page }) => { 165 | const hoverVideoPlayer = page.locator('[data-testid="hover-overlay-only"]'); 166 | const hoverOverlay = hoverVideoPlayer.locator( 167 | '[data-testid="hover-overlay"]' 168 | ); 169 | 170 | await expectOverlayToBeHidden(hoverOverlay); 171 | 172 | await hoverVideoPlayer.hover(); 173 | 174 | await expectOverlayToBeVisible(hoverOverlay); 175 | 176 | // Move the mouse so we're no longer hovering 177 | await page.mouse.move(0, 0); 178 | 179 | await expectOverlayToBeHidden(hoverOverlay); 180 | }); 181 | 182 | test('all overlays work together as expected', async ({ page }) => { 183 | // Apply an artificial delay to all video requests 184 | // to make sure the loading timeout has time to 185 | // kick in 186 | await page.route("**/*.{mp4,webm}", (route) => { 187 | setTimeout(() => { 188 | route.continue(); 189 | }, 300); 190 | }); 191 | 192 | const hoverVideoPlayer = page.locator('[data-testid="all-overlays"]'); 193 | const video = hoverVideoPlayer.locator('video'); 194 | const pausedOverlay = hoverVideoPlayer.locator( 195 | '[data-testid="paused-overlay"]' 196 | ); 197 | const loadingOverlay = hoverVideoPlayer.locator( 198 | '[data-testid="loading-overlay"]' 199 | ); 200 | const hoverOverlay = hoverVideoPlayer.locator( 201 | '[data-testid="hover-overlay"]' 202 | ); 203 | 204 | await Promise.all([ 205 | expectOverlayToBeVisible(pausedOverlay, { 206 | message: "the paused overlay should be visible initially" 207 | }), 208 | expectOverlayToBeHidden(loadingOverlay, { 209 | message: "the loading overlay should be hidden initially" 210 | }), 211 | expectOverlayToBeHidden(hoverOverlay, { 212 | message: "the hover overlay should be hidden initially" 213 | }), 214 | ]); 215 | 216 | await hoverVideoPlayer.hover(); 217 | 218 | await Promise.all([ 219 | expectOverlayToBeVisible(pausedOverlay, { 220 | message: "the paused overlay should stay visible while the video is loading" 221 | }), 222 | // The loading overlay should be "visible", but have 0 opacity until the loading timeout elapses 223 | expectOverlayToBeVisible(loadingOverlay, { 224 | message: "the loading overlay should be visible, although its opacity is 0", 225 | transitionDelay: '0.2s', 226 | }), 227 | expectOverlayToBeVisible(hoverOverlay, { 228 | message: "the hover overlay should be visible while the video is loading" 229 | }), 230 | ]); 231 | 232 | // The video is done loading and should be playing now! 233 | await expect(video).toHaveJSProperty('readyState', 4); 234 | 235 | await Promise.all([ 236 | expectOverlayToBeHidden(pausedOverlay, { 237 | message: "the paused overlay should be hidden once the video is playing", 238 | shouldPoll: true, 239 | }), 240 | expectOverlayToBeHidden(loadingOverlay, { 241 | message: "the loading overlay should be hidden once the video is playing", 242 | shouldPoll: true, 243 | }), 244 | expectOverlayToBeVisible(hoverOverlay, { 245 | message: "the hover overlay should stay visible once the video is playing" 246 | }), 247 | ]); 248 | 249 | // Move the mouse so we're no longer hovering 250 | await page.mouse.move(0, 0); 251 | 252 | await Promise.all([ 253 | expectOverlayToBeVisible(pausedOverlay), 254 | expectOverlayToBeHidden(loadingOverlay), 255 | expectOverlayToBeHidden(hoverOverlay), 256 | ]); 257 | }); 258 | -------------------------------------------------------------------------------- /tests/specs/playback/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import HoverVideoPlayer from '../../../src/HoverVideoPlayer'; 3 | 4 | import { mp4VideoSrc } from '../../constants'; 5 | 6 | export default function PlaybackTestPage(): JSX.Element { 7 | return ( 8 |
9 |

playback

10 | 11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /tests/specs/playback/playback-focus.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | import { mp4VideoSrcURL } from '../../constants'; 4 | 5 | test.beforeEach(async ({ page }) => { 6 | await page.goto('/playback'); 7 | }); 8 | 9 | test('plays correctly when the user focuses the player with their keyboard', async ({ 10 | page, 11 | }) => { 12 | const hoverVideoPlayer = await page.locator('[data-testid="hvp"]'); 13 | const video = await hoverVideoPlayer.locator('video'); 14 | 15 | await expect(video).toHaveJSProperty('paused', true); 16 | 17 | await hoverVideoPlayer.focus(); 18 | 19 | await Promise.all([ 20 | expect(video).toHaveJSProperty('paused', false), 21 | expect(video).toHaveJSProperty('currentSrc', mp4VideoSrcURL), 22 | expect(video).toHaveJSProperty('readyState', 4), 23 | expect(video).not.toHaveJSProperty('currentTime', 0), 24 | ]); 25 | 26 | // Move the mouse so we're no longer hovering 27 | await hoverVideoPlayer.blur(); 28 | 29 | await expect(video).toHaveJSProperty('paused', true); 30 | }); 31 | -------------------------------------------------------------------------------- /tests/specs/playback/playback-mouse.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | import { mp4VideoSrcURL } from '../../constants'; 4 | 5 | test.beforeEach(async ({ page }) => { 6 | await page.goto('/playback'); 7 | }); 8 | 9 | test('plays correctly when the user hovers with their mouse', async ({ 10 | page, 11 | }) => { 12 | const hoverVideoPlayer = await page.locator('[data-testid="hvp"]'); 13 | const video = await hoverVideoPlayer.locator('video'); 14 | 15 | await expect(video).toHaveJSProperty('paused', true); 16 | 17 | await hoverVideoPlayer.hover(); 18 | 19 | await Promise.all([ 20 | expect(video).toHaveJSProperty('paused', false), 21 | expect(video).toHaveJSProperty('currentSrc', mp4VideoSrcURL), 22 | expect(video).toHaveJSProperty('readyState', 4), 23 | expect(video).not.toHaveJSProperty('currentTime', 0), 24 | ]); 25 | 26 | // Move the mouse so we're no longer hovering 27 | await page.mouse.move(0, 0); 28 | 29 | await expect(video).toHaveJSProperty('paused', true); 30 | }); 31 | -------------------------------------------------------------------------------- /tests/specs/playback/playback-touch.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | import { mp4VideoSrcURL } from '../../constants'; 4 | 5 | test.use({ 6 | hasTouch: true, 7 | }); 8 | 9 | test.beforeEach(async ({ page }) => { 10 | await page.goto('/playback'); 11 | }); 12 | 13 | test('plays correctly when the user hovers with a touchscreen tap', async ({ 14 | page, 15 | }) => { 16 | const hoverVideoPlayer = await page.locator('[data-testid="hvp"]'); 17 | const video = await hoverVideoPlayer.locator('video'); 18 | 19 | await expect(video).toHaveJSProperty('paused', true); 20 | 21 | // Tap on the player to start playing 22 | await hoverVideoPlayer.tap(); 23 | 24 | await Promise.all([ 25 | expect(video).toHaveJSProperty('paused', false), 26 | expect(video).toHaveJSProperty('currentSrc', mp4VideoSrcURL), 27 | expect(video).toHaveJSProperty('readyState', 4), 28 | expect(video).not.toHaveJSProperty('currentTime', 0), 29 | ]); 30 | 31 | // Tap on another element outside of the player to stop playing 32 | await page.tap('h1'); 33 | 34 | await expect(video).toHaveJSProperty('paused', true); 35 | }); 36 | -------------------------------------------------------------------------------- /tests/specs/playbackRange/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import HoverVideoPlayer from "../../../src/HoverVideoPlayer"; 3 | 4 | import { mp4VideoSrc } from "../../constants"; 5 | 6 | export default function PlaybackRangeTestPage(): JSX.Element { 7 | return ( 8 |
9 |

playbackRange

10 | 15 | 22 | 27 | 34 | 40 | 48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /tests/specs/playbackRange/playbackRange.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test.beforeEach(async ({ page }) => { 4 | await page.goto('/playbackRange'); 5 | }); 6 | 7 | test('starts from playbackRangeStart time and loops back to that time from the end', async ({ 8 | page, 9 | }) => { 10 | const hoverVideoPlayer = await page.locator('[data-testid="hvp:startOnly-loop"]'); 11 | const video = await hoverVideoPlayer.locator('video'); 12 | 13 | await Promise.all([ 14 | expect(video).toHaveJSProperty('paused', true), 15 | expect(video).toHaveJSProperty('currentTime', 9.5), 16 | ]); 17 | 18 | await hoverVideoPlayer.hover(); 19 | 20 | await expect(video).toHaveJSProperty('paused', false); 21 | 22 | // Wait for the video to get to the end 23 | await expect.poll(() => video.evaluate((video: HTMLVideoElement) => video.currentTime), { 24 | // We're waiting for the video to play for ~0.5s, so we'll wait 300ms for the firt poll and then 25 | // poll every 20ms to make sure we don't miss it 26 | intervals: [300, 20], 27 | }).toBeCloseTo(10); 28 | 29 | // The video should loop back to the start 30 | await expect.poll(() => video.evaluate((video: HTMLVideoElement) => video.currentTime), { 31 | intervals: [20], 32 | }).toBeCloseTo(9.5); 33 | 34 | await page.mouse.move(0, 0); 35 | 36 | await expect(video).toHaveJSProperty('paused', true); 37 | }); 38 | 39 | test('starts from playbackRangeStart time and returns to that time if restartOnPaused is set', async ({ page }) => { 40 | const hoverVideoPlayer = await page.locator('[data-testid="hvp:startOnly-restart"]'); 41 | const video = await hoverVideoPlayer.locator('video'); 42 | 43 | await Promise.all([ 44 | expect(video).toHaveJSProperty('paused', true), 45 | expect(video).toHaveJSProperty('currentTime', 9.5), 46 | ]); 47 | 48 | await hoverVideoPlayer.hover(); 49 | 50 | // Allow the video to play through to the end 51 | await Promise.all([ 52 | expect(video).toHaveJSProperty('ended', true), 53 | expect(video).toHaveJSProperty('paused', false), 54 | ]); 55 | 56 | await expect(await video.evaluate((videoElement: HTMLVideoElement) => videoElement.currentTime)).toBeGreaterThanOrEqual(10); 57 | 58 | await page.mouse.move(0, 0); 59 | 60 | // The video is reset to the correct time 61 | await Promise.all([ 62 | expect(video).toHaveJSProperty('paused', true), 63 | expect(video).toHaveJSProperty('currentTime', 9.5), 64 | ]); 65 | }); 66 | 67 | test('stops at playbackRangeEnd time and loops back to the start', async ({ page }) => { 68 | const hoverVideoPlayer = await page.locator('[data-testid="hvp:endOnly-loop"]'); 69 | const video = await hoverVideoPlayer.locator('video'); 70 | 71 | await Promise.all([ 72 | expect(video).toHaveJSProperty('paused', true), 73 | expect(video).toHaveJSProperty('currentTime', 0), 74 | ]); 75 | 76 | await hoverVideoPlayer.hover(); 77 | 78 | await expect(video).toHaveJSProperty('paused', false); 79 | 80 | // Wait for the video to get to the end 81 | await expect.poll(() => video.evaluate((video: HTMLVideoElement) => video.currentTime), { 82 | // We're waiting for the video to play for ~0.5s, so we'll wait 300ms for the firt poll and then 83 | // poll every 20ms to make sure we don't miss it 84 | intervals: [300, 20], 85 | }).toBeCloseTo(0.5); 86 | 87 | // The video should loop back to the start 88 | await expect.poll(() => video.evaluate((video: HTMLVideoElement) => video.currentTime), { 89 | intervals: [20], 90 | }).toBeCloseTo(0); 91 | 92 | await page.mouse.move(0, 0); 93 | 94 | await expect(video).toHaveJSProperty('paused', true); 95 | }); 96 | 97 | test('stops at playbackRangeEnd time if loop is false', async ({ page }) => { 98 | const hoverVideoPlayer = await page.locator('[data-testid="hvp:endOnly-restart"]'); 99 | const video = await hoverVideoPlayer.locator('video'); 100 | 101 | await Promise.all([ 102 | expect(video).toHaveJSProperty('paused', true), 103 | expect(video).toHaveJSProperty('currentTime', 0), 104 | ]); 105 | 106 | await hoverVideoPlayer.hover(); 107 | 108 | await expect(video).toHaveJSProperty('paused', false); 109 | 110 | // Wait for the video to reach the end of the playback range and pause 111 | await expect(video).toHaveJSProperty('paused', true); 112 | await expect(video).toHaveJSProperty('currentTime', 0.5); 113 | 114 | await page.mouse.move(0, 0); 115 | 116 | await Promise.all([ 117 | expect(video).toHaveJSProperty('paused', true), 118 | expect(video).toHaveJSProperty('currentTime', 0), 119 | ]); 120 | }); 121 | 122 | test('loops between playbackRangeStart time and playbackRangeEnd time', async ({ page }) => { 123 | const hoverVideoPlayer = await page.locator('[data-testid="hvp:startAndEnd-loop"]'); 124 | const video = await hoverVideoPlayer.locator('video'); 125 | 126 | await Promise.all([ 127 | expect(video).toHaveJSProperty('paused', true), 128 | expect(video).toHaveJSProperty('currentTime', 1), 129 | ]); 130 | 131 | await hoverVideoPlayer.hover(); 132 | 133 | await expect(video).toHaveJSProperty('paused', false); 134 | 135 | // Wait for the video to get to the end 136 | await expect.poll(() => video.evaluate((video: HTMLVideoElement) => video.currentTime), { 137 | // We're waiting for the video to play for ~0.5s, so we'll wait 300ms for the first poll and then 138 | // poll every 20ms to make sure we don't miss it 139 | intervals: [300, 20], 140 | }).toBeCloseTo(1.5); 141 | 142 | // The video should loop back to the start 143 | await expect.poll(() => video.evaluate((video: HTMLVideoElement) => video.currentTime), { 144 | intervals: [20], 145 | }).toBeCloseTo(1); 146 | 147 | await page.mouse.move(0, 0); 148 | 149 | await expect(video).toHaveJSProperty('paused', true); 150 | }); 151 | 152 | test('stops at playbackRangeEnd and restarts at playbackRangeStart', async ({ page }) => { 153 | const hoverVideoPlayer = await page.locator('[data-testid="hvp:startAndEnd-restart"]'); 154 | const video = await hoverVideoPlayer.locator('video'); 155 | 156 | await Promise.all([ 157 | expect(video).toHaveJSProperty('paused', true), 158 | expect(video).toHaveJSProperty('currentTime', 1), 159 | ]); 160 | 161 | await hoverVideoPlayer.hover(); 162 | 163 | await expect(video).toHaveJSProperty('paused', false); 164 | 165 | // Wait for the video to reach the end of th playback range and pause 166 | await expect(video).toHaveJSProperty('paused', true); 167 | expect(await video.evaluate((videoElement: HTMLVideoElement) => videoElement.currentTime)).toBe(1.5); 168 | 169 | await page.mouse.move(0, 0); 170 | 171 | await Promise.all([ 172 | expect(video).toHaveJSProperty('paused', true), 173 | expect(video).toHaveJSProperty('currentTime', 1), 174 | ]); 175 | }); 176 | -------------------------------------------------------------------------------- /tests/specs/playbackStartDelay/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import HoverVideoPlayer from "../../../src/HoverVideoPlayer"; 3 | 4 | import { mp4VideoSrc } from "../../constants"; 5 | 6 | export default function PlaybackStartDelayTestPage(): JSX.Element { 7 | return ( 8 |
9 |

playbackStartDelay

10 | loading
} 15 | overlayTransitionDuration={100} 16 | preload="none" 17 | data-testid="hvp" 18 | /> 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /tests/specs/playbackStartDelay/playbackStartDelay.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test.beforeEach(async ({ page }) => { 4 | await page.goto('/playbackStartDelay'); 5 | }); 6 | 7 | test('playbackStartDelay adds a delay before the video starts loading and playing', async ({ 8 | page, 9 | }) => { 10 | const hoverVideoPlayer = await page.locator('[data-testid="hvp"]'); 11 | const video = await hoverVideoPlayer.locator('video'); 12 | const loadingOverlayWrapper = await hoverVideoPlayer.locator('.loading-overlay-wrapper'); 13 | 14 | await Promise.all([ 15 | expect(video).toHaveJSProperty('paused', true), 16 | expect(video).toHaveJSProperty('readyState', 0), 17 | expect(loadingOverlayWrapper).not.toBeVisible(), 18 | ]); 19 | 20 | await hoverVideoPlayer.hover(); 21 | 22 | await Promise.all([ 23 | expect(video).toHaveJSProperty('paused', true), 24 | expect(video).toHaveJSProperty('readyState', 0), 25 | expect(loadingOverlayWrapper).toBeVisible(), 26 | ]); 27 | 28 | await Promise.all([ 29 | expect(video).toHaveJSProperty('paused', false), 30 | expect(video).not.toHaveJSProperty('readyState', 0), 31 | expect(loadingOverlayWrapper).not.toBeVisible(), 32 | ]); 33 | 34 | await page.mouse.move(0, 0); 35 | 36 | await expect(video).toHaveJSProperty('paused', true); 37 | }); 38 | -------------------------------------------------------------------------------- /tests/specs/sizingMode/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import HoverVideoPlayer from "../../../src/HoverVideoPlayer"; 4 | 5 | import { mp4VideoSrc } from "../../constants"; 6 | 7 | const PausedOverlay = (props: React.ComponentPropsWithoutRef<"div">) => ( 8 |
Paused
9 | ); 10 | 11 | export default function SizingModeTestPage(): JSX.Element { 12 | return ( 13 |
18 |

sizingMode

19 | 28 | } 29 | data-testid="hvp:sizingMode-video" 30 | /> 31 | 42 | } 43 | sizingMode="overlay" 44 | data-testid="hvp:sizingMode-overlay" 45 | /> 46 | 59 | } 60 | sizingMode="container" 61 | data-testid="hvp:sizingMode-container" 62 | /> 63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /tests/specs/sizingMode/sizingMode.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test.beforeEach(async ({ page }) => { 4 | await page.goto('/sizingMode'); 5 | }); 6 | 7 | test('sizingMode="video" sizes the player relative to the video dimensions', async ({ 8 | page, 9 | }) => { 10 | const hoverVideoPlayer = page.locator('[data-testid="hvp:sizingMode-video"]'); 11 | const video = hoverVideoPlayer.locator('video'); 12 | const pausedOverlayWrapper = hoverVideoPlayer.locator('.paused-overlay-wrapper'); 13 | 14 | const { videoWidth, videoHeight } = await video.evaluate((videoElement: HTMLVideoElement) => ({ 15 | videoWidth: videoElement.videoWidth, 16 | videoHeight: videoElement.videoHeight, 17 | })); 18 | 19 | const aspectRatio = videoWidth / videoHeight; 20 | 21 | const expectedWidth = "400px"; 22 | const expectedHeight = `${400 / aspectRatio}px`; 23 | 24 | await Promise.all([ 25 | expect(video).toHaveCSS('width', expectedWidth), 26 | expect(video).toHaveCSS('height', expectedHeight), 27 | expect(hoverVideoPlayer).toHaveCSS('width', expectedWidth), 28 | expect(hoverVideoPlayer).toHaveCSS('height', expectedHeight), 29 | expect(pausedOverlayWrapper).toHaveCSS('width', expectedWidth), 30 | expect(pausedOverlayWrapper).toHaveCSS('height', expectedHeight), 31 | ]); 32 | }); 33 | 34 | test('sizingMode="overlay" sizes the player relative to the paused overlay dimensions', async ({ page }) => { 35 | const hoverVideoPlayer = page.locator('[data-testid="hvp:sizingMode-overlay"]'); 36 | const video = hoverVideoPlayer.locator('video'); 37 | const pausedOverlayWrapper = hoverVideoPlayer.locator('.paused-overlay-wrapper'); 38 | 39 | const expectedWidth = "200px"; 40 | const expectedHeight = "200px"; 41 | 42 | await Promise.all([ 43 | expect(video).toHaveCSS('width', expectedWidth), 44 | expect(video).toHaveCSS('height', expectedHeight), 45 | expect(hoverVideoPlayer).toHaveCSS('width', expectedWidth), 46 | expect(hoverVideoPlayer).toHaveCSS('height', expectedHeight), 47 | expect(pausedOverlayWrapper).toHaveCSS('width', expectedWidth), 48 | expect(pausedOverlayWrapper).toHaveCSS('height', expectedHeight), 49 | ]); 50 | }); 51 | 52 | test('sizingMode="container" sizes the player relative to the container dimensions', async ({ page }) => { 53 | const hoverVideoPlayer = page.locator('[data-testid="hvp:sizingMode-container"]'); 54 | const video = hoverVideoPlayer.locator('video'); 55 | const pausedOverlayWrapper = hoverVideoPlayer.locator('.paused-overlay-wrapper'); 56 | 57 | const expectedWidth = "123px"; 58 | const expectedHeight = "456px"; 59 | 60 | await Promise.all([ 61 | expect(video).toHaveCSS('width', expectedWidth), 62 | expect(video).toHaveCSS('height', expectedHeight), 63 | expect(hoverVideoPlayer).toHaveCSS('width', expectedWidth), 64 | expect(hoverVideoPlayer).toHaveCSS('height', expectedHeight), 65 | expect(pausedOverlayWrapper).toHaveCSS('width', expectedWidth), 66 | expect(pausedOverlayWrapper).toHaveCSS('height', expectedHeight), 67 | ]); 68 | }); 69 | -------------------------------------------------------------------------------- /tests/specs/unloadVideoOnPause/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import HoverVideoPlayer from "../../../src/HoverVideoPlayer"; 4 | 5 | import { mp4VideoSrc } from "../../constants"; 6 | 7 | export default function UnloadVideoOnPauseTestPage(): JSX.Element { 8 | return ( 9 |
10 |

unloadVideoOnPause

11 | 20 | } 22 | unloadVideoOnPaused 23 | data-testid="hvp:sourceElement" 24 | style={{ 25 | aspectRatio: "16 / 9", 26 | background: "yellow", 27 | }} 28 | /> 29 | hello!!!
} 33 | pausedOverlayWrapperClassName="paused-overlay-wrapper" 34 | data-testid="hvp:pausedOverlay" 35 | style={{ 36 | aspectRatio: "16 / 9", 37 | background: "magenta", 38 | }} 39 | /> 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /tests/specs/unloadVideoOnPause/unloadVideoOnPause.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | import { mp4VideoSrc, mp4VideoSrcURL } from '../../constants'; 4 | 5 | test.beforeEach(async ({ page }) => { 6 | await page.goto('/unloadVideoOnPause'); 7 | }); 8 | 9 | test("unloads the video's string src on pause", async ({ 10 | page, 11 | }) => { 12 | const hoverVideoPlayer = await page.locator('[data-testid="hvp:stringSrc"]'); 13 | const video = await hoverVideoPlayer.locator('video'); 14 | 15 | await Promise.all([ 16 | expect(await video.getAttribute("src")).toBeNull(), 17 | expect(video).toHaveJSProperty("currentSrc", ""), 18 | expect(video).toHaveJSProperty("readyState", 0), 19 | ]); 20 | 21 | await hoverVideoPlayer.hover(); 22 | 23 | await Promise.all([ 24 | expect(video).toHaveAttribute("src", mp4VideoSrc), 25 | expect(video).toHaveJSProperty("currentSrc", mp4VideoSrcURL), 26 | expect(video).not.toHaveJSProperty("currentTime", 0), 27 | expect(video).not.toHaveJSProperty("readyState", 0) 28 | ]); 29 | 30 | // Mouse out to stop playing and unload the video 31 | await page.mouse.move(0, 0); 32 | 33 | // The video should be unloaded 34 | await Promise.all([ 35 | expect(await video.getAttribute("src")).toBeNull(), 36 | // currentSrc doesn't get cleared when a video src is unloaded 37 | expect(video).toHaveJSProperty("currentSrc", mp4VideoSrcURL), 38 | expect(video).toHaveJSProperty("currentTime", 0), 39 | expect(video).toHaveJSProperty("readyState", 0), 40 | ]); 41 | 42 | // Mouse back in to start playing the video from the point it was at before 43 | await hoverVideoPlayer.hover(); 44 | 45 | await Promise.all([ 46 | // The video should be at a time > 0 right away 47 | expect(await video.evaluate((videoElement: HTMLVideoElement) => videoElement.currentTime)).toBeGreaterThan(0), 48 | expect(video).toHaveAttribute("src", mp4VideoSrc), 49 | expect(video).toHaveJSProperty("currentSrc", mp4VideoSrcURL), 50 | expect(video).not.toHaveJSProperty("readyState", 0) 51 | ]); 52 | 53 | // Mouse out to stop playing and unload the video 54 | await page.mouse.move(0, 0); 55 | 56 | // The video should be unloaded again 57 | await Promise.all([ 58 | expect(await video.getAttribute("src")).toBeNull(), 59 | // currentSrc doesn't get cleared when a video src is unloaded 60 | expect(video).toHaveJSProperty("currentSrc", mp4VideoSrcURL), 61 | expect(video).toHaveJSProperty("currentTime", 0), 62 | expect(video).toHaveJSProperty("readyState", 0), 63 | ]); 64 | }); 65 | 66 | test("unloads the video's source element src on pause", async ({ page }) => { 67 | const hoverVideoPlayer = await page.locator('[data-testid="hvp:sourceElement"]'); 68 | const video = await hoverVideoPlayer.locator('video'); 69 | const source = await hoverVideoPlayer.locator('source'); 70 | 71 | await Promise.all([ 72 | expect(source).toHaveCount(0), 73 | expect(video).toHaveJSProperty("currentSrc", ""), 74 | expect(video).toHaveJSProperty("readyState", 0), 75 | ]); 76 | 77 | await hoverVideoPlayer.hover(); 78 | 79 | await Promise.all([ 80 | expect(source).toHaveCount(1), 81 | expect(video).toHaveJSProperty("currentSrc", mp4VideoSrcURL), 82 | expect(video).not.toHaveJSProperty("currentTime", 0), 83 | expect(video).not.toHaveJSProperty("readyState", 0) 84 | ]); 85 | 86 | // Mouse out to stop playing and unload the video 87 | await page.mouse.move(0, 0); 88 | 89 | // The video should be unloaded 90 | await Promise.all([ 91 | expect(source).toHaveCount(0), 92 | // currentSrc doesn't get cleared when a video src is unloaded 93 | expect(video).toHaveJSProperty("currentSrc", mp4VideoSrcURL), 94 | expect(video).toHaveJSProperty("currentTime", 0), 95 | expect(video).toHaveJSProperty("readyState", 0), 96 | ]); 97 | 98 | // Mouse back in to start playing the video from the point it was at before 99 | await hoverVideoPlayer.hover(); 100 | 101 | await Promise.all([ 102 | // The video should be at a time > 0 right away 103 | expect(await video.evaluate((videoElement: HTMLVideoElement) => videoElement.currentTime)).toBeGreaterThan(0), 104 | expect(source).toHaveCount(1), 105 | expect(video).toHaveJSProperty("currentSrc", mp4VideoSrcURL), 106 | expect(video).not.toHaveJSProperty("readyState", 0) 107 | ]); 108 | 109 | // Mouse out to stop playing and unload the video 110 | await page.mouse.move(0, 0); 111 | 112 | // The video should be unloaded again 113 | await Promise.all([ 114 | expect(await video.getAttribute("src")).toBeNull(), 115 | // currentSrc doesn't get cleared when a video src is unloaded 116 | expect(video).toHaveJSProperty("currentSrc", mp4VideoSrcURL), 117 | expect(video).toHaveJSProperty("currentTime", 0), 118 | expect(video).toHaveJSProperty("readyState", 0), 119 | ]); 120 | }); 121 | 122 | test("delays unloading if there is a pausedOverlay", async ({ page }) => { 123 | const hoverVideoPlayer = await page.locator('[data-testid="hvp:pausedOverlay"]'); 124 | const video = await hoverVideoPlayer.locator('video'); 125 | const pausedOverlayWrapper = await hoverVideoPlayer.locator(".paused-overlay-wrapper"); 126 | 127 | await Promise.all([ 128 | expect(await video.getAttribute("src")).toBeNull(), 129 | expect(video).toHaveJSProperty("currentSrc", ""), 130 | expect(video).toHaveJSProperty("readyState", 0), 131 | expect(pausedOverlayWrapper).toHaveCSS("opacity", "1"), 132 | ]); 133 | 134 | await hoverVideoPlayer.hover(); 135 | 136 | await Promise.all([ 137 | expect(video).toHaveAttribute("src", mp4VideoSrc), 138 | expect(video).toHaveJSProperty("currentSrc", mp4VideoSrcURL), 139 | expect(video).toHaveJSProperty("readyState", 4), 140 | expect(video).not.toHaveJSProperty("currentTime", 0), 141 | ]); 142 | 143 | // Wait for the paused overlay to fade out all the way 144 | await expect(pausedOverlayWrapper).toHaveCSS("opacity", "0"); 145 | 146 | // Mouse out to stop playing and unload the video 147 | await page.mouse.move(0, 0); 148 | 149 | await Promise.all([ 150 | // The paused overlay should not be fully faded in yet, so the video should not be unloaded yet 151 | expect(pausedOverlayWrapper).not.toHaveCSS("opacity", "1"), 152 | // expect(await pausedOverlayWrapper.evaluate((overlay) => Number(getComputedStyle(overlay.parentElement as HTMLElement).opacity))).toBeLessThan(1), 153 | expect(video).toHaveAttribute("src", mp4VideoSrc), 154 | expect(video).not.toHaveJSProperty("readyState", 0), 155 | ]); 156 | 157 | // Wait for the paused overlay to fade all the way back in 158 | // await expect.poll(() => pausedOverlayWrapper.evaluate((overlay) => Number(getComputedStyle(overlay.parentElement as HTMLElement).opacity))).toBe(1); 159 | await expect(pausedOverlayWrapper).toHaveCSS("opacity", "1"); 160 | 161 | // The video should be unloaded now 162 | await Promise.all([ 163 | expect(await video.getAttribute("src")).toBeNull(), 164 | // currentSrc doesn't get cleared when a video src is unloaded 165 | expect(video).toHaveJSProperty("currentSrc", mp4VideoSrcURL), 166 | expect(video).toHaveJSProperty("currentTime", 0), 167 | expect(video).toHaveJSProperty("readyState", 0), 168 | ]); 169 | }); 170 | -------------------------------------------------------------------------------- /tests/specs/videoCaptions/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import HoverVideoPlayer from "../../../src/HoverVideoPlayer"; 3 | 4 | import { mp4VideoSrc, captionsSrc, gaelicSubtitlesSrc } from "../../constants"; 5 | 6 | export default function VideoCaptionsTestPage(): JSX.Element { 7 | return ( 8 |
9 |

videoCaptions

10 | 20 | } 21 | data-testid="hvp:one-caption-track" 22 | /> 23 | 27 | 33 | 39 | 40 | } 41 | data-testid="hvp:multiple-caption-tracks" 42 | /> 43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /tests/specs/videoCaptions/videoCaptions.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, Locator } from '@playwright/test'; 2 | 3 | test.beforeEach(async ({ page }) => { 4 | await page.goto('/videoCaptions'); 5 | }); 6 | 7 | async function getVideoTextTracks(videoLocator: Locator) { 8 | return videoLocator.evaluate((video: HTMLVideoElement) => { 9 | // TextTrackList, TextTrack, TextTrackCueList, and VTTCue instances all 10 | // don't get serialized to JSON correctly, 11 | // so we need to manually convert them to plain objects before returning them 12 | return Array.from(video.textTracks).map((track) => ({ 13 | activeCues: track.activeCues && Array.from(track.activeCues).map((cue) => { 14 | const { startTime, endTime, text } = cue as VTTCue; 15 | return ({ 16 | startTime, 17 | endTime, 18 | text, 19 | }); 20 | }), 21 | cues: track.cues && Array.from(track.cues).map((cue) => { 22 | const { startTime, endTime, text } = cue as VTTCue; 23 | return ({ 24 | startTime, 25 | endTime, 26 | text, 27 | }); 28 | }), 29 | mode: track.mode, 30 | kind: track.kind, 31 | })); 32 | }); 33 | } 34 | 35 | test('loads single caption track as expected', async ({ page }) => { 36 | const hoverVideoPlayer = await page.locator( 37 | '[data-testid="hvp:one-caption-track"]' 38 | ); 39 | const video = await hoverVideoPlayer.locator('video'); 40 | const trackElement = await video.locator('track'); 41 | 42 | // The video should have a single track 43 | await expect(trackElement).toHaveCount(1); 44 | 45 | const loadedTextTracks = await getVideoTextTracks(video); 46 | 47 | expect(loadedTextTracks.length, "We should have a text track loaded in the video").toBe(1); 48 | 49 | const loadedTrack = loadedTextTracks[0]; 50 | 51 | expect(loadedTrack.mode).toBe('showing'); 52 | expect(loadedTrack.kind).toBe('captions'); 53 | expect(loadedTrack.activeCues, "No cues should be active yet").toHaveLength(0); 54 | 55 | expect(loadedTrack.cues).toHaveLength(3); 56 | 57 | if (!loadedTrack.cues) { 58 | throw new Error("Cues should not be null"); 59 | } 60 | const [cue1, cue2, cue3] = loadedTrack.cues; 61 | 62 | expect(cue1.startTime).toBe(0.5); 63 | expect(cue1.endTime).toBe(3); 64 | expect(cue1.text).toBe('This is some caption text'); 65 | 66 | expect(cue2.startTime).toBe(4); 67 | expect(cue2.endTime).toBe(6.5); 68 | expect(cue2.text).toBe('I hope you can read it...'); 69 | 70 | expect(cue3.startTime).toBe(6.7); 71 | expect(cue3.endTime).toBe(9); 72 | expect(cue3.text).toBe( 73 | "...because otherwise that means this doesn't work" 74 | ); 75 | 76 | // Jump the video ahead to a point where a cue should be active 77 | await video.evaluate((videoElement: HTMLVideoElement) => { 78 | videoElement.currentTime = 2; 79 | }); 80 | await expect.poll(() => video.evaluate((videoElement: HTMLVideoElement) => (videoElement.textTracks[0].activeCues?.[0] as VTTCue)?.text)).toBe("This is some caption text"); 81 | }); 82 | 83 | test('loads multiple caption tracks as expected', async ({ page }) => { 84 | const hoverVideoPlayer = await page.locator( 85 | '[data-testid="hvp:multiple-caption-tracks"]' 86 | ); 87 | const video = await hoverVideoPlayer.locator('video'); 88 | const trackElements = await video.locator('track'); 89 | 90 | // The video should have 2 tracks 91 | await expect(trackElements).toHaveCount(2); 92 | 93 | let loadedTextTracks = await getVideoTextTracks(video); 94 | 95 | expect(loadedTextTracks, "We should have 2 text tracks available on the video").toHaveLength(2); 96 | 97 | let [track1, track2] = loadedTextTracks; 98 | 99 | expect(track1.mode, "The first track should be disabled initially").toBe('disabled'); 100 | expect(track1.kind).toBe('captions'); 101 | expect(track1.cues).toBeNull(); 102 | expect(track1.activeCues).toBeNull(); 103 | 104 | expect(track2.mode, "The second track should also be disabled initially").toBe('disabled'); 105 | expect(track2.kind).toBe('subtitles'); 106 | expect(track2.cues).toBeNull(); 107 | expect(track2.activeCues).toBeNull(); 108 | 109 | await video.evaluate((videoElement: HTMLVideoElement) => { 110 | videoElement.textTracks[1].mode = 'showing'; 111 | }); 112 | 113 | loadedTextTracks = await getVideoTextTracks(video); 114 | 115 | [track1, track2] = loadedTextTracks; 116 | 117 | expect(track1.mode, "The first track should be disabled initially").toBe('disabled'); 118 | expect(track1.cues).toBeNull(); 119 | expect(track1.activeCues).toBeNull(); 120 | 121 | expect(track2.mode, "The second track should also be disabled initially").toBe('showing'); 122 | expect(track2.cues).toHaveLength(3); 123 | expect(track2.activeCues).toHaveLength(0); 124 | 125 | if (!track2.cues) { 126 | throw new Error("Cues should not be null"); 127 | } 128 | const [track2Cue1, track2Cue2, track2Cue3] = track2.cues; 129 | 130 | expect(track2Cue1.startTime).toBe(0.5); 131 | expect(track2Cue1.endTime).toBe(3); 132 | expect(track2Cue1.text).toBe('Seo roinnt téacs fotheidil'); 133 | 134 | expect(track2Cue2.startTime).toBe(4); 135 | expect(track2Cue2.endTime).toBe(6.5); 136 | expect(track2Cue2.text).toBe('Tá súil agam gur féidir leat é a léamh ...'); 137 | 138 | expect(track2Cue3.startTime).toBe(6.7); 139 | expect(track2Cue3.endTime).toBe(9); 140 | expect(track2Cue3.text).toBe('... mar gheall ar shlí eile ciallaíonn sé sin nach n-oibríonn sé seo'); 141 | 142 | await video.evaluate((videoElement: HTMLVideoElement) => { 143 | // Scrub the video to a point where a cue should be active 144 | videoElement.currentTime = 2; 145 | }); 146 | 147 | await expect.poll(() => video.evaluate((videoElement: HTMLVideoElement) => (videoElement.textTracks[1].activeCues?.[0] as VTTCue)?.text)).toBe("Seo roinnt téacs fotheidil"); 148 | }); 149 | -------------------------------------------------------------------------------- /tests/specs/videoSrc/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import HoverVideoPlayer from '../../../src/HoverVideoPlayer'; 3 | 4 | import { mp4VideoSrc, webmVideoSrc } from '../../constants'; 5 | 6 | export default function VideoSrcTestPage(): JSX.Element { 7 | return ( 8 |
9 |

videoSrc

10 | 11 | } 13 | data-testid="webm-source-element-only" 14 | /> 15 | 18 | 19 | 20 | 21 | } 22 | data-testid="2-source-elements" 23 | /> 24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /tests/specs/videoSrc/videoSrc.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | import { 4 | mp4VideoSrc, 5 | mp4VideoSrcURL, 6 | webmVideoSrc, 7 | webmVideoSrcURL, 8 | } from '../../constants'; 9 | 10 | test.beforeEach(async ({ page }) => { 11 | await page.goto('/videoSrc'); 12 | }); 13 | 14 | test('loads string URL videoSrc', async ({ page }) => { 15 | const hoverVideoPlayer = await page.locator( 16 | '[data-testid="mp4-string-only"]' 17 | ); 18 | const video = await hoverVideoPlayer.locator('video'); 19 | const videoSource = await video.locator('source'); 20 | 21 | await Promise.all([ 22 | // The video should not have any elements. 23 | expect(videoSource).toHaveCount(0), 24 | // The source should be set on the video's src attribute. 25 | expect(video).toHaveAttribute('src', mp4VideoSrc), 26 | // The source should be loaded on the video as expected. 27 | expect(video).toHaveJSProperty('currentSrc', mp4VideoSrcURL), 28 | ]); 29 | }); 30 | 31 | test('loads single source element videoSrc', async ({ page }) => { 32 | const hoverVideoPlayer = await page.locator( 33 | '[data-testid="webm-source-element-only"]' 34 | ); 35 | const video = await hoverVideoPlayer.locator('video'); 36 | const videoSource = await video.locator('source'); 37 | 38 | await Promise.all([ 39 | // The video should not have a src attribute set. 40 | expect(await video.getAttribute("src")).toBeNull(), 41 | // The video should have one element with the expected src attribute. 42 | expect(videoSource).toHaveCount(1), 43 | expect(videoSource).toHaveAttribute('src', webmVideoSrc), 44 | // The source should be loaded on the video as expected. 45 | expect(video).toHaveJSProperty('currentSrc', webmVideoSrcURL), 46 | ]); 47 | }); 48 | 49 | test('loads multiple source element videoSrc', async ({ page }) => { 50 | const hoverVideoPlayer = await page.locator( 51 | '[data-testid="2-source-elements"]' 52 | ); 53 | const video = await hoverVideoPlayer.locator('video'); 54 | const videoSource = await video.locator('source'); 55 | 56 | await Promise.all([ 57 | // The video should not have a src attribute set. 58 | expect(await video.getAttribute("src")).toBeNull(), 59 | // The video should have two elements with the expected src attributes. 60 | expect(videoSource).toHaveCount(2), 61 | expect(videoSource.nth(0)).toHaveAttribute('src', webmVideoSrc), 62 | expect(videoSource.nth(1)).toHaveAttribute('src', mp4VideoSrc), 63 | // The first source should be loaded on the video as expected. 64 | expect(video).toHaveJSProperty('currentSrc', webmVideoSrcURL), 65 | ]); 66 | }); 67 | -------------------------------------------------------------------------------- /tests/specs/videoSrcChange/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import HoverVideoPlayer from '../../../src/HoverVideoPlayer'; 4 | 5 | import { mp4VideoSrc, webmVideoSrc } from '../../constants'; 6 | 7 | const sourceOptions = { 8 | mp4String: mp4VideoSrc, 9 | webmString: webmVideoSrc, 10 | mp4SourceElement: , 11 | webmSourceElement: , 12 | }; 13 | 14 | export default function VideoSrcChangeTestPage(): JSX.Element { 15 | const [selectedVideoSrc, setSelectedVideoSrc] = 16 | React.useState('mp4String'); 17 | 18 | return ( 19 |
20 |

videoSrcChange

21 |
22 | {Object.keys(sourceOptions).map((key) => ( 23 | 36 | ))} 37 |
38 | 42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /tests/specs/videoSrcChange/videoSrcChange.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | import { 4 | mp4VideoSrc, 5 | mp4VideoSrcURL, 6 | webmVideoSrc, 7 | webmVideoSrcURL, 8 | } from '../../constants'; 9 | 10 | test.beforeEach(async ({ page }) => { 11 | await page.goto('/videoSrcChange'); 12 | }); 13 | 14 | test('reloads when videoSrc changes from string to string', async ({ 15 | page, 16 | }) => { 17 | const hoverVideoPlayer = await page.locator('[data-testid="hvp"]'); 18 | const video = await hoverVideoPlayer.locator('video'); 19 | const source = await video.locator('source'); 20 | 21 | expect(await page.getByLabel('mp4String').isChecked()).toBeTruthy(); 22 | 23 | await Promise.all([ 24 | expect(video).toHaveAttribute('src', mp4VideoSrc), 25 | expect(source).toHaveCount(0), 26 | expect(video).toHaveJSProperty('currentSrc', mp4VideoSrcURL), 27 | ]); 28 | 29 | // Switch to a webm string url 30 | await page.getByLabel('webmString').check(); 31 | 32 | await Promise.all([ 33 | expect(video).toHaveAttribute('src', webmVideoSrc), 34 | expect(source).toHaveCount(0), 35 | expect(video).toHaveJSProperty('currentSrc', webmVideoSrcURL), 36 | ]); 37 | }); 38 | 39 | test('reloads when videoSrc changes from string to source element', async ({ 40 | page, 41 | }) => { 42 | const hoverVideoPlayer = await page.locator('[data-testid="hvp"]'); 43 | const video = await hoverVideoPlayer.locator('video'); 44 | const source = await video.locator('source'); 45 | 46 | expect(await page.getByLabel('mp4String').isChecked()).toBeTruthy(); 47 | 48 | await Promise.all([ 49 | expect(video).toHaveAttribute('src', mp4VideoSrc), 50 | expect(source).toHaveCount(0), 51 | expect(video).toHaveJSProperty('currentSrc', mp4VideoSrcURL), 52 | ]); 53 | 54 | // Switch to a webm source element 55 | await page.getByLabel('webmSourceElement').check(); 56 | 57 | await Promise.all([ 58 | expect(await video.getAttribute("src")).toBeNull(), 59 | expect(source).toHaveCount(1), 60 | expect(source).toHaveAttribute('src', webmVideoSrc), 61 | expect(video).toHaveJSProperty('currentSrc', webmVideoSrcURL), 62 | ]); 63 | 64 | // Return to the mp4 string 65 | await page.getByLabel('mp4String').check(); 66 | 67 | await Promise.all([ 68 | expect(video).toHaveAttribute('src', mp4VideoSrc), 69 | expect(source).toHaveCount(0), 70 | expect(video).toHaveJSProperty('currentSrc', mp4VideoSrcURL), 71 | ]); 72 | }); 73 | 74 | test('reloads when videoSrc changes from source element to source element', async ({ 75 | page, 76 | }) => { 77 | const hoverVideoPlayer = await page.locator('[data-testid="hvp"]'); 78 | const video = await hoverVideoPlayer.locator('video'); 79 | const source = await video.locator('source'); 80 | 81 | await page.getByLabel('mp4SourceElement').check(); 82 | 83 | await Promise.all([ 84 | expect(await video.getAttribute("src")).toBeNull(), 85 | expect(source).toHaveCount(1), 86 | expect(source).toHaveAttribute('src', mp4VideoSrc), 87 | expect(video).toHaveJSProperty('currentSrc', mp4VideoSrcURL), 88 | ]); 89 | 90 | // Switch to a webm source element 91 | await page.getByLabel('webmSourceElement').check(); 92 | 93 | await Promise.all([ 94 | expect(await video.getAttribute("src")).toBeNull(), 95 | expect(source).toHaveCount(1), 96 | expect(source).toHaveAttribute('src', webmVideoSrc), 97 | expect(video).toHaveJSProperty('currentSrc', webmVideoSrcURL), 98 | ]); 99 | }); 100 | 101 | test('waits to reload videoSrc if the video is playing', async ({ page }) => { 102 | const hoverVideoPlayer = await page.locator('[data-testid="hvp"]'); 103 | const video = await hoverVideoPlayer.locator('video'); 104 | const source = await video.locator('source'); 105 | 106 | expect(await page.getByLabel('mp4String').isChecked()).toBeTruthy(); 107 | 108 | await Promise.all([ 109 | expect(video).toHaveAttribute('src', mp4VideoSrc), 110 | expect(source).toHaveCount(0), 111 | expect(video).toHaveJSProperty('currentSrc', mp4VideoSrcURL), 112 | ]); 113 | 114 | // Play the video 115 | await hoverVideoPlayer.hover(); 116 | await expect(video).toHaveJSProperty('paused', false); 117 | 118 | // Select a different option without moving the mouse since that will result 119 | // in the player no longer being hovered 120 | await page 121 | .getByLabel('webmString') 122 | .evaluate((webmStringOption: HTMLInputElement) => webmStringOption.click()); 123 | 124 | await Promise.all([ 125 | expect(video).toHaveAttribute('src', mp4VideoSrc), 126 | expect(source).toHaveCount(0), 127 | expect(video).toHaveJSProperty('currentSrc', mp4VideoSrcURL), 128 | ]); 129 | 130 | // Mousing out of the hover target should pause the video 131 | await page.mouse.move(0, 0); 132 | await expect(video).toHaveJSProperty('paused', true); 133 | 134 | await Promise.all([ 135 | expect(video).toHaveAttribute('src', webmVideoSrc), 136 | expect(source).toHaveCount(0), 137 | expect(video).toHaveJSProperty('currentSrc', webmVideoSrcURL), 138 | ]); 139 | }); 140 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "emitDeclarationOnly": true, 5 | "outDir": "./dist", 6 | "module": "esnext", 7 | "target": "es5", 8 | "lib": ["es6", "dom", "es2016", "es2017"], 9 | "sourceMap": true, 10 | "jsx": "react", 11 | "allowSyntheticDefaultImports": true, 12 | "esModuleInterop": true, 13 | "moduleResolution": "node", 14 | "strict": true, 15 | "strictNullChecks": true, 16 | "noImplicitAny": true 17 | }, 18 | "include": ["src/**/*"], 19 | "exclude": ["node_modules", "dist", "tests", "docs"] 20 | } 21 | --------------------------------------------------------------------------------