├── .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 |
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 |
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 | 
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 | 
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------