├── .eslintrc.json ├── .github └── workflows │ ├── main.yml │ └── size.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── example ├── .editorconfig ├── .gitignore ├── .prettierrc.js ├── LICENSE ├── README.md ├── index.html ├── package.json ├── postcss.config.js ├── src │ ├── App.css │ ├── App.tsx │ ├── favicon.svg │ ├── main.tsx │ └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.json ├── vite.config.ts └── yarn.lock ├── np-config.js ├── package.json ├── src ├── .eslintrc ├── components │ ├── Controls │ │ ├── BackwardButton.tsx │ │ ├── ControlButton │ │ │ ├── ControlButton.module.css │ │ │ ├── ControlButton.tsx │ │ │ └── index.tsx │ │ ├── Controls.module.css │ │ ├── Controls.tsx │ │ ├── ForwardButton.tsx │ │ ├── FullscreenButton.tsx │ │ ├── PlayPauseButton.tsx │ │ ├── ProgressSlider │ │ │ ├── ProgressSlider.module.css │ │ │ ├── ProgressSlider.tsx │ │ │ └── index.tsx │ │ ├── ScreenshotButton.tsx │ │ ├── SettingsButton │ │ │ ├── AudioMenu.tsx │ │ │ ├── PlaybackSpeedMenu.tsx │ │ │ ├── QualityMenu.tsx │ │ │ ├── SettingsButton.tsx │ │ │ ├── SubtitleMenu │ │ │ │ ├── SubtitleBackgroundOpacity.tsx │ │ │ │ ├── SubtitleFontOpacity.tsx │ │ │ │ ├── SubtitleFontSize.tsx │ │ │ │ ├── SubtitleMenu.tsx │ │ │ │ ├── SubtitleSettings.tsx │ │ │ │ ├── SubtitleTextStyle.tsx │ │ │ │ └── index.ts │ │ │ └── index.tsx │ │ ├── SubtitleButton.tsx │ │ ├── ThumbnailHover.tsx │ │ ├── TimeIndicator.tsx │ │ ├── VolumeButton │ │ │ ├── VolumeButton.module.css │ │ │ ├── VolumeButton.tsx │ │ │ └── index.tsx │ │ └── index.tsx │ ├── DefaultUI │ │ ├── DefaultUI.module.css │ │ ├── DefaultUI.tsx │ │ └── index.ts │ ├── Dialog │ │ ├── Dialog.module.css │ │ ├── Dialog.tsx │ │ └── index.tsx │ ├── Indicator │ │ ├── BackwardIndicator.tsx │ │ ├── ForwardIndicator.tsx │ │ ├── Indicator.module.css │ │ ├── Indicator.tsx │ │ ├── MobileBackwardIndicator │ │ │ ├── MobileBackwardIndicator.module.css │ │ │ ├── MobileBackwardIndicator.tsx │ │ │ └── index.ts │ │ ├── MobileForwardIndicator │ │ │ ├── MobileForwardIndicator.module.css │ │ │ ├── MobileForwardIndicator.tsx │ │ │ └── index.ts │ │ ├── MobileVolumeSlider │ │ │ ├── MobileVolumeSlider.module.css │ │ │ ├── MobileVolumeSlider.tsx │ │ │ └── index.ts │ │ ├── PauseIndicator.tsx │ │ ├── PlayIndicator.tsx │ │ └── index.ts │ ├── MobileControls │ │ ├── MobileControls.module.css │ │ ├── MobileControls.tsx │ │ └── index.tsx │ ├── MobileOverlay │ │ ├── MobileOverlay.module.css │ │ ├── MobileOverlay.tsx │ │ └── index.tsx │ ├── NestedMenu │ │ ├── NestedMenu.module.css │ │ ├── NestedMenu.tsx │ │ └── index.tsx │ ├── Overlay │ │ ├── Overlay.module.css │ │ ├── Overlay.tsx │ │ └── index.tsx │ ├── Player │ │ ├── Player.module.css │ │ ├── Player.tsx │ │ └── index.tsx │ ├── Popover │ │ ├── Popover.module.css │ │ ├── Popover.tsx │ │ └── index.tsx │ ├── Portal.tsx │ ├── Slider │ │ ├── Slider.module.css │ │ ├── Slider.tsx │ │ └── index.tsx │ ├── Subtitle │ │ ├── Subtitle.module.css │ │ ├── Subtitle.tsx │ │ └── index.tsx │ ├── TextIcon │ │ ├── TextIcon.module.css │ │ ├── TextIcon.tsx │ │ └── index.ts │ ├── icons │ │ ├── ArrowLeftIcon.tsx │ │ ├── ArrowRightIcon.tsx │ │ ├── AudioIcon.tsx │ │ ├── BackwardIcon.tsx │ │ ├── CameraIcon.tsx │ │ ├── CheckIcon.tsx │ │ ├── ForwardIcon.tsx │ │ ├── FullscreenEnterIcon.tsx │ │ ├── FullscreenExitIcon.tsx │ │ ├── LoadingIcon.tsx │ │ ├── PauseIcon.tsx │ │ ├── PlayIcon.tsx │ │ ├── PlaybackSpeedIcon.tsx │ │ ├── QualityIcon.tsx │ │ ├── SettingsIcon.tsx │ │ ├── SliderIcon.tsx │ │ ├── SubtitleIcon.tsx │ │ ├── SubtitleOffIcon.tsx │ │ ├── VolumeMutedIcon.tsx │ │ ├── VolumeOneIcon.tsx │ │ ├── VolumeThreeIcon.tsx │ │ └── VolumeTwoIcon.tsx │ └── index.ts ├── constants.ts ├── contexts │ ├── GlobalContext.tsx │ ├── SubtitleSettingsContext.tsx │ ├── VideoContext.tsx │ ├── VideoInteractingContext.tsx │ ├── VideoPropsContext.tsx │ ├── VideoStateContext.tsx │ └── index.ts ├── global.css ├── hooks │ ├── index.ts │ ├── useClickOutside.ts │ ├── useDoubleTap.ts │ ├── useGlobalHotKeys.ts │ ├── useHotKey.ts │ ├── usePopover.ts │ ├── usePrevious.ts │ └── useTextScaling.ts ├── hotkeys │ ├── backward.ts │ ├── forward.ts │ ├── fullscreen.ts │ ├── index.ts │ ├── playPause.ts │ └── volume.ts ├── index.tsx ├── reset.css ├── types │ ├── dashjs.ts │ ├── hls.js.ts │ ├── index.ts │ └── types.ts ├── typings.d.ts └── utils │ ├── device.ts │ ├── hotkey.ts │ ├── index.ts │ ├── load-script.ts │ └── screenfull.ts ├── tsconfig.json └── tsdx.config.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:react/recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "prettier", 11 | "plugin:import/recommended" 12 | ], 13 | "globals": { 14 | "Atomics": "readonly", 15 | "SharedArrayBuffer": "readonly" 16 | }, 17 | "parser": "@typescript-eslint/parser", 18 | "parserOptions": { 19 | "ecmaFeatures": { 20 | "jsx": true 21 | }, 22 | "ecmaVersion": 2018, 23 | "sourceType": "module" 24 | }, 25 | "plugins": ["react", "react-hooks", "@typescript-eslint", "prettier"], 26 | "rules": { 27 | "no-unsafe-optional-chaining": "off", 28 | "@typescript-eslint/no-explicit-any": "off", 29 | "@typescript-eslint/no-non-null-assertion": "off", 30 | "@typescript-eslint/no-unused-vars": [ 31 | "warn", 32 | { 33 | "argsIgnorePattern": "^_", 34 | "varsIgnorePattern": "^_", 35 | "caughtErrorsIgnorePattern": "^_" 36 | } 37 | ], 38 | 39 | "react-hooks/rules-of-hooks": "error", 40 | "react-hooks/exhaustive-deps": "warn", 41 | 42 | "prettier/prettier": [ 43 | "warn", 44 | { 45 | "endOfLine": "auto", 46 | "singleQuote": true 47 | } 48 | ] 49 | }, 50 | "settings": { 51 | "import/resolver": { 52 | "typescript": {} 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }} 6 | 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | node: [16, 18] 11 | os: [ubuntu-latest, windows-latest, macOS-latest] 12 | 13 | steps: 14 | - name: Checkout repo 15 | uses: actions/checkout@v2 16 | 17 | - name: Use Node ${{ matrix.node }} 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node }} 21 | 22 | - name: Install deps and build (with cache) 23 | uses: bahmutov/npm-install@v1 24 | 25 | - name: Lint 26 | run: yarn lint 27 | 28 | - name: Test 29 | run: yarn test --ci --coverage --maxWorkers=2 30 | 31 | - name: Build 32 | run: yarn build 33 | -------------------------------------------------------------------------------- /.github/workflows/size.yml: -------------------------------------------------------------------------------- 1 | name: size 2 | on: [pull_request] 3 | jobs: 4 | size: 5 | runs-on: ubuntu-latest 6 | env: 7 | CI_JOB_NUMBER: 1 8 | steps: 9 | - uses: actions/checkout@v1 10 | - uses: andresz1/size-limit-action@v1 11 | with: 12 | github_token: ${{ secrets.GITHUB_TOKEN }} 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist 6 | .parcel-cache 7 | yarn.lock -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | example 3 | node_modules 4 | yarn.lock 5 | yarn-error.log 6 | .github 7 | .parcel-cache 8 | .* 9 | .*.json 10 | tsconfig.json 11 | tests 12 | coverage 13 | *.development.* -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "jsxSingleQuote": true, 3 | "singleQuote": true, 4 | "semi": false, 5 | "tabWidth": 2, 6 | "trailingComma": "all", 7 | "printWidth": 100, 8 | "bracketSameLine": false, 9 | "useTabs": false, 10 | "arrowParens": "always", 11 | "endOfLine": "auto" 12 | } 13 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to NetPlayer 2 | 3 | Thanks for contributing to NetPlayer! 4 | 5 | ## Install 6 | 7 | ```bash 8 | git clone https://github.com/hoangvu12/netplayer.git 9 | cd netplayer 10 | npm install # or yarn 11 | npm start # or yarn start 12 | cd example 13 | npm start # or yarn start 14 | open http://localhost:3000 15 | ``` 16 | 17 | ## `dist` files 18 | 19 | There is **no need** to build or commit files in `dist` after making changes. The `dist` files will be automatically built and committed when new versions are released, so your changes will be included then. 20 | 21 | ## Linting 22 | 23 | This project uses [standard](https://github.com/feross/standard) code style. Be sure to lint the code after making changes and fix any issues that come up. 24 | 25 | ```bash 26 | npm run lint # or yarn lint 27 | ``` 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 hoangvu12 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # netplayer 2 | 3 | 4 | Latest npm version 5 | 6 | 7 |

8 | A simple React component that provide good looking UI video player 9 |

10 | 11 | ## Usage 12 | 13 | ```bash 14 | npm install netplayer # or yarn add netplayer 15 | ``` 16 | 17 | ```jsx 18 | import NetPlayer from 'netplayer'; 19 | 20 | ; 44 | ``` 45 | 46 | Or [play](https://hoangvu12.github.io/netplayer/) around with this component 47 | 48 | ## Props 49 | 50 | NetPlayer accepts video element props and these specific props 51 | 52 | | Prop | Type | Description | Default | Required | 53 | | ----------------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | -------- | 54 | | `sources` | [Source](https://github.com/hoangvu12/netplayer/blob/main/src/types/types.ts#L1)[] | An array of sources contain `file`, `label` and `type` | `null` | `true` | 55 | | `subtitles` | [Subtitle](https://github.com/hoangvu12/netplayer/blob/main/src/types/types.ts#L6)[] | An array of subtitles contain `file`, `lang` and `language` | `null` | `false` | 56 | | `hlsRef` | `React.MutableRefObject` | `hls.js` instance ref | `React.createRef()` | `false` | 57 | | `dashRef` | `React.MutableRefObject` | `dashjs` instance ref | `React.createRef()` | `false` | 58 | | `hlsConfig` | `Hls['config']` | `hls.js` config | `{}` | `false` | 59 | | `changeSourceUrl` | `(currentSourceUrl: string, source: Source): string` | A function that modify given source url (`hls` only) | `() => null` | `false` | 60 | | `onHlsInit` | `(hls: Hls): void` | A function that called after hls.js initialization | `() => null` | `false` | 61 | | `onDashInit` | `(dash: DashJS.MediaPlayerClass): void` | A function that called after dashjs initialization | `() => null` | `false` | 62 | | `onInit` | `(videoEl: HTMLVideoElement): void` | A function that called after video initialization | `() => null` | `false` | 63 | | `ref` | `React.MutableRefObject` | `video` element ref | `null` | `false` | 64 | | `i18n` | [I18n](https://github.com/hoangvu12/netplayer/blob/main/src/contexts/VideoPropsContext.tsx#L41) | Translations | [Default Translations](https://github.com/hoangvu12/netplayer/blob/main/src/contexts/VideoPropsContext.tsx#L69) | `false` | 65 | | `hotkeys` | [Hotkey](https://github.com/hoangvu12/netplayer/blob/main/src/types/types.ts#L25)[] | Hotkeys (shortcuts) | [Default Hotkeys](https://github.com/hoangvu12/netplayer/blob/main/src/contexts/VideoPropsContext.tsx#L99) | `false` | 66 | | `components` | [Component](https://github.com/hoangvu12/netplayer/blob/main/src/contexts/VideoPropsContext.tsx#L99)[] | See [Customization](#customization) | [Default components](https://github.com/hoangvu12/netplayer/blob/main/src/contexts/VideoPropsContext.tsx#L46) | `false` | 67 | | `thumbnail` | string | Thumbnails on progress bar hover | `null` | `false` | 68 | 69 | ## Customization 70 | 71 | You can customize the player by passing defined components with `components` props. See [defined components](https://github.com/hoangvu12/netplayer/blob/main/src/contexts/VideoPropsContext.tsx#L46) 72 | 73 | By passing components, the passed components will override default existing components. Allow you to customize the player how you want it to be. 74 | 75 | ### Example 76 | 77 | ```jsx 78 | import NetPlayer, { TimeIndicator } from 'netplayer'; 79 | 80 | { 84 | return ( 85 |
86 |

A custom Controls component

87 | 88 | 89 |
90 | ); 91 | }, 92 | }} 93 | />; 94 | ``` 95 | 96 | _Note: use built-in [hooks](https://github.com/hoangvu12/netplayer/tree/main/src/hooks) and [components](https://github.com/hoangvu12/netplayer/tree/main/src/components) for better customization_ 97 | 98 | ### Override structure 99 | 100 | NetPlayer use this [default structure](https://github.com/hoangvu12/netplayer/blob/main/src/components/DefaultUI/DefaultUI.tsx) 101 | 102 | To override it, simply pass your own structure as NetPlayer's `children` 103 | 104 | ```jsx 105 | import NetPlayer, { Controls, Player, Overlay } from 'netplayer'; 106 | 107 | 108 |
109 |
110 | 111 |
112 |
113 | 114 |
115 |
116 | 117 |
118 |
119 |

Look I'm over here!

