├── .babelrc ├── .gitignore ├── .npmignore ├── .prettierrc ├── README.md ├── examples ├── index.html └── index.tsx ├── package.json ├── src ├── core │ └── index.tsx ├── danmu │ ├── .DS_Store │ ├── index.tsx │ └── virtutal.tsx ├── index.tsx ├── model │ ├── index.tsx │ └── methods.tsx ├── subtitle │ └── index.tsx ├── theme │ ├── components │ │ ├── danmu-mobile.tsx │ │ ├── danmu.tsx │ │ ├── duration.tsx │ │ ├── fullscreen-mobile.tsx │ │ ├── fullscreen.tsx │ │ ├── icon.tsx │ │ ├── information.tsx │ │ ├── loading.tsx │ │ ├── message.tsx │ │ ├── play.tsx │ │ ├── progress.tsx │ │ ├── setting.tsx │ │ ├── source-mobile.tsx │ │ ├── source.tsx │ │ ├── subtitle-mobile.tsx │ │ ├── subtitle.tsx │ │ ├── thumbnail.tsx │ │ ├── volume.tsx │ │ └── webscreen.tsx │ ├── index.tsx │ ├── mobile │ │ ├── index.tsx │ │ └── style.less │ └── web │ │ ├── index.tsx │ │ └── style.less └── utils │ ├── local.ts │ ├── request.ts │ ├── utils.ts │ └── vttToJson.ts ├── tsconfig.json ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-typescript", "@babel/preset-react"], 3 | "plugins": ["@babel/proposal-class-properties", "@babel/proposal-object-rest-spread"] 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # .gitignore 2 | node_modules 3 | yarn-error.log 4 | dist -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # .npmignore 2 | src 3 | examples 4 | .babelrc 5 | .gitignore 6 | webpack.config.js -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 100, 5 | "overrides": [ 6 | { 7 | "files": ".prettierrc", 8 | "options": { 9 | "parser": "json" 10 | } 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # qinPlayer 2 | 3 | ## 说明 4 | 5 | 一款基于 react 的 html5 播放器, 支持弹幕字幕以及可拓展的主题 6 | 7 | ## 预览图 8 | 9 | ![image.png](https://i.loli.net/2020/04/16/16otDOXuEJ5ypc3.png) 10 | ![image.png](https://i.loli.net/2020/04/16/dmwfZPcRJUrD319.png) 11 | ![image.png](https://i.loli.net/2020/04/16/13bpmto6xlANOv9.png) 12 | ![image.png](https://i.loli.net/2020/04/16/ubLEfJrSwUI5ODx.png) 13 | ![image.png](https://i.loli.net/2020/04/16/Y3Hli45PXJ1at2h.png) 14 | ![image.png](https://i.loli.net/2020/04/16/i1Zm4gphxy2CPOk.png) 15 | 16 | ## 开发方向 17 | 18 | web 端的功能比较完善, 移动端经过考虑去除了设置, 网页全屏, 音量, 弹幕字幕也仅保留开关; 19 | 20 | 后续需要测试多浏览器的兼容性; 21 | 22 | ## 设计理念 23 | 24 | ```jsx 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | ``` 33 | 34 | 首先将整个播放器进行分层设计, 各组件之间尽量解耦, 方便日后随时更换组件; 35 | 36 | - `` 是数据收集与分发的中心, 主要是将数据与方法分发至不同的组件, 然后各组件根据数据调整状态, 或者执行方法改变核心的状态; 37 | - `` 是对 HTML5 的 video 更进一步的封装, 主要是将状态回传至 provider , 并监听状态的改变去执行暂停快进等, 以后可能会加个拓展层去支持更多的格式; 38 | - `` 是目前的默认主题, 主要是参考了 B 站的设计, 但是实际上是完全解耦的, 可以根据移动端的来源自动切换另一套主题, 同时也可以实现多套主题相互切换; 39 | - `` 是弹幕的处理层, 他覆盖在 video 上面, 但是在点击层的下面, 所以暂时无法点击到, 它可以获取弹幕列表, 并渲染弹幕, 而发送弹幕这个则以 children 的形式传入进去, 外部自行处理逻辑, 40 | - `` 是字幕层, 它主要是获取字幕然后解析 WEBVTT 的格式并动态渲染到底部, 由于没有采用原生的字幕显示, 所以在表现上也更加丰富生动, 后续如果想要解析其他格式的话, 也只需要对这一层加强即可; 41 | 42 | ## 问题 43 | 44 | 从理论上来说, 设计上还是比较合理的, 但是 video 频繁的数据回调可能会导致整个的播放器渲染卡顿等, 目前也是在减少其他层的数据依赖,需要进行压测以及一些交互优化 45 | 46 | ## 兼容性 47 | 48 | 目前在 chrome, safari, firefox, edge 使用均没有大问题, 至于 IE 已经被彻底忽略 49 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | qinPlayer Demo 4 | 5 | 6 | 17 | 18 | 19 | 20 | 23 |
24 | 25 | 26 | -------------------------------------------------------------------------------- /examples/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import Player from '../src/index'; 4 | import Stats from 'stats.js'; 5 | 6 | const stats = new Stats(); 7 | stats.showPanel(1); 8 | document.body.appendChild(stats.dom); 9 | 10 | function animate() { 11 | stats.begin(); 12 | stats.end(); 13 | 14 | requestAnimationFrame(animate); 15 | } 16 | 17 | requestAnimationFrame(animate); 18 | 19 | const App = () => ( 20 | {}} 32 | > 33 | 弹幕区 34 | 35 | ); 36 | render(, document.getElementById('root')); 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "qinplayer", 3 | "version": "1.0.0", 4 | "description": "a html player for react", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "webpack-dev-server --mode development", 9 | "babel": "babel src -d dist --copy-files", 10 | "build": "webpack --progress --display-error-details" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/Qinmei/qinPlayer.git" 15 | }, 16 | "keywords": [ 17 | "html5", 18 | "player", 19 | "react", 20 | "danmu" 21 | ], 22 | "author": "qinmei", 23 | "license": "ISC", 24 | "bugs": { 25 | "url": "https://github.com/Qinmei/qinPlayer/issues" 26 | }, 27 | "homepage": "https://github.com/Qinmei/qinPlayer#readme", 28 | "devDependencies": { 29 | "@babel/core": "^7.5.5", 30 | "@babel/plugin-proposal-class-properties": "^7.5.5", 31 | "@babel/plugin-proposal-object-rest-spread": "^7.5.5", 32 | "@babel/preset-env": "^7.5.5", 33 | "@babel/preset-react": "^7.0.0", 34 | "@babel/preset-typescript": "^7.3.3", 35 | "@types/react": "^16.9.2", 36 | "@types/react-dom": "^16.9.0", 37 | "@types/styled-components": "^5.0.1", 38 | "@types/whatwg-fetch": "^0.0.33", 39 | "autoprefixer": "^9.7.0", 40 | "babel-loader": "^8.0.6", 41 | "css-loader": "^3.2.0", 42 | "html-webpack-plugin": "^3.2.0", 43 | "less": "^3.10.3", 44 | "less-loader": "^5.0.0", 45 | "postcss-loader": "^3.0.0", 46 | "stats.js": "^0.17.0", 47 | "style-loader": "^1.0.0", 48 | "url-loader": "^2.1.0", 49 | "webpack": "^4.39.3", 50 | "webpack-cli": "^3.3.7", 51 | "webpack-dev-server": "^3.8.0" 52 | }, 53 | "dependencies": { 54 | "@babel/polyfill": "^7.7.0", 55 | "react": "^16.9.0", 56 | "react-dom": "^16.9.0", 57 | "screenfull": "^5.0.0", 58 | "styled-components": "^4.3.2", 59 | "whatwg-fetch": "^3.0.0" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/core/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @params 属性 3 | * audioTracks : 返回一个表示可用音轨的AudioTrackList对象 4 | * autoplay : 设置或返回视频是否应该在加载后立即开始播放 5 | * buffered : 返回一个TimeRanges对象,表示视频的缓冲部分 6 | * controller : 返回MediaController对象,表示视频的当前媒体控制器 7 | * crossOrigin : 设置或返回视频的CORS设置 8 | * controls : 设置或返回视频是否显示控件(如播放/暂停等) 9 | * currentSrc : 返回当前视频的URL 10 | * currentTime : 设置或返回视频中的当前回放位置(以秒为单位) 11 | * defaultMuted : 设置或返回视频是否应该在默认情况下保持静音 12 | * defaultPlaybackRate: 设置或返回视频播放的默认速度 13 | * duration : 返回当前视频的长度(以秒为单位) 14 | * ended : 返回视频播放是否结束 15 | * error : 返回一个MediaError对象,表示视频的错误状态 16 | * loop : 设置或返回视频结束后是否应重新开始 17 | * mediaGroup : 设置或返回视频所属的组(用于链接多个视频元素) 18 | * muted : 设置或返回视频是否静音 19 | * networkState : 返回视频的当前网络状态 20 | * paused : 返回视频是否暂停 21 | * playbackRate : 设置或返回视频播放的速度 22 | * played : 返回一个TimeRanges对象,表示视频的播放部分 23 | * preload : 设置或返回页面加载时是否加载视频 24 | * readyState : 返回视频的当前就绪状态 25 | * seekable : 返回一个TimeRanges对象,该对象表示视频的可查找部分 26 | * seeking : 返回用户当前是否在视频中搜索 27 | * src : 设置或返回视频元素的当前源 28 | * startDate : 返回表示当前时间偏移量的日期对象 29 | * textTracks : 返回一个表示可用文本轨迹的TextTrackList对象 30 | * videoTracks : 返回一个表示可用视频轨道的VideoTrackList对象 31 | * volume : 设置或返回视频的音量 32 | */ 33 | 34 | /** 35 | * @params 事件 36 | * onLoadStart : 在浏览器开始寻找指定视频触发。 37 | * onDurationChange: 在视频的时长发生变化时触发。 38 | * onLoadedMetadata: 在指定视频的元数据加载后触发。视频的元数据包含: 时长,尺寸大小(视频),文本轨道 39 | * onLoadedData : 在当前帧的数据加载完成且还没有足够的数据播放视频的下一帧时触发。 40 | * onProgress : 在浏览器下载指定的视频时触发。 41 | * onCanPlay : 在用户可以开始播放视频时触发。 42 | * onCanPlayThrough: 在视频可以正常播放且无需停顿和缓冲时触发。 43 | * onPlaying : 在视频暂停或者在缓冲后准备重新开始播放时触发。 44 | * onPause : 在视频暂停时触发。 45 | * onTimeUpdate : 在视频当前的播放位置发送改变时触发。 46 | * onWaiting : 在视频由于要播放下一帧而需要缓冲时触发。 47 | * onEmptied : 当前播放列表为空时触发 48 | * onEncrypted : 在发生encrypted事件时触发,表示媒体已加密 49 | * onEnded : 在视频播放结束时触发。 50 | * onError : 在视频数据加载期间发生错误时触发。 51 | * onPlay : 在视频开始播放时触发。 52 | * onRateChange : 速率变化 53 | * onSeeked : 事件在用户重新定位视频的播放位置后触发。 54 | * onSeeking : 在用户开始重新定位视频时触发。 55 | * onAbort : 该事件在多媒体数据终止加载时触发,而不是发生错误时触发。 56 | * onStalled : 在浏览器获取媒体数据,但媒体数据不可用时触发。 57 | * onSuspend : 读取媒体数据中断 58 | * onVolumeChange : 音量改变 59 | */ 60 | 61 | /** 62 | * @params 在加载过程中,触发的顺序如下: 63 | * onloadstart 64 | * ondurationchange 65 | * onloadedmetadata 66 | * onloadeddata 67 | * onprogress 68 | * oncanplay 69 | * oncanplaythrough 70 | */ 71 | 72 | import React, { useState, useEffect, useRef, useContext } from 'react'; 73 | import { PlayerContext } from '../model'; 74 | import styled from 'styled-components'; 75 | 76 | const Wrapper = styled.div` 77 | width: ${(props: { percent: any }) => props.percent}%; 78 | height: ${(props: { percent: any }) => props.percent}%; 79 | display: flex; 80 | justify-content: center; 81 | align-items: center; 82 | 83 | .video { 84 | width: 100%; 85 | max-height: 100%; 86 | background-position: center; 87 | background-size: cover; 88 | background-color: black; 89 | 90 | &::-webkit-media-controls { 91 | display: none !important; 92 | } 93 | } 94 | `; 95 | 96 | const reactComponent = () => { 97 | const videoRef = useRef({} as HTMLVideoElement); 98 | const addonRef = useRef(0); 99 | 100 | const [fail, setFail] = useState(false); 101 | 102 | const data = useContext(PlayerContext); 103 | const { 104 | state: { 105 | playSource, 106 | poster, 107 | preload, 108 | autoplay, 109 | play, 110 | volume, 111 | seeked, 112 | picture, 113 | rate, 114 | loop, 115 | size, 116 | }, 117 | methods, 118 | } = data; 119 | 120 | const onMethods = { 121 | onPlaying: () => { 122 | methods.changePlay(true); 123 | }, 124 | 125 | onPause: () => { 126 | methods.changePlay(false); 127 | }, 128 | 129 | onTimeUpdate: (e: React.ChangeEvent) => { 130 | const { currentTime } = e.target; 131 | methods.changeCurrent(currentTime); 132 | }, 133 | 134 | onSeeked: (e: React.ChangeEvent) => { 135 | const { readyState } = e.target; 136 | if (readyState > 2) { 137 | if (addonRef.current) { 138 | clearTimeout(addonRef.current); 139 | } 140 | methods.changeLoading(false); 141 | } 142 | }, 143 | onSeeking: () => { 144 | if (addonRef.current) { 145 | clearTimeout(addonRef.current); 146 | } 147 | addonRef.current = setTimeout(() => { 148 | methods.changeLoading(true); 149 | }, 300); 150 | }, 151 | 152 | onProgress: (e: React.ChangeEvent) => { 153 | const { buffered } = e.target; 154 | const length = buffered.length; 155 | const arr = [...Array(length).keys()]; 156 | const buffer = arr.map((item: any) => [buffered.start(item), buffered.end(item)]); 157 | methods.changeBuffered(buffer); 158 | }, 159 | onWaiting: () => { 160 | methods.changeLoading(true); 161 | }, 162 | onLoadStart: () => { 163 | methods.changeMessage('9视频初始化'); 164 | }, 165 | onDurationChange: (e: React.ChangeEvent) => { 166 | const { duration } = e.target; 167 | methods.changeDuration(duration); 168 | methods.changeMessage('9获取视频信息'); 169 | }, 170 | onLoadedMetadata: () => { 171 | methods.changeMessage('9开始加载视频'); 172 | }, 173 | onLoadedData: () => { 174 | methods.changeMessage('1准备播放视频'); 175 | }, 176 | onCanPlay: () => { 177 | methods.changeLoading(false); 178 | }, 179 | onCanPlayThrough: () => {}, // 体验更好, 但是需要更完美的配合, 感觉没必要 180 | onEncrypted: () => { 181 | methods.changeMessage('9视频文件已被加密'); 182 | setFail(true); 183 | }, 184 | onEnded: () => { 185 | console.log('onEnded'); 186 | }, 187 | onError: (e: React.ChangeEvent) => { 188 | const { error } = e.target; 189 | const type: { [propName: number]: string } = { 190 | 1: '请求中止无法获取资源', 191 | 2: '获取视频时发生错误', 192 | 3: '视频解码错误', 193 | 4: '不支持的视频类型或文件', 194 | }; 195 | if (error) { 196 | methods.changeMessage(`9${type[error.code]}`); 197 | setFail(true); 198 | } 199 | }, 200 | onEmptied: () => {}, // 切换视频时会触发, 暂时用不上 201 | onAbort: () => {}, // 没触发过 202 | onStalled: () => {}, // 中间有一部分加载失败会触发 203 | onSuspend: () => {}, // 缓存了一段数据就会触发 204 | onPlay: () => {}, // 切换成播放状态的时候会触发, 用处不大 205 | onRateChange: () => {}, 206 | onVolumeChange: () => {}, 207 | }; 208 | 209 | // 播放 210 | useEffect(() => { 211 | if (play) { 212 | videoRef.current.play(); 213 | } else { 214 | videoRef.current.pause(); 215 | } 216 | }, [play]); 217 | 218 | // 播放连接 219 | useEffect(() => { 220 | videoRef.current.src = playSource; 221 | videoRef.current.currentTime = seeked; 222 | }, [playSource]); 223 | 224 | // 进度条 225 | useEffect(() => { 226 | videoRef.current.currentTime = seeked; 227 | }, [seeked]); 228 | 229 | // 音量 230 | useEffect(() => { 231 | videoRef.current.volume = volume; 232 | }, [volume]); 233 | 234 | // 速率 235 | useEffect(() => { 236 | videoRef.current.playbackRate = rate; 237 | }, [rate]); 238 | 239 | useEffect(() => { 240 | methods.changeLoading(false); 241 | }, [fail]); 242 | 243 | // 画中画 244 | useEffect(() => { 245 | if (!document.pictureInPictureEnabled) return; 246 | if (picture) { 247 | videoRef.current.requestPictureInPicture(); 248 | } else { 249 | if (document.pictureInPictureElement) { 250 | document.exitPictureInPicture(); 251 | } 252 | } 253 | }, [picture]); 254 | 255 | return ( 256 | 257 | 290 | 291 | ); 292 | }; 293 | export default reactComponent; 294 | -------------------------------------------------------------------------------- /src/danmu/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Qinmei/qinPlayer/816ec4888a1468c5cf411310165dcd4333096c90/src/danmu/.DS_Store -------------------------------------------------------------------------------- /src/danmu/index.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * 方案一:直接在给数组添加left, top属性, 然后使用帧动画更新数组; 3 | * 缺点: 动画效果卡顿, 每次更新后的渲染时间在200ms, 弹幕一顿一顿的; 4 | * 优点:使用简单, 可控制动画的暂停启动, 无需关注时间, 只需维护数组; 5 | * 反思:可能是组件依赖过多, 导致每次render时间过长, 下一步尝试将位移动画提成组件, 减少依赖, 提高动画流畅度 6 | * 最终总结:经过几次尝试,应该还是react的更新机制问题,数组已经更新但是没有更新UI, 直到更上层的context每200ms渲染一次, 导致动画卡顿, 于是创建一个state, 每次函数执行的时候强制更新, 导致render间隔为33ms左右, 30fps 7 | */ 8 | 9 | import React, { useEffect, useRef, useContext, useState, useLayoutEffect } from 'react'; 10 | import { PlayerContext } from '../model'; 11 | import styled from 'styled-components'; 12 | import { fontArr, areaArr, opacityArr, getFontLength } from '../utils/utils'; 13 | import fetch from '../utils/request'; 14 | import VirtualList from './virtutal'; 15 | 16 | export interface DanmuText { 17 | id: string; 18 | time: number; 19 | text: string; 20 | color: string; 21 | type: number; 22 | } 23 | 24 | export interface DanmuTextShow extends DanmuText { 25 | left: number; 26 | top: number; 27 | self: number; 28 | } 29 | 30 | interface WrapperType { 31 | size: number; 32 | mode: string; 33 | area: number; 34 | opacity: number; 35 | width: number; 36 | } 37 | 38 | const Wrapper = styled.div` 39 | position: absolute; 40 | top: 0; 41 | left: 0; 42 | width: 100%; 43 | height: 100%; 44 | z-index: 7; 45 | overflow: hidden; 46 | 47 | .con { 48 | width: 100%; 49 | height: ${(props) => areaArr[props.mode][props.area] * 100}%; 50 | color: white; 51 | font-size: ${(props) => fontArr[props.mode][props.size]}px; 52 | line-height: ${(props) => fontArr[props.mode][props.size] + 4}px; 53 | opacity: ${(props) => opacityArr[props.mode][props.opacity]}; 54 | 55 | .danmu { 56 | position: absolute; 57 | display: inline-block; 58 | right: ${(props) => props.width}px; 59 | white-space: nowrap; 60 | transition: all 200 linear; 61 | } 62 | } 63 | `; 64 | 65 | const reactComponent = () => { 66 | const data = useContext(PlayerContext); 67 | const { 68 | state: { 69 | danmu, 70 | current, 71 | danmuArea, 72 | danmuFont, 73 | danmuShow, 74 | mode, 75 | danmuOpacity, 76 | play, 77 | danmuFront = (res: any) => res.data, 78 | }, 79 | } = data; 80 | 81 | if (!danmuShow) return <>; 82 | 83 | const danmuRef = useRef({} as HTMLDivElement); 84 | const storeRef = useRef({}); 85 | 86 | if (!storeRef.current.top) { 87 | storeRef.current.top = []; 88 | } 89 | 90 | const [list, setList] = useState([]); 91 | const [show, setShow] = useState([]); 92 | const [width, setWidth] = useState(0); 93 | const [total, setTotal] = useState(0); 94 | let [toggle, setToggle] = useState(0); 95 | 96 | const initData = async (target: string) => { 97 | const data = await fetch(target) 98 | .then((res) => res.json()) 99 | .then((res) => danmuFront(res)); 100 | setList(data); 101 | }; 102 | 103 | const filterData = (list: DanmuText[]) => { 104 | list 105 | .filter( 106 | (item) => 107 | item.time < current + 0.5 && 108 | item.time > current - 0.5 && 109 | !show.some((ele) => ele.id === item.id), 110 | ) 111 | .slice(0, 300) 112 | .map((item) => { 113 | draw(item); 114 | }); 115 | }; 116 | 117 | const draw = async (value: any) => { 118 | const result = getEmptyDanmuTop(); 119 | const selfWidth = (getFontLength(value.text) * fontArr[mode][danmuFont]) / 2; 120 | const preLeft = width + selfWidth; 121 | if (preLeft > width * 2) return; 122 | 123 | show.push({ 124 | ...value, 125 | left: result.left < width ? preLeft : result.left + 30 + selfWidth, 126 | top: result.top, 127 | self: selfWidth, 128 | }); 129 | setShow(show); 130 | }; 131 | 132 | const getEmptyDanmuTop = () => { 133 | show.map((item) => { 134 | const left = item.left; 135 | const top = item.top; 136 | storeRef.current.top[top] = { 137 | left, 138 | top, 139 | }; 140 | }); 141 | 142 | let result = [...storeRef.current.top] 143 | .sort((a, b) => a.left - b.left) 144 | .filter((item) => item.top < total); 145 | const lessDanmu = result 146 | .filter((item) => item.top < total / 2 && item.top > 0 && Math.abs(width - item.left) < 100) 147 | .sort((a, b) => a.top - b.top); 148 | if (lessDanmu.length > 0) { 149 | result = lessDanmu; 150 | } 151 | 152 | return result[0]; 153 | }; 154 | 155 | const start = () => { 156 | let space = 5; 157 | const now = new Date().getTime(); 158 | if (storeRef.current.count) { 159 | const fps = 1000 / (now - storeRef.current.count); 160 | if (fps < 30) { 161 | space = 5; 162 | } else if (fps < 61) { 163 | space = 3; 164 | } else { 165 | space = 1.5; 166 | } 167 | } 168 | 169 | storeRef.current.count = now; 170 | 171 | show.map((item) => { 172 | item.left -= space; 173 | }); 174 | 175 | for (let index = 0; index < show.length; index++) { 176 | const element = show[index]; 177 | if (element.left < 0) { 178 | show.splice(index, 1); 179 | index--; 180 | } 181 | } 182 | 183 | setToggle(toggle++); 184 | storeRef.current.play = requestAnimationFrame(start); 185 | }; 186 | 187 | const stop = () => { 188 | if (storeRef.current.play) { 189 | cancelAnimationFrame(storeRef.current.play); 190 | } 191 | }; 192 | 193 | const init = () => { 194 | setWidth(danmuRef.current.clientWidth); 195 | const newTotal = Math.floor( 196 | (danmuRef.current.clientHeight * areaArr[mode][danmuArea]) / (fontArr[mode][danmuFont] + 4), 197 | ); 198 | 199 | [...Array(newTotal)].map((item, index) => { 200 | if (!storeRef.current.top[index]) { 201 | storeRef.current.top[index] = { 202 | left: -1, 203 | top: index, 204 | }; 205 | } 206 | }); 207 | 208 | storeRef.current.top = storeRef.current.top.slice(0, newTotal); 209 | 210 | setTotal(newTotal); 211 | }; 212 | 213 | useEffect(() => { 214 | if (play) { 215 | start(); 216 | } else { 217 | stop(); 218 | } 219 | }, [play]); 220 | 221 | useEffect(() => { 222 | initData(danmu); 223 | }, [danmu]); 224 | 225 | useEffect(() => { 226 | filterData(list); 227 | }, [current]); 228 | 229 | useEffect(() => { 230 | init(); 231 | }, [danmuArea]); 232 | 233 | useLayoutEffect(() => { 234 | init(); 235 | }); 236 | 237 | return ( 238 | 246 |
247 | 248 |
249 |
250 | ); 251 | }; 252 | export default reactComponent; 253 | -------------------------------------------------------------------------------- /src/danmu/virtutal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { DanmuTextShow } from './index'; 3 | 4 | interface PropsTypes { 5 | data: DanmuTextShow[]; 6 | gap: number; 7 | width: number; 8 | } 9 | 10 | const reactComponent = (props: PropsTypes) => { 11 | const { data, gap, width } = props; 12 | const show = data.filter((item) => item.left < width + item.self); 13 | 14 | return show.map((item) => ( 15 |
23 | {item.text} 24 |
25 | )); 26 | }; 27 | export default reactComponent; 28 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Core from './core'; 3 | import Sub from './subtitle'; 4 | import Danmu, { DanmuText } from './danmu'; 5 | import Theme from './theme'; 6 | import { PlayerProvider, IndexPropsType } from './model'; 7 | import '@babel/polyfill'; 8 | 9 | const reactComponent: React.FC = (props) => { 10 | const { onStateChange, children, ...args } = props; 11 | 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | }; 22 | export default reactComponent; 23 | -------------------------------------------------------------------------------- /src/model/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useReducer } from 'react'; 2 | import { Methods } from './methods'; 3 | import { DanmuText } from '../danmu'; 4 | import { getMode } from '../utils/utils'; 5 | 6 | export interface IndexPropsType { 7 | source: { 8 | label: string; 9 | value: string; 10 | }[]; 11 | poster?: string; 12 | preload?: 'auto' | 'metadata' | 'none'; 13 | autoplay?: boolean; 14 | color?: string; 15 | subtitle?: string; 16 | danmu?: string; 17 | danmuFront?: (res: any) => DanmuText[]; 18 | danmuBack?: (value: DanmuText) => Promise; 19 | onStateChange?: (type: string, value: any, state: DataType) => void; 20 | mode?: 'web' | 'mobile'; 21 | } 22 | 23 | interface PropsType { 24 | onStateChange?: (type: string, value: any, state: any) => void; 25 | initData: IndexPropsType; 26 | children?: React.ReactNode; 27 | } 28 | 29 | interface ContextProps { 30 | state: DataType; 31 | dispatch: React.Dispatch; 32 | methods: Methods; 33 | } 34 | 35 | export interface SourceType { 36 | label: string; 37 | value: string; 38 | } 39 | 40 | export interface DataType { 41 | source: SourceType[]; 42 | playSource: string; 43 | poster: string; 44 | preload: 'auto' | 'metadata' | 'none'; 45 | mode: 'web' | 'mobile'; 46 | autoplay: boolean; 47 | color: string; 48 | lang: string; 49 | thumbnail: { 50 | count: number; 51 | urls: Array; 52 | }; 53 | subtitle: string; 54 | size: number; 55 | play: boolean; 56 | loading: boolean; 57 | duration: number; 58 | buffered: Array>; 59 | current: number; 60 | seeked: number; 61 | volume: number; 62 | fullscreen: boolean; 63 | webscreen: boolean; 64 | picture: boolean; 65 | light: boolean; 66 | movie: boolean; 67 | message: string; 68 | rate: number; 69 | loop: boolean; 70 | subshow: boolean; 71 | subsize: number; 72 | subcolor: number; 73 | submargin: number; 74 | danmu: string; 75 | danmuShow: boolean; 76 | noTop: boolean; 77 | noBottom: boolean; 78 | noScroll: boolean; 79 | danmuOpacity: number; 80 | danmuArea: number; 81 | danmuFont: number; 82 | danmuFront?: (res: any) => DanmuText[]; 83 | } 84 | 85 | const PlayerContext = createContext({} as ContextProps); 86 | 87 | const reducer = (state: DataType, action: any) => { 88 | return { 89 | ...state, 90 | ...action, 91 | }; 92 | }; 93 | 94 | const PlayerProvider = (props: PropsType) => { 95 | const { onStateChange, children, initData } = props; 96 | 97 | const data: DataType = { 98 | source: [], 99 | playSource: (initData.source && initData.source[0] && initData.source[0].value) || '', 100 | poster: '', 101 | preload: 'auto', 102 | mode: initData.mode ? initData.mode : getMode(), 103 | autoplay: false, 104 | color: '#00a1d6', 105 | play: false, 106 | loading: false, 107 | buffered: [[0, 0]], 108 | duration: 0.0001, 109 | current: 0, 110 | seeked: 0, 111 | volume: 0.75, 112 | size: 100, 113 | fullscreen: false, 114 | webscreen: false, 115 | picture: false, 116 | light: false, 117 | movie: false, 118 | rate: 1, 119 | loop: false, 120 | subtitle: '', 121 | subshow: true, 122 | subsize: 2, 123 | subcolor: 0, 124 | submargin: 2, 125 | danmu: '', 126 | noTop: false, 127 | noBottom: false, 128 | noScroll: false, 129 | danmuShow: true, 130 | danmuOpacity: 4, 131 | danmuArea: 2, 132 | danmuFont: 2, 133 | message: '', 134 | lang: 'CN', 135 | thumbnail: { 136 | count: 0, 137 | urls: [], 138 | }, 139 | ...initData, 140 | }; 141 | const [state, dispatch] = useReducer(reducer, data); 142 | 143 | const sendData = (type: string, value: any) => { 144 | dispatch({ 145 | [type]: value, 146 | }); 147 | onStateChange && onStateChange(type, value, state); 148 | }; 149 | 150 | const contextValue: ContextProps = { 151 | state, 152 | dispatch, 153 | methods: new Methods(state, sendData), 154 | }; 155 | 156 | return {children}; 157 | }; 158 | 159 | export { PlayerContext, PlayerProvider }; 160 | -------------------------------------------------------------------------------- /src/model/methods.tsx: -------------------------------------------------------------------------------- 1 | import { DataType } from './index'; 2 | 3 | type SendData = (type: string, value: any) => void; 4 | 5 | export class Methods { 6 | constructor(private state: DataType, private sendData: SendData) {} 7 | 8 | changePlay = (value: boolean = !this.state.play) => this.sendData('play', value); 9 | changeMode = (value: string = 'auto') => this.sendData('mode', value); 10 | changeScreen = (value: boolean = !this.state.fullscreen) => this.sendData('fullscreen', value); 11 | changeWebScreen = (value: boolean = !this.state.webscreen) => this.sendData('webscreen', value); 12 | changeMovie = (value: boolean = !this.state.movie) => this.sendData('movie', value); 13 | changeVolume = (value: number = 0.75) => 14 | this.sendData('volume', value > 1 ? 1 : value < 0 ? 0 : value); 15 | changeCurrent = (value: number = this.state.current) => 16 | this.sendData( 17 | 'current', 18 | value < 0 ? 0 : value > this.state.duration ? this.state.duration : value, 19 | ); 20 | changeSeeked = (value: number = this.state.seeked) => 21 | this.sendData( 22 | 'seeked', 23 | value < 0 ? 0 : value > this.state.duration ? this.state.duration : value, 24 | ); 25 | changeBuffered = (value: Array>) => this.sendData('buffered', value); 26 | changeDuration = (value: number = 0) => this.sendData('duration', value); 27 | changeLoading = (value: boolean = !this.state.loading) => this.sendData('loading', value); 28 | changeMessage = (value: string = '') => this.sendData('message', value); 29 | changePicture = (value: boolean = !this.state.picture) => this.sendData('picture', value); 30 | changeLight = (value: boolean = !this.state.light) => this.sendData('light', value); 31 | changeLoop = (value: boolean = !this.state.loop) => this.sendData('loop', value); 32 | changeRate = (value: number = 1) => this.sendData('rate', value); 33 | changeSubShow = (value: boolean = !this.state.subshow) => this.sendData('subshow', value); 34 | changeSubColor = (value: number = this.state.subcolor) => this.sendData('subcolor', value); 35 | changeSubSize = (value: number = this.state.subsize) => this.sendData('subsize', value); 36 | changeSubMargin = (value: number = this.state.submargin) => this.sendData('submargin', value); 37 | changeSize = (value: number = this.state.size) => this.sendData('size', value); 38 | changeDanmuShow = (value: boolean = !this.state.danmuShow) => this.sendData('danmuShow', value); 39 | changeNoTop = (value: boolean = !this.state.noTop) => this.sendData('noTop', value); 40 | changeNoBottom = (value: boolean = !this.state.noBottom) => this.sendData('noBottom', value); 41 | changeNoScroll = (value: boolean = !this.state.noScroll) => this.sendData('noScroll', value); 42 | changeDanmuOpacity = (value: number = this.state.danmuOpacity) => 43 | this.sendData('danmuOpacity', value); 44 | changeDanmuArea = (value: number = this.state.danmuArea) => this.sendData('danmuArea', value); 45 | changeDanmuFont = (value: number = this.state.danmuFont) => this.sendData('danmuFont', value); 46 | changeSource = (value: string = '') => this.sendData('playSource', value); 47 | onEnd = () => this.sendData('end', true); 48 | } 49 | -------------------------------------------------------------------------------- /src/subtitle/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useContext, useState } from 'react'; 2 | import { PlayerContext } from '../model'; 3 | import styled from 'styled-components'; 4 | import { colorArr, marginArr, sizeArr } from '../utils/utils'; 5 | import fetch from '../utils/request'; 6 | import vttToJson, { SubList } from '../utils/vttToJson'; 7 | 8 | const Wrapper = styled.div` 9 | position: absolute; 10 | bottom: 0; 11 | left: 0; 12 | width: 100%; 13 | height: 40%; 14 | z-index: 7; 15 | display: flex; 16 | flex-wrap: wrap; 17 | justify-content: center; 18 | align-items: flex-end; 19 | overflow: hidden; 20 | 21 | .sub { 22 | text-align: center; 23 | text-shadow: 0px 0px 5px ${(props: any) => colorArr[props.color]}, 0px 0px 10px black, 24 | 0px 0px 15px black, 0px 0px 20px black; 25 | color: white; 26 | margin-bottom: ${(props: any) => marginArr[props.mode][props.margin]}; 27 | font-size: ${(props: any) => sizeArr[props.mode][props.size]}; 28 | 29 | p { 30 | margin: 0; 31 | } 32 | } 33 | `; 34 | 35 | const reactComponent = () => { 36 | const data = useContext(PlayerContext); 37 | const { 38 | state: { subcolor, subsize, submargin, subtitle, mode, current, subshow }, 39 | } = data; 40 | 41 | if (!subtitle) return <>; 42 | 43 | const [subData, setSubData] = useState([]); 44 | 45 | const initData = async (url: string) => { 46 | const data = await fetch(url).then((res) => res.text()); 47 | const result = data ? await vttToJson(data) : []; 48 | setSubData(result); 49 | }; 50 | 51 | useEffect(() => { 52 | initData(subtitle); 53 | }, [subtitle]); 54 | 55 | const subArr = subData.filter((item) => current >= item.start && current <= item.end); 56 | const sub = subArr.length > 0 ? subArr[0].word : []; 57 | 58 | return ( 59 | 60 | {subshow && ( 61 |
62 | {sub.map((text: string, index: number) => ( 63 |

{text}

64 | ))} 65 |
66 | )} 67 |
68 | ); 69 | }; 70 | export default reactComponent; 71 | -------------------------------------------------------------------------------- /src/theme/components/danmu-mobile.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import Icon from './icon'; 3 | import { PlayerContext } from '../../model'; 4 | import styled from 'styled-components'; 5 | 6 | interface StyleProps { 7 | show: boolean; 8 | } 9 | 10 | const Wrapper = styled.div` 11 | width: 35px; 12 | height: 35px; 13 | display: flex; 14 | justify-content: center; 15 | align-items: center; 16 | cursor: pointer; 17 | position: relative; 18 | opacity: ${(props) => (props.show ? 1 : 0.4)}; 19 | 20 | .iconfont { 21 | width: 25px; 22 | height: 20px; 23 | color: white; 24 | fill: currentColor; 25 | } 26 | `; 27 | 28 | const reactComponent = () => { 29 | const data = useContext(PlayerContext); 30 | const { methods, state } = data; 31 | const { color, danmuShow } = state; 32 | 33 | const toggle = () => { 34 | methods.changeDanmuShow(!danmuShow); 35 | }; 36 | 37 | return ( 38 | 39 | 40 | 41 | ); 42 | }; 43 | export default reactComponent; 44 | -------------------------------------------------------------------------------- /src/theme/components/danmu.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import Icon from './icon'; 3 | import { PlayerContext } from '../../model'; 4 | import lang from '../../utils/local'; 5 | import styled from 'styled-components'; 6 | 7 | const Wrapper = styled.div` 8 | width: 35px; 9 | height: 35px; 10 | display: flex; 11 | justify-content: center; 12 | align-items: center; 13 | cursor: pointer; 14 | position: relative; 15 | 16 | .iconfont { 17 | width: 25px; 18 | height: 20px; 19 | color: white; 20 | fill: currentColor; 21 | } 22 | 23 | .panel { 24 | position: absolute; 25 | bottom: 45px; 26 | right: -65px; 27 | cursor: default; 28 | color: #fff; 29 | width: 266px; 30 | box-sizing: border-box; 31 | background: rgba(21, 21, 21, 0.9); 32 | border-radius: 2px; 33 | padding: 12px 20px; 34 | text-align: left; 35 | font-size: 12px; 36 | display: none; 37 | 38 | &:before { 39 | content: ''; 40 | width: 100%; 41 | height: 30px; 42 | position: absolute; 43 | bottom: -20px; 44 | right: 0; 45 | z-index: 29; 46 | } 47 | 48 | .container { 49 | width: 100%; 50 | height: auto; 51 | 52 | p { 53 | margin: 0; 54 | user-select: none; 55 | } 56 | 57 | .secList { 58 | margin-bottom: 12px; 59 | .labelCon { 60 | display: flex; 61 | justify-content: space-between; 62 | align-items: center; 63 | 64 | .labelList { 65 | margin-top: 6px; 66 | width: calc((100% - 8px) / 2); 67 | background-color: hsla(0, 0%, 100%, 0.3); 68 | line-height: 24px; 69 | border-radius: 2px; 70 | text-align: center; 71 | cursor: pointer; 72 | 73 | &:hover { 74 | background-color: hsla(0, 0%, 100%, 0.4); 75 | } 76 | } 77 | 78 | .secLabel { 79 | width: 25%; 80 | display: flex; 81 | justify-content: center; 82 | align-items: center; 83 | height: 26px; 84 | cursor: pointer; 85 | user-select: none; 86 | padding-bottom: 3px; 87 | border-bottom: solid 2px hsla(0, 0%, 100%, 0.3); 88 | position: relative; 89 | 90 | .ratedot { 91 | position: absolute; 92 | bottom: -3px; 93 | height: 4px; 94 | width: 2px; 95 | background-color: hsla(0, 0%, 100%, 0.3); 96 | } 97 | 98 | .rateSelect { 99 | width: 12px; 100 | height: 12px; 101 | border-radius: 50%; 102 | position: absolute; 103 | bottom: -7px; 104 | } 105 | 106 | &:nth-child(1) { 107 | width: 12.5%; 108 | text-align: left; 109 | 110 | .rate { 111 | margin-left: -4px; 112 | } 113 | 114 | .ratedot { 115 | left: 0; 116 | } 117 | 118 | .rateSelect { 119 | left: 0px; 120 | } 121 | } 122 | 123 | &:nth-last-child(1) { 124 | width: 12.5%; 125 | text-align: right; 126 | 127 | .rate { 128 | margin-right: -4px; 129 | } 130 | 131 | .ratedot { 132 | right: 0; 133 | } 134 | 135 | .rateSelect { 136 | right: 0px; 137 | } 138 | } 139 | } 140 | 141 | .colorLabel { 142 | width: 18px; 143 | height: 18px; 144 | border-radius: 50%; 145 | cursor: pointer; 146 | display: flex; 147 | justify-content: center; 148 | align-items: center; 149 | 150 | .selectIcon { 151 | width: 14px; 152 | height: 14px; 153 | color: white; 154 | fill: currentColor; 155 | } 156 | } 157 | } 158 | } 159 | } 160 | } 161 | 162 | &:hover { 163 | .panel { 164 | display: inline-block; 165 | } 166 | } 167 | `; 168 | 169 | const sizeArr = [0, 1, 2, 3, 4]; 170 | 171 | const reactComponent = () => { 172 | const data = useContext(PlayerContext); 173 | const { methods, state } = data; 174 | const { color } = state; 175 | 176 | return ( 177 | 178 | 179 |
180 |
181 |
182 |

{lang[state.lang].danmuSetting}

183 |
184 |
methods.changeDanmuShow(true)} 188 | > 189 | {lang[state.lang].danmu} 190 |
191 |
methods.changeDanmuShow(false)} 195 | > 196 | {lang[state.lang].nodanmu} 197 |
198 |
199 |
200 |
201 |

