├── demo ├── assets │ └── logo.png ├── public │ └── favicon.ico ├── main.js └── App.vue ├── .gitmodules ├── src ├── styles │ ├── icon │ │ ├── iconfont.ttf │ │ ├── iconfont.woff │ │ ├── iconfont.woff2 │ │ ├── index.css │ │ └── iconfont.css │ ├── keyframes.scss │ ├── flex.scss │ ├── popover.scss │ └── index.scss ├── directives │ └── drag.js ├── index.js ├── utils │ ├── drag │ │ ├── style.css │ │ └── index.js │ └── uuid.js ├── components │ ├── loading.vue │ ├── toolbar.vue │ └── contextmenu.vue ├── multipath-player.vue └── player.vue ├── .npmignore ├── server ├── type.d.ts ├── README.md ├── index.js └── stream-channel.js ├── .gitignore ├── index.html ├── .eslintrc.js ├── jsconfig.json ├── LICENSE ├── .prettierrc.js ├── vite.demo.config.js ├── package-dev.json ├── package.json ├── vite.config.js ├── 更新日志.md └── README.md /demo/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vCloudSail/jsmpeg-player/HEAD/demo/assets/logo.png -------------------------------------------------------------------------------- /demo/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vCloudSail/jsmpeg-player/HEAD/demo/public/favicon.ico -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "src/jsmpeg"] 2 | path = src/jsmpeg 3 | url = https://github.com/vCloudSail/jsmpeg.git 4 | -------------------------------------------------------------------------------- /src/styles/icon/iconfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vCloudSail/jsmpeg-player/HEAD/src/styles/icon/iconfont.ttf -------------------------------------------------------------------------------- /src/styles/icon/iconfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vCloudSail/jsmpeg-player/HEAD/src/styles/icon/iconfont.woff -------------------------------------------------------------------------------- /src/styles/icon/iconfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vCloudSail/jsmpeg-player/HEAD/src/styles/icon/iconfont.woff2 -------------------------------------------------------------------------------- /src/styles/keyframes.scss: -------------------------------------------------------------------------------- 1 | @keyframes omission { 2 | 0% { 3 | content: ''; 4 | } 5 | 6 | 25% { 7 | content: '.'; 8 | } 9 | 10 | 50% { 11 | content: '..'; 12 | } 13 | 14 | 75% { 15 | content: '...'; 16 | } 17 | } 18 | 19 | @keyframes breathing { 20 | 50% { 21 | opacity: 0; 22 | } 23 | } -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .git 3 | .idea/ 4 | .git/ 5 | *.map 6 | *.html 7 | 8 | dist/*.html 9 | 10 | test 11 | npm-debug.log 12 | .DS_Store 13 | node_modules/ 14 | examples/ 15 | packages/ 16 | public/ 17 | src/ 18 | demo/ 19 | simple-server/ 20 | 21 | .eslintrc.js 22 | .gitignore 23 | .npmignore 24 | .prettierrc.js 25 | babel.config.js 26 | jsconfig.json 27 | package-lock.json 28 | vue.config.js -------------------------------------------------------------------------------- /server/type.d.ts: -------------------------------------------------------------------------------- 1 | export interface ServerOptions { 2 | streamPort: number 3 | websocketPort: number 4 | recordStream: boolean 5 | } 6 | 7 | export interface StreamChannelOptions { 8 | name: string 9 | source: string 10 | maxClient: number 11 | timeout: number 12 | ffmpegOptions: { 13 | resolution: string 14 | bitrate: string 15 | } 16 | serverOptions: ServerOptions 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | /package-lock.json 5 | /pnpm-lock.yaml 6 | /report.html 7 | 8 | # local env files 9 | .env.local 10 | .env.*.local 11 | 12 | # Log files 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | 17 | # Editor directories and files 18 | .idea 19 | .vscode 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw* 25 | *.lock 26 | /.history 27 | -------------------------------------------------------------------------------- /demo/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './App.vue' 3 | import JsmpegPlayer from '@' 4 | // import JsmpegPlayer from 'vue-jsmpeg-player' 5 | 6 | import ElementUI from 'element-ui' 7 | import 'element-ui/lib/theme-chalk/index.css' 8 | 9 | Vue.use(JsmpegPlayer) 10 | Vue.use(ElementUI) 11 | 12 | Vue.config.productionTip = false 13 | 14 | new Vue({ 15 | render: (h) => h(App) 16 | }).$mount('#app') 17 | -------------------------------------------------------------------------------- /src/styles/icon/index.css: -------------------------------------------------------------------------------- 1 | @import './iconfont.css'; 2 | 3 | /* * [class*='jm-icon-'] + span { 4 | margin-left: 5px; 5 | } */ 6 | 7 | [class*='jm-icon-'], 8 | [class^='jm-icon-'] { 9 | font-family: 'jsmpeg-player' !important; 10 | speak: none; 11 | font-style: normal; 12 | font-weight: normal; 13 | font-variant: normal; 14 | text-transform: none; 15 | line-height: 1; 16 | vertical-align: baseline; 17 | display: inline-block; 18 | -webkit-font-smoothing: antialiased; 19 | } 20 | -------------------------------------------------------------------------------- /src/directives/drag.js: -------------------------------------------------------------------------------- 1 | import { dragIn } from '@/utils/drag' 2 | 3 | /** 4 | * @type {import('vue').DirectiveOptions} 5 | * @description 使元素支持拖入文件,并触发绑定事件 6 | * @author cloudsail 7 | */ 8 | const vDragIn = { 9 | bind(el, binding, vnode) {}, 10 | inserted(el, binding, vnode) { 11 | dragIn.bind(el, { 12 | callback: binding.value 13 | }) 14 | }, 15 | update(el, binding, vnode) { 16 | dragIn.update(el, { 17 | callback: binding.value 18 | }) 19 | }, 20 | unbind(el, binding, vnode) { 21 | dragIn.unbind(el) 22 | } 23 | } 24 | 25 | export { vDragIn } 26 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | vue-jsmpeg-player 10 | 11 | 12 | 15 |
16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import JSMpegPlayer from './player.vue' 2 | import JSMpegMultipathPlayer from './multipath-player.vue' 3 | 4 | export function install(Vue) { 5 | if (install.installed) return 6 | 7 | install.installed = true 8 | 9 | Vue.component(JSMpegPlayer.name, JSMpegPlayer) 10 | Vue.component('jsmpeg-player.multipath', JSMpegMultipathPlayer) 11 | } 12 | 13 | const plugin = { 14 | install 15 | } 16 | 17 | let GlobalVue = null 18 | if (typeof window !== 'undefined') { 19 | GlobalVue = window.Vue 20 | } else if (typeof global !== 'undefined') { 21 | GlobalVue = global.Vue 22 | } 23 | if (GlobalVue) { 24 | GlobalVue.use(plugin) 25 | } 26 | 27 | export { JSMpegPlayer, JSMpegMultipathPlayer } 28 | export default { 29 | install 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/drag/style.css: -------------------------------------------------------------------------------- 1 | .v-drag { 2 | transition: 0.46s box-shadow, 0.46s border-color; 3 | } 4 | /* .v-drag.is-dragenter::after { 5 | position: absolute; 6 | top: 0; 7 | left: 0; 8 | width: 100%; 9 | height: 100%; 10 | content: ''; 11 | z-index: 2000; 12 | } */ 13 | 14 | .v-drag.is-dragenter { 15 | cursor: copy !important; 16 | box-shadow: 0 0 12px #40a0ffb9 inset !important; 17 | border-color: #40a0ffb9 !important; 18 | } 19 | 20 | .v-drag.is-dragleave { 21 | cursor: copy !important; 22 | box-shadow: 0 0 12px #40a0ffb9 !important; 23 | border-color: #40a0ffb9 !important; 24 | } 25 | 26 | /* .v-drag-.is-dragleave { 27 | cursor: copy !important; 28 | box-shadow: 0 0 12px #40a0ffb9 inset !important; 29 | border-color: #40a0ffb9 !important; 30 | } */ 31 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // module.exports = { 2 | // root: true, 3 | // env: { 4 | // node: true 5 | // }, 6 | // extends: [ 7 | // 'plugin:vue/essential', 8 | // 'eslint:recommended', 9 | // 'plugin:prettier/recommended' 10 | // ], 11 | // parserOptions: { 12 | // // parser: '@babel/eslint-parser' 13 | // }, 14 | // rules: { 15 | // 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 16 | // 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off' 17 | // }, 18 | // overrides: [ 19 | // { 20 | // files: [ 21 | // '**/__tests__/*.{j,t}s?(x)', 22 | // '**/tests/unit/**/*.spec.{j,t}s?(x)' 23 | // ], 24 | // env: { 25 | // jest: true 26 | // } 27 | // } 28 | // ] 29 | // } 30 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@/*": [ 5 | "src/*" 6 | ], 7 | "@cloudsail/jsmpeg/*": [ 8 | "src/jsmpeg/src/*" 9 | ] 10 | }, 11 | "target": "esnext", // 用来指定 ES 版本 ESNext : 最新版 12 | "module": "esnext", // 指定要使用模块化的规范 13 | "baseUrl": ".", 14 | "outDir": "./dist", // 用来指定编译后文件的存放位置 15 | "sourceMap": true, // 是否生成sourceMap,默认false 这个文件里保存的,是转换后代码的位置,和对应的转换前的位置。有了它,出错的时候,通过断点工具可以直接显示原始代码,而不是转换后的代码。 16 | "strict": true, // 所有的严格检查的总开关,默认false 17 | "jsx": "preserve", 18 | "importHelpers": true, 19 | "moduleResolution": "node", 20 | "skipLibCheck": true, // 跳过.d.ts文件检查 21 | "esModuleInterop": true, 22 | "experimentalDecorators": true, 23 | "allowSyntheticDefaultImports": true, // 允许没有默认导出的模块 24 | }, 25 | "exclude": [ 26 | "node_modules", 27 | "dist" 28 | ] 29 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021- 云帆 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/loading.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /src/styles/flex.scss: -------------------------------------------------------------------------------- 1 | .flex { 2 | display: flex; 3 | overflow: hidden; 4 | } 5 | 6 | 7 | .flex-center { 8 | align-items: center; 9 | justify-content: center; 10 | } 11 | 12 | .flex-row { 13 | @extend .flex; 14 | align-items: center; 15 | 16 | &--center { 17 | @extend .flex-row; 18 | @extend .flex-center; 19 | } 20 | } 21 | 22 | .flex-col, 23 | .flex-column { 24 | @extend .flex; 25 | flex-direction: column; 26 | 27 | &--center { 28 | @extend .flex-column; 29 | @extend .flex-center; 30 | } 31 | } 32 | 33 | /* 34 | 注意:如果flex布局元素的祖先元素都没有明确的width、height, 35 | 布局上可能会有一些异常,加上height/width:auto;可能可以解决 36 | */ 37 | .flex-container { 38 | @extend .flex; 39 | flex-direction: column; 40 | 41 | width: 100%; 42 | height: 100%; 43 | box-sizing: border-box; 44 | position: relative; 45 | 46 | >* { 47 | box-sizing: border-box; 48 | } 49 | 50 | .overlayers { 51 | width: 0; 52 | height: 0; 53 | } 54 | } 55 | 56 | .flex-1, 57 | .flex-main { 58 | flex: 1 1 auto; 59 | } 60 | 61 | .flex-header, 62 | .flex-footer, 63 | .flex-aside { 64 | flex-shrink: 0; 65 | // overflow: hidden; 66 | box-sizing: border-box; 67 | z-index: 1; 68 | } 69 | 70 | .flex-aside { 71 | flex: 0 0 auto; 72 | height: 100%; 73 | width: auto; 74 | } 75 | 76 | .flex-header, 77 | .flex-footer { 78 | height: auto; 79 | width: 100%; 80 | } -------------------------------------------------------------------------------- /src/styles/icon/iconfont.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "jsmpeg-player"; /* Project id 2580924 */ 3 | src: url('iconfont.woff2?t=1697092567259') format('woff2'), 4 | url('iconfont.woff?t=1697092567259') format('woff'), 5 | url('iconfont.ttf?t=1697092567259') format('truetype'); 6 | } 7 | 8 | .jsmpeg-player { 9 | font-family: "jsmpeg-player" !important; 10 | font-size: 16px; 11 | font-style: normal; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | .jm-icon-splitscreen-4:before { 17 | content: "\e604"; 18 | } 19 | 20 | .jm-icon-splitscreen-1:before { 21 | content: "\e632"; 22 | } 23 | 24 | .jm-icon-splitscreen-4-1:before { 25 | content: "\e633"; 26 | } 27 | 28 | .jm-icon-splitscreen-6:before { 29 | content: "\e613"; 30 | } 31 | 32 | .jm-icon-splitscreen-8:before { 33 | content: "\e612"; 34 | } 35 | 36 | .jm-icon-splitscreen-9:before { 37 | content: "\e614"; 38 | } 39 | 40 | .jm-icon-close:before { 41 | content: "\e661"; 42 | } 43 | 44 | .jm-icon-settings:before { 45 | content: "\e892"; 46 | } 47 | 48 | .jm-icon-video-play:before { 49 | content: "\e600"; 50 | } 51 | 52 | .jm-icon-more:before { 53 | content: "\e601"; 54 | } 55 | 56 | .jm-icon-screenshots:before { 57 | content: "\e602"; 58 | } 59 | 60 | .jm-icon-video-pause:before { 61 | content: "\e603"; 62 | } 63 | 64 | .jm-icon-recording:before { 65 | content: "\e663"; 66 | } 67 | 68 | .jm-icon-rotate-right:before { 69 | content: "\e698"; 70 | } 71 | 72 | .jm-icon-rotate-left:before { 73 | content: "\e699"; 74 | } 75 | 76 | .jm-icon-stop:before { 77 | content: "\e611"; 78 | } 79 | 80 | .jm-icon-fullscreen-exit:before { 81 | content: "\e65d"; 82 | } 83 | 84 | .jm-icon-fullscreen:before { 85 | content: "\e65e"; 86 | } 87 | 88 | .jm-icon-muted:before { 89 | content: "\e62d"; 90 | } 91 | 92 | .jm-icon-volume:before { 93 | content: "\e62e"; 94 | } 95 | 96 | -------------------------------------------------------------------------------- /src/utils/uuid.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * @param {number} mode 生成模式0-3 4 | * @param {object} params 参数,只有模式3有用 5 | * @param {number} params.radix 基数 6 | * @param {number} params.len 长度 7 | * @returns {string} 8 | */ 9 | function uuid(mode = 0, params) { 10 | let result = '' 11 | switch (mode) { 12 | case 0: 13 | let s = [] 14 | let hexDigits = '0123456789abcdef' 15 | for (let i = 0; i < 36; i++) { 16 | s[i] = hexDigits.substr(Math.floor(Math.random() * 0x10), 1) 17 | } 18 | s[14] = '4' // bits 12-15 of the time_hi_and_version field to 0010 19 | s[19] = hexDigits.substr((s[19] & 0x3) | 0x8, 1) // bits 6-7 of the clock_seq_hi_and_reserved to 01 20 | s[8] = s[13] = s[18] = s[23] = '-' 21 | 22 | result = s.join('') 23 | return result 24 | case 1: 25 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { 26 | let r = (Math.random() * 16) | 0, 27 | v = c == 'x' ? r : (r & 0x3) | 0x8 28 | return v.toString(16) 29 | }) 30 | case 2: 31 | function S4() { 32 | return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1) 33 | } 34 | result = S4() + S4() + '-' + S4() + '-' + S4() + '-' + S4() + '-' + S4() + S4() + S4() 35 | return result 36 | case 3: 37 | let { radix, len } = params 38 | let chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split('') 39 | let i = 0 40 | result = [] 41 | radix = radix || chars.length 42 | 43 | if (len) { 44 | // Compact form 45 | for (i = 0; i < len; i++) result[i] = chars[0 | (Math.random() * radix)] 46 | } else { 47 | // rfc4122, version 4 form 48 | let r 49 | 50 | // rfc4122 requires these characters 51 | result[8] = result[13] = result[18] = result[23] = '-' 52 | result[14] = '4' 53 | 54 | // Fill in random data. At i==19 set the high bits of clock sequence as 55 | // per rfc4122, sec. 4.1.5 56 | for (i = 0; i < 36; i++) { 57 | if (!result[i]) { 58 | r = 0 | (Math.random() * 16) 59 | result[i] = chars[i == 19 ? (r & 0x3) | 0x8 : r] 60 | } 61 | } 62 | } 63 | 64 | return result.join('') 65 | default: 66 | return result 67 | } 68 | } 69 | 70 | export default uuid 71 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @see https://prettier.io/docs/en/options.html#print-width 3 | */ 4 | module.exports = { 5 | /** 6 | * 换行宽度,当代码宽度达到多少时换行 7 | * @default 80 8 | * @type {number} 9 | */ 10 | printWidth: 120, 11 | /** 12 | * 缩进的空格数量 13 | * @default 2 14 | * @type {number} 15 | */ 16 | tabWidth: 2, 17 | /** 18 | * 是否使用制表符代替空格 19 | * @default false 20 | * @type {boolean} 21 | */ 22 | useTabs: false, 23 | /** 24 | * 是否在代码块结尾加上分号 25 | * @default true 26 | * @type {boolean} 27 | */ 28 | semi: false, 29 | /** 30 | * 是否使用单引号替代双引号 31 | * @default false 32 | * @type {boolean} 33 | */ 34 | singleQuote: true, 35 | /** 36 | * 对象属性的引号处理 37 | * @default "as-needed" 38 | * @type {"as-needed"|"consistent"|"preserve"} 39 | */ 40 | quoteProps: 'as-needed', 41 | /** 42 | * jsx中是否使用单引号替代双引号 43 | * @default false 44 | * @type {boolean} 45 | */ 46 | jsxSingleQuote: true, 47 | /** 48 | * 在jsx中使用是否单引号代替双引号 49 | * @default false 50 | * @type {boolean} 51 | */ 52 | /** 53 | * 末尾是否加上逗号 54 | * @default "es5" 55 | * @type {"es5"|"none"|"all"} 56 | */ 57 | trailingComma: 'none', 58 | /** 59 | * 在对象,数组括号与文字之间加空格 "{ foo: bar }" 60 | * @default true 61 | * @type {boolean} 62 | */ 63 | bracketSpacing: true, 64 | /** 65 | * 把多行HTML (HTML, JSX, Vue, Angular)元素的>放在最后一行的末尾,而不是单独放在下一行(不适用于自关闭元素)。 66 | * @default false 67 | * @type {boolean} 68 | */ 69 | bracketSameLine: false, 70 | /** 71 | * 当箭头函数只有一个参数是否加括号 72 | * @default "always" 73 | * @type {"always"|"avoid"} 74 | */ 75 | arrowParens: 'always', 76 | /** 77 | * 为HTML、Vue、Angular和Handlebars指定全局空格敏感性 78 | * @default "css" 79 | * @type {"css"|"strict"|"ignore"} 80 | */ 81 | htmlWhitespaceSensitivity: 'strict', 82 | /** 83 | * 是否缩进Vue文件中的 157 | 158 | 208 | -------------------------------------------------------------------------------- /src/styles/index.scss: -------------------------------------------------------------------------------- 1 | @import './icon/index.css'; 2 | @import './popover.scss'; 3 | @import './keyframes.scss'; 4 | @import './flex.scss'; 5 | 6 | .jsmpeg-player { 7 | position: relative; 8 | overflow: hidden; 9 | display: flex; 10 | background-color: #000; 11 | height: 100%; 12 | 13 | * { 14 | box-sizing: border-box; 15 | } 16 | 17 | button { 18 | background: none; 19 | border: none; 20 | display: flex; 21 | font-size: inherit; 22 | line-height: inherit; 23 | text-transform: none; 24 | text-decoration: none; 25 | cursor: pointer; 26 | overflow: hidden; 27 | box-sizing: border-box; 28 | } 29 | 30 | .player-header { 31 | width: 100%; 32 | height: 40px; 33 | line-height: 40px; 34 | position: absolute; 35 | top: 0; 36 | left: 0; 37 | padding: 0 10px; 38 | background: linear-gradient(#000, transparent); 39 | transform: translateY(-100%); 40 | transition: 0.48s transform ease-in-out; 41 | z-index: 10; 42 | box-sizing: border-box; 43 | 44 | &.is-show { 45 | transform: translateY(0); 46 | 47 | .recording-tips { 48 | display: inline-flex; 49 | transform: translateY(0) !important; 50 | // transition: 0.45s display; 51 | } 52 | } 53 | 54 | .player-title { 55 | max-width: 100%; 56 | color: #fff; 57 | float: left; 58 | overflow: hidden; 59 | text-overflow: ellipsis; 60 | white-space: nowrap; 61 | } 62 | 63 | .recording-tips { 64 | height: 40px; 65 | font-size: 14px; 66 | color: white; 67 | float: right; 68 | display: flex; 69 | flex-direction: row; 70 | justify-content: flex-end; 71 | align-items: center; 72 | transform: translateY(100%); 73 | transition: transform 0.28s; 74 | 75 | font-weight: bold; 76 | letter-spacing: 1px; 77 | 78 | 79 | .recording-icon { 80 | width: 10px; 81 | height: 10px; 82 | background-color: red; 83 | border-radius: 5px; 84 | margin-left: 8px; 85 | margin-right: 6px; 86 | animation: breathing 1s ease-in-out infinite; 87 | // transition: 0.25s background-color ease-in; 88 | 89 | // &.is-hide { 90 | // background-color: transparent; 91 | // } 92 | } 93 | } 94 | 95 | .close-btn { 96 | color: whitesmoke; 97 | transition: 0.28s color; 98 | position: absolute; 99 | top: 0; 100 | right: 5px; 101 | font-size: 16px; 102 | 103 | &:hover { 104 | color: #f56c6c; 105 | } 106 | } 107 | } 108 | 109 | .player-main { 110 | width: 100%; 111 | height: 100%; 112 | display: flex; 113 | justify-content: center; 114 | align-items: center; 115 | z-index: 1; 116 | position: relative; 117 | box-sizing: border-box; 118 | 119 | .player-loading-mask { 120 | width: 100%; 121 | height: 100%; 122 | 123 | position: absolute; 124 | top: 0; 125 | left: 0; 126 | z-index: 5; 127 | // background-color: hsla(0, 0%, 100%, .9); 128 | 129 | 130 | 131 | .player-loading { 132 | position: absolute; 133 | top: 50%; 134 | left: 50%; 135 | transform: translate(-50%, -50%); 136 | 137 | color: #409eff; 138 | display: flex; 139 | flex-direction: column; 140 | align-items: center; 141 | justify-content: center; 142 | 143 | .loading-text { 144 | margin-top: 10px; 145 | 146 | &::after { 147 | display: inline-block; 148 | content: ''; 149 | animation: omission 2s ease-in-out infinite; 150 | } 151 | 152 | } 153 | } 154 | } 155 | 156 | canvas { 157 | max-width: 100%; 158 | max-height: 100%; 159 | // transition: 0.28s transform; 160 | } 161 | 162 | .no-signal-mask { 163 | display: flex; 164 | justify-content: center; 165 | align-items: center; 166 | width: 100%; 167 | height: 100%; 168 | position: absolute; 169 | top: 0; 170 | left: 0; 171 | background-color: #000; 172 | 173 | .no-signal-text { 174 | color: white; 175 | } 176 | } 177 | 178 | .el-loading-mask { 179 | background-color: transparent; 180 | } 181 | } 182 | 183 | .player-toolbar { 184 | width: 100%; 185 | // height: 45px; 186 | height: auto; 187 | // line-height: 36px; 188 | background: linear-gradient(transparent, #000); 189 | padding: 12px 8px; 190 | position: absolute; 191 | bottom: 0px; 192 | left: 0px; 193 | display: flex; 194 | flex-direction: row; 195 | align-items: center; 196 | transform: translateY(100%); 197 | transition: 0.48s transform ease-in-out; 198 | z-index: 10; 199 | box-sizing: border-box; 200 | 201 | &.is-show { 202 | transform: translateY(0); 203 | } 204 | 205 | .toolbar-item { 206 | color: whitesmoke !important; 207 | opacity: 0.8; 208 | transition: 0.28s opacity ease-in-out, 0.28s color; 209 | 210 | &:hover { 211 | opacity: 1; 212 | } 213 | 214 | img.icon { 215 | object-fit: scale-down; 216 | max-width: 100%; 217 | max-height: 100%; 218 | } 219 | } 220 | 221 | >.toolbar-item { 222 | max-height: 35px; 223 | max-width: 35px; 224 | font-size: 24px; 225 | } 226 | 227 | .play-btn { 228 | transition: 0.28s color; 229 | // &:hover { 230 | // color: #409eff !important; 231 | // } 232 | // color: #f56c6c !important; 233 | // &.is-paused { 234 | // color: #409eff !important; 235 | // } 236 | } 237 | 238 | .recording-btn { 239 | &.is-recording { 240 | color: #f56c6c !important; 241 | } 242 | } 243 | 244 | .stop-btn { 245 | color: #f56c6c !important; 246 | } 247 | 248 | .progress-bar { 249 | flex: 1; 250 | padding: 0 10px; 251 | 252 | .current-time { 253 | float: right; 254 | cursor: default; 255 | color: whitesmoke !important; 256 | } 257 | } 258 | } 259 | 260 | .overlayers { 261 | width: 0; 262 | height: 0; 263 | } 264 | } 265 | 266 | 267 | @media screen and (max-width: 768px) { 268 | .jsmpeg-player { 269 | padding: 24px 0; 270 | 271 | .player-title { 272 | font-size: 16px; 273 | } 274 | 275 | .player-toolbar { 276 | padding: 8px; 277 | 278 | .toolbar-item { 279 | font-size: 20px; 280 | } 281 | } 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /src/components/toolbar.vue: -------------------------------------------------------------------------------- 1 | 165 | 166 | 220 | 221 | 225 | -------------------------------------------------------------------------------- /src/components/contextmenu.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 299 | 300 | 312 | -------------------------------------------------------------------------------- /server/stream-channel.js: -------------------------------------------------------------------------------- 1 | const { exec, ChildProcess, spawn } = require('child_process') 2 | const WebSocket = require('ws') 3 | 4 | const restartTime = 10000 5 | /** 6 | 7 | */ 8 | class StreamChannel { 9 | /** 通道名称 */ 10 | /** @type {'running'|'starting'|'stoping'|'stoped'} */ 11 | status = 'stop' 12 | name = '' 13 | /** 流媒体源地址 */ 14 | source = '' 15 | /** 最多连接多少客户端 */ 16 | maxClient = 30 17 | /** 接流超时时间,默认30秒 */ 18 | timeout = 30 19 | /** @type {ChildProcess} */ 20 | ffmpeProcess = null 21 | 22 | /** @type {Array} */ 23 | clients = [] 24 | /** @type {http.IncomingMessage} */ 25 | incomingMessage = null 26 | serverOptions = {} 27 | ffmpegOptions = { 28 | outputResolution: '1920x1080', 29 | outputBitrate: '1500K' 30 | } 31 | timers = { 32 | /** 丢失最后一个客户端 */ 33 | lostLastClient: null, 34 | /** 丢失推流端 */ 35 | lostStreamClient: null, 36 | /** */ 37 | notReceivedFromStream: null 38 | } 39 | /** 40 | * 41 | * @param {import('./type').StreamChannelOptions} options 42 | */ 43 | constructor({ name, source = '', maxClient, timeout, ffmpegOptions, serverOptions } = {}) { 44 | this.name = name 45 | this.source = source?.trim() || '' 46 | this.timeout = timeout || 30 47 | this.maxClient = maxClient || -1 48 | this.ffmpegOptions.outputBitrate = ffmpegOptions?.bitrate || '1500K' 49 | this.ffmpegOptions.outputResolution = ffmpegOptions?.resolution || '1920x1080' 50 | this.serverOptions = serverOptions 51 | 52 | if (this.name === 'desktop' || this.source) { 53 | this.start() 54 | } 55 | } 56 | async broadcast(data) { 57 | try { 58 | for (let client of this.clients) { 59 | if (client.readyState === WebSocket.OPEN) { 60 | client.send(data) 61 | } else { 62 | this.removeClient(client) 63 | } 64 | } 65 | } catch (error) { 66 | this.log('广播数据失败') 67 | } 68 | // if (this.recording) { 69 | // this.recording.write(data) 70 | // } 71 | } 72 | /** 73 | * 74 | * @param {import('http').IncomingMessage} request 75 | */ 76 | // acceptIncomingMessage(request) { 77 | // this.log('启动ffmpeg推流进程成功,开始推流') 78 | // this.status = 'running' 79 | // this.incomingMessage = request 80 | // this.incomingMessage.on('data', (data) => { 81 | // this.#onReceiveStreamData(data) 82 | // }) 83 | // this.incomingMessage.on('end', () => { 84 | // this.incomingMessage = null 85 | // this.log(`推流端断开连接`) 86 | // // if (request.socket.recording) { 87 | // // request.socket.recording.close() 88 | // // } 89 | // }) 90 | // } 91 | /** 92 | * 添加客户端 93 | * @param {WebSocket} client 94 | */ 95 | addClient(client) { 96 | if (this.clients.includes(client)) { 97 | return 98 | } else if (this.maxClient > 0 && this.clients.length > this.maxClient) { 99 | // 客户端超过限制,强制关闭连接 100 | client.close() 101 | return 102 | } 103 | 104 | if (this.timers?.lostLastClient) { 105 | this.log('新客户端接入,清除自动停止推流定时器') 106 | clearTimeout(this.timers.lostLastClient) 107 | this.timers.lostLastClient = null 108 | } 109 | this.clients.push(client) 110 | client.on('close', () => { 111 | this.removeClient(client) 112 | }) 113 | if (this.status !== 'running' && !this.ffmpeProcess) { 114 | this.start() 115 | } 116 | } 117 | /** 118 | * 移除客户端 119 | * @param {WebSocket} client 120 | */ 121 | removeClient(client) { 122 | const index = this.clients.indexOf(client) 123 | if (index > -1) { 124 | this.clients.splice(index, 1) 125 | } 126 | 127 | if (this.clients.length === 0) { 128 | // 当最后一个客户端连接断开后30秒内无任何客户端接入时,停止推流 129 | this.log('最后一个客户端连接断开,倒计时30秒内无任何客户端接入将停止推流') 130 | this.timers.lostLastClient = setTimeout(() => { 131 | this.#onLastClientLost() 132 | }, 30 * 1000) 133 | } 134 | } 135 | restart() { 136 | if (this.status === 'running' && this.ffmpeProcess) { 137 | this.ffmpeProcess.on('exit', (code) => { 138 | this.start() 139 | }) 140 | 141 | this.stop() 142 | } else { 143 | this.status = 'stoped' 144 | this.start() 145 | } 146 | } 147 | log(msg) { 148 | msg && console.log(`${new Date().toLocaleString()} - 通道[${this.name}]`, msg) 149 | } 150 | start() { 151 | if (this.status === 'running' || this.status === 'starting') { 152 | return 153 | } 154 | try { 155 | this.status = 'starting' 156 | 157 | let source = '' 158 | 159 | if (this.name === 'desktop' && !this.source) { 160 | /** 161 | * -f参数说明 162 | * x11grab 指定输入格式为X11抓取 163 | * gdigrab 164 | */ 165 | /** 166 | * 获取真实分辨率 167 | * -s $(xdpyinfo | grep dimensions | awk '{print $2;}') 168 | */ 169 | /** 170 | * 捕获桌面流参考链接 171 | * - https://www.bilibili.com/read/cv20197318/ 172 | * - https://zhuanlan.zhihu.com/p/455572544#h_455572544_20 173 | */ 174 | source = `-f gdigrab -s 1920x1080 -draw_mouse 1 -i desktop` 175 | } else if (/^rtsp[:]/.test(this.source)) { 176 | source = `-rtsp_transport tcp -i ${this.source}` 177 | } else if (/^rtmp[:]/.test(this.source)) { 178 | source = `-i ${this.source}` 179 | } else if (/^https?[:].*[.]m3u8$/.test(this.source)) { 180 | source = `-i ${this.source} -reset_timestamps 1` 181 | } 182 | 183 | if (!source) { 184 | this.log('无法识别流媒体源类型,不启动ffmpeg推流 -> ' + this.source) 185 | return 186 | } 187 | 188 | const options = [ 189 | ...source.split(' '), 190 | '-r', // 强制24fps,因为mpeg1/2不支持过低的帧率 191 | '24', 192 | '-q', 193 | '0', 194 | '-f', 195 | 'mpegts', 196 | '-codec:v', // 编码格式 197 | 'mpeg1video', 198 | '-s', 199 | this.ffmpegOptions.outputResolution || '1920x1080', // 输出分辨率 200 | '-b:v', 201 | this.ffmpegOptions.outputBitrate || '1000k', // 视频码率 202 | '-codec:a', // 音频编码器 203 | 'mp2', 204 | '-ar', // 音频采样率 205 | '44100', 206 | '-ac', // 音频通道 207 | '1', 208 | '-b:a', // 音频码率 209 | '128k', 210 | // `${this.name}.ts` 211 | // , 212 | '-' 213 | // `http://127.0.0.1:${this.serverOptions.streamPort}/${this.name}` 214 | ] 215 | this.log('启动ffmpeg推流进程 -> ' + `ffmpeg ${options.join(' ')}`) 216 | this.ffmpeProcess = spawn(`ffmpeg`, options, { 217 | detached: false 218 | }) 219 | this.log('启动ffmpeg推流进程成功,开始推流') 220 | this.ffmpeProcess.stdout.on('data', (data) => { 221 | this.#onReceiveStreamData(data) 222 | }) 223 | this.ffmpeProcess.on('error', (err) => { 224 | // this.ffmpeProcess.disconnect() 225 | // this.ffmpeProcess = null 226 | // this.start() 227 | // this.status = 'stoped' 228 | this.log('ffmpeg推流进程出错 -> ' + err) 229 | }) 230 | this.ffmpeProcess.on('exit', (code, signal) => { 231 | if (this.timers.notReceivedFromStream) { 232 | clearTimeout(this.timers.notReceivedFromStream) 233 | this.timers.notReceivedFromStream = null 234 | } 235 | this.log( 236 | `ffmpeg推流进程已退出 -> code: ${code} signal: ${signal}` // error: ${this.ffmpeProcess.stderr.read()} 237 | ) 238 | this.status = 'stoped' 239 | this.ffmpeProcess = null 240 | if (this.clients.length > 0) { 241 | // 还有客户端,表示异常退出,重新启动 242 | this.log( 243 | `ffmpeg推流进程异常关闭,${restartTime / 1000}秒后重启` // error: ${this.ffmpeProcess.stderr.read()} 244 | ) 245 | setTimeout(() => { 246 | this.start() 247 | }, restartTime) 248 | } 249 | }) 250 | } catch (error) { 251 | this.status = 'stoped' 252 | console.error(error) 253 | } 254 | } 255 | stop() { 256 | if (!this.ffmpeProcess) { 257 | this.status = 'stoped' 258 | return 259 | } 260 | 261 | this.log('开始停止ffmpeg推流进程') 262 | this.status = 'stoping' 263 | try { 264 | this.ffmpeProcess?.kill() 265 | } catch (error) { 266 | console.error(error) 267 | } 268 | } 269 | #onReceiveStreamData(data) { 270 | this.broadcast(data) 271 | if (this.timers.notReceivedFromStream) { 272 | clearTimeout(this.timers.notReceivedFromStream) 273 | this.timers.notReceivedFromStream = null 274 | } 275 | this.timers.notReceivedFromStream = setTimeout(() => { 276 | this.#onStreamInterrupt() 277 | }, this.timeout * 1000) 278 | } 279 | #onStreamInterrupt() { 280 | this.log('接收ffmpeg推流数据超时,重启ffmpeg进程 -> ' + this.ffmpeProcess.stdout.read()) 281 | this.timers.notReceivedFromStream = null 282 | this.stop() 283 | } 284 | #onLastClientLost() { 285 | this.timers.lostLastClient = null 286 | this.stop() 287 | } 288 | } 289 | 290 | // export default StreamChannel 291 | module.exports = StreamChannel 292 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jsmpeg-player 2 | 3 | ## 介绍 4 | 5 | 本组件是基于[jsmpeg.js](https://github.com/phoboslab/jsmpeg)二次开发的 vue2 组件(未来会支持 vue3) 6 | 7 | - web 播放实时视频流的几种方案对比,详见[此处](https://blog.csdn.net/a843334549/article/details/117319350) 8 | - 本方案详细介绍:[在 Web 中低时延播放 RTSP 视频流(海康、大华)方案 - JSMpeg.js](https://blog.csdn.net/a843334549/article/details/120697574) 9 | - jsmpeg.js 相关链接:[gitee](https://gitee.com/mirrors/jsmpeg)、[github](https://github.com/phoboslab/jsmpeg)、[官网](https://jsmpeg.com/) 10 | - **关于延迟问题**:仅在局域网\本机下实测 1s 左右,在公网下未知,公网要考虑的东西太多(带宽、丢包、流量),公网下的流媒体服务框架这里推荐[ZLMediaKit](https://github.com/ZLMediaKit/ZLMediaKit)、[SRS](https://github.com/ossrs/srs) 11 | - **关于性能问题** 12 | - **注意:本方案存在性能瓶颈,可能不适用于大型项目,对性能有追求的请使用开源流媒体框架 ZLMediaKit、SRS** 13 | - 由于是客户端软解码,所以性能不会太好,在目前的 i5 10 代+8g 这种配置机器上单个页面六路同屏应该是没问题的,再多就不行了,对性能有追求的只能用 webrtc 了,具体性能对比可见[jsmpeg 官网性能对比](https://jsmpeg.com/perf.html) 14 | - 在使用 vue 开发环境时,可能会产生内存溢出的错误,应该是由于频繁热更新导致的,刷新页面即可 15 | - 交流 QQ 群:56370082(请备注来源) 16 | 17 | ### Note 18 | 19 | `[v1.2.1]` 使用了 vite-plugin-libcss 插件打包,esm 方式引入(即 import)不需要再手动引入样式文件 20 | 21 | `[v1.2.0]` jsmpeg.js 已迁移到独立仓库中,以 git 子模块的形式引入 22 | 23 | - 初次拉取仓库时请使用 `git clone --recursive` 24 | - 如果已经克隆了主仓库但没有克隆子模块,则使用 `git submodule update --init --recursive` 25 | 26 | ### 支持的格式 27 | 28 | - 视频:mpeg1 29 | - 音频:mp2 30 | 31 | **仅支持 mpeg1 格式视频、mp2 格式音频!!!仅支持 mpeg1 格式视频、mp2 格式音频!!!仅支持 mpeg1 格式视频、mp2 格式音频!!!不要随便拿个 websocket 流去给 jsmpeg 使用,播放不了的!!!也无法直接播放 rtmp 流!!!** 32 | 33 | ### 组件仓库/npm 地址 34 | 35 | - [giee](https://gitee.com/vCloudsail/jsmpeg-player) 36 | - [github](https://github.com/vCloudSail/jsmpeg-player) 37 | - [npm](https://www.npmjs.com/package/vue-jsmpeg-player?activeTab=readme) 38 | 39 | ## 方案架构 40 | 41 | **rtsp 流 => ffmpeg 转码 => ~~http server 接收~~(已废弃,直接通过命令行获取转码数据) => websocket server 转发 => websocket client => 客户端软解码渲染** 42 | 43 | ![在这里插入图片描述](https://img-blog.csdnimg.cn/2a11509af2b64ee08608518017a7bfad.png) 44 | 45 | ffmpeg 推流命令示例: 46 | 47 | ```shell 48 | ffmpeg ^ 49 | -rtsp_transport tcp -i rtsp://[用户名]:[密码]@[ip]:554/h264/ch1/main/av_stream -q 0 ^ 50 | -f mpegts ^ 51 | -codec:v mpeg1video -s 1920x1080 -b:v 1500k ^ 52 | -codec:a mp2 -ar 44100 -ac 1 -b:a 128k ^ 53 | http://127.0.0.1:8890/jsmpeg 54 | ``` 55 | 56 | PS: 57 | 58 | - 如果是公网,需要自行解决拉取摄像头 rtsp 流 59 | - 本组件仅实现了前端(客户端)部分的功能,后端部分的功能可参考 server 目录下的代码 60 | 61 | ## 安装教程 62 | 63 | 本组件使用了```ES6+```语法,要求如下: 64 | 65 | - `vue > 2.6.0` 66 | - `core-js > 3 ` (如果不安装,编译会报错) 67 | 68 | ```javascript 69 | npm i core-js@3 vue@2 -S 70 | 71 | npm i vue-jsmpeg-player -S 72 | ``` 73 | 74 | **全局组件** 75 | 76 | ```javascript 77 | // main.js 78 | import Vue from 'vue' 79 | 80 | import JSMpegPlayer from 'vue-jsmpeg-player' 81 | import 'vue-jsmpeg-player/style.css' 82 | 83 | Vue.use(JSMpegPlayer) 84 | 85 | // 或者 86 | 87 | import { JSMpegPlayer, JSMpegMultipathPlayer } from 'vue-jsmpeg-player' 88 | import 'vue-jsmpeg-player/style.css' 89 | 90 | Vue.component(JSMpegPlayer.name, JSMpegPlayer) 91 | Vue.component(JSMpegMultipathPlayer.name, JSMpegMultipathPlayer) 92 | ``` 93 | 94 | **局部组件** 95 | 96 | ```javascript 97 | import { JSMpegPlayer, JSMpegMultipathPlayer } from 'vue-jsmpeg-player'; 98 | import 'vue-jsmpeg-player/style.css'; 99 | 100 | export default { 101 | ... 102 | 103 | components: { 104 | [JSMpegPlayer.name]: JSMpegPlayer, 105 | [JSMpegMultipathPlayer.name]: JSMpegMultipathPlayer 106 | }, 107 | 108 | ... 109 | } 110 | 111 | ``` 112 | 113 | **使用** 114 | 115 | - 单路播放 116 | 117 | ```vue 118 | 123 | 124 | 140 | 141 | 142 | ``` 143 | 144 | - 多路播放 145 | 146 | ```vue 147 | 152 | 153 | 174 | 175 | 176 | ``` 177 | 178 | ## 使用说明 179 | 180 | ### JSMpegPlayer - 单路播放器 181 | 182 | #### 属性 & Props 183 | 184 | | 名称 | 类型 | 说明 | 185 | | -------------- | ------- | --------------------------------------------------------------------------------- | 186 | | url | string | 视频流地址(推荐 websocket,实际上 jsmpeg.js 原生也支持 http 方式,但没有经过测试) | 187 | | title | string | 播放器标题 | 188 | | no-signal-text | string | 无信号时的显示文本 | 189 | | options | object | jsmpeg 原生选项,直接透传,详见下表 | 190 | | closeable | boolean | 是否可关闭(单击关闭按钮,仅抛出事件) | 191 | | in-background | boolean | 是否处于后台,如 el-tabs 的切换,路由的切换等,支持.sync 修饰符 | 192 | | show-duration | boolean | 是否显示持续播放时间 | 193 | | default-muted | boolean | 默认静音 | 194 | | with-toolbar | boolean | 是否需要工具栏 | 195 | | loading-text | boolean | 加载时的文本,默认为:拼命加载中 | 196 | 197 | **原生属性:** 198 | 199 | | 名称 | 类型 | 说明 | 200 | | --- | --- | --- | 201 | | canvas | HTMLCanvasElement | 用于视频渲染的 HTML Canvas 元素。如果没有给出,渲染器将创建自己的 Canvas 元素。 | 202 | | loop | boolean | 是否循环播放视频(仅静态文件),默认=true | 203 | | autoplay | boolean | 是否立即开始播放(仅限静态文件),默认=false | 204 | | audio | boolean | 是否解码音频,默认=true | 205 | | video | boolean | 是否解码视频,默认=true | 206 | | poster | string | 预览图像的 URL,用来在视频播放之前作为海报显示。 | 207 | | pauseWhenHidden | boolean | 当页面处于非活动状态时是否暂停播放,默认=true(请注意,浏览器通常会在非活动选项卡中限制 JS) | 208 | | disableGl | boolean | 是否禁用 WebGL,始终使用 Canvas2D 渲染器,默认=false | 209 | | disableWebAssembly | boolean | 是否禁用 WebAssembly 并始终使用 JavaScript 解码器,默认=false(不建议设置为 true) | 210 | | preserveDrawingBuffer | boolean | WebGL 上下文是否创建必要的“截图” | 211 | | progressive | boolean | 是否以块的形式加载数据(仅静态文件)。当启用时,回放可以在完整加载源之前开始。 | 212 | | throttled | boolean | 当不需要回放时是否推迟加载块。默认=progressive | 213 | | chunkSize | number | 使用时,以字节为单位加载的块大小。默认(1 mb)1024\*1024 | 214 | | decodeFirstFrame | boolean | 是否解码并显示视频的第一帧,一般用于设置画布大小以及使用初始帧作为"poster"图像。当使用自动播放或流媒体资源时,此参数不受影响。默认 true | 215 | | maxAudioLag | number | 流媒体时,以秒为单位的最大排队音频长度(可以理解为能接受的最大音画不同步时间)。 | 216 | | videoBufferSize | number | 流媒体时,视频解码缓冲区的字节大小。默认的 4 _ 1024 _ 1024 (4MB)。对于非常高的比特率,您可能需要增加此值。 | 217 | | audioBufferSize | number | 流媒体时,音频解码缓冲区的字节大小。默认的 128 \* 1024 (128 kb)。对于非常高的比特率,您可能需要增加此值。 | 218 | 219 | #### 事件 & Emits 220 | 221 | 支持 jsmpeg.js 所有原生事件,并转换为短横线命名法,[jsmpeg 官方文档 - 事件](https://github.com/phoboslab/jsmpeg#usage) 222 | 223 | | 名称 | 原生回调名称 | 参数 | 说明 | 224 | | --- | --- | --- | --- | 225 | | **vue 组件事件** | - | - | - | 226 | | volume-change | - | number | 当音量变化时触发 | 227 | | muted | - | number | 当静音时触发 | 228 | | **原生事件** | | - | - | 229 | | video-decode | [onVideoDecode]() | decoder, time | 视频帧解码事件,当成功解码视频帧时触发 | 230 | | audio-decode | [onAudioDecode]() | decoder, time | 音频帧解码事件,当成功解码音频帧时触发 | 231 | | play | [onPlay]() | player | 播放开始事件 | 232 | | pause | [onPause]() | player | 播放暂停事件 | 233 | | ended | [onEnded]() | player | 播放结束事件 | 234 | | stalled | [onStalled]() | player | 播放停滞事件,当没有足够的数据播放一帧时触发 | 235 | | source-established | [onSourceEstablished]() | source | 源通道建立事件,当 source 第一次收到数据包时触发 | 236 | | source-completed | [onSourceCompleted]() | source | 源播放完成事件,当 source 收到所有数据时触发(即最后一个数据包) | 237 | | **扩展事件** | | - | - | 238 | | source-connected | - | - | 源连接事件(仅 websocket),当 source(websocket)连接上服务端时触发 | 239 | | source-interrupt | - | - | 源传输中断事件(仅 websocket),当 source(websocket)超过一定时间(5s)没有收到流时触发 | 240 | | source-continue | - | - | 源传输恢复/继续事件(仅 websocket),当 onSourceStreamInterrupt 触发后 websocket 第一次接收到流时触发 | 241 | | source-closed | - | - | 源关闭事件(仅 websocket),当 websocket 关闭后触发 | 242 | | resolution-decode | - | decoder, {width, height} | 分辨率解码事件,当获取到视频分辨率时触发发 | 243 | 244 | #### 插槽 & Slot 245 | 246 | | 名称 | 参数 | 说明 | 247 | | --------- | ---- | -------------------------------------------------- | 248 | | title | 无 | 标题插槽,使用此插槽后 title 属性失效 | 249 | | loading | 无 | loading 插槽,可自定义加载效果 | 250 | | no-signal | 无 | 无信号时的插槽,使用此插槽后 noSignalText 属性失效 | 251 | 252 | #### 方法 & Method 253 | 254 | | 名称 | 参数 | 说明 | 255 | | -------------------------------------- | --------------------------------------------------------- | ------------ | 256 | | **原生方法** | | | 257 | | play() | - | 播放 | 258 | | pause() | - | 暂停播放 | 259 | | stop() | - | 停止播放 | 260 | | nextFrame() | - | 下一帧 | 261 | | **扩展方法** | | - | 262 | | rotate(angle: string, append: bollean) | angle:旋转角度,append:是否为追加角度(即当前角度+angle) | 旋转画面 | 263 | | toggleFullscreen() | | 切换全屏模式 | 264 | | snapshot() | | 截图 | 265 | | toggleMute() | | 切换禁音模式 | 266 | | toggleRecording() | | 切换录制模式 | 267 | 268 | ### JSMpegMultipathPlayer - 多路播放器 269 | 270 | - [x] 多路播放 271 | - [x] 支持拖拽交换播放器位置 272 | - [x] 支持从外部拖入播放器 273 | - [x] 支持多种分屏模式 274 | 275 | #### 属性 & Props 276 | 277 | | 名称 | 类型 | 说明 | 278 | | ------------- | -------- | --------------------------------------------------------------- | 279 | | value/v-model | string[] | 播放链接列表 | 280 | | tabindex | number | 起始的 tabindex,给每个播放器加上 tabindex,方便按下 tab 键切换 | 281 | | playerProps | object | 播放器选项,透传给单个播放器 | 282 | 283 | #### 事件 & Emits 284 | 285 | | 名称 | 参数 | 说明 | 286 | | --- | --- | --- | 287 | | player-click | {data:object,intance:Vue,index:number} | 点击播放器时触发 | 288 | | player-noSignal | {data:object,intance:Vue,index:number} | loading 插槽,可自定义加载效果 | 289 | | player-swap | {sourcePlayer:object,sourceIndex:number,targetPlayer:object,targetIndex:number} | 当两个播放器拖拽交换时触发 | 290 | 291 | #### 插槽 & Slot 292 | 293 | | 名称 | 参数 | 说明 | 294 | | ---- | ---- | ---- | 295 | | - | - | - | 296 | 297 | #### 从外部拖入一个播放器 298 | 299 | 要实现此功能,只需要在目标元素的 drag-out 事件中调用 dataTransfer.setData 方法即可 300 | 301 | ```js 302 | function handleDragOut(ev) { 303 | let dt = ev.dataTransfer 304 | ev.dataTransfer.effectAllowed = 'copy' 305 | dt.setData( 306 | 'text/plain', 307 | JSON.stringify({ 308 | id: '', // 非必传 309 | title: '', 310 | url: '' 311 | }) 312 | ) 313 | } 314 | ``` 315 | 316 | ## 功能 & 计划 317 | 318 | - [x] 自动重连 319 | - [x] 接流中断 loading 320 | - [x] 截图 321 | - [x] 录制 322 | - [x] 画面旋转 323 | - [x] 全屏切换 324 | - [x] 全屏切换 325 | - [x] 事件总线 326 | - [x] 多播放器同屏显示 327 | - [ ] 国际化 328 | - [ ] 移除 element-ui 的依赖 329 | - [ ] 画中画显示(原生只支持 video 元素画中画显示,目前还没想到方案) 330 | - [x] 已知的性能问题(在 data 中定义了 player,导致被双向绑定) 331 | - [x] 构建升级到 vite 332 | - [ ] 同时支持 vue2/vue3 333 | 334 | ## 效果演示 335 | 336 | - 无信号时: 337 | 338 | ![在这里插入图片描述](https://img-blog.csdnimg.cn/cae1fbb2d8c74193b834651a767dad02.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5LqR5biGUGxhbg==,size_20,color_FFFFFF,t_70,g_se,x_16) 339 | 340 | - 正常播放: 341 | 342 | ![在这里插入图片描述](https://img-blog.csdnimg.cn/fe266d7592754a1fa174419694e4fd95.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5LqR5biGUGxhbg==,size_20,color_FFFFFF,t_70,g_se,x_16) 343 | 344 | - 旋转画面: 345 | 346 | ![在这里插入图片描述](https://img-blog.csdnimg.cn/692e974957394a79a76f336ee5e82c56.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5LqR5biGUGxhbg==,size_20,color_FFFFFF,t_70,g_se,x_16) 347 | 348 | - 接流中断: 349 | 350 | ![在这里插入图片描述](https://img-blog.csdnimg.cn/0c5a6646236440f4a863654b09f28389.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5LqR5biGUGxhbg==,size_20,color_FFFFFF,t_70,g_se,x_16) 351 | 352 | - 截图测试: 353 | 354 | ![在这里插入图片描述](https://img-blog.csdnimg.cn/cc9ad53dcd3b4dd8bd69dd2b9c2c247f.gif) 355 | 356 | - 录制测试: 357 | 358 | ![在这里插入图片描述](https://img-blog.csdnimg.cn/d95d0987763546fbb4a5a4107b919f50.gif) 359 | 360 | ## 服务端 361 | 362 | 参考[JSMpegServer 文档](./server/README.md) 363 | 364 | 关于 ffmpeg 拉取桌面流见此文章:https://waitwut.info/blog/2013/06/09/desktop-streaming-with-ffmpeg-for-lower-latency/ 365 | 366 | ## 开发/运行 DEMO 367 | 368 | 1. 克隆仓库,`git clone --recursive https://github.com/vCloudSail/jsmpeg-player` 369 | 2. 安装依赖包,运行 cmd: npm i 370 | 3. 启动服务端,运行 cmd: npm run server (如果使用 vscode,建议通过 vscode 启动) 371 | 4. 启动 DEMO 客户端,运行 cmd: npm run dev 372 | 5. 查看 demo 373 | 374 | ![在这里插入图片描述](https://img-blog.csdnimg.cn/f977c4e6f0434e03a0eb8ea287b55e23.png) 375 | 376 | ## 参与贡献 377 | 378 | 1. Fork 本仓库 379 | 2. 新建 Feat_xxx 分支 380 | 3. 提交代码 381 | 4. 新建 Pull Request 382 | -------------------------------------------------------------------------------- /src/multipath-player.vue: -------------------------------------------------------------------------------- 1 | 98 | 99 | 434 | 435 | 571 | -------------------------------------------------------------------------------- /src/player.vue: -------------------------------------------------------------------------------- 1 | 82 | 83 | 673 | 674 | 675 | --------------------------------------------------------------------------------