原理:基于websocket+mediaRecorder+mediaSource将录制的极短直播视频片段通过websocket实时推送变相完成推流。
1.直播推流端
<template>
<el-container class="palyer-main">
<el-header height="35px">
<span style="float: left">直播设置</span>
<span>xxx直播间(已播 :03:35:12)</span>
<span style="float: right">聊天室</span>
</el-header>
<el-container>
<el-aside width="250px" class="palyer-main-left">
<div class="live-item">
<el-select v-model="videoSourceVal" placeholder="摄像头选择" style="width: 130px">
<el-option
v-for="(item,index) in videoSourceOption"
:key="index"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-switch
v-model="videoActive"
active-text="开"
inactive-text="关"
@change="videoActiveChange"
/>
</div>
<div class="live-item">
<el-select v-model="audioInputVal" placeholder="麦克风选择" style="width: 130px">
<el-option
v-for="(item,index) in audioInputOption"
:key="index"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-switch
v-model="audioInputActive"
active-text="开"
inactive-text="关"
@change="audioInputActiveChange"
/>
</div>
<div class="live-item">
<i class="el-icon-video-camera" />
<span>屏幕录制</span>
<el-switch
v-model="screenRecoderActive"
class="live-item-switch"
active-text="开"
inactive-text="关"
@change="screenRecoderChange"
/>
</div>
<el-button v-if="liveBtn" type="success" size="medium" icon="el-icon-mic" class="live-btn" @click="startRecord">开始直播</el-button>
<el-button v-else type="success" size="medium" icon="el-icon-mic" class="live-btn" @click="stopRecord">结束直播</el-button>
</el-aside>
<el-main class="palyer-main-video">
<el-main :style="{height:tableHeight + 'px'}">
<video ref="canvsVideo" autoplay controls width="557px" height="412px" />
</el-main>
<el-footer height="120px" />
</el-main>
<el-aside width="290px" class="palyer-main-right">
<div style="padding: 15px 25px;height: 82px;">
<el-avatar size="large" :src="circleUrl" class="chat-room-user" />
<div class="chat-room-info">
<div>陈晓二</div>
<div>
<i class="el-icon-view" title="当前在线人数" /> <span>19</span>
</div>
</div>
</div>
<el-tabs v-model="activeName" stretch @tab-click="handleClick">
<el-tab-pane label="聊天" name="first">
<chatroom />
</el-tab-pane>
<el-tab-pane label="在线(26)" name="second">
<userlist />
</el-tab-pane>
<el-tab-pane label="问答" name="third">
<answers />
</el-tab-pane>
</el-tabs>
</el-aside>
</el-container>
<video ref="localVideo" autoplay style="display: none" />
<video ref="screenVideo" autoplay style="display: none" />
<canvas ref="canvs" style="display: none" />
</el-container>
</template>
<script>
import { mapGetters } from 'vuex'
import chatroom from '../module/chatroom'
import answers from '../module/answers'
import userlist from '../module/userlist'
export default {
name: 'LivePalyer',
components: {
chatroom, answers, userlist
},
data() {
return {
SCREEN_WIDTH: 1024,
SCREEN_HEIGHT: 640,
CAMERA_VIDEO_WIDTH: 200,
CAMERA_VIDEO_HEIGHT: 150,
screenHeight: 0,
videoActive: false,
videoSourceVal: '',
videoSourceOption: [],
audioInputActive: false,
audioInputVal: '',
audioInputOption: [],
audioOutActive: false,
audioOutputVal: '',
audioOutputOption: [],
screenRecoderActive: false,
circleUrl: 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png',
activeName: 'first',
liveBtn: true,
roomid: '1111',
fileHeader: []
}
},
computed: {
...mapGetters([
'sysHospital',
'userId',
'username'
]),
tableHeight: function() {
return window.screen.height - 352
}
},
created() {
if (this.$socket.disconnected) {
this.$socket.connect()
}
this.getDevices()
this.getUserMedia()
},
mounted() {
this.canvs = this.$refs.canvs
this.canvs.width = this.SCREEN_WIDTH
this.canvs.height = this.SCREEN_HEIGHT
this.canvsVideo = this.$refs.canvsVideo
this.localVideo = this.$refs.localVideo
this.screenVideo = this.$refs.screenVideo
this._context2d = this.canvs.getContext('2d')
setTimeout(this._animationFrameHandler.bind(this), 30)
},
methods: {
RecordLoop() {
if (this.mediaRecorder.state !== 'inactive') {
this.mediaRecorder.stop()
}
if (this.mediaRecorder.state !== 'recording') {
this.mediaRecorder.start()
}
setTimeout(this.RecordLoop, 2500)
},
// 拿到媒体流
startRecord() {
this.liveBtn = false
console.log(`start Record`)
const _this = this
// 1. Create a `MediaSource`
this.mediaSource = new MediaSource()
// 2. Create an object URL from the `MediaSource`
var url = URL.createObjectURL(this.mediaSource)
// 3. Set the video's `src` to the object URL
this.canvsVideo.src = url
this.sourceBuffer = null
this.mediaSource.addEventListener('sourceopen', async function() {
URL.revokeObjectURL(_this.localVideo.src)
_this.sourceBuffer = _this.mediaSource.addSourceBuffer('video/webm; codecs=opus,vp9')
_this.sourceBuffer.mode = 'sequence'// 不然需要添加时间戳
_this.sourceBuffer.addEventListener('updateend', function() {
})
})
this.RecordLoop()
},
stopRecord() {
this.liveBtn = true
this.canvsVideo.srcObject = null
this.mediaRecorder.stop()
URL.revokeObjectURL(this.canvsVideo.src)
},
async getUserMedia() {
const _this = this
this._stream = new MediaStream()
if (this.videoActive) {
// 视频
if (navigator.mediaDevices.getUserMedia) {
// 最新标准API
this.cameraStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false })
} else if (navigator.webkitGetUserMedia) {
// webkit内核浏览器
this.cameraStream = await navigator.webkitGetUserMedia({ video: true, audio: false })
} else if (navigator.mozGetUserMedia) {
// Firefox浏览器
this.cameraStream = await navigator.mozGetUserMedia({ video: true, audio: false })
} else if (navigator.getUserMedia) {
// 旧版API
this.cameraStream = await navigator.getUserMedia({ video: true, audio: false })
}
this.localVideo.srcObject = this.cameraStream
}
if (this.screenRecoderActive) {
this.captureStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false })
this.screenVideo.srcObject = this.captureStream
}
this._audioStream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true })
if (this.audioInputActive) {
this._audioStream.getAudioTracks().forEach(value => this._stream.addTrack(value))
}
this._audioStream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true })
this._audioStream.getAudioTracks().forEach(value => this._stream.addTrack(value))
const playerCanvasStream = this.canvs.captureStream()
playerCanvasStream.getTracks().forEach(t => this._stream.addTrack(t))
const options = {
mimeType: 'video/webm; codecs=opus,vp9'
}
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
this.$message.error(`${options.mimeType} is not supported!`)
return
}
this.mediaRecorder = new MediaRecorder(this._stream, options)
// 当录制的数据可用时
this.mediaRecorder.ondataavailable = async function(e) {
if (e.data.size > 0) {
var reader = new FileReader()
reader.readAsArrayBuffer(e.data)
reader.onloadend = function(e) {
_this.sourceBuffer.appendBuffer(reader.result)
// if (_this.joinRoomState === 'joinedRoom') {
_this.$socket.emit('sendLiveMessage', _this.roomid, reader.result)
// }
}
}
}
},
getDevices() {
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
this.$message.error('不支持获取设备信息!')
} else {
navigator.mediaDevices.enumerateDevices()
.then(this.showDevice)
.catch((err) => {
console.log(err.name + ':' + err.message)
})
}
},
showDevice(deviceInfos) {
const _this = this
deviceInfos.forEach(function(deviceinfo) {
const option = {
label: deviceinfo.label,
value: deviceinfo.deviceId
}
if (deviceinfo.kind === 'audioinput') {
_this.audioInputOption.push(option)
} else if (deviceinfo.kind === 'audiooutput') {
_this.audioOutputOption.push(option)
} else if (deviceinfo.kind === 'videoinput') {
_this.videoSourceOption.push(option)
}
})
},
async recordScreenStream() {
if (!this.screenRecoderActive) {
return
}
this.captureStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false })
this.screenVideo.srcObject = this.captureStream
},
async recordCameraStream() {
if (this.videoActive) {
// 视频
if (navigator.mediaDevices.getUserMedia) {
// 最新标准API
this.cameraStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false })
} else if (navigator.webkitGetUserMedia) {
// webkit内核浏览器
this.cameraStream = await navigator.webkitGetUserMedia({ video: true, audio: false })
} else if (navigator.mozGetUserMedia) {
// Firefox浏览器
this.cameraStream = await navigator.mozGetUserMedia({ video: true, audio: false })
} else if (navigator.getUserMedia) {
// 旧版API
this.cameraStream = await navigator.getUserMedia({ video: true, audio: false })
}
this.localVideo.srcObject = this.cameraStream
}
},
async recordAudioStream() {
if (this.audioInputActive) {
this._audioStream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true })
this._audioStream.getAudioTracks().forEach(value => this._stream.addTrack(value))
}
},
handleClick(tab, event) {
console.log(tab, event)
},
_animationFrameHandler() {
if (this.screenVideo && this.screenRecoderActive) {
this._context2d.drawImage(this.screenVideo, 0, 0, this.SCREEN_WIDTH, this.SCREEN_HEIGHT)
}
if (this.localVideo && this.videoActive) {
if (this.screenRecoderActive) {
this._context2d.drawImage(
this.localVideo,
this.SCREEN_WIDTH - this.CAMERA_VIDEO_WIDTH,
this.SCREEN_HEIGHT - this.CAMERA_VIDEO_HEIGHT,
this.CAMERA_VIDEO_WIDTH,
this.CAMERA_VIDEO_HEIGHT
)
} else {
this._context2d.drawImage(
this.localVideo, 0, 0, this.SCREEN_WIDTH, this.SCREEN_HEIGHT
)
}
}
setTimeout(this._animationFrameHandler.bind(this), 30)
},
screenRecoderChange(callback) {
this.screenRecoderActive = callback
if (this.screenRecoderActive) {
this.recordScreenStream()
}
},
videoActiveChange(callback) {
this.videoActive = callback
if (this.videoActive) {
this.recordCameraStream()
}
},
audioInputActiveChange(callback) {
this.audioInputActive = callback
if (this.audioInputActive) {
this.recordAudioStream()
}
}
// audioOutActiveChange() {
// this.audioOutActive = !this.audioInputActive
// }
},
sockets: {
/**
* socket自带3个事件connect,disconnect,reconnect
* **/
connect: function() {
// 与socket.io连接后回调
console.log('socket connected')
this.$socket.emit('joinRoom', this.roomid)
},
disconnect: function() {
console.log('socket disconnect')
},
reconnect: function() {
console.log('socket reconnect')
},
joinedRoom: function(data) {
// 加入成功后不能再次连接,可以离开
this.connectFlag = true
this.leaveFlag = false
// 改变状态
this.joinRoomState = 'joinedRoom'
console.log('joinedRoom')
},
otherJoin: function(data) {
console.log('otherJoin' + data.room)
if (this.joinRoomState === 'joinedRoom') {
const msg = `${data.username}加入会议室(${data.room}),当前有${data.numUsers}人`
this.$message.success(msg)
}
},
roomFull: function(data) {
this.joinRoomState = 'leavedRoom'
// 可以再次连接
this.connectFlag = false
this.leaveFlag = true
this.$message.error('房间人数已满,请稍后重试!')
this.$socket.disconnect()
console.log('leavedRoom')
},
leaveRoomed: function(data) {
this.connectFlag = false
this.leaveFlag = true
this.joinRoomState = 'leavedRoom'
this.$socket.disconnect()
console.log('leavedRoom')
},
sayBye: function(data) {
this.connectFlag = false
this.leaveFlag = true
this.joinRoomState = 'leavedRoom'
this.$socket.disconnect()
console.log('leavedRoom')
},
receivedMessage: function(msg) {
}
}
}
</script>
<style scoped>
.palyer-main{
padding: 10px 10px;
}
.palyer-main-left{
border: 1px solid #e5e4e4;
margin-right: 10px;
border-radius: 10px;
}
.palyer-main-right{
border: 1px solid #e5e4e4;
margin-left: 10px;
border-radius: 10px;
}
.palyer-main-video{
padding: 10px;
border: 1px solid #e5e4e4;
}
.el-header {
padding: 0 126px 0 95px;
color: #333;
text-align: center;
line-height: 35px;
}
.el-footer {
color: #333;
text-align: center;
}
.el-aside {
background: #fff;
color: #333;
text-align: center;
margin-bottom: 0;
padding: 8px 5px;
}
.live-item{
padding-top: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #eee
}
.live-item-switch{
float: right;
padding-top: 10px;
padding-right: 8px;
}
.live-btn{
position: absolute;
left: 0;
bottom: 0;
margin-left: 15px;
padding: 10px 80px;
}
.chat-room-info{
float: left;
padding-left: 10px;
color: #aea9a9;
}
.chat-room-user{
position: relative;
float: left;
}
.el-avatar--large{
width: 60px;
height: 60px;
}
.el-main {
padding: 0;
color: #333;
text-align: center;
}
body > .el-container {
margin-bottom: 40px;
}
.el-container:nth-child(5) .el-aside,
.el-container:nth-child(6) .el-aside {
}
.el-container:nth-child(7) .el-aside {
}
</style>
2.直播播放端
<template>
<el-container class="palyer-main">
<el-header height="35px">
<span style="padding-right: 200px">xxx直播间(已播 :03:35:12)</span>
<span style="float: right">聊天室</span>
</el-header>
<el-container>
<el-main class="palyer-main-video" style="margin: 0">
<el-main :style="{height:tableHeight + 'px'}">
<video ref="localVideo" autoplay controls width="725px" height="460px" />
</el-main>
<el-footer height="120px" />
</el-main>
<el-aside width="352px" class="palyer-main-right">
<div style="padding: 15px 25px;height: 82px;">
<el-avatar size="large" :src="circleUrl" class="chat-room-user" />
<div class="chat-room-info">
<div>陈晓二</div>
<div>
<i class="el-icon-view" title="当前在线人数" /> <span>19</span>
</div>
</div>
</div>
<el-tabs v-model="activeName" stretch @tab-click="handleClick">
<el-tab-pane label="聊天" name="first">
<chatroom />
</el-tab-pane>
<el-tab-pane label="在线(26)" name="second">
<userlist />
</el-tab-pane>
<el-tab-pane label="问答" name="third">
<answers />
</el-tab-pane>
</el-tabs>
</el-aside>
</el-container>
</el-container>
</template>
<script>
import { mapGetters } from 'vuex'
import chatroom from '../module/chatroom'
import answers from '../module/answers'
import userlist from '../module/userlist'
export default {
name: 'VideoPlayer',
components: {
chatroom, answers, userlist
},
data() {
return {
screenHeight: 0,
circleUrl: 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png',
activeName: 'first',
roomid: '1111',
palysState: false,
arrayOfBlobs: []
}
},
computed: {
...mapGetters([
'sysHospital',
'userId',
'username'
]),
tableHeight: function() {
return window.screen.height - 302
}
},
created() {
this.getUserMedia()
if (this.$socket.disconnected) {
this.$socket.connect()
}
},
mounted() {
this.localVideo = this.$refs.localVideo
},
methods: {
async getUserMedia() {
await navigator.mediaDevices.enumerateDevices
this.mediaSource = new MediaSource()
const url = URL.createObjectURL(this.mediaSource)
this.localVideo.src = url
this.sourceBuffer = null
const _this = this
this.mediaSource.addEventListener('sourceopen', function() {
URL.revokeObjectURL(_this.localVideo.src)
_this.sourceBuffer = _this.mediaSource.addSourceBuffer('video/webm; codecs=opus,vp9')
_this.sourceBuffer.mode = 'sequence'// 不然需要添加时间戳
_this.sourceBuffer.addEventListener('updateend', function() {
})
})
},
handleClick(tab, event) {
console.log(tab, event)
}
},
sockets: {
/**
* socket自带3个事件connect,disconnect,reconnect
* **/
connect: function() {
// 与socket.io连接后回调
console.log('socket connected')
this.$socket.emit('joinRoom', this.roomid)
},
disconnect: function() {
console.log('socket disconnect')
},
reconnect: function() {
console.log('socket reconnect')
},
joinedRoom: function(data) {
// 加入成功后不能再次连接,可以离开
this.connectFlag = true
this.leaveFlag = false
// 改变状态
this.joinRoomState = 'joinedRoom'
},
otherJoin: function(data) {
if (this.joinRoomState === 'joinedRoom') {
const msg = `${data.username}加入会议室(${data.room}),当前有${data.numUsers}人`
this.$message.success(msg)
}
},
roomFull: function(data) {
this.joinRoomState = 'leavedRoom'
// 可以再次连接
this.connectFlag = false
this.leaveFlag = true
this.$message.error('房间人数已满,请稍后重试!')
this.$socket.disconnect()
},
leaveRoomed: function(data) {
this.connectFlag = false
this.leaveFlag = true
this.joinRoomState = 'leavedRoom'
this.$socket.disconnect()
},
sayBye: function(data) {
this.connectFlag = false
this.leaveFlag = true
this.joinRoomState = 'leavedRoom'
this.$socket.disconnect()
},
receivedMessage: function(msg) {
if (this.sourceBuffer && this.sourceBuffer.updating === false) {
this.sourceBuffer.appendBuffer(msg[1])
}
if (this.localVideo.buffered.length && this.localVideo.buffered.end(0) - this.localVideo.buffered.start(0) > 30
) {
this.sourceBuffer.remove(0, this.localVideo.buffered.end(0) - 30)
}
}
}
}
</script>
<style scoped>
.palyer-main{
padding: 10px 10px;
}
.palyer-main-left{
border: 1px solid #e5e4e4;
margin-right: 10px;
border-radius: 10px;
}
.palyer-main-right{
border: 1px solid #e5e4e4;
margin-left: 10px;
border-radius: 10px;
}
.palyer-main-video{
padding: 10px;
border: 1px solid #e5e4e4;
}
.el-header {
padding: 0 126px 0 95px;
color: #333;
text-align: center;
line-height: 35px;
}
.el-footer {
color: #333;
text-align: center;
}
.el-aside {
background: #fff;
color: #333;
text-align: center;
margin-bottom: 0;
padding: 8px 5px;
}
.live-item{
padding-top: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #eee
}
.live-item-switch{
float: right;
padding-top: 10px;
padding-right: 8px;
}
.live-btn{
position: absolute;
left: 0;
bottom: 0;
margin-left: 15px;
padding: 10px 80px;
}
.chat-room-info{
float: left;
padding-left: 10px;
color: #aea9a9;
}
.chat-room-user{
position: relative;
float: left;
}
.el-avatar--large{
width: 60px;
height: 60px;
}
.el-main {
padding: 0;
color: #333;
text-align: center;
}
body > .el-container {
margin-bottom: 40px;
}
.el-container:nth-child(5) .el-aside,
.el-container:nth-child(6) .el-aside {
}
.el-container:nth-child(7) .el-aside {
}
</style>