{lang[state.lang].danmuFont}

202 |
203 | {sizeArr.map((item: number, index: number) => ( 204 |
methods.changeDanmuFont(item)} key={item}> 205 | {lang[state.lang]['danmuFont' + (index + 1)]} 206 | 207 | {state.danmuFont === item && ( 208 | 209 | )} 210 |
211 | ))} 212 |
213 |
214 |
215 |

{lang[state.lang].danmuOpacity}

216 |
217 | {sizeArr.map((item: number, index: number) => ( 218 |
methods.changeDanmuOpacity(item)} 221 | key={item} 222 | > 223 | {lang[state.lang]['danmuOpacity' + (index + 1)]} 224 | 225 | {state.danmuOpacity === item && ( 226 | 227 | )} 228 |
229 | ))} 230 |
231 |
232 |
233 |

{lang[state.lang].danmuArea}

234 |
235 | {sizeArr.map((item: number, index: number) => ( 236 |
methods.changeDanmuArea(item)} key={item}> 237 | {lang[state.lang]['danmuArea' + (index + 1)]} 238 | 239 | {state.danmuArea === item && ( 240 | 241 | )} 242 |
243 | ))} 244 |
245 |
246 |
247 |
248 |
249 | ); 250 | }; 251 | export default reactComponent; 252 | -------------------------------------------------------------------------------- /src/theme/components/duration.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { PlayerContext } from '../../model'; 3 | import styled from 'styled-components'; 4 | import { timeTransfer } from '../../utils/utils'; 5 | 6 | const Wrapper = styled.div` 7 | user-select: none; 8 | margin-left: 8px; 9 | color: white; 10 | opacity: 0.8; 11 | font-size: 13px; 12 | `; 13 | 14 | const reactComponent = () => { 15 | const data = useContext(PlayerContext); 16 | const { state } = data; 17 | 18 | return ( 19 | 20 | {timeTransfer(state.current)} 21 | / 22 | {timeTransfer(state.duration)} 23 | 24 | ); 25 | }; 26 | export default reactComponent; 27 | -------------------------------------------------------------------------------- /src/theme/components/fullscreen-mobile.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import Icon from './icon'; 3 | import { PlayerContext } from '../../model'; 4 | import lang from '../../utils/local'; 5 | import styled from 'styled-components'; 6 | 7 | const Wrapper = styled.div` 8 | width: 35px; 9 | height: 35px; 10 | display: flex; 11 | justify-content: center; 12 | align-items: center; 13 | cursor: pointer; 14 | position: relative; 15 | 16 | .iconfont { 17 | width: 25px; 18 | height: 20px; 19 | color: white; 20 | fill: currentColor; 21 | } 22 | `; 23 | 24 | interface PropsType { 25 | onChange?: () => void; 26 | } 27 | 28 | const reactComponent = (props: PropsType) => { 29 | const data = useContext(PlayerContext); 30 | const { methods, state } = data; 31 | const { onChange } = props; 32 | 33 | const fullToogle = () => { 34 | methods.changeScreen(); 35 | onChange && onChange(); 36 | }; 37 | 38 | return ( 39 | 40 | 41 | 42 | ); 43 | }; 44 | export default reactComponent; 45 | -------------------------------------------------------------------------------- /src/theme/components/fullscreen.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import Icon from './icon'; 3 | import { PlayerContext } from '../../model'; 4 | import lang from '../../utils/local'; 5 | import styled from 'styled-components'; 6 | 7 | const Wrapper = styled.div` 8 | width: 35px; 9 | height: 35px; 10 | display: flex; 11 | justify-content: center; 12 | align-items: center; 13 | cursor: pointer; 14 | position: relative; 15 | 16 | .iconfont { 17 | width: 25px; 18 | height: 20px; 19 | color: white; 20 | fill: currentColor; 21 | } 22 | 23 | .tips { 24 | position: absolute; 25 | background-color: rgba(0, 0, 0, 0.6); 26 | padding: 5px 8px; 27 | border-radius: 3px; 28 | top: -8px; 29 | left: 17.5px; 30 | transform: translate(-50%, -100%); 31 | display: none; 32 | font-size: 12px; 33 | color: white; 34 | white-space: pre; 35 | user-select: none; 36 | } 37 | 38 | &:hover { 39 | .tips { 40 | display: inline-block; 41 | } 42 | } 43 | `; 44 | 45 | interface PropsType { 46 | onChange?: () => void; 47 | } 48 | 49 | const reactComponent = (props: PropsType) => { 50 | const data = useContext(PlayerContext); 51 | const { methods, state } = data; 52 | const { onChange } = props; 53 | 54 | const fullToogle = () => { 55 | methods.changeScreen(); 56 | onChange && onChange(); 57 | }; 58 | 59 | return ( 60 | 61 | 62 |
{lang[state.lang]['fullscreen']}
63 |
64 | ); 65 | }; 66 | export default reactComponent; 67 | -------------------------------------------------------------------------------- /src/theme/components/icon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface PropsType { 4 | type: string; 5 | color?: string; 6 | style?: any; 7 | className?: string; 8 | } 9 | 10 | const reactComponent: React.FC = props => { 11 | const { type, color = 'white', style, className } = props; 12 | 13 | const getPath = (type: string, color: string) => { 14 | switch (type) { 15 | case 'play': 16 | return ( 17 | 18 | ); 19 | case 'pause': 20 | return ( 21 | 22 | ); 23 | case 'setting': 24 | return ( 25 | 26 | ); 27 | case 'fullscreen': 28 | return ( 29 | 30 | ); 31 | case 'exitscreen': 32 | return ( 33 | 34 | ); 35 | case 'volume0': 36 | return ( 37 | <> 38 | 39 | 40 | 41 | ); 42 | case 'volume1': 43 | return ( 44 | 45 | ); 46 | 47 | case 'volume2': 48 | return ( 49 | 50 | ); 51 | case 'volume3': 52 | return ( 53 | <> 54 | 55 | 56 | 57 | ); 58 | case 'webscreen': 59 | return ( 60 | 61 | ); 62 | case 'exitweb': 63 | return ( 64 | 65 | ); 66 | case 'loading': 67 | return ( 68 | 69 | ); 70 | 71 | case 'picture': 72 | return ( 73 | 74 | ); 75 | case 'nocheck': 76 | return ( 77 | 78 | ); 79 | case 'checked': 80 | return ( 81 | 82 | ); 83 | case 'subtitle': 84 | return ( 85 | 86 | ); 87 | case 'nosubtitle': 88 | return ( 89 | 90 | ); 91 | case 'select': 92 | return ( 93 | 94 | ); 95 | case 'danmu': 96 | return ( 97 | 98 | ); 99 | case 'backward': 100 | return ( 101 | 102 | ); 103 | case 'forward': 104 | return ( 105 | 106 | ); 107 | } 108 | }; 109 | 110 | return ( 111 | 118 | {getPath(type, color)} 119 | 120 | ); 121 | }; 122 | export default reactComponent; 123 | -------------------------------------------------------------------------------- /src/theme/components/information.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState, useImperativeHandle, forwardRef, useRef } from 'react'; 2 | import { PlayerContext } from '../../model'; 3 | import styled from 'styled-components'; 4 | import Icon from './icon'; 5 | import lang from '../../utils/local'; 6 | 7 | const Wrapper = styled.div` 8 | background-color: rgba(255, 255, 255, 0.75); 9 | min-width: 80px; 10 | height: 50px; 11 | border-radius: 5px; 12 | position: absolute; 13 | left: 50%; 14 | top: 50%; 15 | margin-top: -25px; 16 | margin-left: -60px; 17 | color: rgba(0, 0, 0, 0.8); 18 | display: flex; 19 | justify-content: space-between; 20 | align-items: center; 21 | padding: 0 15px; 22 | 23 | .iconfont { 24 | width: 35px; 25 | height: 35px; 26 | fill: currentColor; 27 | } 28 | 29 | .iconfont2 { 30 | width: 30px; 31 | height: 40px; 32 | fill: currentColor; 33 | } 34 | 35 | .text { 36 | font-size: 18px; 37 | } 38 | `; 39 | 40 | interface PropsType {} 41 | 42 | export interface InformationRefAll { 43 | init: (type: string) => void; 44 | } 45 | 46 | const reactComponent = (props: PropsType, ref: React.Ref) => { 47 | const data = useContext(PlayerContext); 48 | const { state } = data; 49 | 50 | const [show, setShow] = useState(false); 51 | const [type, setType] = useState('volume'); 52 | 53 | const methods = { 54 | init: (type: string) => { 55 | setType(type); 56 | showInfo(); 57 | }, 58 | }; 59 | 60 | const debounce = (fun: any, time: number) => { 61 | let t: any = undefined; 62 | return (args?: any) => { 63 | clearTimeout(t); 64 | t = setTimeout(() => fun(args), time); 65 | }; 66 | }; 67 | 68 | const hide = useRef(debounce(() => setShow(false), 1500)); 69 | 70 | const showInfo = () => { 71 | setShow(true); 72 | hide.current(); 73 | }; 74 | useImperativeHandle(ref, () => methods); 75 | 76 | return show ? ( 77 | 78 | {type === 'volume' && ( 79 | <> 80 | 81 | {Math.ceil(state.volume * 100)}% 82 | 83 | )} 84 | 85 | {type === 'forward' && ( 86 | <> 87 | 88 | {lang[state.lang].forward} 89 | 90 | )} 91 | 92 | {type === 'backward' && ( 93 | <> 94 | 95 | {lang[state.lang].backward} 96 | 97 | )} 98 | 99 | ) : ( 100 | <> 101 | ); 102 | }; 103 | export default forwardRef(reactComponent); 104 | -------------------------------------------------------------------------------- /src/theme/components/loading.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { PlayerContext } from '../../model'; 3 | import styled from 'styled-components'; 4 | import Icon from './icon'; 5 | 6 | const Wrapper = styled.div` 7 | .loading { 8 | width: 45px; 9 | height: 45px; 10 | position: absolute; 11 | left: calc(50% - 22.5px); 12 | top: calc(50% - 22.5px); 13 | transform-origin: center; 14 | animation: rotate 1s linear infinite; 15 | fill: white; 16 | 17 | @keyframes rotate { 18 | from { 19 | transform: rotateZ(0); 20 | } 21 | to { 22 | transform: rotateZ(360deg); 23 | } 24 | } 25 | } 26 | `; 27 | 28 | const reactComponent: React.FC<{}> = (props) => { 29 | const data = useContext(PlayerContext); 30 | const { state } = data; 31 | 32 | return {state.loading && }; 33 | }; 34 | export default reactComponent; 35 | -------------------------------------------------------------------------------- /src/theme/components/message.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useContext, useEffect, useRef } from 'react'; 2 | import { PlayerContext } from '../../model'; 3 | import styled from 'styled-components'; 4 | 5 | const Wrapper = styled.div` 6 | .message { 7 | position: absolute; 8 | bottom: 55px; 9 | left: 15px; 10 | padding: 8px 12px; 11 | background-color: rgba(0, 0, 0, 0.75); 12 | color: white; 13 | border-radius: 4px; 14 | font-size: 14px; 15 | line-height: 14px; 16 | } 17 | `; 18 | 19 | const reactComponent: React.FC<{}> = (props) => { 20 | const data = useContext(PlayerContext); 21 | const { 22 | state: { message }, 23 | } = data; 24 | 25 | const [info, setInfo] = useState(''); 26 | const timer = useRef(); 27 | 28 | const getLevel = (time: string) => { 29 | switch (time) { 30 | case '1': 31 | return 500; 32 | case '2': 33 | return 1000; 34 | case '3': 35 | return 3000; 36 | case '4': 37 | return 5000; 38 | case '5': 39 | return 10000; 40 | case '6': 41 | return 20000; 42 | case '7': 43 | return 60000; 44 | case '8': 45 | return 360000; 46 | case '9': 47 | return 100000000; 48 | default: 49 | return 3000; 50 | } 51 | }; 52 | 53 | useEffect(() => { 54 | const time = message.slice(0, 1); 55 | const tips = message.slice(1); 56 | setInfo(tips); 57 | 58 | if (time) { 59 | timer.current && clearTimeout(timer.current); 60 | timer.current = setTimeout(() => { 61 | setInfo(''); 62 | }, getLevel(time)); 63 | } 64 | }, [message]); 65 | 66 | return {info &&
{info}
}
; 67 | }; 68 | export default reactComponent; 69 | -------------------------------------------------------------------------------- /src/theme/components/play.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useContext } from 'react'; 2 | import Icon from './icon'; 3 | import { PlayerContext } from '../../model'; 4 | import styled from 'styled-components'; 5 | 6 | const Wrapper = styled.div` 7 | width: 35px; 8 | height: 35px; 9 | display: flex; 10 | justify-content: center; 11 | align-items: center; 12 | cursor: pointer; 13 | position: relative; 14 | 15 | .iconfont { 16 | width: 25px; 17 | height: 20px; 18 | color: white; 19 | fill: currentColor; 20 | } 21 | `; 22 | 23 | const reactComponent: React.FC<{}> = (props) => { 24 | const data = useContext(PlayerContext); 25 | const { methods, state } = data; 26 | 27 | return ( 28 | methods.changePlay()}> 29 | 30 | 31 | ); 32 | }; 33 | export default reactComponent; 34 | -------------------------------------------------------------------------------- /src/theme/components/progress.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState, useContext } from 'react'; 2 | import { PlayerContext } from '../../model'; 3 | import styled from 'styled-components'; 4 | import { timeTransfer } from '../../utils/utils'; 5 | import Thumbnail from '../components/thumbnail'; 6 | 7 | const Wrapper = styled.div` 8 | position: absolute; 9 | top: -8px; 10 | left: 15px; 11 | width: calc(100% - 30px); 12 | padding: 5px 0; 13 | display: flex; 14 | justify-content: center; 15 | align-items: center; 16 | margin-bottom: -5px; 17 | z-index: 19; 18 | cursor: pointer; 19 | 20 | .line { 21 | position: relative; 22 | height: 3px; 23 | width: 100%; 24 | border-radius: 5px; 25 | background-color: rgba(255, 255, 255, 0.3); 26 | 27 | .buffered { 28 | z-index: -1; 29 | left: 0; 30 | top: 0; 31 | position: absolute; 32 | height: 100%; 33 | background-color: rgba(255, 255, 255, 0.6); 34 | } 35 | 36 | .current { 37 | height: 100%; 38 | position: relative; 39 | 40 | &:after { 41 | content: ''; 42 | display: none; 43 | position: absolute; 44 | right: 0; 45 | top: 1px; 46 | width: 8px; 47 | height: 8px; 48 | border-radius: 50%; 49 | background-color: inherit; 50 | transform: translate(50%, -43.75%); 51 | } 52 | } 53 | } 54 | 55 | .currentTime { 56 | background-color: rgba(0, 0, 0, 0.6); 57 | padding: 3px 6px; 58 | border-radius: 3px; 59 | display: inline-block; 60 | position: absolute; 61 | top: -5px; 62 | transform: translate(-50%, -100%); 63 | display: none; 64 | font-size: 12px; 65 | color: white; 66 | z-index: -1; 67 | pointer-events: none; 68 | } 69 | 70 | .thumbnail { 71 | position: absolute; 72 | z-index: -2; 73 | background-color: black; 74 | width: 160px; 75 | height: 90px; 76 | top: -95px; 77 | transform: translateX(-50%); 78 | display: none; 79 | z-index: -2; 80 | pointer-events: none; 81 | } 82 | 83 | &:hover { 84 | .currentTime, 85 | .thumbnail { 86 | display: inline-block; 87 | } 88 | 89 | .current:after { 90 | display: inline-block; 91 | } 92 | } 93 | `; 94 | 95 | const reactComponent: React.FC<{}> = (props) => { 96 | const data = useContext(PlayerContext); 97 | const { methods, state } = data; 98 | const { color } = state; 99 | 100 | const progressRef = useRef({} as HTMLDivElement); 101 | 102 | const [current, setCurrent] = useState(state.current); // 进度条 103 | const [show, setShow] = useState(false); // 点击拖动进度条判断 104 | const [currentTime, setCurrentTime] = useState(0); // 进度条滑动时间显示 105 | 106 | const onMouseDown = (e: React.MouseEvent) => { 107 | e.preventDefault(); 108 | e.stopPropagation(); 109 | setShow(true); 110 | }; 111 | 112 | const onMouseUp = (e: React.MouseEvent) => { 113 | e.preventDefault(); 114 | e.stopPropagation(); 115 | if (!show) return; 116 | const seeked = getSeeked(e); 117 | setCurrent(seeked); 118 | methods.changeSeeked(seeked); 119 | setShow(false); 120 | }; 121 | 122 | const onMouseMove = (e: React.MouseEvent) => { 123 | if (e.buttons !== 1) return; 124 | const seeked = getSeeked(e); 125 | show && setCurrent(seeked); 126 | }; 127 | 128 | const onMouseMoveCurrentTime = (e: React.MouseEvent) => { 129 | onMouseMove(e); 130 | const seeked = getSeeked(e); 131 | setCurrentTime(seeked); 132 | }; 133 | 134 | const getSeeked = (e: React.MouseEvent) => { 135 | const positionX = window.pageXOffset + progressRef.current.getBoundingClientRect().left; 136 | const offset = e.pageX - positionX; 137 | const total = progressRef.current.clientWidth; 138 | let percent = offset / total; 139 | percent > 1 && (percent = 1); 140 | percent < 0 && (percent = 0); 141 | const seeked = state.duration * percent; 142 | return seeked; 143 | }; 144 | 145 | useEffect(() => { 146 | !show && setCurrent(state.current); 147 | }, [state.current]); 148 | 149 | return ( 150 | 158 |
159 |
166 | {state.buffered.map((item: Array, index: number) => ( 167 |
175 | ))} 176 | 177 |
178 | {timeTransfer(currentTime)} 179 |
180 | 181 | 182 |
183 |
184 | ); 185 | }; 186 | export default reactComponent; 187 | -------------------------------------------------------------------------------- /src/theme/components/setting.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import Icon from './icon'; 3 | import { PlayerContext } from '../../model'; 4 | import lang from '../../utils/local'; 5 | import styled from 'styled-components'; 6 | 7 | const Wrapper = styled.div` 8 | width: 35px; 9 | height: 35px; 10 | display: flex; 11 | justify-content: center; 12 | align-items: center; 13 | cursor: pointer; 14 | position: relative; 15 | 16 | .iconfont { 17 | width: 25px; 18 | height: 20px; 19 | color: white; 20 | fill: currentColor; 21 | } 22 | 23 | .panel { 24 | position: absolute; 25 | bottom: 45px; 26 | right: -65px; 27 | cursor: default; 28 | color: #fff; 29 | width: 266px; 30 | box-sizing: border-box; 31 | background: rgba(21, 21, 21, 0.9); 32 | border-radius: 2px; 33 | padding: 12px 20px; 34 | text-align: left; 35 | font-size: 12px; 36 | display: none; 37 | 38 | &:before { 39 | content: ''; 40 | width: 100%; 41 | height: 30px; 42 | position: absolute; 43 | bottom: -20px; 44 | right: 0; 45 | z-index: 29; 46 | } 47 | 48 | .container { 49 | width: 100%; 50 | height: auto; 51 | 52 | p { 53 | margin: 0; 54 | user-select: none; 55 | } 56 | 57 | .secList { 58 | margin-bottom: 12px; 59 | .labelCon { 60 | display: flex; 61 | justify-content: space-between; 62 | align-items: center; 63 | 64 | .labelList { 65 | margin-top: 6px; 66 | width: calc((100% - 8px) / 2); 67 | background-color: hsla(0, 0%, 100%, 0.3); 68 | line-height: 24px; 69 | border-radius: 2px; 70 | text-align: center; 71 | cursor: pointer; 72 | 73 | &:hover { 74 | background-color: hsla(0, 0%, 100%, 0.4); 75 | } 76 | } 77 | 78 | .secLabel { 79 | width: 20%; 80 | display: flex; 81 | justify-content: center; 82 | align-items: center; 83 | height: 26px; 84 | cursor: pointer; 85 | user-select: none; 86 | padding-bottom: 3px; 87 | border-bottom: solid 2px hsla(0, 0%, 100%, 0.3); 88 | position: relative; 89 | 90 | .ratedot { 91 | position: absolute; 92 | bottom: -3px; 93 | height: 4px; 94 | width: 2px; 95 | background-color: hsla(0, 0%, 100%, 0.3); 96 | } 97 | 98 | .rateSelect { 99 | width: 12px; 100 | height: 12px; 101 | border-radius: 50%; 102 | position: absolute; 103 | bottom: -7px; 104 | } 105 | 106 | &:nth-child(1) { 107 | width: 10%; 108 | text-align: left; 109 | 110 | .rate { 111 | margin-left: -4px; 112 | } 113 | 114 | .ratedot { 115 | left: 0; 116 | } 117 | 118 | .rateSelect { 119 | left: 0px; 120 | } 121 | } 122 | 123 | &:nth-last-child(1) { 124 | width: 10%; 125 | text-align: right; 126 | 127 | .rate { 128 | margin-right: -4px; 129 | } 130 | 131 | .ratedot { 132 | right: 0; 133 | } 134 | 135 | .rateSelect { 136 | right: 0px; 137 | } 138 | } 139 | } 140 | 141 | .checkLabel { 142 | width: calc((100% - 16px) / 3); 143 | display: flex; 144 | justify-content: center; 145 | align-items: center; 146 | height: 26px; 147 | cursor: pointer; 148 | user-select: none; 149 | 150 | .check { 151 | width: 14px; 152 | height: 14px; 153 | margin-right: 6px; 154 | fill: currentColor; 155 | 156 | &.disable { 157 | opacity: 0.6; 158 | } 159 | } 160 | } 161 | } 162 | } 163 | } 164 | } 165 | 166 | &:hover { 167 | .panel { 168 | display: inline-block; 169 | } 170 | } 171 | `; 172 | 173 | const rateArr = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]; 174 | const sizeArr = [50, 60, 70, 80, 90, 100]; 175 | 176 | const reactComponent: React.FC<{}> = (props) => { 177 | const data = useContext(PlayerContext); 178 | const { methods, state } = data; 179 | const { color } = state; 180 | 181 | const pipToggle = () => { 182 | methods.changePicture(); 183 | }; 184 | 185 | const supportPip = document.pictureInPictureEnabled; 186 | 187 | return ( 188 | 189 | 190 |
191 |
192 |
193 |

{lang[state.lang].playMode}

194 |
195 |
{ 199 | methods.changeLoop(false); 200 | }} 201 | > 202 | {lang[state.lang].endStop} 203 |
204 |
{ 208 | methods.changeLoop(true); 209 | }} 210 | > 211 | {lang[state.lang].autoLoop} 212 |
213 |
214 |
215 |
216 |

