[+] 视频添加

This commit is contained in:
acgist
2023-02-26 16:44:30 +08:00
parent a0ebe8c842
commit 71ada0a8ca
10 changed files with 437 additions and 86 deletions

View File

@@ -35,7 +35,7 @@
center
width="30%"
title="房间设置"
@open="init"
@open="loadList"
:show-close="false"
v-model="roomVisible"
>
@@ -80,22 +80,27 @@
</el-dialog>
<!-- 菜单 -->
<div class="menu">
<div class="menus">
<el-button type="primary" @click="signalVisible = true">连接信令</el-button>
<el-button type="primary" @click="roomActive = 'enter'; roomVisible = true;">选择房间</el-button>
<el-button type="primary" @click="roomActive = 'create';roomVisible = true;">创建房间</el-button>
<el-button>邀请终端</el-button>
<el-button>退出房间</el-button>
<el-button type="danger">关闭房间</el-button>
</div>
<!-- 终端 -->
<div class="client">
<div class="clients">
<LocalClient ref="local"></LocalClient>
<RemoteClient :ref="'remote-' + kv[0]" v-for="(kv, index) in remoteClients" :key="index"></RemoteClient>
</div>
</template>
<script>
import { ElMessage } from 'element-plus'
import { Taoyao } from "./components/Taoyao.js";
import LocalClient from './components/LocalClient.vue';
import RemoteClient from './components/RemoteClient.vue';
export default {
name: "Taoyao",
@@ -114,7 +119,8 @@ export default {
taoyao: {},
roomActive: "enter",
roomVisible: false,
signalVisible: true,
signalVisible: false,
remoteClients: new Map(),
};
},
mounted() {
@@ -124,7 +130,14 @@ export default {
`);
},
methods: {
async init() {
async connectSignal() {
let self = this;
self.taoyao = new Taoyao({ ...this.config });
self.remoteClients = self.taoyao.remoteClients;
await self.taoyao.connectSignal(self.callback, self.callbackMedia);
self.signalVisible = false;
},
async loadList() {
this.rooms = await this.taoyao.roomList();
this.medias = await this.taoyao.mediaList();
},
@@ -138,12 +151,6 @@ export default {
this.room = room;
await this.enterRoom(room.roomId);
},
async connectSignal() {
let self = this;
self.taoyao = new Taoyao({ ...this.config });
await self.taoyao.connectSignal(self.callback);
self.signalVisible = false;
},
/**
* 信令回调
*
@@ -172,10 +179,37 @@ export default {
message: data.message,
type: "error",
});
break;
return true;
}
return false;
},
/**
* 媒体回调
*/
callbackMedia(type, track, consumer) {
const self = this;
return new Promise((resolve, reject) => {
if(type === 'local') {
self.$refs.local.media(track);
} else {
this.$refs['remote-' + consumer.sourceId][0].media(track, consumer);
}
resolve();
});
},
},
components: {
LocalClient,
RemoteClient
},
};
</script>
<style>
.menus{width:100%;top:1rem;left:0;text-align:center;position:fixed;z-index:1;}
.clients{width:100%;height:100%;top:0;left:0;position:fixed;}
.client{float:left;width:50vw;height:50vh;border:1px solid #eee;}
.client .buttons{width:100%;bottom:1rem;left:0;text-align:center;position:absolute;padding:0.8rem 0;background: rgba(0,0,0,0.6);text-align:center;}
.client audio{display:none;}
.client video{width:100%;height:100%;}
</style>

View File

@@ -1,24 +1,113 @@
<!-- 本地终端 -->
<template></template>
<template>
<div class="client">
<audio ref="audio"></audio>
<video ref="video"></video>
<div class="buttons">
<el-button type="danger" title="打开麦克风" :icon="Mute" circle />
<el-button type="primary" title="关闭麦克风" :icon="Microphone" circle />
<el-button type="danger" title="打开摄像头" :icon="VideoPause" circle />
<el-button type="primary" title="关闭摄像头" :icon="VideoPlay" circle />
<el-button title="交换媒体" :icon="Refresh" circle />
<el-button title="拍照" :icon="Camera" circle />
<el-button title="录像" :icon="VideoCamera" circle />
<el-button title="媒体信息" :icon="InfoFilled" circle />
<el-select placeholder="视频质量">
<el-option
v-for="option in options"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</div>
</div>
</template>
<script>
import { defineComponent } from "@vue/composition-api";
export default defineComponent({
import {
Mute,
Camera,
Refresh,
VideoPlay,
VideoPause,
InfoFilled,
Microphone,
VideoCamera,
CircleClose,
} from "@element-plus/icons";
export default {
name: "LocalClient",
setup() {
// 本地视频
this._externalVideo = document.createElement("video");
this._externalVideo.controls = true;
this._externalVideo.muted = true;
this._externalVideo.loop = true;
this._externalVideo.setAttribute("playsinline", "");
this._externalVideo.src = EXTERNAL_VIDEO_SRC;
// TODO关闭摄像头、视频、音频
this._externalVideo
.play()
.catch((error) => console.warn("externalVideo.play() failed:%o", error));
return {
Mute,
Camera,
Refresh,
VideoPlay,
VideoPause,
InfoFilled,
Microphone,
VideoCamera,
CircleClose,
};
},
});
data() {
return {
taoyao: null,
audio: null,
video: null,
audioStream: null,
videoStream: null,
dataProducer: null,
audioProducer: null,
videoProducer: null,
options: [
{
value: "HD",
label: "高清",
},
{
value: "SD",
label: "标签",
},
{
value: "FD",
label: "超清",
},
{
value: "BD",
label: "蓝光",
},
{
value: "QD",
label: "2K",
},
{
value: "UD",
label: "4K",
},
],
};
},
mounted() {
this.audio = this.$refs.audio;
this.video = this.$refs.video;
},
methods: {
media(track) {
if (track.kind === "video") {
if (this.videoStream) {
// TODO资源释放
} else {
this.videoStream = new MediaStream();
this.videoStream.addTrack(track);
this.video.srcObject = this.videoStream;
}
this.video.play().catch((error) => console.warn("视频播放失败", error));
} else {
console.debug("本地不支持的媒体类型:", track);
}
},
},
};
</script>

View File

@@ -1,14 +1,88 @@
<!-- 远程终端 -->
<template>
<video></video>
<audio></audio>
<div>
<el-button type="primary" title="关闭麦克风" :icon="Edit" circle />
<div class="client">
<audio ref="audio"></audio>
<video ref="video"></video>
<div class="buttons">
<el-button type="danger" title="打开麦克风" :icon="Mute" circle />
<el-button type="primary" title="关闭麦克风" :icon="Microphone" circle />
<el-button type="danger" title="打开摄像头" :icon="VideoPause" circle />
<el-button type="primary" title="关闭摄像头" :icon="VideoPlay" circle />
<el-button title="拍照" :icon="Camera" circle />
<el-button title="录像" :icon="VideoCamera" circle />
<el-button title="媒体信息" :icon="InfoFilled" circle />
<el-button title="踢出" :icon="CircleClose" circle />
</div>
</div>
</template>
<script>
import {
Mute,
Camera,
Refresh,
VideoPlay,
VideoPause,
InfoFilled,
Microphone,
VideoCamera,
CircleClose,
} from "@element-plus/icons";
export default {
name: "RemoteClient",
}
</script>
setup() {
return {
Mute,
Camera,
Refresh,
VideoPlay,
VideoPause,
InfoFilled,
Microphone,
VideoCamera,
CircleClose,
};
},
data() {
return {
taoyao: null,
audio: null,
video: null,
audioStream: null,
videoStream: null,
dataConsumer: null,
audioConsumer: null,
videoConsumer: null,
};
},
mounted() {
this.audio = this.$refs.audio;
this.video = this.$refs.video;
},
methods: {
media(track, consumer) {
if(track.kind === 'audio') {
if (this.audioStream) {
// TODO资源释放
} else {
this.audioStream = new MediaStream();
this.audioStream.addTrack(track);
this.audio.srcObject = this.audioStream;
}
this.audio.play().catch((error) => console.warn("视频播放失败", error));
} else if(track.kind === 'video') {
if (this.videoStream) {
// TODO资源释放
} else {
this.videoStream = new MediaStream();
this.videoStream.addTrack(track);
this.video.srcObject = this.videoStream;
}
this.video.play().catch((error) => console.warn("视频播放失败", error));
} else {
}
}
}
};
</script>

View File

@@ -11,18 +11,14 @@ import {
} from "./Config.js";
// Used for simulcast webcam video.
const WEBCAM_SIMULCAST_ENCODINGS =
[
{ scaleResolutionDownBy: 4, maxBitrate: 500000, scalabilityMode: 'S1T2' },
{ scaleResolutionDownBy: 2, maxBitrate: 1000000, scalabilityMode: 'S1T2' },
{ scaleResolutionDownBy: 1, maxBitrate: 5000000, scalabilityMode: 'S1T2' }
const WEBCAM_SIMULCAST_ENCODINGS = [
{ scaleResolutionDownBy: 4, maxBitrate: 500000, scalabilityMode: "S1T2" },
{ scaleResolutionDownBy: 2, maxBitrate: 1000000, scalabilityMode: "S1T2" },
{ scaleResolutionDownBy: 1, maxBitrate: 5000000, scalabilityMode: "S1T2" },
];
// Used for VP9 webcam video.
const WEBCAM_KSVC_ENCODINGS =
[
{ scalabilityMode: 'S3T3_KEY' }
];
const WEBCAM_KSVC_ENCODINGS = [{ scalabilityMode: "S3T3_KEY" }];
/**
* 信令通道
@@ -291,8 +287,14 @@ const signalChannel = {
case "client::reboot":
self.defaultClientReboot(message);
break;
case "client::shutdown":
self.defaultClientShutdown(message);
case "client::shutdown":
self.defaultClientShutdown(message);
break;
case "room::enter":
self.defaultRoomEnter(message);
break;
case "room::client::list":
self.defaultRoomClientList(message);
break;
case "platform::error":
self.callbackError(message);
@@ -334,6 +336,24 @@ const signalChannel = {
console.info("关闭终端");
window.close();
},
defaultRoomEnter(message) {
const { roomId, clientId } = message.body;
if(clientId === this.taoyao.clientId) {
// 忽略自己
} else {
this.taoyao.remoteClients.set(clientId, roomId);
}
},
defaultRoomClientList(message) {
const self = this;
message.body.forEach(v => {
if(v.clientId === self.taoyao.clientId) {
// 忽略自己
} else {
self.taoyao.remoteClients.set(v.clientId, self.taoyao.roomId);
}
});
},
};
/**
@@ -354,6 +374,8 @@ class Taoyao {
password;
// 回调事件
callback;
// 媒体回调
callbackMedia;
// 音频媒体配置
audio;
// 视频媒体配置
@@ -364,10 +386,6 @@ class Taoyao {
push;
// 请求信令
request;
// 本地终端
localClient;
// 远程终端
remoteClients = new Map();
// 信令通道
signalChannel;
// 发送媒体通道
@@ -381,30 +399,31 @@ class Taoyao {
// 是否生产
produce;
// 视频来源file | camera | screen
videoSource = "screen";
videoSource = "camera";
// 强制使用TCP
forceTcp;
// 强制使用VP9
forceVP9;
// 强制使用H264
forceH264;
//
useSimulcast;
// 是否生产数据
dataProduce;
// 是否生产音频
audioProduce;
// 是否生成视频
videoProduce;
// 强制使用TCP
forceTcp;
// 使用数据通道
useDataChannel;
// 数据生产者
dataProducer;
// 音频生产者
audioProducer;
// 视频生产者
videoProducer;
// 数据生产者
dataChannnelProducer;
// 媒体消费者
// 消费者:音频、视频、数据
consumers = new Map();
// 数据消费者
dataConsumers = new Map();
// 远程终端
remoteClients = new Map();
constructor({
roomId,
@@ -418,7 +437,7 @@ class Taoyao {
audioProduce = true,
videoProduce = true,
forceTcp = false,
useDataChannel = true,
dataProduce = true,
}) {
this.roomId = roomId;
this.clientId = clientId;
@@ -428,22 +447,24 @@ class Taoyao {
this.password = password;
this.consume = consume;
this.produce = produce;
this.dataProduce = produce && dataProduce;
this.audioProduce = produce && audioProduce;
this.videoProduce = produce && videoProduce;
this.forceTcp = forceTcp;
this.useDataChannel = useDataChannel;
}
/**
* 连接信令
*
* @param {*} callback
* @param {*} callback 信令回调
* @param {*} callbackMedia 媒体回调
*
* @returns
*/
async connectSignal(callback) {
async connectSignal(callback, callbackMedia) {
const self = this;
self.callback = callback;
self.callbackMedia = callbackMedia;
self.signalChannel = signalChannel;
signalChannel.taoyao = self;
// 不能直接this.push = this.signalChannel.push这样导致this对象错误
@@ -469,6 +490,7 @@ class Taoyao {
} else {
console.warn("没有注册回调:", message);
}
return;
}
// 错误回调
const errorMessage = protocol.buildMessage(
@@ -490,6 +512,12 @@ class Taoyao {
);
return response.body;
}
async clientList() {
const response = await this.request(
protocol.buildMessage("client::list", { roomId: self.roomId })
);
return response.body;
}
/**
* 创建房间
*/
@@ -526,7 +554,7 @@ class Taoyao {
? self.mediasoupDevice.rtpCapabilities
: undefined,
sctpCapabilities:
self.consume && self.useDataChannel
self.consume && self.dataProduce
? self.mediasoupDevice.sctpCapabilities
: undefined,
})
@@ -564,7 +592,7 @@ class Taoyao {
forceTcp: self.forceTcp,
producing: true,
consuming: false,
sctpCapabilities: self.useDataChannel
sctpCapabilities: self.dataProduce
? self.mediasoupDevice.sctpCapabilities
: undefined,
})
@@ -661,7 +689,7 @@ class Taoyao {
forceTcp: self.forceTcp,
producing: false,
consuming: true,
sctpCapabilities: self.useDataChannel
sctpCapabilities: self.dataProduce
? self.mediasoupDevice.sctpCapabilities
: undefined,
})
@@ -843,20 +871,24 @@ class Taoyao {
track = stream.getVideoTracks()[0];
} else if (self.videoSource === "screen") {
const stream = await navigator.mediaDevices.getDisplayMedia({
// 如果需要共享声音
audio: false,
video: {
displaySurface: "monitor",
logicalSurface: true,
cursor: true,
width: { max: 1920 },
height: { max: 1080 },
frameRate: { max: 30 },
logicalSurface: true,
displaySurface: "monitor",
},
});
track = stream.getVideoTracks()[0];
} else {
// TODO异常
}
self.callbackMedia("local", track);
let codec;
let encodings;
const codecOptions = {
@@ -904,7 +936,7 @@ class Taoyao {
// if (this._e2eKey && e2e.isSupported()) {
// e2e.setupSenderTransform(this.videoProducer.rtpSender);
// }
this.videoProducer.on("transportclose", () => {
this.videoProducer = null;
});
@@ -924,6 +956,69 @@ class Taoyao {
}
}
async closeVideoProducer() {
console.debug("disableWebcam()");
if (!this.videoProducer) {
return;
}
this.videoProducer.close();
try {
await this.request(
protocol.buildMessage("media::producer::close", {
producerId: this.videoProducer.id,
})
);
} catch (error) {
console.error(error);
}
this._webcamProducer = null;
}
async pauseVideoProducer() {
console.debug("关闭摄像头");
this.videoProducer.pause();
try {
await this.request(
protocol.buildMessage("media::producer::pause", {
producerId: this.videoProducer.id,
})
);
} catch (error) {
console.error("关闭摄像头异常", error);
// TODO异常调用回调
}
}
async resumeVideoProducer() {
console.debug("恢复摄像头");
this.videoProducer.resume();
try {
await this.request(
protocol.buildMessage("media::producer::resume", {
producerId: this.videoProducer.id,
})
);
} catch (error) {
console.error("恢复摄像头异常", error);
}
}
async updateVideoConfig(config) {
console.debug("更新摄像头参数");
try {
this.videoProducer.track.stop();
// TODOscreen、参数配置
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
});
const track = stream.getVideoTracks()[0];
await this.videoProducer.replaceTrack({ track });
} catch (error) {
console.error("changeWebcam() | failed: %o", error);
}
}
/**
* 消费媒体
*
@@ -941,6 +1036,7 @@ class Taoyao {
type,
roomId,
clientId,
sourceId,
streamId,
producerId,
consumerId,
@@ -962,6 +1058,7 @@ class Taoyao {
appData, // Trick.
});
consumer.clientId = clientId;
consumer.sourceId = sourceId;
consumer.streamId = streamId;
self.consumers.set(consumer.id, consumer);
consumer.on("transportclose", () => {
@@ -991,18 +1088,10 @@ class Taoyao {
// )
// );
self.push(message);
console.log(consumer);
const audioElem = document.createElement("video");
document.getElementsByTagName("body")[0].appendChild(audioElem);
const stream = new MediaStream();
stream.addTrack(consumer.track);
audioElem.srcObject = stream;
audioElem
.play()
.catch((error) => console.warn("audioElem.play() failed:%o", error));
console.log("消费者", consumer);
self.callbackMedia("remote", consumer.track, consumer);
// If audio-only mode is enabled, pause it.
if (consumer.kind === "video" && !self.videoProduce) {
// this.pauseConsumer(consumer);
@@ -1013,6 +1102,26 @@ class Taoyao {
}
}
async pauseConsumer(consumer) {
if (consumer.paused) return;
try {
await this._protoo.request("pauseConsumer", { consumerId: consumer.id });
consumer.pause();
} catch (error) {
logger.error("_pauseConsumer() | failed:%o", error);
}
}
async resumeConsumer(consumer) {
if (!consumer.paused) return;
try {
await this._protoo.request("resumeConsumer", { consumerId: consumer.id });
consumer.resume();
} catch (error) {
logger.error("_resumeConsumer() | failed:%o", error);
}
}
/**
* 验证设备
*/