120 |
121 |
122 |
; 123 | ``` 124 | 125 | ## Methods 126 | 127 | You can access to the `video` element by passing `ref` to NetPlayer and use all its methods. 128 | 129 | ## Supported formats 130 | 131 | NetPlayer supports all `video` element supported formats and `HLS` format 132 | 133 | ## Contributing 134 | 135 | See the [contribution guidelines](github.com/hoangvu12/netplayer/blob/main/CONTRIBUTING.md) before creating a pull request. 136 | 137 | ## Other video players 138 | 139 | - [react-player](https://github.com/CookPete/react-player) 140 | - [react-tuby](https://github.com/napthedev/react-tuby) 141 | - [video-react](https://github.com/video-react/video-react) 142 | - [plyr](https://github.com/sampotts/plyr) 143 | - [video.js](https://github.com/videojs/video.js) 144 | -------------------------------------------------------------------------------- /example/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [**/**.{yml,ts,tsx,js,json,jsx,html}] 4 | indent_style = space 5 | indent_size = 2 6 | insert_final_newline = true 7 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/node,jetbrains+all,visualstudiocode,macos 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=node,jetbrains+all,visualstudiocode,macos 4 | 5 | ### JetBrains+all ### 6 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 7 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 8 | 9 | # User-specific stuff 10 | .idea/**/workspace.xml 11 | .idea/**/tasks.xml 12 | .idea/**/usage.statistics.xml 13 | .idea/**/dictionaries 14 | .idea/**/shelf 15 | 16 | # AWS User-specific 17 | .idea/**/aws.xml 18 | 19 | # Generated files 20 | .idea/**/contentModel.xml 21 | 22 | # Sensitive or high-churn files 23 | .idea/**/dataSources/ 24 | .idea/**/dataSources.ids 25 | .idea/**/dataSources.local.xml 26 | .idea/**/sqlDataSources.xml 27 | .idea/**/dynamic.xml 28 | .idea/**/uiDesigner.xml 29 | .idea/**/dbnavigator.xml 30 | 31 | # Gradle 32 | .idea/**/gradle.xml 33 | .idea/**/libraries 34 | 35 | # Gradle and Maven with auto-import 36 | # When using Gradle or Maven with auto-import, you should exclude module files, 37 | # since they will be recreated, and may cause churn. Uncomment if using 38 | # auto-import. 39 | # .idea/artifacts 40 | # .idea/compiler.xml 41 | # .idea/jarRepositories.xml 42 | # .idea/modules.xml 43 | # .idea/*.iml 44 | # .idea/modules 45 | # *.iml 46 | # *.ipr 47 | 48 | # CMake 49 | cmake-build-*/ 50 | 51 | # Mongo Explorer plugin 52 | .idea/**/mongoSettings.xml 53 | 54 | # File-based project format 55 | *.iws 56 | 57 | # IntelliJ 58 | out/ 59 | 60 | # mpeltonen/sbt-idea plugin 61 | .idea_modules/ 62 | 63 | # JIRA plugin 64 | atlassian-ide-plugin.xml 65 | 66 | # Cursive Clojure plugin 67 | .idea/replstate.xml 68 | 69 | # Crashlytics plugin (for Android Studio and IntelliJ) 70 | com_crashlytics_export_strings.xml 71 | crashlytics.properties 72 | crashlytics-build.properties 73 | fabric.properties 74 | 75 | # Editor-based Rest Client 76 | .idea/httpRequests 77 | 78 | # Android studio 3.1+ serialized cache file 79 | .idea/caches/build_file_checksums.ser 80 | 81 | ### JetBrains+all Patch ### 82 | # Ignores the whole .idea folder and all .iml files 83 | # See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 84 | 85 | .idea/ 86 | 87 | # Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 88 | 89 | *.iml 90 | modules.xml 91 | .idea/misc.xml 92 | *.ipr 93 | 94 | # Sonarlint plugin 95 | .idea/sonarlint 96 | 97 | ### macOS ### 98 | # General 99 | .DS_Store 100 | .AppleDouble 101 | .LSOverride 102 | 103 | # Icon must end with two \r 104 | Icon 105 | 106 | # Thumbnails 107 | ._* 108 | 109 | # Files that might appear in the root of a volume 110 | .DocumentRevisions-V100 111 | .fseventsd 112 | .Spotlight-V100 113 | .TemporaryItems 114 | .Trashes 115 | .VolumeIcon.icns 116 | .com.apple.timemachine.donotpresent 117 | 118 | # Directories potentially created on remote AFP share 119 | .AppleDB 120 | .AppleDesktop 121 | Network Trash Folder 122 | Temporary Items 123 | .apdisk 124 | 125 | ### Node ### 126 | # Logs 127 | logs 128 | *.log 129 | npm-debug.log* 130 | yarn-debug.log* 131 | yarn-error.log* 132 | lerna-debug.log* 133 | .pnpm-debug.log* 134 | 135 | # Diagnostic reports (https://nodejs.org/api/report.html) 136 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 137 | 138 | # Runtime data 139 | pids 140 | *.pid 141 | *.seed 142 | *.pid.lock 143 | 144 | # Directory for instrumented libs generated by jscoverage/JSCover 145 | lib-cov 146 | 147 | # Coverage directory used by tools like istanbul 148 | coverage 149 | *.lcov 150 | 151 | # nyc test coverage 152 | .nyc_output 153 | 154 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 155 | .grunt 156 | 157 | # Bower dependency directory (https://bower.io/) 158 | bower_components 159 | 160 | # node-waf configuration 161 | .lock-wscript 162 | 163 | # Compiled binary addons (https://nodejs.org/api/addons.html) 164 | build/Release 165 | 166 | # Dependency directories 167 | node_modules/ 168 | jspm_packages/ 169 | 170 | # Snowpack dependency directory (https://snowpack.dev/) 171 | web_modules/ 172 | 173 | # TypeScript cache 174 | *.tsbuildinfo 175 | 176 | # Optional npm cache directory 177 | .npm 178 | 179 | # Optional eslint cache 180 | .eslintcache 181 | 182 | # Microbundle cache 183 | .rpt2_cache/ 184 | .rts2_cache_cjs/ 185 | .rts2_cache_es/ 186 | .rts2_cache_umd/ 187 | 188 | # Optional REPL history 189 | .node_repl_history 190 | 191 | # Output of 'npm pack' 192 | *.tgz 193 | 194 | # Yarn Integrity file 195 | .yarn-integrity 196 | 197 | # dotenv environment variables file 198 | .env 199 | .env.test 200 | .env.production 201 | 202 | # parcel-bundler cache (https://parceljs.org/) 203 | .cache 204 | .parcel-cache 205 | 206 | # Next.js build output 207 | .next 208 | out 209 | 210 | # Nuxt.js build / generate output 211 | .nuxt 212 | dist 213 | 214 | # Gatsby files 215 | .cache/ 216 | # Comment in the public line in if your project uses Gatsby and not Next.js 217 | # https://nextjs.org/blog/next-9-1#public-directory-support 218 | # public 219 | 220 | # vuepress build output 221 | .vuepress/dist 222 | 223 | # Serverless directories 224 | .serverless/ 225 | 226 | # FuseBox cache 227 | .fusebox/ 228 | 229 | # DynamoDB Local files 230 | .dynamodb/ 231 | 232 | # TernJS port file 233 | .tern-port 234 | 235 | # Stores VSCode versions used for testing VSCode extensions 236 | .vscode-test 237 | 238 | # yarn v2 239 | .yarn/cache 240 | .yarn/unplugged 241 | .yarn/build-state.yml 242 | .yarn/install-state.gz 243 | .pnp.* 244 | 245 | ### Node Patch ### 246 | # Serverless Webpack directories 247 | .webpack/ 248 | 249 | ### VisualStudioCode ### 250 | .vscode/* 251 | !.vscode/settings.json 252 | !.vscode/tasks.json 253 | !.vscode/launch.json 254 | !.vscode/extensions.json 255 | *.code-workspace 256 | 257 | # Local History for Visual Studio Code 258 | .history/ 259 | 260 | ### VisualStudioCode Patch ### 261 | # Ignore all local history of files 262 | .history 263 | .ionide 264 | 265 | # End of https://www.toptal.com/developers/gitignore/api/node,jetbrains+all,visualstudiocode,macos 266 | -------------------------------------------------------------------------------- /example/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 80, 3 | tabWidth: 2, 4 | singleQuote: true, 5 | quoteProps: 'as-needed', 6 | trailingComma: 'none', 7 | bracketSpacing: true, 8 | semi: false, 9 | useTabs: false, 10 | bracketSameLine: false, 11 | proseWrap: 'never' 12 | } 13 | -------------------------------------------------------------------------------- /example/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 PDMLab 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 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # vite React TypeScript tailwindcss starter 2 | 3 | Template for vite, React + tailwindcss + TypeScript projects with some tools preconfigured. 4 | 5 | ## About 6 | 7 | Template for vite, React + tailwindcss + TypeScript projects with some tools preconfigured. 8 | 9 | ![Screenshot](assets/screenshot.png) 10 | 11 | ### Libraries 12 | 13 | - [Jest](https://jestjs.io/) 14 | - [React](https://reactjs.org/) 15 | - [tailwindcss](https://tailwindcss.com/) 16 | - [tailwindcss forms plugin](https://tailwindcss-forms.vercel.app/) 17 | - [TypeScript](https://www.typescriptlang.org/) 18 | - [vite](https://vitejs.dev/) 19 | 20 | ### Tools 21 | 22 | - [commitlint](https://commitlint.js.org) 23 | - [Conventional Commits](https://www.conventionalcommits.org) 24 | - [editorconfig](https://editorconfig.org/) 25 | - [eslint](https://eslint.org/) 26 | - [husky](https://typicode.github.io/husky/#/) 27 | - [Prettier](https://prettier.io/) 28 | - [VS Code settings](https://code.visualstudio.com/) 29 | 30 | ## Usage 31 | 32 | Create a new project from this template by clicking the "Use this template" button: 33 | 34 | ![](assets/template-usage.png) 35 | 36 | ```bash 37 | yarn 38 | yarn dev 39 | yarn test 40 | ``` 41 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite App 8 | 9 | 10 |
14 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "netplayer-example", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "tsc && vite build --base=./", 7 | "serve": "vite preview --port 3000", 8 | "lint": "eslint . --ext .ts,.tsx,.js", 9 | "push-gh-pages": "push-dir --dir=dist --branch=gh-pages --cleanup --verbose" 10 | }, 11 | "dependencies": { 12 | "netplayer": "link:../", 13 | "react": "link:../node_modules/react", 14 | "react-app-polyfill": "^1.0.0", 15 | "react-dom": "link:../node_modules/react-dom/profiling", 16 | "react-live": "^2.4.1", 17 | "url-toolkit": "^2.2.5" 18 | }, 19 | "alias": { 20 | "react": "../node_modules/react", 21 | "react-dom": "../node_modules/react-dom/profiling", 22 | "scheduler/tracing": "../node_modules/scheduler/tracing-profiling" 23 | }, 24 | "devDependencies": { 25 | "@commitlint/cli": "^15.0.0", 26 | "@commitlint/config-conventional": "^15.0.0", 27 | "@heroicons/react": "^1.0.5", 28 | "@tailwindcss/forms": "^0.4.0", 29 | "@types/jest": "^27.0.3", 30 | "@types/node": "16", 31 | "@types/react": "^16.9.11", 32 | "@types/react-dom": "^16.8.4", 33 | "@typescript-eslint/eslint-plugin": "^5.8.0", 34 | "@typescript-eslint/parser": "^5.8.0", 35 | "@vitejs/plugin-react": "^1.3.2", 36 | "autoprefixer": "^10.4.0", 37 | "eslint": "^8.5.0", 38 | "eslint-config-prettier": "^8.3.0", 39 | "eslint-plugin-import": "^2.25.3", 40 | "eslint-plugin-prettier": "^4.0.0", 41 | "eslint-watch": "^8.0.0", 42 | "husky": "^7.0.4", 43 | "jest": "^27.4.5", 44 | "postcss": "^8.4.5", 45 | "prettier": "^2.5.1", 46 | "push-dir": "^0.4.1", 47 | "tailwindcss": "^3.0.7", 48 | "ts-jest": "^27.1.2", 49 | "typescript": "^4.5.4", 50 | "vite": "^2.9.9" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /example/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {} 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /example/src/App.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { LiveProvider, LiveEditor, LiveError, LivePreview } from 'react-live' 3 | import editorTheme from 'prism-react-renderer/themes/nightOwl' 4 | import Player from 'netplayer' 5 | import { buildAbsoluteURL } from 'url-toolkit' 6 | 7 | const initialCode = ` 8 | 30 | ` 31 | 32 | const App: React.FC = () => { 33 | return ( 34 |
35 | 40 |
44 | 45 |
46 | 47 | 54 | 55 |
56 |
57 | ) 58 | } 59 | 60 | export default App 61 | -------------------------------------------------------------------------------- /example/src/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /example/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import './App.css' 4 | import App from './App' 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById('root') 11 | ) 12 | -------------------------------------------------------------------------------- /example/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /example/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ['./dist/**/*.html', './src/**/*.{js,jsx,ts,tsx}', './*.html'], 3 | plugins: [require('@tailwindcss/forms')], 4 | variants: { 5 | extend: { 6 | opacity: ['disabled'] 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["./src"] 20 | } 21 | -------------------------------------------------------------------------------- /example/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | optimizeDeps: { 8 | exclude: ['netplayer'] 9 | }, 10 | build: { 11 | commonjsOptions: { exclude: ['netplayer'], include: [] } 12 | }, 13 | server: { 14 | watch: { 15 | ignored: ['!**/node_modules/netplayer/**'] 16 | } 17 | } 18 | }) 19 | -------------------------------------------------------------------------------- /np-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | contents: 'dist', 3 | }; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.6.6", 3 | "license": "MIT", 4 | "main": "dist/index.js", 5 | "typings": "dist/index.d.ts", 6 | "engines": { 7 | "node": ">=10" 8 | }, 9 | "scripts": { 10 | "start": "tsdx watch", 11 | "build": "tsdx build", 12 | "test": "tsdx test --passWithNoTests", 13 | "lint": "eslint src/**/*.{js,jsx,ts,tsx,json}", 14 | "lint:fix": "eslint --fix src/**/*.{js,jsx,ts,tsx,json}", 15 | "format": "prettier --write src/**/*.{js,jsx,ts,tsx,css,md,json} --config ./.prettierrc", 16 | "prepare": "tsdx build", 17 | "size": "size-limit", 18 | "analyze": "size-limit --why", 19 | "release": "shx cp package.json ./dist && np" 20 | }, 21 | "peerDependencies": { 22 | "react": ">=16", 23 | "react-dom": ">=16" 24 | }, 25 | "husky": { 26 | "hooks": { 27 | "pre-commit": "tsdx lint" 28 | } 29 | }, 30 | "prettier": { 31 | "printWidth": 80, 32 | "semi": true, 33 | "singleQuote": true, 34 | "trailingComma": "es5" 35 | }, 36 | "name": "netplayer", 37 | "author": "hoangvu12", 38 | "module": "dist/index.js", 39 | "size-limit": [ 40 | { 41 | "path": "./dist/index.js", 42 | "limit": "40 KB" 43 | } 44 | ], 45 | "devDependencies": { 46 | "@size-limit/preset-small-lib": "^7.0.8", 47 | "@types/node": "^17.0.35", 48 | "@types/react": "16.14.0", 49 | "@types/react-dom": "16.9.16", 50 | "@types/resize-observer-browser": "^0.1.7", 51 | "@typescript-eslint/eslint-plugin": "^5.28.0", 52 | "@typescript-eslint/parser": "^5.28.0", 53 | "autoprefixer": "^10.4.7", 54 | "cssnano": "^5.1.9", 55 | "eslint": "^8.18.0", 56 | "eslint-config-prettier": "^8.5.0", 57 | "eslint-config-react-app": "^7.0.1", 58 | "eslint-import-resolver-typescript": "^2.7.1", 59 | "eslint-plugin-import": "^2.26.0", 60 | "eslint-plugin-jsx-a11y": "^6.5.1", 61 | "eslint-plugin-prettier": "^4.0.0", 62 | "eslint-plugin-react": "^7.30.0", 63 | "eslint-plugin-react-hooks": "^4.6.0", 64 | "husky": "^8.0.1", 65 | "np": "^8.0.4", 66 | "npm-run-all": "^4.1.5", 67 | "postcss": "^8.4.14", 68 | "prettier": "^2.7.1", 69 | "react": "16.14.0", 70 | "react-dom": "16.14.0", 71 | "rollup-plugin-postcss": "^4.0.2", 72 | "shx": "^0.3.4", 73 | "size-limit": "^7.0.8", 74 | "tsdx": "^0.14.1", 75 | "tslib": "^2.4.0", 76 | "typescript": "^4.6.4" 77 | }, 78 | "dependencies": { 79 | "@plussub/srt-vtt-parser": "^1.1.0", 80 | "load-script": "^2.0.0", 81 | "url-toolkit": "^2.2.5" 82 | }, 83 | "publishConfig": { 84 | "registry": "https://registry.npmjs.org/" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/components/Controls/BackwardButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import ControlButton from './ControlButton'; 3 | import BackwardIcon from '../icons/BackwardIcon'; 4 | import { useVideo } from '../../contexts/VideoContext'; 5 | import BackwardIndicator from '../Indicator/BackwardIndicator'; 6 | import { IndicatorRef } from '../Indicator/Indicator'; 7 | import { useVideoProps } from '../../contexts/VideoPropsContext'; 8 | import { stringInterpolate } from '../../utils'; 9 | 10 | const BackwardButton = () => { 11 | const { videoEl } = useVideo(); 12 | const { i18n } = useVideoProps(); 13 | const backwardIndicator = useRef(null); 14 | 15 | const handleClick = () => { 16 | if (!videoEl) return; 17 | 18 | backwardIndicator.current?.show(); 19 | videoEl.currentTime = videoEl.currentTime - 10; 20 | }; 21 | 22 | return ( 23 | 27 | 28 | 29 | 30 | 31 | ); 32 | }; 33 | 34 | export default BackwardButton; 35 | -------------------------------------------------------------------------------- /src/components/Controls/ControlButton/ControlButton.module.css: -------------------------------------------------------------------------------- 1 | .controlButton { 2 | width: 100%; 3 | height: 100%; 4 | display: flex; 5 | justify-content: center; 6 | align-items: center; 7 | color: white; 8 | } 9 | 10 | .controlButton > svg { 11 | width: 100%; 12 | height: 100%; 13 | } 14 | 15 | .popper { 16 | margin-left: 0 !important; 17 | } 18 | -------------------------------------------------------------------------------- /src/components/Controls/ControlButton/ControlButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { PLAYER_CONTAINER_CLASS } from '../../../constants'; 4 | import { classNames } from '../../../utils'; 5 | import { isDesktop } from '../../../utils/device'; 6 | import Popover from '../../Popover'; 7 | import { PopoverProps } from '../../Popover/Popover'; 8 | import styles from './ControlButton.module.css'; 9 | 10 | interface ControlButtonProps extends React.HTMLAttributes { 11 | tooltip?: React.ReactNode; 12 | tooltipProps?: PopoverProps; 13 | } 14 | 15 | const selector = `.${PLAYER_CONTAINER_CLASS}`; 16 | 17 | const ControlButton: React.FC = ({ 18 | className = '', 19 | tooltip, 20 | tooltipProps, 21 | ...props 22 | }) => { 23 | const button = ( 24 | 30 | ); 31 | 32 | if (tooltip && isDesktop) { 33 | return ( 34 | 43 |

{tooltip}

44 |
45 | ); 46 | } 47 | 48 | return button; 49 | }; 50 | 51 | export default ControlButton; 52 | -------------------------------------------------------------------------------- /src/components/Controls/ControlButton/index.tsx: -------------------------------------------------------------------------------- 1 | import ControlButton from './ControlButton'; 2 | 3 | export default ControlButton; 4 | -------------------------------------------------------------------------------- /src/components/Controls/Controls.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 100%; 3 | padding: 1rem; 4 | background-image: linear-gradient( 5 | to top, 6 | rgba(0, 0, 0, 0.8), 7 | rgba(0, 0, 0, 0.6), 8 | transparent 9 | ); 10 | transition: 300ms; 11 | opacity: 1; 12 | visibility: visible; 13 | } 14 | 15 | .container.hide { 16 | opacity: 0; 17 | visibility: hidden; 18 | } 19 | 20 | .sliderContainer { 21 | margin-bottom: 1rem; 22 | } 23 | 24 | .buttonContainer { 25 | width: 100%; 26 | display: flex; 27 | justify-content: space-between; 28 | align-items: center; 29 | color: white; 30 | } 31 | 32 | .leftButtonContainer { 33 | display: flex; 34 | align-items: center; 35 | } 36 | 37 | .rightButtonContainer { 38 | display: flex; 39 | align-items: center; 40 | } 41 | 42 | .leftButtonContainer > * + * { 43 | margin-left: 1rem; 44 | } 45 | 46 | .rightButtonContainer > * + * { 47 | margin-left: 1rem; 48 | } 49 | 50 | .container :global(.control-button) { 51 | width: 2rem; 52 | height: 2rem; 53 | } 54 | -------------------------------------------------------------------------------- /src/components/Controls/Controls.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { useVideo } from '../../contexts/VideoContext'; 4 | import { useInteract } from '../../contexts/VideoInteractingContext'; 5 | import { classNames } from '../../utils'; 6 | import BackwardButton from './BackwardButton'; 7 | import styles from './Controls.module.css'; 8 | import ForwardButton from './ForwardButton'; 9 | import FullscreenButton from './FullscreenButton'; 10 | import PlayPauseButton from './PlayPauseButton'; 11 | import ProgressSlider from './ProgressSlider'; 12 | import ScreenshotButton from './ScreenshotButton'; 13 | import SettingsButton from './SettingsButton'; 14 | import SubtitleButton from './SubtitleButton'; 15 | import TimeIndicator from './TimeIndicator'; 16 | import VolumeButton from './VolumeButton'; 17 | 18 | const Controls = () => { 19 | const { isInteracting } = useInteract(); 20 | const { videoState } = useVideo(); 21 | 22 | return ( 23 |
32 |
33 | 34 |
35 | 36 |
37 |
38 | 39 | 40 | 41 | 42 | 43 |
44 |
45 | 46 | 47 | 48 | 49 |
50 |
51 |
52 | ); 53 | }; 54 | 55 | export default Controls; 56 | -------------------------------------------------------------------------------- /src/components/Controls/ForwardButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import ControlButton from './ControlButton'; 3 | import ForwardIcon from '../icons/ForwardIcon'; 4 | import { useVideo } from '../../contexts/VideoContext'; 5 | import { IndicatorRef } from '../Indicator/Indicator'; 6 | import ForwardIndicator from '../Indicator/ForwardIndicator'; 7 | import { useVideoProps } from '../../contexts/VideoPropsContext'; 8 | import { stringInterpolate } from '../../utils'; 9 | 10 | const ForwardButton = () => { 11 | const { videoEl } = useVideo(); 12 | const { i18n } = useVideoProps(); 13 | const forwardIndicator = React.useRef(null); 14 | 15 | const handleClick = () => { 16 | if (!videoEl) return; 17 | 18 | forwardIndicator.current?.show(); 19 | videoEl.currentTime = videoEl.currentTime + 10; 20 | }; 21 | 22 | return ( 23 | 27 | 28 | 29 | 30 | 31 | ); 32 | }; 33 | 34 | export default React.memo(ForwardButton); 35 | -------------------------------------------------------------------------------- /src/components/Controls/FullscreenButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useState } from 'react'; 2 | import { PLAYER_CONTAINER_CLASS } from '../../constants'; 3 | import { useVideoProps } from '../../contexts/VideoPropsContext'; 4 | import useHotKey, { parseHotKey } from '../../hooks/useHotKey'; 5 | import { stringInterpolate } from '../../utils'; 6 | import { isIOS, isMobile } from '../../utils/device'; 7 | import screenfull from '../../utils/screenfull'; 8 | import FullscreenEnterIcon from '../icons/FullscreenEnterIcon'; 9 | import FullscreenExitIcon from '../icons/FullscreenExitIcon'; 10 | import ControlButton from './ControlButton'; 11 | 12 | const FullscreenButton = () => { 13 | const [isFullscreen, setIsFullscreen] = useState(screenfull.isFullscreen); 14 | const { i18n } = useVideoProps(); 15 | const hotkey = useHotKey('fullscreen'); 16 | 17 | const handleFullscreen = useCallback(() => { 18 | if (!screenfull.isEnabled) return; 19 | 20 | const containerElSelector = !isIOS 21 | ? `.${PLAYER_CONTAINER_CLASS}` 22 | : `.${PLAYER_CONTAINER_CLASS} video`; 23 | 24 | const containerEl = document.querySelector(containerElSelector); 25 | 26 | if (!isFullscreen) { 27 | // @ts-ignore 28 | screenfull.request(containerEl).then(() => { 29 | if (!isMobile) return; 30 | 31 | screen.orientation.lock('landscape'); 32 | }); 33 | setIsFullscreen(true); 34 | } else { 35 | screenfull.exit().then(() => { 36 | if (!isMobile) return; 37 | 38 | screen.orientation.lock('portrait'); 39 | }); 40 | setIsFullscreen(false); 41 | } 42 | }, [isFullscreen]); 43 | 44 | useEffect(() => { 45 | const handleFullscreen = () => { 46 | const isFullscreen = !!document.fullscreenElement; 47 | 48 | setIsFullscreen(isFullscreen); 49 | 50 | if (!isMobile) return; 51 | 52 | if (isFullscreen) { 53 | screen.orientation.lock('landscape'); 54 | } else { 55 | screen.orientation.lock('portrait'); 56 | } 57 | }; 58 | 59 | const containerEl = document.querySelector(`.${PLAYER_CONTAINER_CLASS}`); 60 | 61 | containerEl?.addEventListener('fullscreenchange', handleFullscreen); 62 | 63 | return () => { 64 | containerEl?.removeEventListener('fullscreenchange', handleFullscreen); 65 | }; 66 | }, []); 67 | 68 | return ( 69 | 81 | {!isFullscreen ? : } 82 | 83 | ); 84 | }; 85 | 86 | export default FullscreenButton; 87 | -------------------------------------------------------------------------------- /src/components/Controls/PlayPauseButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import ControlButton from './ControlButton'; 3 | import PlayIcon from '../icons/PlayIcon'; 4 | import PauseIcon from '../icons/PauseIcon'; 5 | import { useVideo } from '../../contexts/VideoContext'; 6 | import LoadingIcon from '../icons/LoadingIcon'; 7 | import PlayIndicator from '../Indicator/PlayIndicator'; 8 | import PauseIndicator from '../Indicator/PauseIndicator'; 9 | import { IndicatorRef } from '../Indicator/Indicator'; 10 | import { stringInterpolate } from '../../utils'; 11 | import { useVideoProps } from '../../contexts/VideoPropsContext'; 12 | import useHotKey, { parseHotKey } from '../../hooks/useHotKey'; 13 | 14 | const PlayPauseButton = () => { 15 | const playIndicator = React.useRef(null); 16 | const pauseIndicator = React.useRef(null); 17 | 18 | const hotkey = useHotKey('playPause'); 19 | const { i18n } = useVideoProps(); 20 | const { videoState, videoEl } = useVideo(); 21 | 22 | const handleClick = () => { 23 | if (!videoEl) return; 24 | 25 | if (videoState.paused) { 26 | playIndicator.current?.show(); 27 | videoEl.play(); 28 | } else { 29 | pauseIndicator.current?.show(); 30 | videoEl.pause(); 31 | } 32 | }; 33 | 34 | return ( 35 | 47 | {videoState.buffering ? ( 48 | 49 | ) : videoState.paused ? ( 50 | 51 | ) : ( 52 | 53 | )} 54 | 55 | 56 | 57 | 58 | ); 59 | }; 60 | 61 | export default React.memo(PlayPauseButton); 62 | -------------------------------------------------------------------------------- /src/components/Controls/ProgressSlider/ProgressSlider.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 100%; 3 | height: 5px; 4 | cursor: pointer; 5 | display: flex; 6 | flex-direction: column; 7 | justify-content: flex-end; 8 | } 9 | 10 | .container.desktop { 11 | height: 16px; 12 | } 13 | 14 | .container:hover .innerContainer { 15 | height: 5px; 16 | } 17 | 18 | .container:hover .dot { 19 | display: block !important; 20 | } 21 | 22 | .container.desktop .dot { 23 | display: none; 24 | } 25 | 26 | .innerContainer { 27 | width: 100%; 28 | height: 3px; 29 | position: relative; 30 | } 31 | 32 | .dot { 33 | width: 13px; 34 | height: 13px; 35 | position: absolute; 36 | background-color: red; 37 | border-radius: 9999px; 38 | } 39 | 40 | .playBar { 41 | background-color: red; 42 | } 43 | 44 | .backgroundBar { 45 | background-color: rgba(255, 255, 255, 0.2); 46 | width: 100%; 47 | } 48 | 49 | .bufferBar { 50 | background-color: rgba(255, 255, 255, 0.4); 51 | } 52 | 53 | .hoverBar { 54 | background-color: rgba(255, 255, 255, 0.5); 55 | } 56 | 57 | .hoverTime { 58 | background-color: rgba(0, 0, 0, 0.8); 59 | color: white; 60 | padding: 0.25rem 0.5rem; 61 | position: absolute; 62 | bottom: 100%; 63 | margin-bottom: 0.75rem; 64 | transform: translateX(-50%); 65 | } 66 | 67 | .hoverThumbnailReference { 68 | position: absolute; 69 | bottom: 100%; 70 | margin-bottom: 2.75rem; 71 | } 72 | 73 | .hoverThumbnail { 74 | background-color: 'black'; 75 | border: 2px solid white; 76 | } 77 | -------------------------------------------------------------------------------- /src/components/Controls/ProgressSlider/ProgressSlider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useMemo, useState } from 'react'; 2 | import { useVideo } from '../../../contexts/VideoContext'; 3 | import { classNames, convertTime } from '../../../utils'; 4 | import { isDesktop } from '../../../utils/device'; 5 | import Slider from '../../Slider'; 6 | import ThumbnailHover from '../ThumbnailHover'; 7 | import styles from './ProgressSlider.module.css'; 8 | 9 | const ProgressSlider = () => { 10 | const { videoEl, setVideoState } = useVideo(); 11 | const [bufferPercent, setBufferPercent] = useState(0); 12 | const [hoverPercent, setHoverPercent] = useState(0); 13 | const [currentTime, setCurrentTime] = useState(0); 14 | 15 | // https://stackoverflow.com/questions/5029519/html5-video-percentage-loaded 16 | useEffect(() => { 17 | if (!videoEl) return; 18 | 19 | const handleProgressBuffer = () => { 20 | const buffer = videoEl.buffered; 21 | 22 | if (!buffer.length) return; 23 | if (!videoEl.duration) return; 24 | 25 | const bufferedTime = buffer.end(buffer.length - 1); 26 | const bufferedPercent = (bufferedTime / videoEl.duration) * 100; 27 | 28 | setBufferPercent(bufferedPercent); 29 | }; 30 | 31 | const handleTimeUpdate = () => { 32 | setCurrentTime(videoEl.currentTime); 33 | }; 34 | 35 | videoEl.addEventListener('progress', handleProgressBuffer); 36 | videoEl.addEventListener('timeupdate', handleTimeUpdate); 37 | 38 | return () => { 39 | videoEl.removeEventListener('progress', handleProgressBuffer); 40 | videoEl.removeEventListener('timeupdate', handleTimeUpdate); 41 | }; 42 | }, [videoEl]); 43 | 44 | const currentPercent = useMemo(() => { 45 | if (!videoEl?.duration) return 0; 46 | 47 | return (currentTime / videoEl.duration) * 100; 48 | }, [currentTime, videoEl?.duration]); 49 | 50 | const handlePercentIntent = useCallback((percent: number) => { 51 | setHoverPercent(percent); 52 | }, []); 53 | 54 | const handlePercentChange = useCallback( 55 | (percent: number) => { 56 | if (!videoEl?.duration) return; 57 | 58 | const newTime = (percent / 100) * videoEl.duration; 59 | 60 | videoEl.currentTime = newTime; 61 | 62 | if (videoEl.paused) { 63 | videoEl.play(); 64 | } 65 | 66 | setVideoState({ seeking: false }); 67 | setCurrentTime(newTime); 68 | }, 69 | [setVideoState, videoEl] 70 | ); 71 | 72 | const handleDragStart = useCallback(() => { 73 | setVideoState({ seeking: true }); 74 | }, [setVideoState]); 75 | 76 | const handleDragEnd = useCallback(() => { 77 | setVideoState({ seeking: true }); 78 | }, [setVideoState]); 79 | 80 | const handlePercentChanging = useCallback( 81 | (percent) => { 82 | if (!videoEl?.duration) return; 83 | 84 | if (!videoEl.paused) { 85 | videoEl.pause(); 86 | } 87 | 88 | const newTime = (percent / 100) * videoEl.duration; 89 | 90 | setVideoState({ seeking: true }); 91 | setCurrentTime(newTime); 92 | }, 93 | [setVideoState, videoEl] 94 | ); 95 | 96 | return ( 97 | 105 |
106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | {!!hoverPercent && videoEl?.duration && ( 115 |
119 | {convertTime((hoverPercent / 100) * videoEl.duration)} 120 |
121 | )} 122 |
123 |
124 | ); 125 | }; 126 | 127 | export default ProgressSlider; 128 | -------------------------------------------------------------------------------- /src/components/Controls/ProgressSlider/index.tsx: -------------------------------------------------------------------------------- 1 | import ProgressSlider from './ProgressSlider'; 2 | 3 | export default ProgressSlider; 4 | -------------------------------------------------------------------------------- /src/components/Controls/ScreenshotButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useVideo, useVideoProps } from '../../contexts'; 3 | import { download, randomString } from '../../utils'; 4 | import CameraIcon from '../icons/CameraIcon'; 5 | import ControlButton from './ControlButton'; 6 | 7 | const ScreenshotButton = () => { 8 | const { videoEl } = useVideo(); 9 | const { i18n } = useVideoProps(); 10 | 11 | const snapshot = function () { 12 | if (!videoEl) return; 13 | 14 | const canvas = document.createElement('canvas'); 15 | const ctx = canvas.getContext('2d'); 16 | 17 | if (!ctx) return; 18 | 19 | canvas.width = videoEl.videoWidth; 20 | canvas.height = videoEl.videoHeight; 21 | 22 | ctx.drawImage(videoEl, 0, 0); 23 | const fileName = randomString(10) + '.png'; 24 | const imageUrl = canvas.toDataURL('image/png'); 25 | 26 | download(imageUrl, fileName); 27 | }; 28 | 29 | return ( 30 | 31 | 32 | 33 | ); 34 | }; 35 | 36 | export default ScreenshotButton; 37 | -------------------------------------------------------------------------------- /src/components/Controls/SettingsButton/AudioMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useVideoProps } from '../../../contexts/VideoPropsContext'; 3 | import { useVideoState } from '../../../contexts/VideoStateContext'; 4 | import AudioIcon from '../../icons/AudioIcon'; 5 | import NestedMenu from '../../NestedMenu'; 6 | 7 | const AudioMenu = () => { 8 | const { state, setState } = useVideoState(); 9 | const { i18n } = useVideoProps(); 10 | 11 | const handleAudioChange = (value: string) => { 12 | setState((prev) => ({ 13 | ...prev, 14 | currentAudio: value, 15 | })); 16 | }; 17 | 18 | return state.audios.length ? ( 19 | } 26 | onChange={handleAudioChange} 27 | > 28 | {state.audios.map((audio) => ( 29 | 35 | ))} 36 | 37 | ) : null; 38 | }; 39 | 40 | export default React.memo(AudioMenu); 41 | -------------------------------------------------------------------------------- /src/components/Controls/SettingsButton/PlaybackSpeedMenu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { useVideo } from '../../../contexts/VideoContext'; 4 | import { useVideoProps } from '../../../contexts/VideoPropsContext'; 5 | import PlaybackSpeedIcon from '../../icons/PlaybackSpeedIcon'; 6 | import NestedMenu from '../../NestedMenu'; 7 | 8 | const speeds = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]; 9 | 10 | const PlaybackSpeedMenu = () => { 11 | const { videoEl } = useVideo(); 12 | const { i18n } = useVideoProps(); 13 | 14 | const currentSpeed = videoEl?.playbackRate || 1; 15 | 16 | const handleChangeSpeed = (value: string) => { 17 | if (!videoEl) return; 18 | 19 | videoEl.playbackRate = Number(value); 20 | }; 21 | 22 | return ( 23 | } 28 | onChange={handleChangeSpeed} 29 | > 30 | {speeds.map((speed) => ( 31 | 37 | ))} 38 | 39 | ); 40 | }; 41 | 42 | export default React.memo(PlaybackSpeedMenu); 43 | -------------------------------------------------------------------------------- /src/components/Controls/SettingsButton/QualityMenu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useVideoProps } from '../../../contexts/VideoPropsContext'; 3 | import { useVideoState } from '../../../contexts/VideoStateContext'; 4 | import QualityIcon from '../../icons/QualityIcon'; 5 | import NestedMenu from '../../NestedMenu'; 6 | 7 | const QualityMenu = () => { 8 | const { state, setState } = useVideoState(); 9 | const { i18n } = useVideoProps(); 10 | 11 | const handleQualityChange = (value: string) => { 12 | setState(() => ({ currentQuality: value })); 13 | }; 14 | 15 | return state.qualities.length ? ( 16 | } 21 | onChange={handleQualityChange} 22 | > 23 | {state.qualities.map((quality, index) => ( 24 | 30 | ))} 31 | 32 | ) : null; 33 | }; 34 | 35 | export default React.memo(QualityMenu); 36 | -------------------------------------------------------------------------------- /src/components/Controls/SettingsButton/SettingsButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { PLAYER_CONTAINER_CLASS } from '../../../constants'; 4 | import { useVideoProps } from '../../../contexts/VideoPropsContext'; 5 | import { isDesktop, isMobile } from '../../../utils/device'; 6 | import Dialog from '../../Dialog'; 7 | import SettingsIcon from '../../icons/SettingsIcon'; 8 | import NestedMenu from '../../NestedMenu'; 9 | import Popover from '../../Popover'; 10 | import ControlButton from '../ControlButton'; 11 | import AudioMenu from './AudioMenu'; 12 | import PlaybackSpeedMenu from './PlaybackSpeedMenu'; 13 | import QualityMenu from './QualityMenu'; 14 | import SubtitleMenu from './SubtitleMenu'; 15 | 16 | const Menu = React.memo(() => ( 17 | 26 | 27 | 28 | 29 | 30 | 31 | )); 32 | 33 | Menu.displayName = 'SettingsMenu'; 34 | 35 | const selector = `.${PLAYER_CONTAINER_CLASS}`; 36 | 37 | const SettingsButton = () => { 38 | const { i18n } = useVideoProps(); 39 | 40 | return ( 41 | 42 | {isMobile && ( 43 | 47 | 48 | 49 | } 50 | > 51 | 52 | 53 | )} 54 | 55 | {isDesktop && ( 56 | 60 | 61 | 62 | } 63 | position="top" 64 | overflowElement={selector} 65 | > 66 | 67 | 68 | )} 69 | 70 | ); 71 | }; 72 | 73 | export default React.memo(SettingsButton); 74 | -------------------------------------------------------------------------------- /src/components/Controls/SettingsButton/SubtitleMenu/SubtitleBackgroundOpacity.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { useSubtitleSettings } from '../../../../contexts/SubtitleSettingsContext'; 4 | import { useVideoProps } from '../../../../contexts/VideoPropsContext'; 5 | import NestedMenu, { SubMenuProps } from '../../../NestedMenu/NestedMenu'; 6 | 7 | const opacities = [0, 25, 50, 75, 100]; 8 | 9 | const SubtitleBackgroundOpacity: React.FC> = (props) => { 10 | const { state, setState } = useSubtitleSettings(); 11 | const { i18n } = useVideoProps(); 12 | 13 | return ( 14 | { 19 | setState(() => ({ backgroundOpacity: Number(value) / 100 })); 20 | }} 21 | activeItemKey={(state.backgroundOpacity * 100).toString()} 22 | > 23 | {opacities.map((opacity) => ( 24 | 30 | ))} 31 | 32 | ); 33 | }; 34 | 35 | export default SubtitleBackgroundOpacity; 36 | -------------------------------------------------------------------------------- /src/components/Controls/SettingsButton/SubtitleMenu/SubtitleFontOpacity.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { useSubtitleSettings } from '../../../../contexts/SubtitleSettingsContext'; 4 | import { useVideoProps } from '../../../../contexts/VideoPropsContext'; 5 | import NestedMenu, { SubMenuProps } from '../../../NestedMenu/NestedMenu'; 6 | 7 | const opacities = [0, 25, 50, 75, 100]; 8 | 9 | const SubtitleFontOpacity: React.FC> = (props) => { 10 | const { state, setState } = useSubtitleSettings(); 11 | const { i18n } = useVideoProps(); 12 | 13 | return ( 14 | { 19 | setState(() => ({ fontOpacity: Number(value) / 100 })); 20 | }} 21 | activeItemKey={(state.fontOpacity * 100).toString()} 22 | > 23 | {opacities.map((opacity) => ( 24 | 30 | ))} 31 | 32 | ); 33 | }; 34 | 35 | export default SubtitleFontOpacity; 36 | -------------------------------------------------------------------------------- /src/components/Controls/SettingsButton/SubtitleMenu/SubtitleFontSize.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { useSubtitleSettings } from '../../../../contexts/SubtitleSettingsContext'; 4 | import { useVideoProps } from '../../../../contexts/VideoPropsContext'; 5 | import NestedMenu, { SubMenuProps } from '../../../NestedMenu/NestedMenu'; 6 | 7 | const fontSizes = [0.5, 0.75, 1, 1.5, 2, 3, 4]; 8 | 9 | const SubtitleFontSize: React.FC> = (props) => { 10 | const { state, setState } = useSubtitleSettings(); 11 | const { i18n } = useVideoProps(); 12 | 13 | return ( 14 | { 19 | setState(() => ({ fontSize: Number(value) })); 20 | }} 21 | activeItemKey={state.fontSize.toString()} 22 | > 23 | {fontSizes.map((size) => ( 24 | 30 | ))} 31 | 32 | ); 33 | }; 34 | 35 | export default SubtitleFontSize; 36 | -------------------------------------------------------------------------------- /src/components/Controls/SettingsButton/SubtitleMenu/SubtitleMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useVideoProps } from '../../../../contexts/VideoPropsContext'; 3 | import { useVideoState } from '../../../../contexts/VideoStateContext'; 4 | import SubtitleIcon from '../../../icons/SubtitleIcon'; 5 | import NestedMenu from '../../../NestedMenu'; 6 | import SubtitleSettings from './SubtitleSettings'; 7 | 8 | const SubtitleMenu = () => { 9 | const { state, setState } = useVideoState(); 10 | const { i18n } = useVideoProps(); 11 | 12 | const handleSubtitleChange = (value: string) => { 13 | if (value === 'off') { 14 | setState((prev) => ({ 15 | ...prev, 16 | isSubtitleDisabled: true, 17 | currentSubtitle: null, 18 | })); 19 | 20 | return; 21 | } 22 | 23 | setState((prev) => ({ 24 | ...prev, 25 | isSubtitleDisabled: false, 26 | currentSubtitle: value, 27 | })); 28 | }; 29 | 30 | return state.subtitles.length ? ( 31 | } 42 | onChange={handleSubtitleChange} 43 | > 44 | 45 | 46 | 47 | 48 | {state.subtitles.map((subtitle) => ( 49 | 55 | ))} 56 | 57 | ) : null; 58 | }; 59 | 60 | export default React.memo(SubtitleMenu); 61 | -------------------------------------------------------------------------------- /src/components/Controls/SettingsButton/SubtitleMenu/SubtitleSettings.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { 4 | defaultSubtitleSettings, 5 | useSubtitleSettings, 6 | } from '../../../../contexts/SubtitleSettingsContext'; 7 | import { useVideoProps } from '../../../../contexts/VideoPropsContext'; 8 | import SettingsIcon from '../../../icons/SettingsIcon'; 9 | import NestedMenu from '../../../NestedMenu'; 10 | import { SubMenuProps } from '../../../NestedMenu/NestedMenu'; 11 | import SubtitleBackgroundOpacity from './SubtitleBackgroundOpacity'; 12 | import SubtitleFontOpacity from './SubtitleFontOpacity'; 13 | import SubtitleFontSize from './SubtitleFontSize'; 14 | import SubtitleTextStyle from './SubtitleTextStyle'; 15 | 16 | const SubtitleSettings: React.FC> = (props) => { 17 | const { setState } = useSubtitleSettings(); 18 | const { i18n } = useVideoProps(); 19 | 20 | return ( 21 | } 26 | onChange={(val) => { 27 | if (val !== 'reset') return; 28 | 29 | setState(() => defaultSubtitleSettings); 30 | }} 31 | > 32 | 33 | 34 | 35 | 36 | 37 | 42 | 43 | ); 44 | }; 45 | 46 | export default React.memo(SubtitleSettings); 47 | -------------------------------------------------------------------------------- /src/components/Controls/SettingsButton/SubtitleMenu/SubtitleTextStyle.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { useSubtitleSettings } from '../../../../contexts/SubtitleSettingsContext'; 4 | import { useVideoProps } from '../../../../contexts/VideoPropsContext'; 5 | import NestedMenu, { SubMenuProps } from '../../../NestedMenu/NestedMenu'; 6 | 7 | const SubtitleTextStyle: React.FC> = (props) => { 8 | const { state, setState } = useSubtitleSettings(); 9 | const { i18n } = useVideoProps(); 10 | 11 | return ( 12 | { 17 | // @ts-ignore 18 | setState(() => ({ textStyle: value })); 19 | }} 20 | activeItemKey={state.textStyle} 21 | > 22 | 27 | 32 | 33 | ); 34 | }; 35 | 36 | export default SubtitleTextStyle; 37 | -------------------------------------------------------------------------------- /src/components/Controls/SettingsButton/SubtitleMenu/index.ts: -------------------------------------------------------------------------------- 1 | import SubtitleMenu from './SubtitleMenu'; 2 | 3 | export default SubtitleMenu; 4 | -------------------------------------------------------------------------------- /src/components/Controls/SettingsButton/index.tsx: -------------------------------------------------------------------------------- 1 | import SettingsButton from './SettingsButton'; 2 | 3 | export default SettingsButton; 4 | -------------------------------------------------------------------------------- /src/components/Controls/SubtitleButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useVideoProps } from '../../contexts/VideoPropsContext'; 3 | import { useVideoState } from '../../contexts/VideoStateContext'; 4 | import SubtitleIcon from '../icons/SubtitleIcon'; 5 | import SubtitleOffIcon from '../icons/SubtitleOffIcon'; 6 | import ControlButton from './ControlButton'; 7 | 8 | const SubtitleButton = () => { 9 | const { state, setState } = useVideoState(); 10 | const { i18n } = useVideoProps(); 11 | 12 | const toggle = React.useCallback(() => { 13 | setState((prev) => ({ 14 | isSubtitleDisabled: !prev.isSubtitleDisabled, 15 | })); 16 | }, [setState]); 17 | 18 | return state?.subtitles?.length ? ( 19 | 27 | {state.isSubtitleDisabled ? : } 28 | 29 | ) : null; 30 | }; 31 | 32 | export default React.memo(SubtitleButton); 33 | -------------------------------------------------------------------------------- /src/components/Controls/ThumbnailHover.tsx: -------------------------------------------------------------------------------- 1 | import { parse } from '@plussub/srt-vtt-parser'; 2 | import { Entry } from '@plussub/srt-vtt-parser/dist/src/types'; 3 | import React, { useEffect, useLayoutEffect, useMemo, useState } from 'react'; 4 | import { buildAbsoluteURL } from 'url-toolkit'; 5 | import { PLAYER_CONTAINER_CLASS } from '../../constants'; 6 | import { useVideo, useVideoProps } from '../../contexts'; 7 | import { usePopover } from '../../hooks'; 8 | import { isValidUrl } from '../../utils'; 9 | import Portal from '../Portal'; 10 | import styles from './ProgressSlider/ProgressSlider.module.css'; 11 | 12 | const playerContainerClass = '.' + PLAYER_CONTAINER_CLASS; 13 | 14 | interface ThumbnailHoverProps { 15 | hoverPercent: number; 16 | } 17 | 18 | const ThumbnailHover: React.FC = ({ hoverPercent }) => { 19 | const [portalElement, setPortalElement] = useState( 20 | null 21 | ); 22 | const [thumbnailEntries, setThumbnailEntries] = useState([]); 23 | const { thumbnail } = useVideoProps(); 24 | const { videoEl } = useVideo(); 25 | 26 | const { floatingRef, referenceRef, update, strategy, x, y } = usePopover< 27 | HTMLDivElement, 28 | HTMLDivElement 29 | >({ 30 | offset: 10, 31 | strategy: 'fixed', 32 | overflowElement: playerContainerClass, 33 | position: 'top', 34 | }); 35 | 36 | useLayoutEffect(() => { 37 | const el = document.querySelector(playerContainerClass) as HTMLDivElement; 38 | 39 | if (!el) return; 40 | 41 | setPortalElement(el); 42 | 43 | update(); 44 | }, [update]); 45 | 46 | useEffect(() => { 47 | if (!thumbnail) { 48 | setThumbnailEntries([]); 49 | 50 | return; 51 | } 52 | if (!videoEl) return; 53 | 54 | const fetchThumbnails = async () => { 55 | const response = await fetch(thumbnail); 56 | 57 | const text = await response.text(); 58 | 59 | const { entries = [] } = parse(text); 60 | 61 | setThumbnailEntries(entries); 62 | }; 63 | 64 | fetchThumbnails(); 65 | }, [thumbnail, videoEl]); 66 | 67 | const currentThumbnail = useMemo(() => { 68 | if (!thumbnail) return undefined; 69 | if (!thumbnailEntries?.length) return undefined; 70 | if (!videoEl?.duration) return undefined; 71 | 72 | const currentTime = (hoverPercent / 100) * videoEl.duration * 1000; 73 | 74 | const currentEntry = thumbnailEntries.find( 75 | (entry) => entry.from <= currentTime && entry.to > currentTime 76 | ); 77 | 78 | if (!currentEntry?.text) return undefined; 79 | 80 | const thumbnailUrlRaw = isValidUrl(currentEntry.text) 81 | ? currentEntry.text 82 | : buildAbsoluteURL(thumbnail, currentEntry.text); 83 | 84 | const { origin, pathname } = new URL(thumbnailUrlRaw); 85 | 86 | const thumbnailUrl = origin + pathname; 87 | 88 | const [x, y, w, h] = thumbnailUrlRaw 89 | ?.split('=')[1] 90 | .split(',') 91 | .map((a) => a.trim()); 92 | 93 | // Update thumbnail position 94 | update(); 95 | 96 | return { 97 | rect: { 98 | x: -1 * Number(x), 99 | y: -1 * Number(y), 100 | w: Number(w), 101 | h: Number(h), 102 | }, 103 | url: thumbnailUrl, 104 | }; 105 | }, [hoverPercent, thumbnail, thumbnailEntries, update, videoEl?.duration]); 106 | 107 | return currentThumbnail && portalElement ? ( 108 | 109 |
114 | 115 | 116 |
0 ? 'block' : 'none', 125 | width: currentThumbnail.rect.w, 126 | height: currentThumbnail.rect.h, 127 | backgroundImage: `url(${currentThumbnail.url})`, 128 | backgroundPositionX: currentThumbnail.rect.x, 129 | backgroundPositionY: currentThumbnail.rect.y, 130 | backgroundRepeat: 'no-repeat', 131 | }} 132 | /> 133 | 134 | 135 | ) : null; 136 | }; 137 | 138 | export default ThumbnailHover; 139 | -------------------------------------------------------------------------------- /src/components/Controls/TimeIndicator.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { useVideo } from '../../contexts/VideoContext'; 4 | import { convertTime } from '../../utils'; 5 | 6 | const TimeIndicator = () => { 7 | const { videoState } = useVideo(); 8 | 9 | return ( 10 |

11 | {convertTime(videoState.currentTime)} /{' '} 12 | {convertTime(videoState.duration || 0)} 13 |

14 | ); 15 | }; 16 | 17 | export default React.memo(TimeIndicator); 18 | -------------------------------------------------------------------------------- /src/components/Controls/VolumeButton/VolumeButton.module.css: -------------------------------------------------------------------------------- 1 | .buttonContainer { 2 | display: flex; 3 | align-items: center; 4 | } 5 | 6 | .buttonContainer:hover .sliderContainer { 7 | width: 4rem !important; 8 | } 9 | 10 | .buttonContainer:hover .dot { 11 | opacity: 1; 12 | } 13 | 14 | .sliderContainer { 15 | margin-left: 0.5rem; 16 | width: 0 !important; 17 | align-items: center; 18 | display: flex; 19 | transition: all 200ms; 20 | transform-origin: left center; 21 | height: 5px !important; 22 | position: relative; 23 | } 24 | 25 | .backgroundBar { 26 | background-color: rgba(255, 255, 255, 0.3); 27 | } 28 | 29 | .mainBar { 30 | background-color: red; 31 | } 32 | 33 | .dot { 34 | background-color: red; 35 | transition: opacity 300ms; 36 | opacity: 0; 37 | } 38 | -------------------------------------------------------------------------------- /src/components/Controls/VolumeButton/VolumeButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useMemo, useRef } from 'react'; 2 | import { useVideo } from '../../../contexts/VideoContext'; 3 | import { useVideoProps } from '../../../contexts/VideoPropsContext'; 4 | import useHotKey, { parseHotKey } from '../../../hooks/useHotKey'; 5 | import { stringInterpolate } from '../../../utils'; 6 | import VolumeMutedIcon from '../../icons/VolumeMutedIcon'; 7 | import VolumeOneIcon from '../../icons/VolumeOneIcon'; 8 | import VolumeThreeIcon from '../../icons/VolumeThreeIcon'; 9 | import VolumeTwoIcon from '../../icons/VolumeTwoIcon'; 10 | import Slider from '../../Slider'; 11 | import ControlButton from '../ControlButton'; 12 | import styles from './VolumeButton.module.css'; 13 | 14 | const VolumeComponents = { 15 | 0: VolumeMutedIcon, 16 | 0.25: VolumeOneIcon, 17 | 0.5: VolumeTwoIcon, 18 | 1: VolumeThreeIcon, 19 | }; 20 | 21 | const VolumeButton = () => { 22 | const { videoState, videoEl } = useVideo(); 23 | const { i18n } = useVideoProps(); 24 | const hotkey = useHotKey('volume'); 25 | const previousVolume = useRef(videoState.volume); 26 | 27 | const VolumeComponent = useMemo(() => { 28 | const entries = Object.entries(VolumeComponents).sort( 29 | (a, b) => Number(a[0]) - Number(b[0]) 30 | ); 31 | 32 | for (const [key, value] of entries) { 33 | if (videoState.volume <= Number(key)) { 34 | return value; 35 | } 36 | } 37 | 38 | return VolumeMutedIcon; 39 | }, [videoState.volume]); 40 | 41 | const handleClick = useCallback(() => { 42 | if (!videoEl) return; 43 | 44 | if (videoEl.volume === 0) { 45 | videoEl.volume = previousVolume.current; 46 | } else { 47 | previousVolume.current = videoEl.volume; 48 | videoEl.volume = 0; 49 | } 50 | }, [videoEl]); 51 | 52 | const handleVolumeChange = useCallback( 53 | (percent: number) => { 54 | if (!videoEl) return; 55 | 56 | videoEl.volume = percent / 100; 57 | }, 58 | [videoEl] 59 | ); 60 | 61 | return ( 62 |
63 | 75 | 76 | 77 | 78 | 83 | 87 | 88 | 89 | 90 |
91 | ); 92 | }; 93 | 94 | export default VolumeButton; 95 | -------------------------------------------------------------------------------- /src/components/Controls/VolumeButton/index.tsx: -------------------------------------------------------------------------------- 1 | import VolumeButton from './VolumeButton'; 2 | 3 | export default VolumeButton; 4 | -------------------------------------------------------------------------------- /src/components/Controls/index.tsx: -------------------------------------------------------------------------------- 1 | export { default as ControlButton } from './ControlButton'; 2 | export { default as ProgressSlider } from './ProgressSlider'; 3 | export { default as SettingsButton } from './SettingsButton'; 4 | export { default as VolumeButton } from './VolumeButton'; 5 | export { default as BackwardButton } from './BackwardButton'; 6 | export { default as ForwardButton } from './ForwardButton'; 7 | export { default as FullscreenButton } from './FullscreenButton'; 8 | export { default as PlayPauseButton } from './PlayPauseButton'; 9 | export { default as SubtitleButton } from './SubtitleButton'; 10 | export { default as TimeIndicator } from './TimeIndicator'; 11 | export { default as MobileVolumeSlider } from '../Indicator/MobileVolumeSlider'; 12 | export { default as ThumbnailHover } from './ThumbnailHover'; 13 | export { default as ScreenshotButton } from './ScreenshotButton'; 14 | 15 | import Controls from './Controls'; 16 | 17 | export default Controls; 18 | -------------------------------------------------------------------------------- /src/components/DefaultUI/DefaultUI.module.css: -------------------------------------------------------------------------------- 1 | /* add css module styles here (optional) */ 2 | 3 | .container { 4 | width: 100%; 5 | height: 100%; 6 | background-color: black; 7 | position: relative; 8 | } 9 | 10 | .playerContainer { 11 | width: 100%; 12 | height: 100%; 13 | } 14 | 15 | .controlsContainer { 16 | width: 100%; 17 | position: absolute; 18 | bottom: 0; 19 | z-index: 2; 20 | } 21 | 22 | .overlayContainer { 23 | position: absolute; 24 | z-index: 1; 25 | top: 0; 26 | left: 0; 27 | right: 0; 28 | bottom: 0; 29 | } 30 | -------------------------------------------------------------------------------- /src/components/DefaultUI/index.ts: -------------------------------------------------------------------------------- 1 | import DefaultUI from './DefaultUI'; 2 | 3 | export default DefaultUI; 4 | -------------------------------------------------------------------------------- /src/components/Dialog/Dialog.module.css: -------------------------------------------------------------------------------- 1 | .dialogContainer, 2 | .dialogOverlay { 3 | position: fixed; 4 | top: 0; 5 | right: 0; 6 | bottom: 0; 7 | left: 0; 8 | z-index: 9999; 9 | } 10 | 11 | .dialogContainer { 12 | z-index: 9999; 13 | display: flex; 14 | margin: 0; 15 | transition: all 200ms; 16 | } 17 | 18 | .dialogContainer[aria-hidden='true'] { 19 | visibility: hidden; 20 | opacity: 0; 21 | } 22 | 23 | .dialogOverlay { 24 | background-color: rgba(0, 0, 0, 0.8); 25 | } 26 | 27 | .dialogContent { 28 | z-index: 9999; 29 | position: relative; 30 | background-color: rgba(0, 0, 0, 0.9); 31 | width: auto; 32 | margin: auto; 33 | } 34 | 35 | .dialogContentMobile { 36 | width: 100%; 37 | margin-top: auto; 38 | margin-left: 0; 39 | margin-right: 0; 40 | margin-bottom: 0; 41 | } 42 | 43 | .dialogContainer > .dialogContentMobile { 44 | transition: all 200ms; 45 | transform: translateY(0%); 46 | } 47 | 48 | .dialogContainer[aria-hidden='true'] > .dialogContentMobile { 49 | transform: translateY(20%); 50 | } 51 | -------------------------------------------------------------------------------- /src/components/Dialog/Dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { classNames } from '../../utils'; 3 | import { isMobile } from '../../utils/device'; 4 | import Portal from '../Portal'; 5 | import styles from './Dialog.module.css'; 6 | 7 | interface DialogProps { 8 | reference: React.ReactNode; 9 | portalSelector?: string; 10 | } 11 | 12 | const Dialog: React.FC = ({ 13 | reference, 14 | children, 15 | portalSelector, 16 | }) => { 17 | const [portalElement, setPortalElement] = React.useState( 18 | document.body 19 | ); 20 | const [show, setShow] = React.useState(false); 21 | 22 | React.useLayoutEffect(() => { 23 | if (!portalSelector) return; 24 | 25 | const el = document.querySelector(portalSelector); 26 | 27 | if (!el) return; 28 | 29 | setPortalElement(el); 30 | }, [portalSelector]); 31 | 32 | return ( 33 | 34 |
setShow(true)}>{reference}
35 | 36 | 37 |
38 |
setShow(false)} 40 | className={styles.dialogOverlay} 41 | /> 42 | 43 |
49 | {children} 50 |
51 |
52 | 53 | 54 | ); 55 | }; 56 | 57 | export default Dialog; 58 | -------------------------------------------------------------------------------- /src/components/Dialog/index.tsx: -------------------------------------------------------------------------------- 1 | import Dialog from './Dialog'; 2 | 3 | export default Dialog; 4 | -------------------------------------------------------------------------------- /src/components/Indicator/BackwardIndicator.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import BackwardIcon from '../icons/BackwardIcon'; 4 | import Indicator, { createIndicator } from './Indicator'; 5 | 6 | const BackwardIndicator = createIndicator((props, ref) => { 7 | return ( 8 | 17 |
18 | 19 |
20 |
21 | ); 22 | }); 23 | 24 | export default React.memo(BackwardIndicator); 25 | -------------------------------------------------------------------------------- /src/components/Indicator/ForwardIndicator.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import ForwardIcon from '../icons/ForwardIcon'; 4 | import Indicator, { createIndicator } from './Indicator'; 5 | 6 | const ForwardIndicator = createIndicator((props, ref) => { 7 | return ( 8 | 17 |
18 | 19 |
20 |
21 | ); 22 | }); 23 | 24 | export default React.memo(ForwardIndicator); 25 | -------------------------------------------------------------------------------- /src/components/Indicator/Indicator.module.css: -------------------------------------------------------------------------------- 1 | .indicator { 2 | width: 5rem; 3 | height: 5rem; 4 | border-radius: 50%; 5 | animation: fadeOut 500ms linear forwards; 6 | display: flex; 7 | justify-content: center; 8 | align-items: center; 9 | background-color: rgba(0, 0, 0, 0.8); 10 | position: absolute; 11 | z-index: 9999; 12 | } 13 | 14 | @keyframes fadeOut { 15 | 0% { 16 | opacity: 1; 17 | visibility: visible; 18 | } 19 | 20 | 100% { 21 | opacity: 0; 22 | visibility: hidden; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/components/Indicator/Indicator.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { PLAYER_CONTAINER_CLASS } from '../../constants'; 3 | import { useInteract } from '../../contexts'; 4 | import { classNames } from '../../utils'; 5 | import { isDesktop } from '../../utils/device'; 6 | import Portal from '../Portal'; 7 | import styles from './Indicator.module.css'; 8 | 9 | export interface IndicatorRef { 10 | show(shouldReshow?: boolean): void; 11 | hide(): void; 12 | } 13 | 14 | export const createIndicator = ( 15 | component: React.ForwardRefRenderFunction 16 | ) => { 17 | return React.forwardRef(component); 18 | }; 19 | 20 | interface BaseIndicatorProps extends React.HTMLAttributes { 21 | className?: string; 22 | animationTime?: number; 23 | } 24 | 25 | const ANIMATION_TIME = 500; 26 | 27 | export const BaseIndicator = React.forwardRef( 28 | ( 29 | { className = '', children = '', animationTime = ANIMATION_TIME, ...props }, 30 | ref 31 | ) => { 32 | const [show, setShow] = React.useState(false); 33 | const [container, setContainer] = React.useState(); 34 | const innerRef = React.useRef(null); 35 | const { setIsShowingIndicator } = useInteract(); 36 | const timeout = React.useRef(); 37 | 38 | React.useImperativeHandle(ref, () => ({ 39 | show: (shouldReshow = true) => { 40 | if (timeout.current) { 41 | clearTimeout(timeout.current); 42 | } 43 | 44 | if (shouldReshow) { 45 | setShow(false); 46 | } 47 | 48 | setTimeout(() => { 49 | setShow(true); 50 | 51 | setIsShowingIndicator(true); 52 | }, 0); 53 | 54 | timeout.current = setTimeout(() => { 55 | setShow(false); 56 | 57 | setIsShowingIndicator(false); 58 | }, animationTime); 59 | }, 60 | hide: () => setShow(false), 61 | })); 62 | 63 | React.useLayoutEffect(() => { 64 | const containerEl = document.querySelector('.' + PLAYER_CONTAINER_CLASS); 65 | 66 | if (!containerEl) return; 67 | 68 | setContainer(containerEl); 69 | }, []); 70 | 71 | return ( 72 | 73 | {show && ( 74 |
75 | {children} 76 |
77 | )} 78 |
79 | ); 80 | } 81 | ); 82 | 83 | const Indicator = React.forwardRef( 84 | ({ children, className = '', ...props }, ref) => { 85 | return isDesktop ? ( 86 | 91 | {children} 92 | 93 | ) : null; 94 | } 95 | ); 96 | 97 | BaseIndicator.displayName = 'BaseIndicator'; 98 | Indicator.displayName = 'Indicator'; 99 | 100 | export default Indicator; 101 | -------------------------------------------------------------------------------- /src/components/Indicator/MobileBackwardIndicator/MobileBackwardIndicator.module.css: -------------------------------------------------------------------------------- 1 | .indicator { 2 | background-color: rgba(0, 0, 0, 0.6); 3 | position: absolute; 4 | left: 0; 5 | top: 0; 6 | width: 45vw; 7 | height: 100%; 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | flex-direction: column; 12 | border-radius: 0% 20% 20% 0% / 50% 50% 50% 50%; 13 | animation: fadeOut 500ms linear forwards; 14 | z-index: 50; 15 | } 16 | 17 | .iconContainer { 18 | width: 4rem; 19 | height: 4rem; 20 | animation: rotate 500ms cubic-bezier(0.32, 0.94, 0.99, 1.12); 21 | } 22 | 23 | @keyframes fadeOut { 24 | 0% { 25 | opacity: 1; 26 | visibility: visible; 27 | } 28 | 29 | 90% { 30 | opacity: 1; 31 | visibility: visible; 32 | } 33 | 34 | 100% { 35 | opacity: 0; 36 | visibility: hidden; 37 | } 38 | } 39 | 40 | @keyframes rotate { 41 | 0% { 42 | transform: rotate(0deg); 43 | } 44 | 45 | 50% { 46 | transform: rotate(-45deg); 47 | } 48 | 49 | 100% { 50 | transform: rotate(0deg); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/components/Indicator/MobileBackwardIndicator/MobileBackwardIndicator.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import BackwardIcon from '../../icons/BackwardIcon'; 3 | import { BaseIndicator, createIndicator } from '../Indicator'; 4 | import styles from './MobileBackwardIndicator.module.css'; 5 | 6 | const MobileBackwardIndicator = createIndicator((props, ref) => { 7 | return ( 8 | 9 |
10 | 11 |
12 |
13 | ); 14 | }); 15 | 16 | export default MobileBackwardIndicator; 17 | -------------------------------------------------------------------------------- /src/components/Indicator/MobileBackwardIndicator/index.ts: -------------------------------------------------------------------------------- 1 | import MobileBackwardIndicator from './MobileBackwardIndicator'; 2 | 3 | export default MobileBackwardIndicator; 4 | -------------------------------------------------------------------------------- /src/components/Indicator/MobileForwardIndicator/MobileForwardIndicator.module.css: -------------------------------------------------------------------------------- 1 | .indicator { 2 | background-color: rgba(0, 0, 0, 0.6); 3 | position: absolute; 4 | right: 0; 5 | top: 0; 6 | width: 45vw; 7 | height: 100%; 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | flex-direction: column; 12 | border-radius: 20% 0% 0% 20% / 50% 50% 50% 50%; 13 | animation: fadeOut 500ms linear forwards; 14 | z-index: 50; 15 | } 16 | 17 | .iconContainer { 18 | width: 4rem; 19 | height: 4rem; 20 | animation: rotate 500ms cubic-bezier(0.32, 0.94, 0.99, 1.12); 21 | } 22 | 23 | @keyframes fadeOut { 24 | 0% { 25 | opacity: 1; 26 | visibility: visible; 27 | } 28 | 29 | 90% { 30 | opacity: 1; 31 | visibility: visible; 32 | } 33 | 34 | 100% { 35 | opacity: 0; 36 | visibility: hidden; 37 | } 38 | } 39 | 40 | @keyframes rotate { 41 | 0% { 42 | transform: rotate(0deg); 43 | } 44 | 45 | 50% { 46 | transform: rotate(45deg); 47 | } 48 | 49 | 100% { 50 | transform: rotate(0deg); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/components/Indicator/MobileForwardIndicator/MobileForwardIndicator.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import ForwardIcon from '../../icons/ForwardIcon'; 3 | import { BaseIndicator, createIndicator } from '../Indicator'; 4 | import styles from './MobileForwardIndicator.module.css'; 5 | 6 | const MobileForwardIndicator = createIndicator((props, ref) => { 7 | return ( 8 | 9 |
10 | 11 |
12 |
13 | ); 14 | }); 15 | 16 | export default MobileForwardIndicator; 17 | -------------------------------------------------------------------------------- /src/components/Indicator/MobileForwardIndicator/index.ts: -------------------------------------------------------------------------------- 1 | import MobileForwardIndicator from './MobileForwardIndicator'; 2 | 3 | export default MobileForwardIndicator; 4 | -------------------------------------------------------------------------------- /src/components/Indicator/MobileVolumeSlider/MobileVolumeSlider.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 0 1rem; 3 | height: 80%; 4 | width: max-content; 5 | display: flex; 6 | flex-direction: column; 7 | align-items: center; 8 | justify-content: center; 9 | /* animation: fadeOut 500ms linear forwards; */ 10 | z-index: 50; 11 | position: absolute; 12 | left: 1rem; 13 | top: 50%; 14 | transform: translateY(-50%); 15 | width: 4rem; 16 | } 17 | 18 | .sliderContainer { 19 | height: 100%; 20 | width: 100%; 21 | border-radius: 4rem; 22 | position: relative; 23 | background-color: #333333; 24 | } 25 | 26 | .slider { 27 | width: 100%; 28 | border-radius: 4rem; 29 | background-color: red; 30 | position: absolute; 31 | bottom: 0; 32 | } 33 | 34 | .volumeIcon { 35 | margin-bottom: 0.5rem; 36 | width: 1.5rem; 37 | height: 1.5rem; 38 | } 39 | 40 | @keyframes fadeOut { 41 | 0% { 42 | opacity: 1; 43 | visibility: visible; 44 | } 45 | 46 | 90% { 47 | opacity: 1; 48 | visibility: visible; 49 | } 50 | 51 | 100% { 52 | opacity: 0; 53 | visibility: hidden; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/components/Indicator/MobileVolumeSlider/MobileVolumeSlider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { useVideo } from '../../../contexts'; 3 | import { classNames } from '../../../utils'; 4 | import VolumeMutedIcon from '../../icons/VolumeMutedIcon'; 5 | import VolumeOneIcon from '../../icons/VolumeOneIcon'; 6 | import VolumeThreeIcon from '../../icons/VolumeThreeIcon'; 7 | import VolumeTwoIcon from '../../icons/VolumeTwoIcon'; 8 | import { BaseIndicator, createIndicator } from '../Indicator'; 9 | import styles from './MobileVolumeSlider.module.css'; 10 | 11 | const VolumeComponents = { 12 | 0: VolumeMutedIcon, 13 | 0.25: VolumeOneIcon, 14 | 0.5: VolumeTwoIcon, 15 | 1: VolumeThreeIcon, 16 | }; 17 | 18 | const MobileVolumeSlider = createIndicator((props, ref) => { 19 | const { videoState } = useVideo(); 20 | 21 | const VolumeComponent = useMemo(() => { 22 | const entries = Object.entries(VolumeComponents).sort( 23 | (a, b) => Number(a[0]) - Number(b[0]) 24 | ); 25 | 26 | for (const [key, value] of entries) { 27 | if (videoState.volume <= Number(key)) { 28 | return value; 29 | } 30 | } 31 | 32 | return VolumeMutedIcon; 33 | }, [videoState.volume]); 34 | 35 | return ( 36 | 42 |
43 | 44 |
45 | 46 |
47 |
51 |
52 |
53 | ); 54 | }); 55 | 56 | export default MobileVolumeSlider; 57 | -------------------------------------------------------------------------------- /src/components/Indicator/MobileVolumeSlider/index.ts: -------------------------------------------------------------------------------- 1 | import MobileVolumeSlider from './MobileVolumeSlider'; 2 | 3 | export default MobileVolumeSlider; 4 | -------------------------------------------------------------------------------- /src/components/Indicator/PauseIndicator.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import PauseIcon from '../icons/PauseIcon'; 4 | import Indicator, { createIndicator } from './Indicator'; 5 | 6 | const PauseIndicator = createIndicator((props, ref) => { 7 | return ( 8 | 17 |
18 | 19 |
20 |
21 | ); 22 | }); 23 | 24 | export default React.memo(PauseIndicator); 25 | -------------------------------------------------------------------------------- /src/components/Indicator/PlayIndicator.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import PlayIcon from '../icons/PlayIcon'; 4 | import Indicator, { createIndicator } from './Indicator'; 5 | 6 | const PlayIndicator = createIndicator((props, ref) => { 7 | return ( 8 | 17 |
18 | 19 |
20 |
21 | ); 22 | }); 23 | 24 | export default React.memo(PlayIndicator); 25 | -------------------------------------------------------------------------------- /src/components/Indicator/index.ts: -------------------------------------------------------------------------------- 1 | export { default as MobileBackwardIndicator } from './MobileBackwardIndicator'; 2 | export { default as MobileForwardIndicator } from './MobileForwardIndicator'; 3 | export { default as PauseIndicator } from './PauseIndicator'; 4 | export { default as PlayIndicator } from './PlayIndicator'; 5 | 6 | import Indicator from './Indicator'; 7 | 8 | export default Indicator; 9 | -------------------------------------------------------------------------------- /src/components/MobileControls/MobileControls.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 100%; 3 | padding: 1rem; 4 | visibility: visible; 5 | opacity: 1; 6 | transition: visibility 300ms, opacity 300ms; 7 | } 8 | 9 | .container.inactive { 10 | visibility: hidden; 11 | opacity: 0; 12 | } 13 | 14 | .controlsContainer { 15 | display: flex; 16 | width: 100%; 17 | justify-content: space-between; 18 | align-items: center; 19 | } 20 | 21 | .fullscreenButton { 22 | width: 1rem; 23 | height: 1rem; 24 | } 25 | 26 | .sliderContainer { 27 | margin-top: 0.5rem; 28 | } 29 | -------------------------------------------------------------------------------- /src/components/MobileControls/MobileControls.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useVideo } from '../../contexts/VideoContext'; 3 | import { useInteract } from '../../contexts/VideoInteractingContext'; 4 | import { classNames } from '../../utils'; 5 | import FullscreenButton from '../Controls/FullscreenButton'; 6 | import ProgressSlider from '../Controls/ProgressSlider'; 7 | import TimeIndicator from '../Controls/TimeIndicator'; 8 | import styles from './MobileControls.module.css'; 9 | 10 | const MobileControls = () => { 11 | const { isInteracting, isShowingIndicator } = useInteract(); 12 | const { videoState } = useVideo(); 13 | 14 | const shouldInactive = React.useMemo(() => { 15 | return ( 16 | (!videoState.seeking && !isInteracting && !videoState.buffering) || 17 | isShowingIndicator 18 | ); 19 | }, [ 20 | isInteracting, 21 | isShowingIndicator, 22 | videoState.buffering, 23 | videoState.seeking, 24 | ]); 25 | 26 | return ( 27 |
33 |
34 | 35 | 36 |
37 | 38 |
39 |
40 | 41 |
42 | 43 |
44 |
45 | ); 46 | }; 47 | 48 | export default React.memo(MobileControls); 49 | -------------------------------------------------------------------------------- /src/components/MobileControls/index.tsx: -------------------------------------------------------------------------------- 1 | import MobileControls from './MobileControls'; 2 | 3 | export default MobileControls; 4 | -------------------------------------------------------------------------------- /src/components/MobileOverlay/MobileOverlay.module.css: -------------------------------------------------------------------------------- 1 | .overlayContainer { 2 | width: 100%; 3 | height: 100%; 4 | background-color: rgba(0, 0, 0, 0.8); 5 | visibility: visible; 6 | opacity: 1; 7 | transition: visibility 300ms, opacity 300ms; 8 | position: relative; 9 | } 10 | 11 | .overlayContainer.inactive { 12 | visibility: hidden; 13 | opacity: 0; 14 | pointer-events: none; 15 | } 16 | 17 | .overlayContainer.inactive * { 18 | pointer-events: none; 19 | } 20 | 21 | .uiContainer { 22 | visibility: visible; 23 | opacity: 1; 24 | transition: visibility 300ms, opacity 300ms; 25 | } 26 | 27 | .dragMessage { 28 | position: absolute; 29 | left: 50%; 30 | transform: translateX(-50%); 31 | margin-top: 1rem; 32 | visibility: visible; 33 | opacity: 1; 34 | transition: visibility 300ms, opacity 300ms; 35 | } 36 | 37 | .dragMessageIcon { 38 | width: 2rem; 39 | height: 2rem; 40 | } 41 | 42 | .inactive { 43 | visibility: hidden; 44 | opacity: 0; 45 | } 46 | 47 | .playerControlsContainer { 48 | position: absolute; 49 | width: 100%; 50 | top: 50%; 51 | transform: translateY(-50%); 52 | display: flex; 53 | align-items: center; 54 | justify-content: center; 55 | pointer-events: none; 56 | } 57 | 58 | .playerControlsInnerContainer { 59 | width: 100%; 60 | display: flex; 61 | align-items: center; 62 | justify-content: space-evenly; 63 | pointer-events: none; 64 | } 65 | 66 | .playerControlsInnerContainer > * { 67 | pointer-events: auto; 68 | } 69 | 70 | :global(.control-button) { 71 | color: white; 72 | } 73 | 74 | .backwardButton { 75 | width: 3rem; 76 | height: 3rem; 77 | } 78 | 79 | .playButton { 80 | width: 4rem; 81 | height: 4rem; 82 | } 83 | 84 | .forwardButton { 85 | width: 3rem; 86 | height: 3rem; 87 | } 88 | 89 | .mobileTopButtons { 90 | position: absolute; 91 | right: 1rem; 92 | top: 1rem; 93 | display: flex; 94 | align-items: center; 95 | gap: 0.5rem; 96 | } 97 | 98 | .mobileTopButtons :global(.control-button) { 99 | width: 2rem; 100 | height: 2rem; 101 | } 102 | -------------------------------------------------------------------------------- /src/components/MobileOverlay/MobileOverlay.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useVideo } from '../../contexts/VideoContext'; 3 | import { useInteract } from '../../contexts/VideoInteractingContext'; 4 | import { useVideoProps } from '../../contexts/VideoPropsContext'; 5 | import { classNames } from '../../utils'; 6 | import BackwardButton from '../Controls/BackwardButton'; 7 | import ForwardButton from '../Controls/ForwardButton'; 8 | import PlayPauseButton from '../Controls/PlayPauseButton'; 9 | import ScreenshotButton from '../Controls/ScreenshotButton'; 10 | import SettingsButton from '../Controls/SettingsButton'; 11 | import SliderIcon from '../icons/SliderIcon'; 12 | import TextIcon from '../TextIcon'; 13 | import styles from './MobileOverlay.module.css'; 14 | 15 | const MobileOverlay = () => { 16 | const { isInteracting, isShowingIndicator } = useInteract(); 17 | const { i18n } = useVideoProps(); 18 | const { videoState } = useVideo(); 19 | 20 | const shouldInactive = React.useMemo(() => { 21 | return ( 22 | (!isInteracting && !videoState.seeking && !videoState.buffering) || 23 | isShowingIndicator 24 | ); 25 | }, [ 26 | isInteracting, 27 | isShowingIndicator, 28 | videoState.buffering, 29 | videoState.seeking, 30 | ]); 31 | 32 | return ( 33 |
40 | 43 | 44 |
45 | } 46 | className={classNames( 47 | styles.dragMessage, 48 | !videoState.seeking && styles.inactive 49 | )} 50 | > 51 | {i18n.controls.sliderDragMessage} 52 | 53 | 54 |
60 |
61 |
62 |
63 | 64 |
65 | 66 |
67 | 68 |
69 | 70 |
71 | 72 |
73 |
74 |
75 | 76 |
77 | 78 | 79 |
80 |
81 |
82 | ); 83 | }; 84 | 85 | export default React.memo(MobileOverlay); 86 | -------------------------------------------------------------------------------- /src/components/MobileOverlay/index.tsx: -------------------------------------------------------------------------------- 1 | import MobileOverlay from './MobileOverlay'; 2 | 3 | export default MobileOverlay; 4 | -------------------------------------------------------------------------------- /src/components/NestedMenu/NestedMenu.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: relative; 3 | overflow-y: auto; 4 | padding: 0.5rem; 5 | height: 100%; 6 | width: 100%; 7 | color: white; 8 | } 9 | 10 | .goBackButton { 11 | margin-bottom: 1rem; 12 | transition: all 300ms; 13 | padding: 0.5rem; 14 | cursor: pointer; 15 | } 16 | 17 | .goBackButton:hover { 18 | background-color: rgba(255, 255, 255, 0.2); 19 | } 20 | 21 | .itemContainer > * + * { 22 | margin-top: 0.5rem; 23 | } 24 | 25 | .baseItem { 26 | position: relative; 27 | transition: all 300ms; 28 | padding: 0.5rem; 29 | cursor: pointer; 30 | border-radius: 0.125rem; 31 | display: flex; 32 | align-items: center; 33 | } 34 | 35 | .baseItem:hover { 36 | background-color: rgba(255, 255, 255, 0.2); 37 | } 38 | 39 | .baseItemTitle { 40 | padding-left: 2rem; 41 | } 42 | 43 | .activeIconContainer { 44 | position: absolute; 45 | left: 0.5rem; 46 | top: 50%; 47 | transform: translateY(-50%); 48 | width: 1.5rem; 49 | height: 1.5rem; 50 | } 51 | 52 | .subMenuContainer > * + * { 53 | margin-top: 0.5rem; 54 | } 55 | 56 | .subMenuSlotContainer { 57 | display: flex; 58 | align-items: center; 59 | position: absolute; 60 | right: 0.5rem; 61 | top: 50%; 62 | transform: translateY(-50%); 63 | } 64 | 65 | .subMenuSlotContainer > * + * { 66 | margin-left: 0.5rem; 67 | } 68 | 69 | .subMenuSlotTitle { 70 | color: #e5e7eb; 71 | display: -webkit-box; 72 | -webkit-line-clamp: 1; 73 | -webkit-box-orient: vertical; 74 | overflow: hidden; 75 | } 76 | 77 | .subMenuSlotIcon { 78 | width: 1.25rem; 79 | height: 1.25rem; 80 | } 81 | -------------------------------------------------------------------------------- /src/components/NestedMenu/NestedMenu.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | PropsWithChildren, 4 | useCallback, 5 | useContext, 6 | useMemo, 7 | useState, 8 | } from 'react'; 9 | import { classNames } from '../../utils'; 10 | import ArrowLeftIcon from '../icons/ArrowLeftIcon'; 11 | import ArrowRightIcon from '../icons/ArrowRightIcon'; 12 | import TextIcon from '../TextIcon'; 13 | import styles from './NestedMenu.module.css'; 14 | import CheckIcon from '../icons/CheckIcon'; 15 | interface Menu { 16 | title: string; 17 | menuKey: string; 18 | } 19 | 20 | interface ContextProps { 21 | activeMenu: Menu; 22 | push: (menu: Menu) => void; 23 | pop: () => void; 24 | } 25 | 26 | interface NestedMenuProps extends React.HTMLProps {} 27 | 28 | const defaultHistory: Menu[] = [{ menuKey: 'base', title: 'base' }]; 29 | 30 | const NestedMenuContext = createContext({ 31 | activeMenu: { menuKey: 'base', title: 'Base' }, 32 | push: () => {}, 33 | pop: () => {}, 34 | }); 35 | 36 | const NestedMenu = ({ 37 | children, 38 | className = '', 39 | ...props 40 | }: PropsWithChildren) => { 41 | const [history, setHistory] = useState(defaultHistory); 42 | 43 | const handleGoBack = useCallback(() => { 44 | setHistory((prev) => prev.slice(0, -1)); 45 | }, []); 46 | 47 | const historyPush = useCallback((menu: Menu) => { 48 | setHistory((prev) => [...prev, menu]); 49 | }, []); 50 | 51 | const historyPop = useCallback(() => { 52 | setHistory((prev) => prev.slice(0, -1)); 53 | }, []); 54 | 55 | const activeMenu = useMemo(() => history[history.length - 1], [history]); 56 | 57 | return ( 58 | 65 |
66 | {activeMenu.menuKey !== 'base' && ( 67 | 71 | 72 |
73 | } 74 | className={styles.goBackButton} 75 | > 76 | {activeMenu.title} 77 | 78 | )} 79 | 80 |
    {children}
81 |
82 | 83 | ); 84 | }; 85 | 86 | export interface ItemProps 87 | extends Omit, 'onChange'> { 88 | parentMenuKey?: string; 89 | itemKey: string; 90 | title: string; 91 | activeItemKey?: string; 92 | value: string; 93 | onChange?: (value: string) => void; 94 | } 95 | 96 | export interface BaseItemProps 97 | extends Omit, 'slot'> { 98 | title: string; 99 | isShown?: boolean; 100 | isActive?: boolean; 101 | activeIcon?: React.ReactNode; 102 | slot?: React.ReactNode; 103 | } 104 | 105 | const BaseItem = React.memo( 106 | ({ 107 | title, 108 | isShown, 109 | isActive, 110 | className = '', 111 | activeIcon, 112 | slot, 113 | ...props 114 | }: BaseItemProps) => { 115 | return isShown ? ( 116 |
  • 117 | {isActive && activeIcon && ( 118 |
    {activeIcon}
    119 | )} 120 | 121 |

    {title}

    122 | 123 | {slot} 124 |
  • 125 | ) : null; 126 | } 127 | ); 128 | 129 | BaseItem.displayName = 'BaseItem'; 130 | 131 | const Item = React.memo( 132 | ({ 133 | title, 134 | parentMenuKey = 'base', 135 | itemKey, 136 | activeItemKey, 137 | value, 138 | onChange, 139 | ...props 140 | }: ItemProps) => { 141 | const { activeMenu } = useContext(NestedMenuContext); 142 | 143 | const isMenuActive = parentMenuKey === activeMenu.menuKey; 144 | const isItemActive = activeItemKey === itemKey; 145 | 146 | return ( 147 | { 152 | onChange?.(value); 153 | }} 154 | activeIcon={} 155 | {...props} 156 | /> 157 | ); 158 | } 159 | ); 160 | 161 | Item.displayName = 'Item'; 162 | 163 | export interface SubMenuProps 164 | extends Omit, 'onChange'> { 165 | menuKey: string; 166 | title: string; 167 | activeItemKey?: string; 168 | parentMenuKey?: string; 169 | icon?: React.ReactNode; 170 | onChange?: (value: string) => void; 171 | } 172 | 173 | const SubMenu: React.FC = ({ 174 | children, 175 | menuKey, 176 | title, 177 | activeItemKey, 178 | parentMenuKey = 'base', 179 | className = '', 180 | icon, 181 | onChange, 182 | ...props 183 | }) => { 184 | const { activeMenu, push } = useContext(NestedMenuContext); 185 | 186 | const isActive = useMemo( 187 | () => activeMenu.menuKey === menuKey, 188 | [activeMenu.menuKey, menuKey] 189 | ); 190 | const isParentActive = useMemo( 191 | () => activeMenu.menuKey === parentMenuKey, 192 | [activeMenu.menuKey, parentMenuKey] 193 | ); 194 | 195 | const handleSetMenu = useCallback(() => { 196 | push({ 197 | menuKey, 198 | title, 199 | }); 200 | }, [menuKey, push, title]); 201 | 202 | const resolvedChildren: 203 | | React.ReactElement> 204 | | React.ReactPortal = 205 | React.isValidElement(children) && children.type === React.Fragment 206 | ? children.props.children 207 | : children; 208 | 209 | if (React.Children.count(resolvedChildren) === 0) { 210 | return null; 211 | } 212 | 213 | const childrenWithMenuKey = React.Children.map(resolvedChildren, (child) => { 214 | if (!React.isValidElement(child)) return; 215 | 216 | const newElement = React.cloneElement(child, { 217 | ...child.props, 218 | parentMenuKey: menuKey, 219 | activeItemKey, 220 | onChange, 221 | }); 222 | 223 | return newElement; 224 | }); 225 | 226 | const itemProps = React.Children.map(resolvedChildren, (child) => { 227 | if (!React.isValidElement(child)) return; 228 | 229 | return child.props as ItemProps; 230 | }); 231 | 232 | const activeItem = itemProps?.find((item) => item?.itemKey === activeItemKey); 233 | 234 | return isActive ? ( 235 |
      236 | {childrenWithMenuKey} 237 |
    238 | ) : isParentActive ? ( 239 | 247 | {activeItem?.title && ( 248 |

    {activeItem.title}

    249 | )} 250 | 251 |
    252 | 253 |
    254 |
    255 | } 256 | /> 257 | ) : ( 258 | {children} 259 | ); 260 | }; 261 | 262 | NestedMenu.Item = Item; 263 | NestedMenu.SubMenu = SubMenu; 264 | 265 | export default NestedMenu; 266 | -------------------------------------------------------------------------------- /src/components/NestedMenu/index.tsx: -------------------------------------------------------------------------------- 1 | import NestedMenu from './NestedMenu'; 2 | 3 | export default NestedMenu; 4 | -------------------------------------------------------------------------------- /src/components/Overlay/Overlay.module.css: -------------------------------------------------------------------------------- 1 | .overlayContainer { 2 | width: 100%; 3 | height: 100%; 4 | } 5 | -------------------------------------------------------------------------------- /src/components/Overlay/Overlay.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useVideo } from '../../contexts/VideoContext'; 3 | import styles from './Overlay.module.css'; 4 | 5 | const Overlay = () => { 6 | const { videoEl } = useVideo(); 7 | 8 | const handleToggleVideo = () => { 9 | if (!videoEl) return; 10 | 11 | if (videoEl.paused) { 12 | videoEl.play(); 13 | } else { 14 | videoEl.pause(); 15 | } 16 | }; 17 | 18 | return ( 19 |
    20 | ); 21 | }; 22 | 23 | export default Overlay; 24 | -------------------------------------------------------------------------------- /src/components/Overlay/index.tsx: -------------------------------------------------------------------------------- 1 | import Overlay from './Overlay'; 2 | 3 | export default Overlay; 4 | -------------------------------------------------------------------------------- /src/components/Player/Player.module.css: -------------------------------------------------------------------------------- 1 | .video { 2 | width: 100%; 3 | height: 100%; 4 | object-fit: contain; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/Player/index.tsx: -------------------------------------------------------------------------------- 1 | import Player from './Player'; 2 | 3 | export default Player; 4 | -------------------------------------------------------------------------------- /src/components/Popover/Popover.module.css: -------------------------------------------------------------------------------- 1 | .popperContainer { 2 | visibility: hidden; 3 | opacity: 0; 4 | transition: opacity 300ms, visibility 300ms; 5 | z-index: 50; 6 | } 7 | 8 | .popperContainer.isOpen { 9 | visibility: visible; 10 | opacity: 1; 11 | } 12 | -------------------------------------------------------------------------------- /src/components/Popover/Popover.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useLayoutEffect, useState } from 'react'; 2 | import usePopover, { UsePopoverOptions } from '../../hooks/usePopover'; 3 | import { classNames } from '../../utils'; 4 | import Portal from '../Portal'; 5 | import styles from './Popover.module.css'; 6 | 7 | export interface PopoverProps extends Partial { 8 | reference: React.ReactNode; 9 | referenceProps?: React.HTMLAttributes; 10 | popperProps?: React.HTMLAttributes; 11 | type?: 'click' | 'hover'; 12 | portalSelector?: string; 13 | } 14 | 15 | const noop = () => {}; 16 | 17 | const Popover: React.FC = ({ 18 | reference, 19 | children, 20 | popperProps = {}, 21 | referenceProps = {}, 22 | type = 'click', 23 | portalSelector, 24 | ...options 25 | }) => { 26 | const [portalElement, setPortalElement] = useState(document.body); 27 | const { floatingRef, referenceRef, update, strategy, x, y } = usePopover< 28 | HTMLDivElement, 29 | HTMLDivElement 30 | >({ offset: 10, strategy: 'fixed', ...options }); 31 | 32 | const { className: popperClassName = '', ...popperRest } = popperProps!; 33 | const { className: referenceClassName = '', ...referenceRest } = 34 | referenceProps!; 35 | 36 | const [isOpen, setIsOpen] = useState(false); 37 | 38 | const handleOpen: React.MouseEventHandler = useCallback( 39 | (e) => { 40 | e.stopPropagation(); 41 | e.preventDefault(); 42 | 43 | update(); 44 | 45 | setIsOpen(true); 46 | }, 47 | [update] 48 | ); 49 | 50 | const handleClose: React.MouseEventHandler = useCallback( 51 | (e) => { 52 | e.preventDefault(); 53 | e.stopPropagation(); 54 | 55 | update(); 56 | 57 | setIsOpen(false); 58 | }, 59 | [update] 60 | ); 61 | 62 | useLayoutEffect(() => { 63 | if (!portalSelector) return; 64 | 65 | const el = document.querySelector(portalSelector); 66 | 67 | if (!el) return; 68 | 69 | setPortalElement(el); 70 | 71 | update(); 72 | }, [portalSelector, update]); 73 | 74 | return ( 75 | 76 |
    84 | {reference} 85 |
    86 | 87 | 88 |
    102 | {children} 103 |
    104 | 105 | {isOpen && type === 'click' && ( 106 |
    117 | )} 118 |
    119 |
    120 | ); 121 | }; 122 | 123 | export default Popover; 124 | -------------------------------------------------------------------------------- /src/components/Popover/index.tsx: -------------------------------------------------------------------------------- 1 | import Popover from './Popover'; 2 | 3 | export default Popover; 4 | -------------------------------------------------------------------------------- /src/components/Portal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { createPortal } from 'react-dom'; 4 | 5 | interface PortalProps { 6 | element?: Element; 7 | } 8 | 9 | const Portal: React.FC = ({ 10 | element = document.body, 11 | children, 12 | }) => { 13 | return createPortal({children}, element); 14 | }; 15 | 16 | export default Portal; 17 | -------------------------------------------------------------------------------- /src/components/Slider/Slider.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | cursor: pointer; 3 | } 4 | 5 | .bar { 6 | position: absolute; 7 | } 8 | 9 | .dot { 10 | position: absolute; 11 | border-radius: 9999px; 12 | top: 50%; 13 | transform: translate(-50%) translateY(-50%); 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Slider/index.tsx: -------------------------------------------------------------------------------- 1 | import Slider from './Slider'; 2 | 3 | export default Slider; 4 | -------------------------------------------------------------------------------- /src/components/Subtitle/Subtitle.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: absolute; 3 | bottom: 1rem; 4 | left: 50%; 5 | transform: translateX(-50%); 6 | transition: 300ms; 7 | width: 80%; 8 | display: flex; 9 | align-items: center; 10 | justify-content: space-evenly; 11 | } 12 | 13 | .container.interacting { 14 | bottom: 6rem; 15 | } 16 | 17 | .text { 18 | width: fit-content; 19 | color: white; 20 | background-color: rgba(0, 0, 0, 0.8); 21 | border-radius: 0.125rem; 22 | line-height: 1.75rem; 23 | text-align: center; 24 | padding: 0.25rem 0.5rem; 25 | white-space: pre-wrap; 26 | } 27 | -------------------------------------------------------------------------------- /src/components/Subtitle/Subtitle.tsx: -------------------------------------------------------------------------------- 1 | import { parse } from '@plussub/srt-vtt-parser'; 2 | import React, { useEffect, useMemo, useState } from 'react'; 3 | import { buildAbsoluteURL } from 'url-toolkit'; 4 | import { useSubtitleSettings } from '../../contexts/SubtitleSettingsContext'; 5 | import { useVideo } from '../../contexts/VideoContext'; 6 | import { useInteract } from '../../contexts/VideoInteractingContext'; 7 | import { useVideoState } from '../../contexts/VideoStateContext'; 8 | import useTextScaling from '../../hooks/useTextScaling'; 9 | import { classNames, isValidUrl } from '../../utils'; 10 | import { isDesktop } from '../../utils/device'; 11 | import styles from './Subtitle.module.css'; 12 | 13 | const textStyles = { 14 | none: '', 15 | outline: `black 0px 0px 3px, black 0px 0px 3px, black 0px 0px 3px, black 0px 0px 3px, black 0px 0px 3px`, 16 | }; 17 | 18 | const BASE_FONT_SIZE = 16; 19 | const LINE_HEIHT_RATIO = 1.333; 20 | const PADDING_X_RATIO = 0.5; 21 | const PADDING_Y_RATIO = 0.25; 22 | 23 | const M3U8_SUBTITLE_REGEX = /.*\.(vtt|srt)/g; 24 | 25 | const requestSubtitle = async (url: string): Promise => { 26 | if (url.includes('vtt') || url.includes('srt')) { 27 | const response = await fetch(url); 28 | const text = await response.text(); 29 | 30 | return text; 31 | } 32 | 33 | if (url.includes('m3u8')) { 34 | const response = await fetch(url); 35 | const text = await response.text(); 36 | 37 | const matches = text.match(M3U8_SUBTITLE_REGEX); 38 | 39 | if (!matches?.length) return null; 40 | 41 | if (!matches[0]) return null; 42 | 43 | const nextUrl = isValidUrl(matches[0]) 44 | ? matches[0] 45 | : buildAbsoluteURL(url, matches[0]); 46 | 47 | return requestSubtitle(nextUrl); 48 | } 49 | 50 | return null; 51 | }; 52 | 53 | const Subtitle = () => { 54 | const { state } = useVideoState(); 55 | const { state: subtitleSettings } = useSubtitleSettings(); 56 | const { moderateScale } = useTextScaling(); 57 | const { videoEl } = useVideo(); 58 | const { isInteracting } = useInteract(); 59 | const [currentText, setCurrentText] = useState(''); 60 | const [subtitleText, setSubtitleText] = useState(''); 61 | const [isLoading, setIsLoading] = useState(false); 62 | 63 | const subtitle = useMemo( 64 | () => state.subtitles?.find((sub) => sub.lang === state.currentSubtitle), 65 | [state.subtitles, state.currentSubtitle] 66 | ); 67 | 68 | useEffect(() => { 69 | if (!subtitle?.file) return; 70 | 71 | const getSubtitle = async () => { 72 | setIsLoading(true); 73 | 74 | const text = await requestSubtitle(subtitle.file); 75 | 76 | setIsLoading(false); 77 | 78 | if (!text) return; 79 | 80 | setSubtitleText(text); 81 | }; 82 | 83 | getSubtitle(); 84 | }, [subtitle]); 85 | 86 | useEffect(() => { 87 | if (!subtitleText) return; 88 | if (!videoEl) return; 89 | 90 | const { entries = [] } = parse(subtitleText); 91 | 92 | const handleSubtitle = () => { 93 | const currentTime = videoEl.currentTime * 1000; 94 | const currentEntry = entries.find( 95 | (entry) => entry.from <= currentTime && entry.to >= currentTime 96 | ); 97 | 98 | setCurrentText(currentEntry?.text || ''); 99 | }; 100 | 101 | videoEl.addEventListener('timeupdate', handleSubtitle); 102 | 103 | return () => { 104 | videoEl.removeEventListener('timeupdate', handleSubtitle); 105 | }; 106 | // eslint-disable-next-line react-hooks/exhaustive-deps 107 | }, [subtitleText]); 108 | 109 | const fontSize = useMemo(() => { 110 | return moderateScale(subtitleSettings.fontSize * BASE_FONT_SIZE); 111 | }, [moderateScale, subtitleSettings.fontSize]); 112 | 113 | const padding = useMemo(() => { 114 | return { 115 | horizontal: fontSize * PADDING_X_RATIO, 116 | vertical: fontSize * PADDING_Y_RATIO, 117 | }; 118 | }, [fontSize]); 119 | 120 | const lineHeight = useMemo(() => { 121 | return fontSize * LINE_HEIHT_RATIO; 122 | }, [fontSize]); 123 | 124 | if (isLoading || !subtitle?.file || !currentText || state.isSubtitleDisabled) 125 | return null; 126 | 127 | return ( 128 |
    134 |

    151 |
    152 | ); 153 | }; 154 | 155 | export default Subtitle; 156 | -------------------------------------------------------------------------------- /src/components/Subtitle/index.tsx: -------------------------------------------------------------------------------- 1 | import Subtitle from './Subtitle'; 2 | 3 | export default Subtitle; 4 | -------------------------------------------------------------------------------- /src/components/TextIcon/TextIcon.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | align-items: center; 4 | } 5 | 6 | .container > * + * { 7 | margin-left: 0.25rem; 8 | } 9 | -------------------------------------------------------------------------------- /src/components/TextIcon/TextIcon.tsx: -------------------------------------------------------------------------------- 1 | import { classNames } from '../../utils'; 2 | import * as React from 'react'; 3 | import styles from './TextIcon.module.css'; 4 | 5 | export interface TextIconProps 6 | extends Omit, 'as'> { 7 | leftIcon?: React.ReactNode; 8 | rightIcon?: React.ReactNode; 9 | className?: string; 10 | as?: string | React.ComponentType<{ className: string }>; 11 | } 12 | 13 | const TextIcon: React.FC = ({ 14 | leftIcon, 15 | rightIcon, 16 | as: Component = 'div', 17 | children, 18 | className = '', 19 | ...props 20 | }) => { 21 | return ( 22 | 23 | {leftIcon} 24 |

    {children}

    25 | {rightIcon} 26 |
    27 | ); 28 | }; 29 | 30 | export default TextIcon; 31 | -------------------------------------------------------------------------------- /src/components/TextIcon/index.ts: -------------------------------------------------------------------------------- 1 | import TextIcon from './TextIcon'; 2 | 3 | export default TextIcon; 4 | -------------------------------------------------------------------------------- /src/components/icons/ArrowLeftIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { SVGProps } from 'react'; 3 | 4 | const ArrowLeftIcon = (props: SVGProps) => ( 5 | 20 | ); 21 | 22 | export default React.memo(ArrowLeftIcon); 23 | -------------------------------------------------------------------------------- /src/components/icons/ArrowRightIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { SVGProps, memo } from 'react'; 3 | 4 | const ArrowRightIcon = (props: SVGProps) => ( 5 | 20 | ); 21 | 22 | export default memo(ArrowRightIcon); 23 | -------------------------------------------------------------------------------- /src/components/icons/AudioIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { SVGProps, memo } from 'react'; 3 | 4 | const AudioIcon = (props: SVGProps) => ( 5 | 20 | ); 21 | 22 | const Memo = memo(AudioIcon); 23 | export default Memo; 24 | -------------------------------------------------------------------------------- /src/components/icons/BackwardIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { SVGProps, memo } from 'react'; 3 | 4 | const BackwardIcon = (props: SVGProps) => ( 5 | 15 | 21 | 22 | ); 23 | 24 | export default memo(BackwardIcon); 25 | -------------------------------------------------------------------------------- /src/components/icons/CameraIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { SVGProps, memo } from 'react'; 3 | 4 | const CameraIcon = (props: SVGProps) => ( 5 | 12 | 16 | 17 | ); 18 | 19 | const Memo = memo(CameraIcon); 20 | export default Memo; 21 | -------------------------------------------------------------------------------- /src/components/icons/CheckIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { SVGProps, memo } from 'react'; 3 | 4 | const CheckIcon = (props: SVGProps) => ( 5 | 20 | ); 21 | 22 | const Memo = memo(CheckIcon); 23 | export default Memo; 24 | -------------------------------------------------------------------------------- /src/components/icons/ForwardIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { SVGProps, memo } from 'react'; 3 | 4 | const ForwardIcon = (props: SVGProps) => ( 5 | 15 | 21 | 22 | ); 23 | 24 | export default memo(ForwardIcon); 25 | -------------------------------------------------------------------------------- /src/components/icons/FullscreenEnterIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { SVGProps, memo } from 'react'; 3 | 4 | const FullscreenEnterIcon = (props: SVGProps) => ( 5 | 15 | 21 | 22 | ); 23 | 24 | export default memo(FullscreenEnterIcon); 25 | -------------------------------------------------------------------------------- /src/components/icons/FullscreenExitIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { SVGProps, memo } from 'react'; 3 | 4 | const FullscreenExitIcon = (props: SVGProps) => ( 5 | 15 | 21 | 22 | ); 23 | 24 | export default memo(FullscreenExitIcon); 25 | -------------------------------------------------------------------------------- /src/components/icons/LoadingIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { SVGProps, memo } from 'react'; 3 | 4 | const LoadingIcon = (props: SVGProps) => ( 5 | 15 | 20 | 21 | 29 | 30 | 31 | ); 32 | 33 | export default memo(LoadingIcon); 34 | -------------------------------------------------------------------------------- /src/components/icons/PauseIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { SVGProps, memo } from 'react'; 3 | 4 | const PauseIcon = (props: SVGProps) => ( 5 | 15 | 21 | 22 | ); 23 | 24 | export default memo(PauseIcon); 25 | -------------------------------------------------------------------------------- /src/components/icons/PlayIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { SVGProps, memo } from 'react'; 3 | 4 | const PlayIcon = (props: SVGProps) => ( 5 | 15 | 19 | 20 | ); 21 | 22 | export default memo(PlayIcon); 23 | -------------------------------------------------------------------------------- /src/components/icons/PlaybackSpeedIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { SVGProps, memo } from 'react'; 3 | 4 | const PlaybackSpeedIcon = (props: SVGProps) => ( 5 | 15 | 21 | 22 | ); 23 | 24 | export default memo(PlaybackSpeedIcon); 25 | -------------------------------------------------------------------------------- /src/components/icons/QualityIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { SVGProps, memo } from 'react'; 3 | 4 | const QualityIcon = (props: SVGProps) => ( 5 | 20 | ); 21 | 22 | export default memo(QualityIcon); 23 | -------------------------------------------------------------------------------- /src/components/icons/SettingsIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { SVGProps } from 'react'; 3 | 4 | const SettingsIcon = (props: SVGProps) => ( 5 | 6 | 10 | 11 | ); 12 | 13 | export default React.memo(SettingsIcon); 14 | -------------------------------------------------------------------------------- /src/components/icons/SliderIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { SVGProps, memo } from 'react'; 3 | 4 | const SliderIcon = (props: SVGProps) => ( 5 | 20 | ); 21 | 22 | export default memo(SliderIcon); 23 | -------------------------------------------------------------------------------- /src/components/icons/SubtitleIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { SVGProps, memo } from 'react'; 3 | 4 | const SubtitleIcon = (props: SVGProps) => ( 5 | 15 | 19 | 20 | ); 21 | 22 | export default memo(SubtitleIcon); 23 | -------------------------------------------------------------------------------- /src/components/icons/SubtitleOffIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { SVGProps, memo } from 'react'; 3 | 4 | const SubtitleOffIcon = (props: SVGProps) => ( 5 | 15 | 19 | 23 | 24 | ); 25 | 26 | export default memo(SubtitleOffIcon); 27 | -------------------------------------------------------------------------------- /src/components/icons/VolumeMutedIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { SVGProps, memo } from 'react'; 3 | 4 | const VolumeMutedIcon = (props: SVGProps) => ( 5 | 15 | 21 | 22 | ); 23 | 24 | export default memo(VolumeMutedIcon); 25 | -------------------------------------------------------------------------------- /src/components/icons/VolumeOneIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { SVGProps, memo } from 'react'; 3 | 4 | const VolumeOneIcon = (props: SVGProps) => ( 5 | 15 | 21 | 22 | ); 23 | 24 | export default memo(VolumeOneIcon); 25 | -------------------------------------------------------------------------------- /src/components/icons/VolumeThreeIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { SVGProps, memo } from 'react'; 3 | 4 | const VolumeThreeIcon = (props: SVGProps) => ( 5 | 15 | 21 | 22 | ); 23 | 24 | export default memo(VolumeThreeIcon); 25 | -------------------------------------------------------------------------------- /src/components/icons/VolumeTwoIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { SVGProps, memo } from 'react'; 3 | 4 | const VolumeTwoIcon = (props: SVGProps) => ( 5 | 15 | 21 | 22 | ); 23 | 24 | export default memo(VolumeTwoIcon); 25 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Controls'; 2 | export * from './Indicator'; 3 | export { default as Dialog } from './Dialog'; 4 | export { default as MobileControls } from './MobileControls'; 5 | export { default as NestedMenu } from './NestedMenu'; 6 | export { default as Overlay } from './Overlay'; 7 | export { default as Popover } from './Popover'; 8 | export { default as Player } from './Player'; 9 | export { default as Slider } from './Slider'; 10 | export { default as Subtitle } from './Subtitle'; 11 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const PLAYER_CONTAINER_CLASS = 'netplayer-container'; 2 | -------------------------------------------------------------------------------- /src/contexts/GlobalContext.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { SubtitleSettingsProvider } from './SubtitleSettingsContext'; 3 | import { VideoInteractingContextProvider } from './VideoInteractingContext'; 4 | import { NetPlayerProps, VideoPropsProvider } from './VideoPropsContext'; 5 | import { VideoStateContextProvider } from './VideoStateContext'; 6 | 7 | const GlobalContext: React.FC = ({ 8 | sources, 9 | subtitles = [], 10 | children, 11 | ...props 12 | }) => { 13 | return ( 14 | 15 | 16 | 17 | {children} 18 | 19 | 20 | 21 | ); 22 | }; 23 | 24 | export default GlobalContext; 25 | -------------------------------------------------------------------------------- /src/contexts/SubtitleSettingsContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useContext, useEffect } from 'react'; 2 | 3 | interface SubtitleSettings { 4 | fontSize: number; 5 | backgroundOpacity: number; 6 | textStyle: 'none' | 'outline'; 7 | fontOpacity: number; 8 | } 9 | 10 | type StateSelector = ( 11 | currentState: SubtitleSettings 12 | ) => Partial; 13 | 14 | type UpdateStateAction = (stateSelector: StateSelector) => void; 15 | 16 | interface SubtitleSettingsProps { 17 | state: SubtitleSettings; 18 | setState: UpdateStateAction; 19 | } 20 | 21 | interface SubtitleSettingsProviderProps { 22 | defaultState?: Partial; 23 | } 24 | 25 | export const defaultSubtitleSettings: SubtitleSettings = { 26 | fontSize: 1, 27 | backgroundOpacity: 0.75, 28 | fontOpacity: 1, 29 | textStyle: 'none', 30 | }; 31 | 32 | export const SubtitleSettingsContext = 33 | React.createContext({ 34 | state: defaultSubtitleSettings, 35 | setState: () => {}, 36 | }); 37 | 38 | const LOCALSTORAGE_KEY = 'netplayer_subtitle_settings'; 39 | 40 | export const SubtitleSettingsProvider: React.FC< 41 | SubtitleSettingsProviderProps 42 | > = ({ defaultState = {}, children }) => { 43 | const [state, setState] = React.useState({ 44 | ...defaultSubtitleSettings, 45 | ...defaultState, 46 | }); 47 | 48 | const updateState: UpdateStateAction = useCallback( 49 | (stateSelector) => { 50 | const newState = stateSelector(state); 51 | 52 | setState({ ...state, ...newState }); 53 | }, 54 | [state] 55 | ); 56 | 57 | useEffect(() => { 58 | const rawSettings = localStorage.getItem(LOCALSTORAGE_KEY); 59 | 60 | if (!rawSettings) return; 61 | 62 | const settings = JSON.parse(rawSettings); 63 | 64 | setState(settings); 65 | }, []); 66 | 67 | useEffect(() => { 68 | localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(state)); 69 | }, [state]); 70 | 71 | return ( 72 | 73 | {children} 74 | 75 | ); 76 | }; 77 | 78 | export const useSubtitleSettings = () => { 79 | return useContext(SubtitleSettingsContext); 80 | }; 81 | -------------------------------------------------------------------------------- /src/contexts/VideoContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useContext, useEffect } from 'react'; 2 | import Hls from '../types/hls.js'; 3 | 4 | interface VideoState { 5 | currentTime: number; 6 | duration: number; 7 | ended: boolean; 8 | paused: boolean; 9 | volume: number; 10 | buffering: boolean; 11 | error: string | null; 12 | seeking: boolean; 13 | } 14 | 15 | interface VideoContextProps { 16 | videoEl: HTMLVideoElement | null; 17 | videoState: VideoState; 18 | setVideoState: (state: Partial) => void; 19 | } 20 | 21 | interface VideoContextProviderProps { 22 | videoRef: React.RefObject; 23 | hlsRef: React.RefObject; 24 | } 25 | 26 | const defaultState: VideoState = { 27 | currentTime: 0, 28 | buffering: true, 29 | duration: 0, 30 | ended: false, 31 | paused: true, 32 | volume: 1, 33 | seeking: false, 34 | error: '', 35 | }; 36 | 37 | export const VideoContext = React.createContext({ 38 | videoEl: null, 39 | videoState: defaultState, 40 | setVideoState: () => {}, 41 | }); 42 | 43 | export const VideoContextProvider: React.FC = ({ 44 | videoRef, 45 | hlsRef, 46 | children, 47 | }) => { 48 | const [videoState, setVideoState] = React.useState(defaultState); 49 | const [videoEl, setVideoEl] = React.useState(null); 50 | const [hls, setHls] = React.useState(null); 51 | 52 | const updateState = useCallback((state: Partial) => { 53 | setVideoState((prev) => ({ ...prev, ...state })); 54 | }, []); 55 | 56 | useEffect(() => { 57 | if (!videoRef?.current) return; 58 | 59 | setVideoEl(videoRef.current); 60 | }, [videoRef]); 61 | 62 | useEffect(() => { 63 | if (!hlsRef?.current) return; 64 | 65 | setHls(hlsRef.current); 66 | }, [hlsRef]); 67 | 68 | useEffect(() => { 69 | if (!videoEl) return; 70 | 71 | const handleError = () => { 72 | updateState({ 73 | error: videoEl.error?.message || 'Something went wrong with video', 74 | }); 75 | }; 76 | 77 | const handleWaiting = () => { 78 | updateState({ 79 | buffering: true, 80 | }); 81 | }; 82 | 83 | const handleloadeddata = () => { 84 | updateState({ 85 | currentTime: videoEl.currentTime, 86 | duration: videoEl.duration, 87 | buffering: false, 88 | error: null, 89 | }); 90 | }; 91 | 92 | const handlePlay = () => { 93 | updateState({ 94 | paused: false, 95 | buffering: false, 96 | }); 97 | }; 98 | 99 | const handlePause = () => { 100 | updateState({ 101 | paused: true, 102 | }); 103 | }; 104 | 105 | const handleTimeupdate = () => { 106 | updateState({ 107 | currentTime: videoEl.currentTime, 108 | duration: videoEl.duration, 109 | buffering: false, 110 | error: null, 111 | paused: false, 112 | }); 113 | }; 114 | 115 | const handleEnded = () => { 116 | updateState({ ended: true, paused: true }); 117 | }; 118 | 119 | const handleVolumeChange = () => { 120 | updateState({ volume: videoEl.volume }); 121 | }; 122 | 123 | videoEl.addEventListener('waiting', handleWaiting); 124 | videoEl.addEventListener('loadeddata', handleloadeddata); 125 | videoEl.addEventListener('play', handlePlay); 126 | videoEl.addEventListener('playing', handlePlay); 127 | videoEl.addEventListener('pause', handlePause); 128 | videoEl.addEventListener('timeupdate', handleTimeupdate); 129 | videoEl.addEventListener('ended', handleEnded); 130 | videoEl.addEventListener('volumechange', handleVolumeChange); 131 | videoEl.addEventListener('error', handleError); 132 | 133 | return () => { 134 | videoEl.removeEventListener('waiting', handleWaiting); 135 | videoEl.removeEventListener('loadeddata', handleloadeddata); 136 | videoEl.removeEventListener('play', handlePlay); 137 | videoEl.removeEventListener('playing', handlePlay); 138 | videoEl.removeEventListener('pause', handlePause); 139 | videoEl.removeEventListener('timeupdate', handleTimeupdate); 140 | videoEl.removeEventListener('ended', handleEnded); 141 | videoEl.removeEventListener('volumechange', handleVolumeChange); 142 | videoEl.removeEventListener('error', handleError); 143 | }; 144 | }, [updateState, videoEl]); 145 | 146 | useEffect(() => { 147 | if (!hls) return; 148 | 149 | hls.on(Hls.Events.ERROR, (_, data) => { 150 | updateState({ 151 | error: data.details, 152 | }); 153 | }); 154 | }, [hls, updateState]); 155 | 156 | return ( 157 | 160 | {children} 161 | 162 | ); 163 | }; 164 | 165 | export const useVideo = () => { 166 | return useContext(VideoContext); 167 | }; 168 | -------------------------------------------------------------------------------- /src/contexts/VideoInteractingContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState } from 'react'; 2 | 3 | interface ContextValue { 4 | isInteracting: boolean; 5 | setIsInteracting: React.Dispatch>; 6 | isShowingIndicator: boolean; 7 | setIsShowingIndicator: React.Dispatch>; 8 | } 9 | 10 | interface VideoContextProviderProps { 11 | defaultValue?: boolean; 12 | } 13 | 14 | export const VideoInteractingContext = React.createContext({ 15 | isInteracting: false, 16 | setIsInteracting: () => {}, 17 | isShowingIndicator: false, 18 | setIsShowingIndicator: () => {}, 19 | }); 20 | 21 | export const VideoInteractingContextProvider: React.FC< 22 | VideoContextProviderProps 23 | > = ({ children, defaultValue = false }) => { 24 | const [isInteracting, setIsInteracting] = useState(defaultValue); 25 | const [isShowingIndicator, setIsShowingIndicator] = useState(false); 26 | 27 | return ( 28 | 36 | {children} 37 | 38 | ); 39 | }; 40 | 41 | export const useInteract = () => { 42 | return useContext(VideoInteractingContext); 43 | }; 44 | -------------------------------------------------------------------------------- /src/contexts/VideoPropsContext.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { IndicatorRef } from '../components/Indicator/Indicator'; 3 | import { PlayerProps } from '../components/Player/Player'; 4 | import backwardHotKey from '../hotkeys/backward'; 5 | import forwardHotKey from '../hotkeys/forward'; 6 | import fullscreenHotKey from '../hotkeys/fullscreen'; 7 | import playPauseHotKey from '../hotkeys/playPause'; 8 | import volumeHotKey from '../hotkeys/volume'; 9 | import { HotKey, Shortcuts, Subtitle } from '../types'; 10 | import { mergeDeep } from '../utils'; 11 | import { VideoState } from './VideoStateContext'; 12 | 13 | interface I18nControls extends I18nField { 14 | play: string; 15 | pause: string; 16 | forward: string; 17 | backward: string; 18 | enableSubtitle: string; 19 | disableSubtitle: string; 20 | settings: string; 21 | enterFullscreen: string; 22 | exitFullscreen: string; 23 | muteVolume: string; 24 | unmuteVolume: string; 25 | sliderDragMessage: string; 26 | } 27 | 28 | interface I18nSettings extends I18nField { 29 | playbackSpeed: string; 30 | subtitle: string; 31 | quality: string; 32 | subtitleSettings: string; 33 | reset: string; 34 | off: string; 35 | none: string; 36 | subtitleTextStyle: string; 37 | subtitleBackgroundOpacity: string; 38 | subtitleFontOpacity: string; 39 | subtitleFontSize: string; 40 | audio: string; 41 | } 42 | 43 | type I18nField = { [k: string]: string | I18nField }; 44 | export interface I18n extends I18nField { 45 | controls: I18nControls; 46 | settings: I18nSettings; 47 | } 48 | 49 | export type Components = { 50 | Subtitle: React.FC; 51 | MobileBackwardIndicator: React.ForwardRefExoticComponent< 52 | React.RefAttributes 53 | >; 54 | MobileForwardIndicator: React.ForwardRefExoticComponent< 55 | React.RefAttributes 56 | >; 57 | MobileVolumeSlider: React.ForwardRefExoticComponent< 58 | React.RefAttributes 59 | >; 60 | Player: React.ForwardRefExoticComponent< 61 | PlayerProps & React.RefAttributes 62 | >; 63 | MobileOverlay: React.FC; 64 | Overlay: React.FC; 65 | Controls: React.FC; 66 | MobileControls: React.FC; 67 | }; 68 | 69 | export interface NetPlayerProps extends PlayerProps { 70 | thumbnail?: string; 71 | i18n?: I18n; 72 | shortcuts?: Shortcuts; 73 | hotkeys?: HotKey[]; 74 | subtitles?: Subtitle[]; 75 | components?: Partial; 76 | defaultVideoState?: Pick< 77 | VideoState, 78 | 'currentAudio' | 'currentQuality' | 'currentSubtitle' | 'isSubtitleDisabled' 79 | >; 80 | disableVolumeSlider?: Boolean; 81 | } 82 | 83 | const defaultI18n: I18n = { 84 | controls: { 85 | play: 'Play ({{shortcut}})', 86 | pause: 'Pause ({{shortcut}})', 87 | forward: 'Forward {{time}} seconds', 88 | backward: 'Backward {{time}} seconds', 89 | enableSubtitle: 'Enable subtitles', 90 | disableSubtitle: 'Disable subtitles', 91 | settings: 'Settings', 92 | enterFullscreen: 'Enter fullscreen ({{shortcut}})', 93 | exitFullscreen: 'Exit fullscreen ({{shortcut}})', 94 | muteVolume: 'Mute ({{shortcut}})', 95 | unmuteVolume: 'Unmute ({{shortcut}})', 96 | sliderDragMessage: 'Drag to seek video', 97 | screenshot: 'Screenshot', 98 | }, 99 | settings: { 100 | audio: 'Audio', 101 | playbackSpeed: 'Playback speed', 102 | quality: 'Quality', 103 | subtitle: 'Subtitle', 104 | subtitleSettings: 'Subtitle settings', 105 | reset: 'Reset', 106 | none: 'None', 107 | off: 'Off', 108 | subtitleBackgroundOpacity: 'Background Opacity', 109 | subtitleFontOpacity: 'Font Opacity', 110 | subtitleFontSize: 'Font Size', 111 | subtitleTextStyle: 'Text Style', 112 | }, 113 | }; 114 | 115 | const defaultHotKeys: HotKey[] = [ 116 | playPauseHotKey(), 117 | backwardHotKey(), 118 | forwardHotKey(), 119 | fullscreenHotKey(), 120 | volumeHotKey(), 121 | ]; 122 | 123 | const mergeHotkeys = (main: HotKey[], target: HotKey[]) => { 124 | for (const hotkey of target) { 125 | const index = main.findIndex((h) => h.hotKey === hotkey.hotKey); 126 | 127 | if (index !== -1) { 128 | main[index] = hotkey; 129 | } else { 130 | main.push(hotkey); 131 | } 132 | } 133 | 134 | return main; 135 | }; 136 | 137 | export const VideoPropsContext = 138 | // @ts-ignore 139 | React.createContext>(null); 140 | 141 | export const VideoPropsProvider: React.FC> = ({ 142 | children, 143 | ...props 144 | }) => { 145 | const i18n = React.useMemo( 146 | () => mergeDeep(defaultI18n, props.i18n), 147 | [props.i18n] 148 | ); 149 | const hotkeys = React.useMemo( 150 | () => mergeHotkeys(defaultHotKeys, props.hotkeys || []), 151 | [props.hotkeys] 152 | ); 153 | 154 | return ( 155 | // @ts-ignore 156 | 157 | {children} 158 | 159 | ); 160 | }; 161 | 162 | export const useVideoProps = () => { 163 | return React.useContext(VideoPropsContext); 164 | }; 165 | 166 | export default VideoPropsContext; 167 | -------------------------------------------------------------------------------- /src/contexts/VideoStateContext.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | import React, { useCallback, useContext, useEffect, useMemo } from 'react'; 3 | import { Audio, Subtitle } from '../types'; 4 | import { isInArray } from '../utils'; 5 | import { useVideoProps } from './VideoPropsContext'; 6 | 7 | export interface VideoState { 8 | subtitles: Subtitle[]; 9 | qualities: string[]; 10 | currentQuality: string | null; 11 | currentSubtitle: string | null; 12 | isSubtitleDisabled: boolean; 13 | currentAudio: string | null; 14 | audios: Audio[]; 15 | } 16 | 17 | type StateSelector = (currentState: VideoState) => Partial; 18 | 19 | type UpdateStateAction = (stateSelector: StateSelector) => void; 20 | 21 | interface VideoContextProps { 22 | state: VideoState; 23 | setState: UpdateStateAction; 24 | } 25 | 26 | interface VideoContextProviderProps { 27 | defaultState?: Partial; 28 | } 29 | 30 | const defaultVideoState: VideoState = { 31 | subtitles: [], 32 | qualities: [], 33 | audios: [], 34 | currentQuality: null, 35 | currentSubtitle: null, 36 | currentAudio: null, 37 | isSubtitleDisabled: false, 38 | }; 39 | 40 | export const VideoStateContext = React.createContext({ 41 | state: defaultVideoState, 42 | setState: () => {}, 43 | }); 44 | 45 | const LOCALSTORAGE_KEY = 'netplayer_video_settings'; 46 | 47 | export const VideoStateContextProvider: React.FC = ({ 48 | children, 49 | }) => { 50 | const props = useVideoProps(); 51 | 52 | const defaultQualities = useMemo( 53 | () => 54 | props.sources 55 | .filter((source) => source.label) 56 | .map((source) => source.label!), 57 | [props.sources] 58 | ); 59 | 60 | const defaultState = useMemo( 61 | () => ({ 62 | currentSubtitle: props.subtitles[0]?.lang, 63 | subtitles: props.subtitles, 64 | qualities: defaultQualities, 65 | }), 66 | [props.subtitles, defaultQualities] 67 | ); 68 | 69 | const getState = useCallback(() => { 70 | const rawSettings = localStorage.getItem(LOCALSTORAGE_KEY); 71 | 72 | const newState = { 73 | ...defaultVideoState, 74 | ...defaultState, 75 | ...props?.defaultVideoState, 76 | }; 77 | 78 | if (!rawSettings) return newState; 79 | 80 | const settings: Partial = JSON.parse(rawSettings); 81 | 82 | const langAudios = newState.audios 83 | .filter((a) => a?.lang) 84 | .map((a) => a.lang); 85 | const langSubtitles = newState.subtitles 86 | .filter((a) => a?.lang) 87 | .map((s) => s.lang); 88 | const langQualities = newState.qualities; 89 | 90 | const filteredSettings = { 91 | currentAudio: 92 | isInArray(settings?.currentAudio, langAudios) || langAudios.length === 0 93 | ? (settings.currentAudio as string) || null 94 | : newState.currentAudio, 95 | currentQuality: 96 | isInArray(settings?.currentQuality, langQualities) || 97 | langQualities.length === 0 98 | ? (settings.currentQuality as string) || null 99 | : newState.currentQuality, 100 | currentSubtitle: 101 | isInArray(settings?.currentSubtitle, langSubtitles) || 102 | langSubtitles.length === 0 103 | ? (settings.currentSubtitle as string) || null 104 | : newState.currentSubtitle, 105 | }; 106 | 107 | return { ...newState, ...filteredSettings }; 108 | }, [defaultState, props?.defaultVideoState]); 109 | 110 | const [state, setState] = React.useState(getState); 111 | 112 | useEffect(() => { 113 | const state = getState(); 114 | 115 | setState(state); 116 | }, [getState]); 117 | 118 | useEffect(() => { 119 | const { 120 | currentAudio, 121 | currentQuality, 122 | currentSubtitle, 123 | isSubtitleDisabled, 124 | } = state; 125 | 126 | localStorage.setItem( 127 | LOCALSTORAGE_KEY, 128 | JSON.stringify({ 129 | currentAudio, 130 | currentQuality, 131 | currentSubtitle, 132 | isSubtitleDisabled, 133 | }) 134 | ); 135 | }, [state]); 136 | 137 | const updateState: UpdateStateAction = (stateSelector) => { 138 | setState((prev) => ({ ...prev, ...stateSelector(prev) })); 139 | }; 140 | 141 | return ( 142 | 143 | {children} 144 | 145 | ); 146 | }; 147 | 148 | export const useVideoState = () => { 149 | return useContext(VideoStateContext); 150 | }; 151 | -------------------------------------------------------------------------------- /src/contexts/index.ts: -------------------------------------------------------------------------------- 1 | export * from './GlobalContext'; 2 | export * from './SubtitleSettingsContext'; 3 | export * from './VideoContext'; 4 | export * from './VideoInteractingContext'; 5 | export * from './VideoPropsContext'; 6 | export * from './VideoStateContext'; 7 | -------------------------------------------------------------------------------- /src/global.css: -------------------------------------------------------------------------------- 1 | body { 2 | color: white; 3 | } 4 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useClickOutside } from './useClickOutside'; 2 | export { default as useDoubleTap } from './useDoubleTap'; 3 | export { default as useGlobalHotKeys } from './useGlobalHotKeys'; 4 | export { default as usePopover } from './usePopover'; 5 | export { default as usePrevious } from './usePrevious'; 6 | export { default as useTextScaling } from './useTextScaling'; 7 | export { default as useHotKey } from './useHotKey'; 8 | -------------------------------------------------------------------------------- /src/hooks/useClickOutside.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | const useClickOutside = ( 4 | ref: React.MutableRefObject, 5 | handler: (event: TouchEvent | MouseEvent) => void 6 | ) => { 7 | useEffect(() => { 8 | const listener = (event: TouchEvent | MouseEvent) => { 9 | const target = event.target as HTMLElement; 10 | 11 | if (!ref.current) return; 12 | 13 | if (ref.current?.contains(target)) { 14 | return; 15 | } 16 | 17 | handler(event); 18 | }; 19 | 20 | document.addEventListener('mousedown', listener); 21 | document.addEventListener('touchstart', listener); 22 | 23 | return () => { 24 | document.removeEventListener('mousedown', listener); 25 | document.removeEventListener('touchstart', listener); 26 | }; 27 | }, [ref, handler]); 28 | }; 29 | 30 | export default useClickOutside; 31 | -------------------------------------------------------------------------------- /src/hooks/useDoubleTap.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | 3 | type Event = React.DOMAttributes['onTouchStart']; 4 | 5 | interface Props { 6 | onDoubleTap: Event; 7 | onTap?: Event; 8 | tapThreshold?: number; 9 | } 10 | 11 | const useDoubleTap = ({ onDoubleTap, onTap, tapThreshold = 300 }: Props) => { 12 | const lastTap = useRef(0); 13 | const timeout = useRef(); 14 | 15 | const handleTap: Event = (e, ...args) => { 16 | e.persist(); 17 | 18 | if (timeout.current) { 19 | clearTimeout(timeout.current); 20 | } 21 | 22 | const now = new Date().getTime(); 23 | const timeFromLastTap = now - lastTap.current; 24 | 25 | if (timeFromLastTap <= tapThreshold && timeFromLastTap > 0) { 26 | onDoubleTap?.(e, ...args); 27 | } else { 28 | timeout.current = setTimeout(() => { 29 | onTap?.(e, ...args); 30 | }, tapThreshold); 31 | } 32 | 33 | lastTap.current = new Date().getTime(); 34 | }; 35 | 36 | return handleTap; 37 | }; 38 | 39 | export default useDoubleTap; 40 | -------------------------------------------------------------------------------- /src/hooks/useGlobalHotKeys.ts: -------------------------------------------------------------------------------- 1 | import isHotkey from '../utils/hotkey'; 2 | import { useEffect } from 'react'; 3 | import { useVideoProps } from '../contexts/VideoPropsContext'; 4 | 5 | const useGlobalHotKeys = (videoEl: HTMLVideoElement) => { 6 | const { hotkeys } = useVideoProps(); 7 | 8 | useEffect(() => { 9 | const handleKeyDown = (event: KeyboardEvent) => { 10 | const focusEl = document.activeElement; 11 | 12 | if ( 13 | focusEl && 14 | ((focusEl.tagName.toLowerCase() == 'input' && 15 | // @ts-ignore 16 | focusEl?.type == 'text') || 17 | focusEl.tagName.toLowerCase() == 'textarea') 18 | ) 19 | return; 20 | 21 | const matchedHotKey = hotkeys.find((hotkey) => 22 | isHotkey(hotkey.hotKey, event) 23 | ); 24 | 25 | if (!matchedHotKey) return; 26 | 27 | const { fn, preventDefault = true } = matchedHotKey; 28 | 29 | if (preventDefault) event.preventDefault(); 30 | 31 | fn(videoEl); 32 | }; 33 | 34 | window.addEventListener('keydown', handleKeyDown); 35 | 36 | return () => { 37 | window.removeEventListener('keydown', handleKeyDown); 38 | }; 39 | }, [videoEl, hotkeys]); 40 | }; 41 | 42 | export default useGlobalHotKeys; 43 | -------------------------------------------------------------------------------- /src/hooks/useHotKey.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { useVideoProps } from '../contexts/VideoPropsContext'; 3 | 4 | const useHotKey = (hotKeyName: string) => { 5 | const { hotkeys } = useVideoProps(); 6 | 7 | const hotkey = useMemo( 8 | () => hotkeys.find(({ name }) => name === hotKeyName), 9 | [hotkeys, hotKeyName] 10 | ); 11 | 12 | return hotkey; 13 | }; 14 | 15 | export const parseHotKey = ( 16 | hotkey: string | string[] | undefined, 17 | seperator = '/' 18 | ) => { 19 | if (!hotkey) return ''; 20 | 21 | if (Array.isArray(hotkey)) { 22 | return hotkey.join(seperator); 23 | } 24 | 25 | return hotkey; 26 | }; 27 | 28 | export default useHotKey; 29 | -------------------------------------------------------------------------------- /src/hooks/usePopover.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useCallback, useEffect, useState } from 'react'; 2 | 3 | type Position = 'top' | 'bottom' | 'left' | 'right'; 4 | type Strategy = 'absolute' | 'fixed'; 5 | 6 | export interface UsePopoverData { 7 | x: number; 8 | y: number; 9 | strategy: Strategy; 10 | } 11 | 12 | export interface UsePopoverOptions { 13 | position: Position; 14 | offset: number; 15 | strategy: Strategy; 16 | overflowElement: HTMLElement | string; 17 | } 18 | 19 | const defaultOptions: UsePopoverOptions = { 20 | position: 'top', 21 | offset: 0, 22 | strategy: 'absolute', 23 | overflowElement: document.documentElement, 24 | }; 25 | 26 | const usePopover = ( 27 | options: Partial = defaultOptions 28 | ) => { 29 | const { position, offset, strategy, overflowElement } = { 30 | ...defaultOptions, 31 | ...options, 32 | }; 33 | 34 | const [data, setData] = useState({ 35 | x: 0, 36 | y: 0, 37 | strategy, 38 | }); 39 | 40 | const floatingRef = useRef(null); 41 | const referenceRef = useRef(null); 42 | 43 | const update = useCallback(() => { 44 | if (!referenceRef.current || !floatingRef.current) { 45 | return; 46 | } 47 | 48 | let overflowRect: DOMRect; 49 | 50 | if (typeof overflowElement === 'string') { 51 | overflowRect = document 52 | .querySelector(overflowElement)! 53 | .getBoundingClientRect(); 54 | } else { 55 | overflowRect = overflowElement.getBoundingClientRect(); 56 | } 57 | 58 | const referenceRect = referenceRef.current.getBoundingClientRect(); 59 | const floatingRect = floatingRef.current.getBoundingClientRect(); 60 | 61 | const rawX = 62 | position === 'left' 63 | ? referenceRect.left - floatingRect.width - offset 64 | : position === 'right' 65 | ? referenceRect.right + offset 66 | : referenceRect.left + referenceRect.width / 2 - floatingRect.width / 2; 67 | 68 | const rawY = 69 | position === 'top' 70 | ? referenceRect.top - floatingRect.height - offset 71 | : position === 'bottom' 72 | ? referenceRect.bottom + offset 73 | : referenceRect.top + 74 | referenceRect.height / 2 - 75 | floatingRect.height / 2; 76 | 77 | let x = rawX; 78 | const y = rawY; 79 | 80 | if (x + floatingRect.width > overflowRect.right) { 81 | x = overflowRect.right - floatingRect.width - offset; 82 | } else if (x < overflowRect.left) { 83 | x = overflowRect.left + offset; 84 | } 85 | 86 | setData({ 87 | x, 88 | y, 89 | strategy, 90 | }); 91 | }, [offset, overflowElement, position, strategy]); 92 | 93 | useEffect(() => { 94 | if (!referenceRef.current || !floatingRef.current) return; 95 | 96 | const resizeObserver = new ResizeObserver(update); 97 | 98 | resizeObserver.observe(floatingRef.current); 99 | resizeObserver.observe(floatingRef.current); 100 | 101 | document.addEventListener('scroll', update, { 102 | passive: false, 103 | capture: true, 104 | }); 105 | window.addEventListener('resize', update); 106 | 107 | update(); 108 | 109 | return () => { 110 | window.removeEventListener('resize', update); 111 | document.removeEventListener('scroll', update, { capture: true }); 112 | resizeObserver.disconnect(); 113 | }; 114 | // eslint-disable-next-line react-hooks/exhaustive-deps 115 | }, [update, floatingRef.current, referenceRef.current]); 116 | 117 | return { 118 | floatingRef, 119 | referenceRef, 120 | update, 121 | ...data, 122 | }; 123 | }; 124 | 125 | export default usePopover; 126 | -------------------------------------------------------------------------------- /src/hooks/usePrevious.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | function usePrevious(value: T) { 4 | // The ref object is a generic container whose current property is mutable ... 5 | // ... and can hold any value, similar to an instance property on a class 6 | const ref = useRef(); 7 | // Store current value in ref 8 | useEffect(() => { 9 | ref.current = value; 10 | }, [value]); // Only re-run if value changes 11 | // Return previous value (happens before update in useEffect above) 12 | return ref.current; 13 | } 14 | 15 | export default usePrevious; 16 | -------------------------------------------------------------------------------- /src/hooks/useTextScaling.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/nirsky/react-native-size-matters/blob/master/lib/scaling-utils.js 2 | 3 | import { useCallback, useEffect, useState } from 'react'; 4 | import { PLAYER_CONTAINER_CLASS } from '../constants'; 5 | 6 | const guidelineBaseWidth = 350; 7 | const guidelineBaseHeight = 680; 8 | 9 | const getDimension = () => { 10 | let width = window.screen.width; 11 | let height = window.screen.height; 12 | 13 | const containerEl = document.querySelector('.' + PLAYER_CONTAINER_CLASS); 14 | 15 | if (containerEl) { 16 | const { width: containerWidth, height: containerHeight } = 17 | containerEl.getBoundingClientRect(); 18 | 19 | width = containerWidth; 20 | height = containerHeight; 21 | } 22 | 23 | const [shortDimension, longDimension] = 24 | width < height ? [width, height] : [height, width]; 25 | 26 | return [shortDimension, longDimension]; 27 | }; 28 | 29 | const useTextScaling = () => { 30 | const [[shortDimension, longDimension], setDimension] = 31 | useState(getDimension); 32 | 33 | useEffect(() => { 34 | const containerEl = document.querySelector('.' + PLAYER_CONTAINER_CLASS); 35 | 36 | const handleResize = () => { 37 | setDimension(getDimension()); 38 | }; 39 | 40 | if (containerEl) { 41 | containerEl.addEventListener('resize', handleResize); 42 | } 43 | 44 | window.addEventListener('resize', handleResize); 45 | 46 | return () => { 47 | containerEl?.removeEventListener('resize', handleResize); 48 | window.removeEventListener('resize', handleResize); 49 | }; 50 | }, []); 51 | 52 | const update = useCallback(() => { 53 | const [shortDimension, longDimension] = getDimension(); 54 | 55 | setDimension([shortDimension, longDimension]); 56 | }, []); 57 | 58 | const scale = useCallback( 59 | (size: number) => (shortDimension / guidelineBaseWidth) * size, 60 | [shortDimension] 61 | ); 62 | const verticalScale = useCallback( 63 | (size: number) => (longDimension / guidelineBaseHeight) * size, 64 | [longDimension] 65 | ); 66 | const moderateScale = useCallback( 67 | (size: number, factor = 0.5) => size + (scale(size) - size) * factor, 68 | [scale] 69 | ); 70 | const moderateVerticalScale = useCallback( 71 | (size: number, factor = 0.5) => 72 | size + (verticalScale(size) - size) * factor, 73 | [verticalScale] 74 | ); 75 | 76 | return { 77 | scale, 78 | verticalScale, 79 | moderateScale, 80 | moderateVerticalScale, 81 | update, 82 | }; 83 | }; 84 | 85 | export default useTextScaling; 86 | -------------------------------------------------------------------------------- /src/hotkeys/backward.ts: -------------------------------------------------------------------------------- 1 | import { HotKey } from '../types'; 2 | 3 | const backwardHotKey = (hotKey: string | string[] = 'left'): HotKey => ({ 4 | fn: (videoEl: HTMLVideoElement) => { 5 | videoEl.currentTime = videoEl.currentTime - 10; 6 | }, 7 | name: 'backward', 8 | hotKey: hotKey, 9 | }); 10 | 11 | export default backwardHotKey; 12 | -------------------------------------------------------------------------------- /src/hotkeys/forward.ts: -------------------------------------------------------------------------------- 1 | import { HotKey } from '../types'; 2 | 3 | const forwardHotKey = (hotKey: string | string[] = 'right'): HotKey => ({ 4 | fn: (videoEl: HTMLVideoElement) => { 5 | videoEl.currentTime = videoEl.currentTime + 10; 6 | }, 7 | name: 'forward', 8 | hotKey: hotKey, 9 | }); 10 | 11 | export default forwardHotKey; 12 | -------------------------------------------------------------------------------- /src/hotkeys/fullscreen.ts: -------------------------------------------------------------------------------- 1 | import { PLAYER_CONTAINER_CLASS } from '../constants'; 2 | import { HotKey } from '../types'; 3 | import { isIOS, isMobile } from '../utils/device'; 4 | import screenfull from '../utils/screenfull'; 5 | 6 | const fullscreenHotKey = (hotKey: string | string[] = 'f'): HotKey => ({ 7 | fn: () => { 8 | if (!screenfull.isEnabled) return; 9 | 10 | const containerElSelector = !isIOS 11 | ? `.${PLAYER_CONTAINER_CLASS}` 12 | : `.${PLAYER_CONTAINER_CLASS} video`; 13 | 14 | const containerEl = document.querySelector(containerElSelector); 15 | 16 | if (!containerEl) return; 17 | 18 | if (!document.fullscreenElement) { 19 | screenfull.request(containerEl as HTMLElement).then(() => { 20 | if (!isMobile) return; 21 | 22 | screen.orientation.lock('landscape'); 23 | }); 24 | } else { 25 | screenfull.exit().then(() => { 26 | if (!isMobile) return; 27 | 28 | screen.orientation.lock('portrait'); 29 | }); 30 | } 31 | }, 32 | name: 'fullscreen', 33 | hotKey: hotKey, 34 | }); 35 | 36 | export default fullscreenHotKey; 37 | -------------------------------------------------------------------------------- /src/hotkeys/index.ts: -------------------------------------------------------------------------------- 1 | export { default as backwardHotKey } from './backward'; 2 | export { default as forwardHotKey } from './forward'; 3 | export { default as fullscreenHotKey } from './fullscreen'; 4 | export { default as playPauseHotKey } from './playPause'; 5 | export { default as volumeHotKey } from './volume'; 6 | -------------------------------------------------------------------------------- /src/hotkeys/playPause.ts: -------------------------------------------------------------------------------- 1 | import { HotKey } from '../types'; 2 | 3 | const playPauseHotKey = ( 4 | hotKey: string | string[] = ['k', 'space'] 5 | ): HotKey => ({ 6 | fn: (videoEl: HTMLVideoElement) => { 7 | if (videoEl.paused) { 8 | videoEl.play(); 9 | } else { 10 | videoEl.pause(); 11 | } 12 | }, 13 | name: 'playPause', 14 | hotKey: hotKey, 15 | }); 16 | 17 | export default playPauseHotKey; 18 | -------------------------------------------------------------------------------- /src/hotkeys/volume.ts: -------------------------------------------------------------------------------- 1 | import { HotKey } from '../types'; 2 | 3 | const volumeHotKey = (hotKey: string | string[] = 'm'): HotKey => { 4 | let previousVolume = 1; 5 | 6 | return { 7 | fn: (videoEl: HTMLVideoElement) => { 8 | if (videoEl.volume === 0) { 9 | videoEl.volume = previousVolume; 10 | } else { 11 | previousVolume = videoEl.volume; 12 | videoEl.volume = 0; 13 | } 14 | }, 15 | name: 'volume', 16 | hotKey: hotKey, 17 | }; 18 | }; 19 | 20 | export default volumeHotKey; 21 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import './global.css'; 2 | import './reset.css'; 3 | 4 | import * as React from 'react'; 5 | import GlobalContext from './contexts/GlobalContext'; 6 | import { VideoContextProvider } from './contexts/VideoContext'; 7 | import { NetPlayerProps } from './contexts/VideoPropsContext'; 8 | import DefaultUI from './components/DefaultUI'; 9 | 10 | const InnerPlayer = React.forwardRef( 11 | ({ hlsRef = React.createRef(), children, ...props }, ref) => { 12 | const videoRef = React.useRef(null); 13 | 14 | const playerRef = React.useCallback( 15 | (node) => { 16 | videoRef.current = node; 17 | if (typeof ref === 'function') { 18 | ref(node); 19 | } else if (ref) { 20 | (ref as React.MutableRefObject).current = node; 21 | } 22 | }, 23 | [ref] 24 | ); 25 | 26 | return ( 27 | 28 | {children || } 29 | 30 | ); 31 | } 32 | ); 33 | 34 | const NetPlayer = React.forwardRef( 35 | ( 36 | { sources, subtitles = [], hlsRef = React.createRef(), children, ...props }, 37 | ref 38 | ) => { 39 | return ( 40 | 41 | 48 | {children} 49 | 50 | 51 | ); 52 | } 53 | ); 54 | 55 | InnerPlayer.displayName = 'InnerPlayer'; 56 | NetPlayer.displayName = 'NetPlayer'; 57 | 58 | export * from './components'; 59 | export * from './hooks'; 60 | export * from './hotkeys'; 61 | export * from './contexts'; 62 | 63 | export default NetPlayer; 64 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | export * from './hls.js'; 3 | -------------------------------------------------------------------------------- /src/types/types.ts: -------------------------------------------------------------------------------- 1 | export type Source = { 2 | file: string; 3 | label?: string; 4 | type?: string | 'hls' | 'dash'; 5 | }; 6 | 7 | export type Subtitle = { 8 | file: string; 9 | lang: string; 10 | language: string; 11 | }; 12 | 13 | export type Audio = { 14 | lang: string; 15 | language: string; 16 | }; 17 | 18 | const shortcuts = [ 19 | 'play', 20 | 'pause', 21 | 'forward', 22 | 'backward', 23 | 'subtitle', 24 | 'fullscreen', 25 | 'volume', 26 | ] as const; 27 | 28 | export type Shortcut = string | string[]; 29 | export type Shortcuts = Record; 30 | 31 | export type HotKey = { 32 | fn: (videoEl: HTMLVideoElement) => void; 33 | name: string; 34 | hotKey: string | string[]; 35 | preventDefault?: boolean; 36 | }; 37 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Default CSS definition for typescript, 3 | * will be overridden with file-specific definitions by rollup 4 | */ 5 | declare module '*.css' { 6 | const content: { [className: string]: string }; 7 | export default content; 8 | } 9 | 10 | interface SvgrComponent 11 | extends React.FunctionComponent> {} 12 | 13 | declare module '*.svg' { 14 | const svgUrl: string; 15 | const svgComponent: SvgrComponent; 16 | export default svgUrl; 17 | export { svgComponent as ReactComponent }; 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/device.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | // @ts-ignore 4 | export const isMobile: boolean = (function (a, b) { 5 | if ( 6 | /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test( 7 | a 8 | ) || 9 | /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test( 10 | a.substr(0, 4) 11 | ) 12 | ) 13 | return true; 14 | })( 15 | // @ts-ignore 16 | navigator.userAgent || navigator.vendor || window.opera 17 | ); 18 | 19 | // https://stackoverflow.com/questions/9038625/detect-if-device-is-ios 20 | export const isIOS = (function () { 21 | const iosQuirkPresent = function () { 22 | const audio = new Audio(); 23 | 24 | audio.volume = 0.5; 25 | return audio.volume === 1; // volume cannot be changed from "1" on iOS 12 and below 26 | }; 27 | 28 | const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); 29 | const isAppleDevice = navigator.userAgent.includes('Macintosh'); 30 | const isTouchScreen = navigator.maxTouchPoints >= 1; // true for iOS 13 (and hopefully beyond) 31 | 32 | return isIOS || (isAppleDevice && (isTouchScreen || iosQuirkPresent())); 33 | })(); 34 | 35 | export const isDesktop = !isMobile; 36 | -------------------------------------------------------------------------------- /src/utils/hotkey.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/ianstormtaylor/is-hotkey 2 | 3 | const IS_MAC = 4 | typeof window != 'undefined' && 5 | /Mac|iPod|iPhone|iPad/.test(window.navigator.platform); 6 | 7 | const MODIFIERS: Record = { 8 | alt: 'altKey', 9 | control: 'ctrlKey', 10 | meta: 'metaKey', 11 | shift: 'shiftKey', 12 | }; 13 | 14 | const ALIASES: Record = { 15 | add: '+', 16 | break: 'pause', 17 | cmd: 'meta', 18 | command: 'meta', 19 | ctl: 'control', 20 | ctrl: 'control', 21 | del: 'delete', 22 | down: 'arrowdown', 23 | esc: 'escape', 24 | ins: 'insert', 25 | left: 'arrowleft', 26 | mod: IS_MAC ? 'meta' : 'control', 27 | opt: 'alt', 28 | option: 'alt', 29 | return: 'enter', 30 | right: 'arrowright', 31 | space: ' ', 32 | spacebar: ' ', 33 | up: 'arrowup', 34 | win: 'meta', 35 | windows: 'meta', 36 | }; 37 | 38 | const CODES: Record = { 39 | backspace: 8, 40 | tab: 9, 41 | enter: 13, 42 | shift: 16, 43 | control: 17, 44 | alt: 18, 45 | pause: 19, 46 | capslock: 20, 47 | escape: 27, 48 | ' ': 32, 49 | pageup: 33, 50 | pagedown: 34, 51 | end: 35, 52 | home: 36, 53 | arrowleft: 37, 54 | arrowup: 38, 55 | arrowright: 39, 56 | arrowdown: 40, 57 | insert: 45, 58 | delete: 46, 59 | meta: 91, 60 | numlock: 144, 61 | scrolllock: 145, 62 | ';': 186, 63 | '=': 187, 64 | ',': 188, 65 | '-': 189, 66 | '.': 190, 67 | '/': 191, 68 | '`': 192, 69 | '[': 219, 70 | '\\': 220, 71 | ']': 221, 72 | "'": 222, 73 | }; 74 | 75 | for (let f = 1; f < 20; f++) { 76 | CODES['f' + f] = 111 + f; 77 | } 78 | 79 | /** 80 | * Is hotkey? 81 | */ 82 | 83 | function isHotkey(hotkey: string | string[], event: KeyboardEvent) { 84 | if (!Array.isArray(hotkey)) { 85 | hotkey = [hotkey]; 86 | } 87 | 88 | const array = hotkey.map((string) => parseHotkey(string)); 89 | const check = (e: KeyboardEvent) => 90 | array.some((object) => compareHotkey(object, e)); 91 | const ret = event == null ? check : check(event); 92 | return ret; 93 | } 94 | 95 | /** 96 | * Parse. 97 | */ 98 | 99 | function parseHotkey(hotkey: string) { 100 | const ret: Record = {}; 101 | 102 | // Special case to handle the `+` key since we use it as a separator. 103 | hotkey = hotkey.replace('++', '+add'); 104 | const values = hotkey.split('+'); 105 | const { length } = values; 106 | 107 | // Ensure that all the modifiers are set to false unless the hotkey has them. 108 | for (const k in MODIFIERS) { 109 | ret[MODIFIERS[k]] = false; 110 | } 111 | 112 | for (let value of values) { 113 | const optional = value.endsWith('?') && value.length > 1; 114 | 115 | if (optional) { 116 | value = value.slice(0, -1); 117 | } 118 | 119 | const name = toKeyName(value); 120 | const modifier = MODIFIERS[name]; 121 | 122 | if (value.length > 1 && !modifier && !ALIASES[value] && !CODES[name]) { 123 | throw new TypeError(`Unknown modifier: "${value}"`); 124 | } 125 | 126 | if (length === 1 || !modifier) { 127 | ret.which = toKeyCode(value); 128 | } 129 | 130 | if (modifier) { 131 | ret[modifier] = optional ? null : true; 132 | } 133 | } 134 | 135 | return ret; 136 | } 137 | 138 | /** 139 | * Compare. 140 | */ 141 | 142 | function compareHotkey( 143 | object: Record, 144 | event: KeyboardEvent 145 | ) { 146 | for (const key in object) { 147 | const expected = object[key]; 148 | let actual; 149 | 150 | if (expected == null) { 151 | continue; 152 | } 153 | 154 | if (key === 'key' && event.key != null) { 155 | actual = event.key.toLowerCase(); 156 | } else if (key === 'which') { 157 | actual = expected === 91 && event.which === 93 ? 91 : event.which; 158 | } else { 159 | // @ts-ignore 160 | actual = event[key]; 161 | } 162 | 163 | if (actual == null && expected === false) { 164 | continue; 165 | } 166 | 167 | if (actual !== expected) { 168 | return false; 169 | } 170 | } 171 | 172 | return true; 173 | } 174 | 175 | /** 176 | * Utils. 177 | */ 178 | 179 | function toKeyCode(name: string) { 180 | name = toKeyName(name); 181 | const code = CODES[name] || name.toUpperCase().charCodeAt(0); 182 | return code; 183 | } 184 | 185 | function toKeyName(name: string) { 186 | name = name.toLowerCase(); 187 | name = ALIASES[name] || name; 188 | return name; 189 | } 190 | 191 | export default isHotkey; 192 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export const classNames = (...args: (string | boolean)[]) => { 2 | return args.filter(Boolean).join(' '); 3 | }; 4 | 5 | export function convertTime(seconds: string | number) { 6 | seconds = seconds.toString(); 7 | let minutes = Math.floor(Number(seconds) / 60).toString(); 8 | let hours = ''; 9 | 10 | if (Number(minutes) > 59) { 11 | hours = Math.floor(Number(minutes) / 60).toString(); 12 | hours = Number(hours) >= 10 ? hours : `0${hours}`; 13 | minutes = (Number(minutes) - Number(hours) * 60).toString(); 14 | minutes = Number(minutes) >= 10 ? minutes : `0${minutes}`; 15 | } 16 | 17 | seconds = Math.floor(Number(seconds) % 60).toString(); 18 | seconds = Number(seconds) >= 10 ? seconds : '0' + seconds; 19 | 20 | if (hours) { 21 | return `${hours}:${minutes}:${seconds}`; 22 | } 23 | 24 | return `${minutes}:${seconds}`; 25 | } 26 | 27 | // parse number from string 28 | export function parseNumberFromString(str: string) { 29 | return Number(str.replace(/[^0-9]/g, '')); 30 | } 31 | 32 | export function isObject(item: T) { 33 | return item && typeof item === 'object' && !Array.isArray(item); 34 | } 35 | 36 | // @ts-ignore 37 | export function mergeDeep(target, ...sources) { 38 | if (!sources.length) return target; 39 | const source = sources.shift(); 40 | 41 | if (isObject(target) && isObject(source)) { 42 | for (const key in source) { 43 | if (isObject(source[key])) { 44 | if (!target[key]) Object.assign(target, { [key]: {} }); 45 | mergeDeep(target[key], source[key]); 46 | } else { 47 | Object.assign(target, { [key]: source[key] }); 48 | } 49 | } 50 | } 51 | 52 | return mergeDeep(target, ...sources); 53 | } 54 | 55 | // https://stackoverflow.com/questions/6860853/generate-random-string-for-div-id 56 | export const randomString = (length: number) => { 57 | const chars = 58 | '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghiklmnopqrstuvwxyz'.split(''); 59 | 60 | if (!length) { 61 | length = Math.floor(Math.random() * chars.length); 62 | } 63 | 64 | let str = ''; 65 | 66 | for (let i = 0; i < length; i++) { 67 | str += chars[Math.floor(Math.random() * chars.length)]; 68 | } 69 | return str; 70 | }; 71 | 72 | export const stringInterpolate = (str: string, data: Record) => { 73 | Object.entries(data).forEach(([key, value]) => { 74 | str = str.replace(`{{${key}}}`, value); 75 | }); 76 | 77 | return str; 78 | }; 79 | 80 | export const isValidUrl = (url: string) => { 81 | const pattern = new RegExp( 82 | '^(https?:\\/\\/)?' + // protocol 83 | '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // domain name 84 | '((\\d{1,3}\\.){3}\\d{1,3}))' + // OR ip (v4) address 85 | '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' + // port and path 86 | '(\\?[;&a-z\\d%_.~+=-]*)?' + // query string 87 | '(\\#[-a-z\\d_]*)?$', 88 | 'i' 89 | ); // fragment locator 90 | 91 | return !!pattern.test(url); 92 | }; 93 | 94 | export const isInArray = (value: T, array: T[]) => { 95 | return array.indexOf(value) > -1; 96 | }; 97 | 98 | export const getHeightAndWidthFromDataUrl = (dataURL: string) => 99 | new Promise<{ width: number; height: number }>((resolve) => { 100 | const img = new Image(); 101 | img.onload = () => { 102 | resolve({ 103 | height: img.height, 104 | width: img.width, 105 | }); 106 | }; 107 | img.src = dataURL; 108 | }); 109 | 110 | export function download(url: string, name: string) { 111 | const elink = document.createElement('a'); 112 | elink.style.display = 'none'; 113 | elink.href = url; 114 | elink.download = name; 115 | document.body.appendChild(elink); 116 | elink.click(); 117 | document.body.removeChild(elink); 118 | } 119 | 120 | export function clamp(num: number, min: number, max: number) { 121 | return Math.min(Math.max(num, min), max); 122 | } 123 | -------------------------------------------------------------------------------- /src/utils/load-script.ts: -------------------------------------------------------------------------------- 1 | import load from 'load-script'; 2 | 3 | type AllowedAttributes = 'type' | 'charset' | 'async' | 'text'; 4 | 5 | type Options = Partial> & { 6 | attrs?: Record; 7 | }; 8 | 9 | const loadedScripts: Record = {}; 10 | 11 | const getDefinedScript = (variableName: string) => { 12 | // @ts-ignore 13 | if (window[variableName]) { 14 | // @ts-ignore 15 | return window[variableName]; 16 | } 17 | 18 | if (window.exports && window.exports[variableName]) { 19 | return window.exports[variableName]; 20 | } 21 | 22 | if ( 23 | window.module && 24 | window.module.exports && 25 | window.module.exports[variableName] 26 | ) { 27 | return window.module.exports[variableName]; 28 | } 29 | 30 | return null; 31 | }; 32 | 33 | function loadScript( 34 | src: HTMLScriptElement['src'], 35 | variableName: string, 36 | options: Options = {} 37 | ): Promise { 38 | return new Promise((resolve, reject) => { 39 | if (loadedScripts[variableName]) { 40 | resolve(getDefinedScript(variableName)); 41 | 42 | return; 43 | } 44 | 45 | load(src, options, (err, script) => { 46 | if (err) return reject(err); 47 | 48 | loadedScripts[variableName] = script; 49 | 50 | resolve(getDefinedScript(variableName)); 51 | }); 52 | }); 53 | } 54 | 55 | function loadBackupScript( 56 | src: HTMLScriptElement['src'][], 57 | variableName: string, 58 | options: Options = {} 59 | ): Promise { 60 | return new Promise((resolve, reject) => { 61 | let index = 0; 62 | 63 | const loadNextScript = () => { 64 | if (index >= src.length) { 65 | reject(new Error('Failed to load script from all URLs')); 66 | return; 67 | } 68 | 69 | const currentSrc = src[index]; 70 | 71 | loadScript(currentSrc, variableName, options) 72 | .then(resolve) 73 | .catch(() => { 74 | index++; 75 | loadNextScript(); 76 | }); 77 | }; 78 | 79 | loadNextScript(); 80 | }); 81 | } 82 | 83 | export default loadBackupScript; 84 | -------------------------------------------------------------------------------- /src/utils/screenfull.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | const methodMap = [ 4 | [ 5 | 'requestFullscreen', 6 | 'exitFullscreen', 7 | 'fullscreenElement', 8 | 'fullscreenEnabled', 9 | 'fullscreenchange', 10 | 'fullscreenerror', 11 | ], 12 | // New WebKit 13 | [ 14 | 'webkitRequestFullscreen', 15 | 'webkitExitFullscreen', 16 | 'webkitFullscreenElement', 17 | 'webkitFullscreenEnabled', 18 | 'webkitfullscreenchange', 19 | 'webkitfullscreenerror', 20 | ], 21 | // Old WebKit 22 | [ 23 | 'webkitRequestFullScreen', 24 | 'webkitCancelFullScreen', 25 | 'webkitCurrentFullScreenElement', 26 | 'webkitCancelFullScreen', 27 | 'webkitfullscreenchange', 28 | 'webkitfullscreenerror', 29 | ], 30 | [ 31 | 'mozRequestFullScreen', 32 | 'mozCancelFullScreen', 33 | 'mozFullScreenElement', 34 | 'mozFullScreenEnabled', 35 | 'mozfullscreenchange', 36 | 'mozfullscreenerror', 37 | ], 38 | [ 39 | 'msRequestFullscreen', 40 | 'msExitFullscreen', 41 | 'msFullscreenElement', 42 | 'msFullscreenEnabled', 43 | 'MSFullscreenChange', 44 | 'MSFullscreenError', 45 | ], 46 | ]; 47 | 48 | const getNativeAPI = () => { 49 | const unprefixedMethods = methodMap[0]; 50 | const returnValue = {}; 51 | 52 | for (const methodList of methodMap) { 53 | if (!methodList) continue; 54 | 55 | const exitFullscreenMethod = methodList[1]; 56 | if (exitFullscreenMethod in document) { 57 | for (const [index, method] of methodList.entries()) { 58 | returnValue[unprefixedMethods[index]] = method; 59 | } 60 | 61 | return returnValue; 62 | } 63 | } 64 | 65 | return false; 66 | }; 67 | 68 | const nativeAPI = getNativeAPI(); 69 | 70 | const eventNameMap = { 71 | change: nativeAPI.fullscreenchange, 72 | error: nativeAPI.fullscreenerror, 73 | }; 74 | 75 | // eslint-disable-next-line import/no-mutable-exports 76 | let screenfull = { 77 | // eslint-disable-next-line default-param-last 78 | request(element = document.documentElement, options = {}) { 79 | return new Promise((resolve, reject) => { 80 | const onFullScreenEntered = () => { 81 | screenfull.off('change', onFullScreenEntered); 82 | resolve(); 83 | }; 84 | 85 | screenfull.on('change', onFullScreenEntered); 86 | 87 | const returnPromise = element[nativeAPI.requestFullscreen](options); 88 | 89 | if (returnPromise instanceof Promise) { 90 | returnPromise.then(onFullScreenEntered).catch(reject); 91 | } 92 | }); 93 | }, 94 | exit() { 95 | return new Promise((resolve, reject) => { 96 | if (!screenfull.isFullscreen) { 97 | resolve(); 98 | return; 99 | } 100 | 101 | const onFullScreenExit = () => { 102 | screenfull.off('change', onFullScreenExit); 103 | resolve(); 104 | }; 105 | 106 | screenfull.on('change', onFullScreenExit); 107 | 108 | const returnPromise = document[nativeAPI.exitFullscreen](); 109 | 110 | if (returnPromise instanceof Promise) { 111 | returnPromise.then(onFullScreenExit).catch(reject); 112 | } 113 | }); 114 | }, 115 | toggle(element, options) { 116 | return screenfull.isFullscreen 117 | ? screenfull.exit() 118 | : screenfull.request(element, options); 119 | }, 120 | onchange(callback) { 121 | screenfull.on('change', callback); 122 | }, 123 | onerror(callback) { 124 | screenfull.on('error', callback); 125 | }, 126 | on(event, callback) { 127 | const eventName = eventNameMap[event]; 128 | if (eventName) { 129 | document.addEventListener(eventName, callback, false); 130 | } 131 | }, 132 | off(event, callback) { 133 | const eventName = eventNameMap[event]; 134 | if (eventName) { 135 | document.removeEventListener(eventName, callback, false); 136 | } 137 | }, 138 | raw: nativeAPI, 139 | isEnabled: false, 140 | isFullscreen: false, 141 | element: null, 142 | }; 143 | 144 | Object.defineProperties(screenfull, { 145 | isFullscreen: { 146 | get: () => Boolean(document[nativeAPI.fullscreenElement]), 147 | }, 148 | element: { 149 | enumerable: true, 150 | get: () => document[nativeAPI.fullscreenElement], 151 | }, 152 | isEnabled: { 153 | enumerable: true, 154 | // Coerce to boolean in case of old WebKit. 155 | get: () => Boolean(document[nativeAPI.fullscreenEnabled]), 156 | }, 157 | }); 158 | 159 | if (!nativeAPI) { 160 | screenfull = { isEnabled: false }; 161 | } 162 | 163 | export default screenfull; 164 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "typeRoots": ["node_modules/@types"], 6 | "types": ["node", "resize-observer-browser"], 7 | "module": "esnext", 8 | "lib": ["dom", "esnext"], 9 | "importHelpers": true, 10 | // output .d.ts declaration files for consumers 11 | "declaration": true, 12 | // output .js.map sourcemap files for consumers 13 | "sourceMap": true, 14 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 15 | "rootDir": "./src", 16 | // stricter type-checking for stronger correctness. Recommended by TS 17 | "strict": true, 18 | // linter checks for common issues 19 | "noImplicitReturns": true, 20 | "noFallthroughCasesInSwitch": true, 21 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | // use Node's module resolution algorithm, instead of the legacy TS one 25 | "moduleResolution": "node", 26 | // transpile JSX to React.createElement 27 | "jsx": "react", 28 | // interop between ESM and CJS modules. Recommended by TS 29 | "esModuleInterop": true, 30 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 31 | "skipLibCheck": true, 32 | // error out if import and file system have a casing mismatch. Recommended by TS 33 | "forceConsistentCasingInFileNames": true, 34 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` 35 | "noEmit": true 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tsdx.config.js: -------------------------------------------------------------------------------- 1 | const postcss = require('rollup-plugin-postcss'); 2 | const autoprefixer = require('autoprefixer'); 3 | const cssnano = require('cssnano'); 4 | 5 | module.exports = { 6 | /** 7 | * @param {import('rollup/dist/rollup').InputOptions} config 8 | */ 9 | rollup(config, options) { 10 | config.plugins.push( 11 | postcss({ 12 | modules: true, 13 | plugins: [ 14 | autoprefixer(), 15 | cssnano({ 16 | preset: 'default', 17 | }), 18 | ], 19 | inject: true, // only write out CSS for the first bundle (avoids pointless extra files): 20 | extract: !!options.writeMeta, 21 | }) 22 | ); 23 | 24 | if (options.environment === 'development') { 25 | // redirect dev build to nowhere 26 | config.output.file = '/dev/null'; 27 | } else { 28 | // rename prod build to index.js 29 | config.output.file = './dist/index.js'; 30 | } 31 | 32 | return config; 33 | }, 34 | }; 35 | --------------------------------------------------------------------------------