{lang[state.lang].playRate}

217 |
218 | {rateArr.map((item: number) => ( 219 |
methods.changeRate(item)} key={item}> 220 | 221 | {[0.75, 1.25].includes(item) ? item : item.toFixed(1)} 222 | 223 | 224 | {state.rate === item && ( 225 | 226 | )} 227 |
228 | ))} 229 |
230 |
231 |
232 |

{lang[state.lang].sizePercent}

233 |
234 | {sizeArr.map((item: number) => ( 235 |
methods.changeSize(item)} key={item}> 236 | {(item / 100).toFixed(1)} 237 | 238 | {state.size === item && ( 239 | 240 | )} 241 |
242 | ))} 243 |
244 |
245 |
246 |

{lang[state.lang].playSetting}

247 |
248 |
methods.changeLight()} 252 | > 253 | {!state.light ? ( 254 | 255 | ) : ( 256 | 257 | )} 258 | {lang[state.lang].noLight} 259 |
260 |
265 | {!state.picture ? ( 266 | 267 | ) : ( 268 | 269 | )} 270 | {lang[state.lang].picture} 271 |
272 |
methods.changeMovie()} 276 | > 277 | {!state.movie ? ( 278 | 279 | ) : ( 280 | 281 | )} 282 | {lang[state.lang].intotheater} 283 |
284 |
285 |
286 |
287 |
288 |
289 | ); 290 | }; 291 | export default reactComponent; 292 | -------------------------------------------------------------------------------- /src/theme/components/source-mobile.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState } from 'react'; 2 | import { PlayerContext, SourceType } from '../../model'; 3 | import styled from 'styled-components'; 4 | 5 | const Wrapper = styled.div` 6 | min-width: 50px; 7 | height: 35px; 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | cursor: pointer; 12 | position: relative; 13 | 14 | .label { 15 | color: white; 16 | font-size: 14px; 17 | padding: 0 10px; 18 | white-space: nowrap; 19 | } 20 | 21 | .panel { 22 | position: absolute; 23 | bottom: 45px; 24 | right: transform(translateX(-50%)); 25 | cursor: default; 26 | color: #fff; 27 | box-sizing: border-box; 28 | background: rgba(21, 21, 21, 0.9); 29 | border-radius: 2px; 30 | padding: 5px 0; 31 | text-align: left; 32 | font-size: 12px; 33 | display: none; 34 | 35 | &:before { 36 | content: ''; 37 | width: 100%; 38 | height: 30px; 39 | position: absolute; 40 | bottom: -20px; 41 | right: 0; 42 | z-index: 29; 43 | } 44 | 45 | .container { 46 | width: 100%; 47 | height: auto; 48 | 49 | .list { 50 | font-size: 14px; 51 | white-space: nowrap; 52 | padding: 5px 20px; 53 | cursor: pointer; 54 | 55 | &:hover { 56 | background-color: rgba(255, 255, 255, 0.2); 57 | } 58 | 59 | &.active { 60 | color: ${(props: { color: string }) => props.color}; 61 | } 62 | } 63 | } 64 | } 65 | 66 | &:hover { 67 | .panel { 68 | display: inline-block; 69 | } 70 | } 71 | `; 72 | 73 | const reactComponent: React.FC<{}> = (props) => { 74 | const data = useContext(PlayerContext); 75 | const { methods, state } = data; 76 | const { color, source } = state; 77 | 78 | const [select, setSelect] = useState(source[0]); 79 | const [show, setShow] = useState(false); 80 | 81 | const changeSource = (value: SourceType) => { 82 | if (select === value) return; 83 | setSelect(value); 84 | setShow(false); 85 | methods.changeSource(value.value); 86 | methods.changePlay(false); 87 | methods.changeCurrent(); 88 | }; 89 | 90 | return ( 91 | 92 | setShow(true)}> 93 | {select.label} 94 | 95 | {show && ( 96 |
97 |
98 | {source.map((item) => ( 99 |
changeSource(item)} 103 | > 104 | {item.label} 105 |
106 | ))} 107 |
108 |
109 | )} 110 |
111 | ); 112 | }; 113 | export default reactComponent; 114 | -------------------------------------------------------------------------------- /src/theme/components/source.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState } from 'react'; 2 | import { PlayerContext, SourceType } from '../../model'; 3 | import styled from 'styled-components'; 4 | 5 | const Wrapper = styled.div` 6 | min-width: 50px; 7 | height: 35px; 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | cursor: pointer; 12 | position: relative; 13 | 14 | .label { 15 | color: white; 16 | font-size: 14px; 17 | padding: 0 10px; 18 | white-space: nowrap; 19 | } 20 | 21 | .panel { 22 | position: absolute; 23 | bottom: 45px; 24 | right: transform(translateX(-50%)); 25 | cursor: default; 26 | color: #fff; 27 | box-sizing: border-box; 28 | background: rgba(21, 21, 21, 0.9); 29 | border-radius: 2px; 30 | padding: 5px 0; 31 | text-align: left; 32 | font-size: 12px; 33 | display: none; 34 | 35 | &:before { 36 | content: ''; 37 | width: 100%; 38 | height: 30px; 39 | position: absolute; 40 | bottom: -20px; 41 | right: 0; 42 | z-index: 29; 43 | } 44 | 45 | .container { 46 | width: 100%; 47 | height: auto; 48 | 49 | .list { 50 | font-size: 14px; 51 | white-space: nowrap; 52 | padding: 5px 20px; 53 | cursor: pointer; 54 | 55 | &:hover { 56 | background-color: rgba(255, 255, 255, 0.2); 57 | } 58 | 59 | &.active { 60 | color: ${(props: { color: string }) => props.color}; 61 | } 62 | } 63 | } 64 | } 65 | 66 | &:hover { 67 | .panel { 68 | display: inline-block; 69 | } 70 | } 71 | `; 72 | 73 | const reactComponent: React.FC<{}> = (props) => { 74 | const data = useContext(PlayerContext); 75 | const { methods, state } = data; 76 | const { color, source } = state; 77 | 78 | const [select, setSelect] = useState(source[0]); 79 | const [show, setShow] = useState(false); 80 | 81 | const changeSource = (value: SourceType) => { 82 | if (select === value) return; 83 | setSelect(value); 84 | setShow(false); 85 | methods.changeSource(value.value); 86 | methods.changePlay(false); 87 | methods.changeCurrent(); 88 | }; 89 | 90 | return ( 91 | 92 | setShow(true)}> 93 | {select.label} 94 | 95 | {show && ( 96 |
97 |
98 | {source.map((item) => ( 99 |
changeSource(item)} 103 | > 104 | {item.label} 105 |
106 | ))} 107 |
108 |
109 | )} 110 |
111 | ); 112 | }; 113 | export default reactComponent; 114 | -------------------------------------------------------------------------------- /src/theme/components/subtitle-mobile.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import Icon from './icon'; 3 | import { PlayerContext } from '../../model'; 4 | import styled, { StyledComponent } from 'styled-components'; 5 | 6 | interface StyleProps { 7 | show: boolean; 8 | } 9 | 10 | const Wrapper = styled.div` 11 | width: 35px; 12 | height: 35px; 13 | display: flex; 14 | justify-content: center; 15 | align-items: center; 16 | cursor: pointer; 17 | position: relative; 18 | opacity: ${(props) => (props.show ? 1 : 0.4)}; 19 | 20 | .iconfont { 21 | width: 25px; 22 | height: 20px; 23 | color: white; 24 | fill: currentColor; 25 | } 26 | `; 27 | 28 | const reactComponent = () => { 29 | const data = useContext(PlayerContext); 30 | const { methods, state } = data; 31 | const { color, subshow } = state; 32 | 33 | const toggle = () => { 34 | methods.changeSubShow(!subshow); 35 | }; 36 | 37 | return ( 38 | 39 | 40 | 41 | ); 42 | }; 43 | export default reactComponent; 44 | -------------------------------------------------------------------------------- /src/theme/components/subtitle.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import Icon from './icon'; 3 | import { PlayerContext } from '../../model'; 4 | import lang from '../../utils/local'; 5 | import styled from 'styled-components'; 6 | import { colorArr } from '../../utils/utils'; 7 | 8 | const Wrapper = styled.div` 9 | width: 35px; 10 | height: 35px; 11 | display: flex; 12 | justify-content: center; 13 | align-items: center; 14 | cursor: pointer; 15 | position: relative; 16 | 17 | .iconfont { 18 | width: 25px; 19 | height: 20px; 20 | color: white; 21 | fill: currentColor; 22 | } 23 | 24 | .panel { 25 | position: absolute; 26 | bottom: 45px; 27 | right: -65px; 28 | cursor: default; 29 | color: #fff; 30 | width: 266px; 31 | box-sizing: border-box; 32 | background: rgba(21, 21, 21, 0.9); 33 | border-radius: 2px; 34 | padding: 12px 20px; 35 | text-align: left; 36 | font-size: 12px; 37 | display: none; 38 | 39 | &:before { 40 | content: ''; 41 | width: 100%; 42 | height: 30px; 43 | position: absolute; 44 | bottom: -20px; 45 | right: 0; 46 | z-index: 29; 47 | } 48 | 49 | .container { 50 | width: 100%; 51 | height: auto; 52 | 53 | p { 54 | margin: 0; 55 | user-select: none; 56 | } 57 | 58 | .secList { 59 | margin-bottom: 12px; 60 | .labelCon { 61 | display: flex; 62 | justify-content: space-between; 63 | align-items: center; 64 | 65 | .labelList { 66 | margin-top: 6px; 67 | width: calc((100% - 8px) / 2); 68 | background-color: hsla(0, 0%, 100%, 0.3); 69 | line-height: 24px; 70 | border-radius: 2px; 71 | text-align: center; 72 | cursor: pointer; 73 | 74 | &:hover { 75 | background-color: hsla(0, 0%, 100%, 0.4); 76 | } 77 | } 78 | 79 | .secLabel { 80 | width: 25%; 81 | display: flex; 82 | justify-content: center; 83 | align-items: center; 84 | height: 26px; 85 | cursor: pointer; 86 | user-select: none; 87 | padding-bottom: 3px; 88 | border-bottom: solid 2px hsla(0, 0%, 100%, 0.3); 89 | position: relative; 90 | 91 | .ratedot { 92 | position: absolute; 93 | bottom: -3px; 94 | height: 4px; 95 | width: 2px; 96 | background-color: hsla(0, 0%, 100%, 0.3); 97 | } 98 | 99 | .rateSelect { 100 | width: 12px; 101 | height: 12px; 102 | border-radius: 50%; 103 | position: absolute; 104 | bottom: -7px; 105 | } 106 | 107 | &:nth-child(1) { 108 | width: 12.5%; 109 | text-align: left; 110 | 111 | .rate { 112 | margin-left: -4px; 113 | } 114 | 115 | .ratedot { 116 | left: 0; 117 | } 118 | 119 | .rateSelect { 120 | left: 0px; 121 | } 122 | } 123 | 124 | &:nth-last-child(1) { 125 | width: 12.5%; 126 | text-align: right; 127 | 128 | .rate { 129 | margin-right: -4px; 130 | } 131 | 132 | .ratedot { 133 | right: 0; 134 | } 135 | 136 | .rateSelect { 137 | right: 0px; 138 | } 139 | } 140 | } 141 | 142 | .colorLabel { 143 | width: 18px; 144 | height: 18px; 145 | border-radius: 50%; 146 | cursor: pointer; 147 | display: flex; 148 | justify-content: center; 149 | align-items: center; 150 | 151 | .selectIcon { 152 | width: 14px; 153 | height: 14px; 154 | color: white; 155 | fill: currentColor; 156 | } 157 | } 158 | } 159 | } 160 | } 161 | } 162 | 163 | &:hover { 164 | .panel { 165 | display: inline-block; 166 | } 167 | } 168 | `; 169 | 170 | const sizeArr = [0, 1, 2, 3, 4]; 171 | 172 | const reactComponent: React.FC<{}> = (props) => { 173 | const data = useContext(PlayerContext); 174 | const { methods, state } = data; 175 | const { color } = state; 176 | 177 | return ( 178 | 179 | 180 |
181 |
182 |
183 |

{lang[state.lang].showSubtitle}

184 |
185 |
methods.changeSubShow(true)} 189 | > 190 | {lang[state.lang].subtitle} 191 |
192 |
methods.changeSubShow(false)} 196 | > 197 | {lang[state.lang].nosubtitle} 198 |
199 |
200 |
201 |
202 |

{lang[state.lang].subsize}

203 |
204 | {sizeArr.map((item: number, index: number) => ( 205 |
methods.changeSubSize(item)} key={item}> 206 | {lang[state.lang]['subsize' + (index + 1)]} 207 | 208 | {state.subsize === item && ( 209 | 210 | )} 211 |
212 | ))} 213 |
214 |
215 |
216 |

{lang[state.lang].submargin}

217 |
218 | {sizeArr.map((item: number, index: number) => ( 219 |
methods.changeSubMargin(item)} key={item}> 220 | {lang[state.lang]['submargin' + (index + 1)]} 221 | 222 | {state.submargin === item && ( 223 | 224 | )} 225 |
226 | ))} 227 |
228 |
229 |
230 |

{lang[state.lang].subcolor}

231 |
232 | {colorArr.map((item: string, index: number) => ( 233 |
methods.changeSubColor(index)} 236 | key={item} 237 | style={{ backgroundColor: item }} 238 | > 239 | {state.subcolor === index && } 240 |
241 | ))} 242 |
243 |
244 |
245 |
246 |
247 | ); 248 | }; 249 | export default reactComponent; 250 | -------------------------------------------------------------------------------- /src/theme/components/thumbnail.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { PlayerContext } from '../../model'; 3 | import styled from 'styled-components'; 4 | 5 | interface PropsType { 6 | currentTime: number; 7 | progressRef: React.RefObject; 8 | } 9 | 10 | const Wrapper = styled.div` 11 | position: absolute; 12 | z-index: -2; 13 | background-color: black; 14 | width: 160px; 15 | height: 90px; 16 | top: -95px; 17 | transform: translateX(-50%); 18 | display: none; 19 | z-index: -2; 20 | pointer-events: none; 21 | `; 22 | 23 | const reactComponent: React.FC = (props) => { 24 | const { currentTime, progressRef } = props; 25 | const data = useContext(PlayerContext); 26 | const { methods, state } = data; 27 | const { color } = state; 28 | 29 | const getThumbnailLeft = (currentTime: number) => { 30 | const percent = currentTime / state.duration; 31 | const total = progressRef.current ? progressRef.current.clientWidth : 0; 32 | let result = percent * total; 33 | result < 80 && (result = 80); 34 | total - result < 80 && (result = total - 80); 35 | return (result / total) * 100; 36 | }; 37 | 38 | const getThumbnailImg = (currentTime: number) => { 39 | const percent = currentTime / state.duration; 40 | const { count, urls } = state.thumbnail; 41 | const num = Math.round(count * percent); 42 | const page = Math.floor(num / 100); 43 | const row = Math.floor((num - 100 * page) / 10); 44 | const col = num - 100 * page - 10 * row; 45 | return { 46 | url: urls[page], 47 | left: row * 160 + 'px', 48 | top: col * 90 + 'px', 49 | }; 50 | }; 51 | 52 | return ( 53 | <> 54 | {state.thumbnail && state.thumbnail.count > 0 && ( 55 | 66 | )} 67 | 68 | ); 69 | }; 70 | export default reactComponent; 71 | -------------------------------------------------------------------------------- /src/theme/components/volume.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState, useContext } from 'react'; 2 | import Icon from './icon'; 3 | import { PlayerContext } from '../../model'; 4 | import styled from 'styled-components'; 5 | 6 | const Wrapper = styled.div` 7 | width: 35px; 8 | height: 35px; 9 | display: flex; 10 | justify-content: center; 11 | align-items: center; 12 | cursor: pointer; 13 | position: relative; 14 | 15 | .iconfont { 16 | width: 25px; 17 | height: 20px; 18 | color: white; 19 | fill: currentColor; 20 | } 21 | 22 | .volumePanel { 23 | width: 35px; 24 | position: absolute; 25 | bottom: 30px; 26 | left: 17.5px; 27 | transform: translate(-50%, 0); 28 | z-index: 29; 29 | display: none; 30 | 31 | .volumeCon { 32 | width: 100%; 33 | background-color: rgba(21, 21, 21, 0.9); 34 | border-radius: 3px; 35 | font-size: 12px; 36 | color: white; 37 | display: flex; 38 | flex-direction: column; 39 | align-items: center; 40 | padding: 6px 0 10px 0; 41 | justify-content: space-between; 42 | margin-bottom: 16px; 43 | position: relative; 44 | 45 | .volumeBg { 46 | position: absolute; 47 | width: 100%; 48 | height: 100%; 49 | background-color: transparent; 50 | top: 0; 51 | left: 0; 52 | z-index: 29; 53 | } 54 | 55 | .volumeProgress { 56 | margin-top: 8px; 57 | height: 70px; 58 | width: 2px; 59 | background-color: rgba(255, 255, 255, 0.6); 60 | display: flex; 61 | align-items: flex-end; 62 | 63 | span { 64 | user-select: none; 65 | } 66 | 67 | .volumeCurrent { 68 | width: 100%; 69 | position: relative; 70 | 71 | &::after { 72 | content: ''; 73 | position: absolute; 74 | right: -4px; 75 | top: 0px; 76 | width: 10px; 77 | height: 10px; 78 | border-radius: 50%; 79 | background-color: inherit; 80 | } 81 | } 82 | } 83 | } 84 | } 85 | 86 | &:hover { 87 | .volumePanel { 88 | display: inline-block; 89 | } 90 | } 91 | `; 92 | 93 | const reactComponent: React.FC<{}> = (props) => { 94 | const data = useContext(PlayerContext); 95 | const { methods, state } = data; 96 | const { color } = state; 97 | 98 | const volumeRef = useRef({} as HTMLDivElement); 99 | 100 | const [volumeShow, setVolumeShow] = useState(false); //音量拖动条点击判断 101 | const [currentVolume, setCurrentVolume] = useState(75); // 当前音量显示 102 | 103 | const onVolumeDown = (e: React.MouseEvent) => { 104 | e.preventDefault(); 105 | e.stopPropagation(); 106 | setVolumeShow(true); 107 | }; 108 | 109 | const onVolumeUp = (e: React.MouseEvent) => { 110 | e.preventDefault(); 111 | e.stopPropagation(); 112 | if (!volumeShow) return; 113 | const volume = getVolume(e); 114 | if (typeof volume === 'object') return; 115 | methods.changeVolume(volume / 100); 116 | setVolumeShow(false); 117 | }; 118 | 119 | const onVolumeMove = (e: React.MouseEvent) => { 120 | if (e.buttons !== 1) return; 121 | const volume = getVolume(e); 122 | if (typeof volume === 'object') return; 123 | setVolumeShow && setCurrentVolume(volume); 124 | }; 125 | 126 | const onVolumeMute = (e: React.MouseEvent) => { 127 | methods.changeVolume(state.volume ? 0 : 0.75); 128 | }; 129 | 130 | const getVolume = (e: React.MouseEvent) => { 131 | if (e.target !== volumeRef.current) return; 132 | const offset = e.nativeEvent.offsetY; 133 | const total = volumeRef.current ? volumeRef.current.clientHeight : 0; 134 | 135 | if (total === 0) return null; 136 | let height = total - 8 - offset; 137 | height <= 0 && (height = 0); 138 | height > 70 && (height = 70); 139 | return Math.round((height / 70) * 100); 140 | }; 141 | 142 | const volumeNum = (volume: number) => { 143 | if (volume === 0) { 144 | return 'volume0'; 145 | } else if (volume < 0.5) { 146 | return 'volume1'; 147 | } else if (volume < 1) { 148 | return 'volume2'; 149 | } else if (volume === 1) { 150 | return 'volume3'; 151 | } else { 152 | return 'volume1'; 153 | } 154 | }; 155 | 156 | useEffect(() => { 157 | !volumeShow && setCurrentVolume(Math.round(state.volume * 100)); 158 | }, [state.volume]); 159 | 160 | return ( 161 | 162 | 163 |
164 |
165 |
173 | {currentVolume} 174 |
175 |
182 |
183 |
184 |
185 |
186 | ); 187 | }; 188 | export default reactComponent; 189 | -------------------------------------------------------------------------------- /src/theme/components/webscreen.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import Icon from './icon'; 3 | import { PlayerContext } from '../../model'; 4 | import lang from '../../utils/local'; 5 | import styled from 'styled-components'; 6 | 7 | const Wrapper = styled.div` 8 | width: 35px; 9 | height: 35px; 10 | display: flex; 11 | justify-content: center; 12 | align-items: center; 13 | cursor: pointer; 14 | position: relative; 15 | 16 | .iconfont { 17 | width: 25px; 18 | height: 20px; 19 | color: white; 20 | fill: currentColor; 21 | } 22 | 23 | .tips { 24 | position: absolute; 25 | background-color: rgba(0, 0, 0, 0.6); 26 | padding: 5px 8px; 27 | border-radius: 3px; 28 | top: -8px; 29 | left: 17.5px; 30 | transform: translate(-50%, -100%); 31 | display: none; 32 | font-size: 12px; 33 | color: white; 34 | white-space: pre; 35 | user-select: none; 36 | } 37 | 38 | &:hover { 39 | .tips { 40 | display: inline-block; 41 | } 42 | } 43 | `; 44 | 45 | const reactComponent: React.FC<{}> = (props) => { 46 | const data = useContext(PlayerContext); 47 | const { methods, state } = data; 48 | const { color } = state; 49 | 50 | return ( 51 | methods.changeWebScreen()}> 52 | 53 |
{lang[state.lang][state.webscreen ? 'exitweb' : 'webscreen']}
54 |
55 | ); 56 | }; 57 | export default reactComponent; 58 | -------------------------------------------------------------------------------- /src/theme/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useContext, useState } from 'react'; 2 | import { PlayerContext } from '../model'; 3 | import WebTheme from './web'; 4 | import MobileTheme from './mobile'; 5 | 6 | interface PropsTypes { 7 | fullNode: React.ReactNode; 8 | } 9 | 10 | const reactComponent: React.FC = (props) => { 11 | const { fullNode, children } = props; 12 | const data = useContext(PlayerContext); 13 | const { 14 | state: { mode }, 15 | } = data; 16 | 17 | let CustomTheme = WebTheme; 18 | if (mode === 'mobile') { 19 | CustomTheme = MobileTheme; 20 | } 21 | 22 | return {children}; 23 | }; 24 | export default reactComponent; 25 | -------------------------------------------------------------------------------- /src/theme/mobile/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useContext, useRef, useEffect } from 'react'; 2 | import screenfull from 'screenfull'; 3 | import styles from './style.less'; 4 | import { PlayerContext } from '../../model'; 5 | import Subtitle from '../components/subtitle-mobile'; 6 | import Danmu from '../components/danmu-mobile'; 7 | import FullScreen from '../components/fullscreen-mobile'; 8 | import Play from '../components/play'; 9 | import Duration from '../components/duration'; 10 | import Message from '../components/message'; 11 | import Loading from '../components/loading'; 12 | import Progress from '../components/progress'; 13 | import Source from '../components/source-mobile'; 14 | import Information, { InformationRefAll } from '../components/information'; 15 | 16 | interface PropType { 17 | fullNode?: React.ReactNode; 18 | } 19 | 20 | const reactComponent: React.FC = (props) => { 21 | const data = useContext(PlayerContext); 22 | const { methods, state } = data; 23 | const { webscreen, light, source, play, fullscreen } = state; 24 | const { children, fullNode } = props; 25 | 26 | const [visible, setVisible] = useState(true); 27 | const [clickTime, setClickTime] = useState(0); 28 | 29 | const playerRef = useRef({} as HTMLElement); 30 | const infoRef = useRef({} as InformationRefAll); 31 | const timeRef = useRef(0); 32 | 33 | const preventDefault = (e: React.MouseEvent) => { 34 | hideControl(6000); 35 | e.preventDefault(); 36 | e.stopPropagation(); 37 | }; 38 | 39 | const toggleFullscreen = () => { 40 | if (screenfull.isEnabled) { 41 | screenfull.toggle(playerRef.current); 42 | } 43 | }; 44 | 45 | const hideControl = (time = 3000) => { 46 | setVisible(true); 47 | if (timeRef.current) { 48 | clearTimeout(timeRef.current); 49 | } 50 | if (!play) return; 51 | 52 | timeRef.current = setTimeout(() => { 53 | setVisible(false); 54 | }, time); 55 | }; 56 | 57 | const toggleControl = () => { 58 | if (!play) { 59 | methods.changePlay(); 60 | } else { 61 | if (doubleClick()) { 62 | methods.changePlay(); 63 | return; 64 | } 65 | } 66 | if (visible) { 67 | if (timeRef.current) { 68 | clearTimeout(timeRef.current); 69 | } 70 | setVisible(false); 71 | } else { 72 | hideControl(); 73 | } 74 | }; 75 | 76 | const doubleClick = () => { 77 | const now = new Date().getTime(); 78 | if (now - clickTime < 300) { 79 | setClickTime(0); 80 | return true; 81 | } 82 | setClickTime(now); 83 | return false; 84 | }; 85 | 86 | useEffect(() => { 87 | if (play) { 88 | hideControl(); 89 | } else { 90 | setVisible(true); 91 | } 92 | }, [play]); 93 | 94 | return ( 95 |
101 |
102 | 103 | 104 | 105 | {(visible || !play) && ( 106 |
107 |
108 |
109 | 110 |
111 |
112 | 113 | 114 |
115 |
{(fullscreen || webscreen) && fullNode}
116 |
117 | {source.length > 0 && } 118 | {state.subtitle && } 119 | {state.danmu && } 120 | 121 |
122 |
123 |
124 |
125 | )} 126 |
127 | {children} 128 |
129 | ); 130 | }; 131 | export default reactComponent; 132 | -------------------------------------------------------------------------------- /src/theme/mobile/style.less: -------------------------------------------------------------------------------- 1 | .qinplayer { 2 | position: relative; 3 | width: 100%; 4 | height: auto; 5 | display: flex; 6 | justify-content: center; 7 | align-items: center; 8 | user-select: none; 9 | -webkit-tap-highlight-color: transparent; 10 | outline: none; 11 | font-family: Arial, Helvetica, sans-serif; 12 | 13 | &.webscreen { 14 | position: fixed; 15 | z-index: 100000; 16 | left: 0; 17 | top: 0; 18 | width: 100%; 19 | height: 100%; 20 | background-color: black; 21 | } 22 | 23 | &.nolight { 24 | z-index: 100000; 25 | &:after { 26 | content: ''; 27 | position: fixed; 28 | z-index: -1; 29 | left: 0; 30 | top: 0; 31 | width: 100%; 32 | height: 100%; 33 | background-color: rgba(0, 0, 0, 0.95); 34 | } 35 | } 36 | 37 | &.novisible { 38 | cursor: none; 39 | } 40 | .control { 41 | z-index: 8; 42 | position: absolute; 43 | width: 100%; 44 | height: 100%; 45 | top: 0; 46 | left: 0; 47 | 48 | .bar { 49 | position: absolute; 50 | width: 100%; 51 | left: 0; 52 | bottom: 0; 53 | z-index: 9; 54 | 55 | .bg { 56 | width: 100%; 57 | position: absolute; 58 | width: 100%; 59 | height: 140%; 60 | bottom: 0; 61 | overflow: hidden; 62 | background: linear-gradient( 63 | to top, 64 | rgba(0, 0, 0, 0.6) 0%, 65 | rgba(0, 0, 0, 0.3) 50%, 66 | rgba(0, 0, 0, 0.1) 70%, 67 | rgba(0, 0, 0, 0) 100% 68 | ); 69 | } 70 | 71 | .content { 72 | position: relative; 73 | height: 100%; 74 | padding: 0 10px; 75 | box-sizing: border-box; 76 | 77 | .option { 78 | display: flex; 79 | justify-content: space-between; 80 | align-items: center; 81 | line-height: 100%; 82 | height: 40px; 83 | z-index: 18; 84 | 85 | .left { 86 | height: 100%; 87 | display: flex; 88 | justify-content: flex-start; 89 | align-items: center; 90 | } 91 | 92 | .middle { 93 | flex: 1; 94 | } 95 | 96 | .right { 97 | height: 100%; 98 | display: flex; 99 | justify-content: flex-end; 100 | align-items: center; 101 | } 102 | } 103 | } 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/theme/web/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useContext, useRef, useEffect } from 'react'; 2 | import screenfull from 'screenfull'; 3 | import styles from './style.less'; 4 | import { PlayerContext } from '../../model'; 5 | import Subtitle from '../components/subtitle'; 6 | import Danmu from '../components/danmu'; 7 | import Volume from '../components/volume'; 8 | import Setting from '../components/setting'; 9 | import WebScreen from '../components/webscreen'; 10 | import FullScreen from '../components/fullscreen'; 11 | import Play from '../components/play'; 12 | import Duration from '../components/duration'; 13 | import Message from '../components/message'; 14 | import Loading from '../components/loading'; 15 | import Progress from '../components/progress'; 16 | import Source from '../components/source'; 17 | import Information, { InformationRefAll } from '../components/information'; 18 | 19 | interface PropType { 20 | fullNode?: React.ReactNode; 21 | } 22 | 23 | const reactComponent: React.FC = (props) => { 24 | const data = useContext(PlayerContext); 25 | const { methods, state } = data; 26 | const { webscreen, light, source, play, fullscreen } = state; 27 | const { children, fullNode } = props; 28 | 29 | const [visible, setVisible] = useState(true); 30 | 31 | const playerRef = useRef({} as HTMLElement); 32 | const infoRef = useRef({} as InformationRefAll); 33 | const timeRef = useRef(0); 34 | 35 | const preventDefault = (e: React.MouseEvent) => { 36 | e.preventDefault(); 37 | e.stopPropagation(); 38 | }; 39 | 40 | const togglePlay = () => { 41 | methods.changePlay(); 42 | }; 43 | 44 | const toggleFullscreen = () => { 45 | if (screenfull.isEnabled) { 46 | screenfull.toggle(playerRef.current); 47 | } 48 | }; 49 | 50 | const onKeyPress = (e: React.KeyboardEvent) => { 51 | switch (e.keyCode) { 52 | case 32: // space 53 | methods.changePlay(); 54 | break; 55 | case 37: // left 56 | methods.changeSeeked(state.current - 15); 57 | infoRef.current.init('backward'); 58 | break; 59 | case 38: // up 60 | methods.changeVolume(state.volume + 0.05); 61 | infoRef.current.init('volume'); 62 | break; 63 | case 39: // right 64 | methods.changeSeeked(state.current + 15); 65 | infoRef.current.init('forward'); 66 | break; 67 | case 40: // down 68 | methods.changeVolume(state.volume - 0.05); 69 | infoRef.current.init('volume'); 70 | break; 71 | default: 72 | break; 73 | } 74 | }; 75 | 76 | const hideControl = () => { 77 | setVisible(true); 78 | if (timeRef.current) { 79 | clearTimeout(timeRef.current); 80 | } 81 | if (!play) return; 82 | 83 | timeRef.current = setTimeout(() => { 84 | setVisible(false); 85 | }, 3000); 86 | }; 87 | 88 | useEffect(() => { 89 | if (play) { 90 | hideControl(); 91 | } else { 92 | setVisible(true); 93 | } 94 | }, [play]); 95 | 96 | return ( 97 |
setVisible(false)} 106 | onMouseEnter={() => setVisible(true)} 107 | > 108 |
109 | 110 | 111 | 112 | {(visible || !play) && ( 113 |
114 |
115 |
116 | 117 |
118 |
119 | 120 | 121 |
122 |
{(fullscreen || webscreen) && fullNode}
123 |
124 | 125 | {source.length > 0 && } 126 | {state.subtitle && } 127 | {state.danmu && } 128 | 129 | 130 | 131 |
132 |
133 |
134 |
135 | )} 136 |
137 | {children} 138 |
139 | ); 140 | }; 141 | export default reactComponent; 142 | -------------------------------------------------------------------------------- /src/theme/web/style.less: -------------------------------------------------------------------------------- 1 | .qinplayer { 2 | position: relative; 3 | width: 100%; 4 | height: auto; 5 | display: flex; 6 | justify-content: center; 7 | align-items: center; 8 | user-select: none; 9 | -webkit-tap-highlight-color: transparent; 10 | outline: none; 11 | font-family: Arial, Helvetica, sans-serif; 12 | 13 | &.webscreen { 14 | position: fixed; 15 | z-index: 100000; 16 | left: 0; 17 | top: 0; 18 | width: 100%; 19 | height: 100%; 20 | background-color: black; 21 | } 22 | 23 | &.nolight { 24 | z-index: 100000; 25 | &:after { 26 | content: ''; 27 | position: fixed; 28 | z-index: -1; 29 | left: 0; 30 | top: 0; 31 | width: 100%; 32 | height: 100%; 33 | background-color: rgba(0, 0, 0, 0.95); 34 | } 35 | } 36 | 37 | &.novisible { 38 | cursor: none; 39 | } 40 | .control { 41 | z-index: 8; 42 | position: absolute; 43 | width: 100%; 44 | height: 100%; 45 | top: 0; 46 | left: 0; 47 | 48 | .bar { 49 | position: absolute; 50 | width: 100%; 51 | left: 0; 52 | bottom: 0; 53 | z-index: 9; 54 | 55 | .bg { 56 | width: 100%; 57 | position: absolute; 58 | width: 100%; 59 | height: 140%; 60 | bottom: 0; 61 | overflow: hidden; 62 | background: linear-gradient( 63 | to top, 64 | rgba(0, 0, 0, 0.6) 0%, 65 | rgba(0, 0, 0, 0.3) 50%, 66 | rgba(0, 0, 0, 0.1) 70%, 67 | rgba(0, 0, 0, 0) 100% 68 | ); 69 | } 70 | 71 | .content { 72 | position: relative; 73 | height: 100%; 74 | padding: 0 10px; 75 | box-sizing: border-box; 76 | 77 | .option { 78 | display: flex; 79 | justify-content: space-between; 80 | align-items: center; 81 | line-height: 100%; 82 | height: 40px; 83 | z-index: 18; 84 | 85 | .left { 86 | height: 100%; 87 | display: flex; 88 | justify-content: flex-start; 89 | align-items: center; 90 | } 91 | 92 | .middle { 93 | flex: 1; 94 | } 95 | 96 | .right { 97 | height: 100%; 98 | display: flex; 99 | justify-content: flex-end; 100 | align-items: center; 101 | } 102 | } 103 | } 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/utils/local.ts: -------------------------------------------------------------------------------- 1 | const lang: any = { 2 | CN: { 3 | setting: '设置', 4 | fullscreen: '全屏', 5 | webscreen: '网页全屏', 6 | exitweb: '退出全屏', 7 | intotheater: '剧场模式', 8 | picture: '画中画', 9 | playMode: '播放方式', 10 | playRate: '播放速度', 11 | playSetting: '其他设置', 12 | noLight: '关灯模式', 13 | default: '默认', 14 | autoLoop: '自动循环', 15 | endStop: '播完暂停', 16 | showSubtitle: '字幕设置', 17 | subtitle: '显示字幕', 18 | nosubtitle: '隐藏字幕', 19 | subsize: '字幕大小', 20 | subsize1: '极小', 21 | subsize2: '小', 22 | subsize3: '正常', 23 | subsize4: '大', 24 | subsize5: '超大', 25 | subcolor: '描边颜色', 26 | submargin: '字幕高度', 27 | submargin1: '极低', 28 | submargin2: '低', 29 | submargin3: '正常', 30 | submargin4: '高', 31 | submargin5: '超高', 32 | sizePercent: '画面比例', 33 | forward: '+15s', 34 | backward: '-15s', 35 | danmuSetting: '弹幕设置', 36 | danmu: '显示弹幕', 37 | nodanmu: '隐藏弹幕', 38 | danmuFont: '字体大小', 39 | danmuFont1: '极小', 40 | danmuFont2: '小', 41 | danmuFont3: '正常', 42 | danmuFont4: '大', 43 | danmuFont5: '超大', 44 | danmuOpacity: '不透明度', 45 | danmuOpacity1: '0.2', 46 | danmuOpacity2: '0.4', 47 | danmuOpacity3: '0.6', 48 | danmuOpacity4: '0.8', 49 | danmuOpacity5: '1', 50 | danmuArea: '显示区域', 51 | danmuArea1: '1/4屏', 52 | danmuArea2: '1/2屏', 53 | danmuArea3: '2/3屏', 54 | danmuArea4: '3/4屏', 55 | danmuArea5: '全屏', 56 | }, 57 | EN: {}, 58 | }; 59 | 60 | export default lang; 61 | -------------------------------------------------------------------------------- /src/utils/request.ts: -------------------------------------------------------------------------------- 1 | import { fetch as fetchPolyfill } from 'whatwg-fetch'; 2 | 3 | const fetch: (url: string) => Promise = (url: string) => fetchPolyfill(url); 4 | 5 | export default fetch; 6 | -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | export const timeTransfer = (time: number) => { 2 | if (time > 3600) { 3 | const hour = Math.floor(time / 3600); 4 | const minute = Math.floor((time - hour * 3600) / 60); 5 | const seconds = Math.floor(time - 3600 * hour - 60 * minute); 6 | return `${numFixTwo(hour)}:${numFixTwo(minute)}:${numFixTwo(seconds)}`; 7 | } else { 8 | const minute = Math.floor(time / 60); 9 | const seconds = Math.floor(time - 60 * minute); 10 | return `${numFixTwo(minute)}:${numFixTwo(seconds)}`; 11 | } 12 | }; 13 | 14 | export const numFixTwo = (num: number) => { 15 | return num > 9 ? num : `0${num}`; 16 | }; 17 | 18 | export const getMode = () => { 19 | const agent = navigator.userAgent; 20 | if (/Android|iPhone|webOS|BlackBerry|SymbianOS|Windows Phone|iPad|iPod/.test(agent)) { 21 | return 'mobile'; 22 | } 23 | return 'web'; 24 | }; 25 | 26 | export const getStyleName = (styles: any, type: string, prefix: any) => { 27 | const total: string = type + '-' + prefix; 28 | return styles[total]; 29 | }; 30 | 31 | export const getFontLength = (text: string) => { 32 | const Alllength = text.length; 33 | const TotalLength = text.replace(/[^\x00-\xff]/g, '01').length; 34 | const CNlength = TotalLength - Alllength; 35 | const ENlength = TotalLength - CNlength * 2; 36 | const length = CNlength * 2 + (ENlength * 6) / 5; 37 | return length; 38 | }; 39 | 40 | export const colorArr: Array = [ 41 | 'black', 42 | '#ff0000', 43 | '#ff7d00', 44 | '#ffff00', 45 | '#00ff00', 46 | '#0000ff', 47 | '#00ffff', 48 | '#ff00ff', 49 | ]; 50 | 51 | export const marginArr: any = { 52 | web: ['20px', '35px', '50px', '65px', '80px'], 53 | mobile: ['14px', '18px', '22px', '26px', '30px'], 54 | }; 55 | 56 | export const sizeArr: any = { 57 | web: ['25px', '30px', '35px', '40px', '45px'], 58 | mobile: ['12px', '14px', '16px', '18px', '20px'], 59 | }; 60 | 61 | export const areaArr: any = { 62 | web: ['0.25', '0.5', '0.67', '0.75', '1'], 63 | mobile: ['0.25', '0.5', '0.67', '0.75', '1'], 64 | }; 65 | 66 | export const fontArr: any = { 67 | web: [12, 16, 18, 20, 24], 68 | mobile: [12, 16, 18, 20, 24], 69 | }; 70 | 71 | export const opacityArr: any = { 72 | web: [0.2, 0.4, 0.6, 0.8, 1], 73 | mobile: [0.2, 0.4, 0.6, 0.8, 1], 74 | }; 75 | -------------------------------------------------------------------------------- /src/utils/vttToJson.ts: -------------------------------------------------------------------------------- 1 | export type SubList = { 2 | start: number; 3 | end: number; 4 | word: string[]; 5 | }; 6 | 7 | const timeString2ms = (time: string) => { 8 | let timeArr = time.split('.'); 9 | const timems = parseInt(timeArr[1]); 10 | timeArr = timeArr[0].split(':'); 11 | const times = timeArr[2] 12 | ? parseInt(timeArr[1]) * 3600 + parseInt(timeArr[1]) * 60 + parseInt(timeArr[2]) 13 | : parseInt(timeArr[0]) * 60 + parseInt(timeArr[1]); 14 | 15 | return times + timems / 1000; 16 | }; 17 | 18 | const convertVttToJson = (vttString: string): Promise => { 19 | return new Promise((resolve, reject) => { 20 | let current: any = {}; 21 | let sections: Array = []; 22 | let start: boolean = false; 23 | 24 | const vttArray: Array = vttString.split('\n'); 25 | 26 | vttArray.forEach((item, index) => { 27 | const line = item.trim(); 28 | if (!start && /-->/.test(line)) { 29 | start = true; 30 | current = { 31 | start: timeString2ms(line.split('-->')[0].trimRight().split(' ').pop()), 32 | end: timeString2ms(line.split('-->')[1].trimLeft().split(' ').shift()), 33 | word: [], 34 | }; 35 | } else if (start && line) { 36 | const newLine = line.replace(/<\/?[^>]+(>|$)/g, ' ').trim(); 37 | current.word.push(newLine); 38 | } else if (start && !line) { 39 | start = false; 40 | sections.push({ ...current }); 41 | current = {}; 42 | } 43 | }); 44 | 45 | resolve(sections); 46 | }); 47 | }; 48 | 49 | export default convertVttToJson; 50 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Target latest version of ECMAScript. 4 | "target": "esnext", 5 | // Search under node_modules for non-relative imports. 6 | "moduleResolution": "node", 7 | "allowSyntheticDefaultImports": true, 8 | // Process & infer types from .js files. 9 | "allowJs": true, 10 | // Don't emit; allow Babel to transform files. 11 | "noEmit": true, 12 | // Enable strictest settings like strictNullChecks & noImplicitAny. 13 | "strict": true, 14 | // Disallow features that require cross-file information for emit. 15 | "isolatedModules": true, 16 | // Import non-ES modules as default imports. 17 | "esModuleInterop": true, 18 | "jsx": "preserve" 19 | }, 20 | "exclude": [ 21 | "node_modules" // 这个目录下的代码不会被 typescript 处理 22 | ] 23 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const autoprefixer = require('autoprefixer'); 4 | 5 | const htmlWebpackPlugin = new HtmlWebpackPlugin({ 6 | template: path.join(__dirname, 'examples/index.html'), 7 | filename: './index.html', 8 | }); 9 | module.exports = { 10 | entry: path.join(__dirname, 'examples/index.tsx'), 11 | module: { 12 | rules: [ 13 | { 14 | test: /\.(ts|tsx)$/, 15 | use: ['babel-loader'], 16 | exclude: /node_modules/, 17 | }, 18 | { 19 | test: /\.less$/, 20 | exclude: /node_modules/, 21 | use: [ 22 | 'style-loader', 23 | { 24 | loader: 'css-loader', 25 | options: { 26 | modules: { 27 | mode: 'local', 28 | localIdentName: '[local]-[hash:base64:6]', 29 | }, 30 | }, 31 | }, 32 | { 33 | loader: 'postcss-loader', 34 | options: { 35 | plugins: [autoprefixer], 36 | }, 37 | }, 38 | 'less-loader', 39 | ], 40 | }, 41 | { 42 | test: /\.(png|jpg|svg)$/i, 43 | use: [ 44 | { 45 | loader: 'url-loader', 46 | options: { 47 | limit: 1024, 48 | }, 49 | }, 50 | ], 51 | }, 52 | ], 53 | }, 54 | plugins: [htmlWebpackPlugin], 55 | resolve: { 56 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 57 | }, 58 | devServer: { 59 | port: 3009, 60 | host: '0.0.0.0', 61 | }, 62 | }; 63 | --------------------------------------------------------------------------------