【m】删除无用代码
@@ -1,54 +0,0 @@
|
||||
div#select, div#resolution {
|
||||
margin: 1em;
|
||||
}
|
||||
|
||||
button {
|
||||
margin: 0 20px 5px 0;
|
||||
vertical-align: top;
|
||||
width: 155px;
|
||||
}
|
||||
|
||||
div#buttons {
|
||||
border-top: 1px solid #eee;
|
||||
border-bottom: 1px solid #eee;
|
||||
margin: 1em 0 1em 0;
|
||||
padding: 1em 0 1em 0;
|
||||
}
|
||||
|
||||
div#local {
|
||||
margin: 0 20px 0 0;
|
||||
}
|
||||
|
||||
div#preview {
|
||||
border-bottom: 1px solid #eee;
|
||||
margin: 0 0 1em 0;
|
||||
padding: 0 0 0.5em 0;
|
||||
}
|
||||
|
||||
div#preview>div {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: calc(50% - 20px);
|
||||
}
|
||||
|
||||
div#connectionId {
|
||||
margin: 1em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 0.5em 0;
|
||||
}
|
||||
|
||||
textarea {
|
||||
color: #444;
|
||||
font-size: 0.9em;
|
||||
font-weight: 300;
|
||||
width: calc(20% - 10px);
|
||||
height: 1.3em;
|
||||
line-height: 1.3;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
video {
|
||||
height: 225px;
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<link rel="icon" href="../images/favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="../css/main.css" />
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
<title>Bidirectional Sample</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="container">
|
||||
<h1>Bidirectional Sample</h1>
|
||||
|
||||
<div id="warning" hidden=true></div>
|
||||
|
||||
<div id="select">
|
||||
<label for="videoSource">Video source: </label>
|
||||
<select id="videoSource" autocomplete="off"></select>
|
||||
<label for="audioSource">Audio source: </label>
|
||||
<select id="audioSource" autocomplete="off"></select>
|
||||
</div>
|
||||
|
||||
<div id="resolutionSelect">
|
||||
<label for="videoResolution">Video resolution: </label><select id="videoResolution" autocomplete="off"></select>
|
||||
</div>
|
||||
<div id="resolutionInput">
|
||||
<label for="cameraWidth">Camera width:</label><input id="cameraWidth" type="number" min="0" max="4096" autocomplete="off" disabled>
|
||||
<label for="cameraHeight">Camera height:</label><input id="cameraHeight" type="number" min="0" max="4096" autocomplete="off" disabled>
|
||||
</div>
|
||||
|
||||
<div id="buttons">
|
||||
<button type="button" id="startVideoButton" autocomplete="off">Start Video</button>
|
||||
<button type="button" id="setUpButton" autocomplete="off" disabled>Set Up</button>
|
||||
<button type="button" id="hangUpButton" autocomplete="off" disabled>Hang Up</button>
|
||||
</div>
|
||||
|
||||
<div id="preview">
|
||||
<div id="local">
|
||||
<h2>Local</h2>
|
||||
<video id="localVideo" playsinline autoplay muted=true></video>
|
||||
<div id="localVideoStats"></div>
|
||||
</div>
|
||||
<div id="remote">
|
||||
<h2>Remote</h2>
|
||||
<video id="remoteVideo" playsinline autoplay></video>
|
||||
<div id="remoteVideoStats"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<span>Connection ID:</span>
|
||||
<textarea id="textForConnectionId"></textarea>
|
||||
</div>
|
||||
<div class="box">
|
||||
<span>Codec preferences:</span>
|
||||
<select id="codecPreferences" autocomplete="off" disabled>
|
||||
<option selected value="">Default</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p>For more information about <code>Bidirectional</code> sample, see <a
|
||||
href="https://docs.unity3d.com/Packages/com.unity.renderstreaming@3.1/manual/sample-bidirectional.html">Bidirectional
|
||||
sample</a> document page.</p>
|
||||
|
||||
<div id="message"></div>
|
||||
|
||||
<section>
|
||||
<a href="https://github.com/Unity-Technologies/UnityRenderStreaming/tree/develop/WebApp/public/bidirectional"
|
||||
title="View source for this page on GitHub" id="viewSource">View source on GitHub</a>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
<script type="text/javascript" src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
|
||||
<script src="https://unpkg.com/event-target@latest/min.js"></script>
|
||||
<script src="https://unpkg.com/resize-observer-polyfill@1.5.0/dist/ResizeObserver.global.js"></script>
|
||||
<script type="module" src="js/main.js"></script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,382 +0,0 @@
|
||||
/**
|
||||
* 双向视频通话应用主文件
|
||||
* 负责初始化视频设备、建立WebRTC连接、处理信令和显示视频流
|
||||
*/
|
||||
|
||||
// 导入必要的模块
|
||||
import { SendVideo } from "./sendvideo.js"; // 视频发送和接收处理
|
||||
import { getServerConfig, getRTCConfiguration } from "../../js/config.js"; // 服务器配置和RTC配置
|
||||
import { createDisplayStringArray } from "../../js/stats.js"; // 统计信息处理
|
||||
import { RenderStreaming } from "../../module/renderstreaming.js"; // WebRTC连接管理
|
||||
import { Signaling, WebSocketSignaling } from "../../module/signaling.js"; // 信令管理
|
||||
|
||||
// 默认视频流尺寸
|
||||
const defaultStreamWidth = 1280;
|
||||
const defaultStreamHeight = 720;
|
||||
|
||||
// 预定义的视频分辨率列表
|
||||
const streamSizeList =
|
||||
[
|
||||
{ width: 640, height: 360 }, // 标清
|
||||
{ width: 1280, height: 720 }, // 高清
|
||||
{ width: 1920, height: 1080 }, // 全高清
|
||||
{ width: 2560, height: 1440 }, // 2K
|
||||
{ width: 3840, height: 2160 }, // 4K
|
||||
{ width: 360, height: 640 }, // 竖屏标清
|
||||
{ width: 720, height: 1280 }, // 竖屏高清
|
||||
{ width: 1080, height: 1920 }, // 竖屏全高清
|
||||
{ width: 1440, height: 2560 }, // 竖屏2K
|
||||
{ width: 2160, height: 3840 }, // 竖屏4K
|
||||
];
|
||||
|
||||
// DOM元素引用
|
||||
const localVideo = document.getElementById('localVideo'); // 本地视频元素
|
||||
const remoteVideo = document.getElementById('remoteVideo'); // 远程视频元素
|
||||
const localVideoStatsDiv = document.getElementById('localVideoStats'); // 本地视频统计信息
|
||||
const remoteVideoStatsDiv = document.getElementById('remoteVideoStats'); // 远程视频统计信息
|
||||
const textForConnectionId = document.getElementById('textForConnectionId'); // 连接ID输入框
|
||||
textForConnectionId.value = getRandom(); // 生成随机连接ID
|
||||
const videoSelect = document.querySelector('select#videoSource'); // 视频设备选择
|
||||
const audioSelect = document.querySelector('select#audioSource'); // 音频设备选择
|
||||
const videoResolutionSelect = document.querySelector('select#videoResolution'); // 视频分辨率选择
|
||||
const cameraWidthInput = document.querySelector('input#cameraWidth'); // 自定义宽度输入
|
||||
const cameraHeightInput = document.querySelector('input#cameraHeight'); // 自定义高度输入
|
||||
|
||||
// 编解码器偏好设置
|
||||
const codecPreferences = document.getElementById('codecPreferences');
|
||||
// 检查浏览器是否支持设置编解码器偏好
|
||||
const supportsSetCodecPreferences = window.RTCRtpTransceiver &&
|
||||
'setCodecPreferences' in window.RTCRtpTransceiver.prototype;
|
||||
const messageDiv = document.getElementById('message'); // 消息显示区域
|
||||
messageDiv.style.display = 'none'; // 初始隐藏消息区域
|
||||
|
||||
let useCustomResolution = false; // 是否使用自定义分辨率
|
||||
|
||||
// 初始化输入选择和编解码器选择
|
||||
setUpInputSelect();
|
||||
showCodecSelect();
|
||||
|
||||
/** @type {SendVideo} */
|
||||
let sendVideo = new SendVideo(localVideo, remoteVideo); // 视频处理实例
|
||||
/** @type {RenderStreaming} */
|
||||
let renderstreaming; // WebRTC连接管理实例
|
||||
let useWebSocket; // 是否使用WebSocket信令
|
||||
let connectionId; // 连接ID
|
||||
|
||||
// 按钮事件绑定
|
||||
const startButton = document.getElementById('startVideoButton');
|
||||
startButton.addEventListener('click', startVideo); // 启动视频按钮
|
||||
const setupButton = document.getElementById('setUpButton');
|
||||
setupButton.addEventListener('click', setUp); // 设置连接按钮
|
||||
const hangUpButton = document.getElementById('hangUpButton');
|
||||
hangUpButton.addEventListener('click', hangUp); // 挂断按钮
|
||||
|
||||
// 页面卸载前清理
|
||||
window.addEventListener('beforeunload', async () => {
|
||||
if(!renderstreaming)
|
||||
return;
|
||||
await renderstreaming.stop(); // 停止WebRTC连接
|
||||
}, true);
|
||||
|
||||
// 初始化配置
|
||||
setupConfig();
|
||||
|
||||
/**
|
||||
* 初始化服务器配置
|
||||
* @async
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function setupConfig() {
|
||||
const res = await getServerConfig(); // 获取服务器配置
|
||||
useWebSocket = res.useWebSocket; // 设置是否使用WebSocket
|
||||
showWarningIfNeeded(res.startupMode); // 显示启动模式警告
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据启动模式显示警告信息
|
||||
* @param {string} startupMode - 启动模式,可能的值包括"public"和"private"
|
||||
*/
|
||||
function showWarningIfNeeded(startupMode) {
|
||||
const warningDiv = document.getElementById("warning");
|
||||
if (startupMode == "public") {
|
||||
warningDiv.innerHTML = "<h4>Warning</h4> This sample is not working on Public Mode.";
|
||||
warningDiv.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动本地视频
|
||||
* @async
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function startVideo() {
|
||||
// 禁用相关输入控件
|
||||
videoSelect.disabled = true;
|
||||
audioSelect.disabled = true;
|
||||
videoResolutionSelect.disabled = true;
|
||||
cameraWidthInput.disabled = true;
|
||||
cameraHeightInput.disabled = true;
|
||||
startButton.disabled = true;
|
||||
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
|
||||
// 根据选择的分辨率设置视频尺寸
|
||||
if (useCustomResolution) {
|
||||
width = cameraWidthInput.value ? cameraWidthInput.value : defaultStreamWidth;
|
||||
height = cameraHeightInput.value ? cameraHeightInput.value : defaultStreamHeight;
|
||||
} else {
|
||||
const size = streamSizeList[videoResolutionSelect.value];
|
||||
width = size.width;
|
||||
height = size.height;
|
||||
}
|
||||
|
||||
// 启动本地视频
|
||||
await sendVideo.startLocalVideo(videoSelect.value, audioSelect.value, width, height);
|
||||
|
||||
// 启用设置按钮
|
||||
setupButton.disabled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置WebRTC连接
|
||||
* @async
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function setUp() {
|
||||
setupButton.disabled = true; // 禁用设置按钮
|
||||
hangUpButton.disabled = false; // 启用挂断按钮
|
||||
connectionId = textForConnectionId.value; // 获取连接ID
|
||||
codecPreferences.disabled = true; // 禁用编解码器选择
|
||||
|
||||
// 创建信令实例
|
||||
const signaling = useWebSocket ? new WebSocketSignaling() : new Signaling();
|
||||
const config = getRTCConfiguration(); // 获取RTC配置
|
||||
renderstreaming = new RenderStreaming(signaling, config); // 创建WebRTC连接管理实例
|
||||
|
||||
// 连接建立回调
|
||||
renderstreaming.onConnect = () => {
|
||||
const tracks = sendVideo.getLocalTracks(); // 获取本地媒体轨道
|
||||
for (const track of tracks) {
|
||||
renderstreaming.addTransceiver(track, { direction: 'sendonly' }); // 添加发送轨道
|
||||
}
|
||||
setCodecPreferences(); // 设置编解码器偏好
|
||||
showStatsMessage(); // 显示统计信息
|
||||
};
|
||||
|
||||
// 连接断开回调
|
||||
renderstreaming.onDisconnect = () => {
|
||||
hangUp(); // 挂断连接
|
||||
};
|
||||
|
||||
// 轨道事件回调
|
||||
renderstreaming.onTrackEvent = (data) => {
|
||||
const direction = data.transceiver.direction;
|
||||
if (direction == "sendrecv" || direction == "recvonly") {
|
||||
sendVideo.addRemoteTrack(data.track); // 添加远程轨道
|
||||
}
|
||||
};
|
||||
|
||||
// 启动WebRTC连接
|
||||
await renderstreaming.start();
|
||||
await renderstreaming.createConnection(connectionId);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置编解码器偏好
|
||||
*/
|
||||
function setCodecPreferences() {
|
||||
/** @type {RTCRtpCodecCapability[] | null} */
|
||||
let selectedCodecs = null;
|
||||
|
||||
if (supportsSetCodecPreferences) {
|
||||
const preferredCodec = codecPreferences.options[codecPreferences.selectedIndex];
|
||||
if (preferredCodec.value !== '') {
|
||||
const [mimeType, sdpFmtpLine] = preferredCodec.value.split(' ');
|
||||
const { codecs } = RTCRtpSender.getCapabilities('video');
|
||||
const selectedCodecIndex = codecs.findIndex(c => c.mimeType === mimeType && c.sdpFmtpLine === sdpFmtpLine);
|
||||
const selectCodec = codecs[selectedCodecIndex];
|
||||
selectedCodecs = [selectCodec];
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedCodecs == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取视频收发器并设置编解码器偏好
|
||||
const transceivers = renderstreaming.getTransceivers().filter(t => t.receiver.track.kind == "video");
|
||||
if (transceivers && transceivers.length > 0) {
|
||||
transceivers.forEach(t => t.setCodecPreferences(selectedCodecs));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 挂断WebRTC连接
|
||||
* @async
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function hangUp() {
|
||||
clearStatsMessage(); // 清除统计信息
|
||||
messageDiv.style.display = 'block';
|
||||
messageDiv.innerText = `Disconnect peer on ${connectionId}.`;
|
||||
|
||||
hangUpButton.disabled = true; // 禁用挂断按钮
|
||||
setupButton.disabled = false; // 启用设置按钮
|
||||
|
||||
// 删除连接并停止WebRTC
|
||||
await renderstreaming.deleteConnection();
|
||||
await renderstreaming.stop();
|
||||
renderstreaming = null;
|
||||
remoteVideo.srcObject = null; // 清除远程视频源
|
||||
|
||||
textForConnectionId.value = getRandom(); // 生成新的随机连接ID
|
||||
connectionId = null;
|
||||
|
||||
// 启用编解码器选择
|
||||
if (supportsSetCodecPreferences) {
|
||||
codecPreferences.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机连接ID
|
||||
* @returns {string} 5位随机数字字符串
|
||||
*/
|
||||
function getRandom() {
|
||||
const max = 99999;
|
||||
const length = String(max).length;
|
||||
const number = Math.floor(Math.random() * max);
|
||||
return (Array(length).join('0') + number).slice(-length); // 补零确保5位
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置输入选择控件
|
||||
* @async
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function setUpInputSelect() {
|
||||
// 获取媒体设备列表
|
||||
const deviceInfos = await navigator.mediaDevices.enumerateDevices();
|
||||
|
||||
// 填充视频设备选择
|
||||
for (let i = 0; i !== deviceInfos.length; ++i) {
|
||||
const deviceInfo = deviceInfos[i];
|
||||
if (deviceInfo.kind === 'videoinput') {
|
||||
const option = document.createElement('option');
|
||||
option.value = deviceInfo.deviceId;
|
||||
option.text = deviceInfo.label || `camera ${videoSelect.length + 1}`;
|
||||
videoSelect.appendChild(option);
|
||||
} else if (deviceInfo.kind === 'audioinput') {
|
||||
// 填充音频设备选择
|
||||
const option = document.createElement('option');
|
||||
option.value = deviceInfo.deviceId;
|
||||
option.text = deviceInfo.label || `mic ${audioSelect.length + 1}`;
|
||||
audioSelect.appendChild(option);
|
||||
}
|
||||
}
|
||||
|
||||
// 填充视频分辨率选择
|
||||
for (let i = 0; i < streamSizeList.length; i++) {
|
||||
const streamSize = streamSizeList[i];
|
||||
const option = document.createElement('option');
|
||||
option.value = i;
|
||||
option.text = `${streamSize.width} x ${streamSize.height}`;
|
||||
videoResolutionSelect.appendChild(option);
|
||||
}
|
||||
|
||||
// 添加自定义分辨率选项
|
||||
const option = document.createElement('option');
|
||||
option.value = streamSizeList.length;
|
||||
option.text = 'Custom';
|
||||
videoResolutionSelect.appendChild(option);
|
||||
videoResolutionSelect.value = 1; // 默认选择1280 x 720
|
||||
|
||||
// 分辨率选择变化事件
|
||||
videoResolutionSelect.addEventListener('change', (event) => {
|
||||
const isCustom = event.target.value >= streamSizeList.length;
|
||||
cameraWidthInput.disabled = !isCustom;
|
||||
cameraHeightInput.disabled = !isCustom;
|
||||
useCustomResolution = isCustom;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示编解码器选择
|
||||
*/
|
||||
function showCodecSelect() {
|
||||
if (!supportsSetCodecPreferences) {
|
||||
messageDiv.style.display = 'block';
|
||||
messageDiv.innerHTML = `Current Browser does not support <a href="https://developer.mozilla.org/en-US/docs/Web/API/RTCRtpTransceiver/setCodecPreferences">RTCRtpTransceiver.setCodecPreferences</a>.`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取视频编解码器能力
|
||||
const codecs = RTCRtpSender.getCapabilities('video').codecs;
|
||||
codecs.forEach(codec => {
|
||||
// 跳过冗余和FEC编解码器
|
||||
if (['video/red', 'video/ulpfec', 'video/rtx'].includes(codec.mimeType)) {
|
||||
return;
|
||||
}
|
||||
const option = document.createElement('option');
|
||||
option.value = (codec.mimeType + ' ' + (codec.sdpFmtpLine || '')).trim();
|
||||
option.innerText = option.value;
|
||||
codecPreferences.appendChild(option);
|
||||
});
|
||||
codecPreferences.disabled = false;
|
||||
}
|
||||
|
||||
// 统计信息相关变量
|
||||
let lastStats; // 上次统计信息
|
||||
let intervalId; // 统计信息更新间隔ID
|
||||
|
||||
/**
|
||||
* 显示统计信息
|
||||
*/
|
||||
function showStatsMessage() {
|
||||
// 每秒更新一次统计信息
|
||||
intervalId = setInterval(async () => {
|
||||
// 显示本地视频分辨率
|
||||
if (localVideo.videoWidth) {
|
||||
localVideoStatsDiv.innerHTML = `<strong>Sending resolution:</strong> ${localVideo.videoWidth} x ${localVideo.videoHeight} px`;
|
||||
}
|
||||
// 显示远程视频分辨率
|
||||
if (remoteVideo.videoWidth) {
|
||||
remoteVideoStatsDiv.innerHTML = `<strong>Receiving resolution:</strong> ${remoteVideo.videoWidth} x ${remoteVideo.videoHeight} px`;
|
||||
}
|
||||
|
||||
if (renderstreaming == null || connectionId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取WebRTC统计信息
|
||||
const stats = await renderstreaming.getStats();
|
||||
if (stats == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建统计信息显示数组
|
||||
const array = createDisplayStringArray(stats, lastStats);
|
||||
if (array.length) {
|
||||
messageDiv.style.display = 'block';
|
||||
messageDiv.innerHTML = array.join('<br>');
|
||||
}
|
||||
lastStats = stats;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除统计信息
|
||||
*/
|
||||
function clearStatsMessage() {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId); // 清除定时器
|
||||
}
|
||||
lastStats = null;
|
||||
intervalId = null;
|
||||
localVideoStatsDiv.innerHTML = '';
|
||||
remoteVideoStatsDiv.innerHTML = '';
|
||||
messageDiv.style.display = 'none';
|
||||
messageDiv.innerHTML = '';
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import * as Logger from "../../module/logger.js";
|
||||
|
||||
export class SendVideo {
|
||||
constructor(localVideoElement, remoteVideoElement) {
|
||||
this.localVideo = localVideoElement;
|
||||
this.remoteVideo = remoteVideoElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MediaTrackConstraints} videoSource
|
||||
* @param {MediaTrackConstraints} audioSource
|
||||
* @param {number} videoWidth
|
||||
* @param {number} videoHeight
|
||||
*/
|
||||
async startLocalVideo(videoSource, audioSource, videoWidth, videoHeight) {
|
||||
try {
|
||||
const constraints = {
|
||||
video: { deviceId: videoSource ? { exact: videoSource } : undefined },
|
||||
audio: { deviceId: audioSource ? { exact: audioSource } : undefined }
|
||||
};
|
||||
|
||||
if (videoWidth != null || videoWidth != 0) {
|
||||
constraints.video.width = videoWidth;
|
||||
}
|
||||
if (videoHeight != null || videoHeight != 0) {
|
||||
constraints.video.height = videoHeight;
|
||||
}
|
||||
|
||||
const localStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
this.localVideo.srcObject = localStream;
|
||||
await this.localVideo.play();
|
||||
} catch (err) {
|
||||
Logger.error(`mediaDevice.getUserMedia() error:${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {MediaStreamTrack[]}
|
||||
*/
|
||||
getLocalTracks() {
|
||||
return this.localVideo.srcObject.getTracks();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MediaStreamTrack} track
|
||||
*/
|
||||
addRemoteTrack(track) {
|
||||
if (this.remoteVideo.srcObject == null) {
|
||||
this.remoteVideo.srcObject = new MediaStream();
|
||||
}
|
||||
this.remoteVideo.srcObject.addTrack(track);
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
h1 {
|
||||
border-bottom: 1px solid #ccc;
|
||||
font-weight: 500;
|
||||
margin: 0 0 0.8em 0;
|
||||
padding: 0 0 0.2em 0;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
padding: 0 0 0.2em 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
font-weight: 300;
|
||||
margin: 0;
|
||||
padding: 1em;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
button {
|
||||
margin: 20px 10px 0 0;
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
button#gather {
|
||||
display: block;
|
||||
}
|
||||
|
||||
section {
|
||||
border-bottom: 1px solid #eee;
|
||||
margin: 0 0 1.5em 0;
|
||||
padding: 0 0 1.5em 0;
|
||||
}
|
||||
|
||||
section#iceServers label {
|
||||
display: inline-block;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
section#iceServers input {
|
||||
margin: 0 0 10px;
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
select {
|
||||
margin: 0 1em 1em 0;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
select#servers {
|
||||
font-size: 1em;
|
||||
padding: 5px;
|
||||
width: 420px;
|
||||
}
|
||||
|
||||
section:last-child {
|
||||
border-bottom: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
div#container {
|
||||
margin: 0 auto 0 auto;
|
||||
max-width: 60em;
|
||||
padding: 1em 1.5em 1.3em 1.5em;
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 0.1em 0.25em;
|
||||
color: #444;
|
||||
background-color: #e7edf3;
|
||||
border-radius: 3px;
|
||||
border: solid 1px #d6dde4;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #444;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
p#data {
|
||||
border-top: 1px dotted #666;
|
||||
line-height: 1.3em;
|
||||
max-height: 1000px;
|
||||
overflow-y: auto;
|
||||
padding: 1em 0 0 0;
|
||||
}
|
||||
|
||||
p.borderBelow {
|
||||
border-bottom: 1px solid #aaa;
|
||||
padding: 0 0 20px 0;
|
||||
}
|
||||
|
||||
video {
|
||||
background: #222;
|
||||
margin: 0 0 20px 0;
|
||||
--width: 100%;
|
||||
width: var(--width);
|
||||
height: calc(var(--width) * 0.75);
|
||||
}
|
||||
|
||||
div#warning {
|
||||
color: #8a6d3b;
|
||||
background-color: #fcf8e3;
|
||||
border-color: #faebcc;
|
||||
padding: 1em;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
div.box {
|
||||
margin: 1em;
|
||||
}
|
||||
|
||||
div#message {
|
||||
border-top: 1px solid #666;
|
||||
margin: 1em;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 650px) {
|
||||
.highlight {
|
||||
font-size: 1em;
|
||||
margin: 0 0 20px 0;
|
||||
padding: 0.2em 1em;
|
||||
}
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 550px) {
|
||||
button:active {
|
||||
background-color: darkRed;
|
||||
}
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 450px) {
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
BIN
client/public/images/head/三花猫.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
client/public/images/head/仓鼠.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
client/public/images/head/可达鸭.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
client/public/images/head/哈士奇.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
client/public/images/head/奶牛猫.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
client/public/images/head/布偶猫.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
client/public/images/head/无毛猫.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
client/public/images/head/暹罗猫.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
client/public/images/head/柯基.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
client/public/images/head/柴犬.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
client/public/images/head/橘猫.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
client/public/images/head/法斗.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
client/public/images/head/田园犬.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
client/public/images/head/白猫.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
client/public/images/head/羊.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
client/public/images/head/腊肠犬.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
client/public/images/head/荷兰猪.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
client/public/images/head/蓝猫.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
client/public/images/head/藏獒.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
client/public/images/head/边牧.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
client/public/images/head/金毛.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
client/public/images/head/黑猫.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
@@ -1,77 +1,731 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<link rel="icon" href="images/favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="css/main.css" />
|
||||
<title>Unity Render Streaming Samples</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VideoCall - 一对一视频通话</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="container">
|
||||
<h1>Unity Render Streaming Samples</h1>
|
||||
<body class="h-screen w-screen flex flex-col text-white bg-grid relative">
|
||||
<!--
|
||||
============================================================
|
||||
connect视图:初始连接界面(输入连接ID、创建/加入通话)
|
||||
WebSocket在此视图建立连接
|
||||
============================================================
|
||||
-->
|
||||
<div id="connectView" class="h-full w-full flex flex-col">
|
||||
<!-- 用户设置区域 -->
|
||||
<div class="absolute top-4 right-4 z-10">
|
||||
<button id="userSettingsBtn" class="flex items-center gap-2 glass px-3 py-2 rounded-full hover:bg-white/10 transition-colors">
|
||||
<img id="userAvatar" src="/images/p1.png" class="w-8 h-8 rounded-full object-cover">
|
||||
<span id="userName" class="text-sm font-medium">我</span>
|
||||
<i class="fas fa-chevron-down text-xs text-gray-400"></i>
|
||||
</button>
|
||||
|
||||
<section>
|
||||
<p>These are WebClient samples for use with <a href="https://docs.unity3d.com/Packages/com.unity.renderstreaming@latest/index.html">Unity Render
|
||||
Streaming</a>.</p>
|
||||
</section>
|
||||
<!-- 设置菜单 -->
|
||||
<div id="settingsMenu" class="hidden absolute top-full right-0 mt-2 glass rounded-xl shadow-lg w-48 z-20">
|
||||
<div class="p-4 border-b border-white/10">
|
||||
<h3 class="text-sm font-medium mb-2">个人设置</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">昵称</label>
|
||||
<input type="text" id="nicknameInput" class="w-full bg-transparent border border-white/20 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" placeholder="输入昵称">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">头像</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<img id="avatarPreview" src="/images/p1.png" class="w-10 h-10 rounded-full object-cover">
|
||||
<input type="file" id="avatarInput" accept="image/*" class="hidden" onchange="handleAvatarUpload(event)">
|
||||
<button onclick="document.getElementById('avatarInput').click()" class="text-xs text-indigo-400 hover:text-indigo-300 transition-colors">更换头像</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">用户ID</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="text" id="userIdInput" class="w-full bg-transparent border border-white/20 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" readonly>
|
||||
<button onclick="copyUserId()" class="text-xs text-indigo-400 hover:text-indigo-300 transition-colors">复制</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<button onclick="saveSettings()" class="w-full px-4 py-2 text-sm text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors">保存设置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2>Server Configuration</h2>
|
||||
<div id="startup"></div>
|
||||
</section>
|
||||
<!-- 连接表单 -->
|
||||
<div class="h-full w-full flex items-center justify-center bg-black/90">
|
||||
<div class="text-center max-w-md px-8">
|
||||
<div class="w-24 h-24 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-full flex items-center justify-center mx-auto mb-8 shadow-lg">
|
||||
<i class="fas fa-video text-white text-4xl"></i>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">VideoCall</h1>
|
||||
<p class="text-gray-400 mb-8">一对一视频通话</p>
|
||||
|
||||
<section id="iceServers">
|
||||
<h2>ICE servers</h2>
|
||||
<select id="servers" size="4">
|
||||
</select>
|
||||
<div>
|
||||
<label for="url">STUN or TURN URI:</label>
|
||||
<input id="url">
|
||||
</div>
|
||||
<div>
|
||||
<label for="username">TURN username:</label>
|
||||
<input id="username">
|
||||
</div>
|
||||
<div>
|
||||
<label for="password">TURN password:</label>
|
||||
<input id="password">
|
||||
</div>
|
||||
<div>
|
||||
<button id="add">Add Server</button>
|
||||
<button id="remove">Remove Server</button>
|
||||
<button id="reset">Reset to defaults</button>
|
||||
</div>
|
||||
</section>
|
||||
<div class="space-y-4 mb-8">
|
||||
<div class="glass rounded-xl p-4">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">连接ID</label>
|
||||
<input type="text"
|
||||
id="connectionIdInput"
|
||||
placeholder="输入连接ID"
|
||||
class="w-full bg-transparent border border-white/20 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">
|
||||
连接ID是用于建立点对点通话的唯一标识,由发起方生成并分享给接收方。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2 id="receiver"><a href="receiver/index.html">Receiver Sample</a></h2>
|
||||
<p>This is a sample for receiving video / audio from Unity.</p>
|
||||
<p>It can be used in combination with the <code>Broadcast</code> scene of Unity Render Streaming.</p>
|
||||
</section>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center mb-6">
|
||||
<button id="connectBtn" class="flex-1 px-6 py-3 bg-indigo-600 hover:bg-indigo-700 rounded-xl transition-colors flex items-center justify-center gap-2">
|
||||
<i class="fas fa-phone"></i>
|
||||
<span>加入通话</span>
|
||||
</button>
|
||||
<button id="createCallBtn" class="flex-1 px-6 py-3 glass hover:bg-white/10 rounded-xl transition-colors flex items-center justify-center gap-2">
|
||||
<i class="fas fa-plus"></i>
|
||||
<span>创建通话</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2 id="bidirectional"><a href="bidirectional/index.html">Bidirectional Sample</a></h2>
|
||||
<p>This is a sample for sending and receiving video in both directions.</p>
|
||||
<p>It can be used in combination with the <code>Bidirectional</code> scene of Unity Render Streaming.</p>
|
||||
<p>The WebApp must be running in Private mode.</p>
|
||||
</section>
|
||||
<!-- 浏览全部ID按钮 -->
|
||||
<button id="browseIdsBtn" class="w-full px-6 py-3 glass hover:bg-white/10 rounded-xl transition-colors flex items-center justify-center gap-2 mb-4">
|
||||
<i class="fas fa-list"></i>
|
||||
<span>浏览全部ID</span>
|
||||
</button>
|
||||
|
||||
<section>
|
||||
<h2 id="multiplay"><a href="multiplay/index.html">Multiplay Sample</a></h2>
|
||||
<p>This sample connects as a Guest in the <code>Multiplay</code> scene of Unity Render Streaming.</p>
|
||||
</section>
|
||||
<!-- 连接ID列表 -->
|
||||
<div id="connectionIdsList" class="glass rounded-xl p-4 mb-6 hidden">
|
||||
<h3 class="text-sm font-medium text-gray-300 mb-2">可用的连接ID</h3>
|
||||
<div id="idsContainer" class="max-h-40 overflow-y-auto space-y-2">
|
||||
<!-- 连接ID将在这里动态生成 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2 id="videoplayer"><a href="videoplayer/index.html">VideoPlayer Sample</a></h2>
|
||||
<p>This is a sample to receive the camera image rendered on Unity. You can operate the camera in Unity from the
|
||||
browser.</p>
|
||||
<p>It can be used in combination with the <code>WebBrowserInput</code> scene of Unity Render Streaming.</p>
|
||||
<p>The WebApp must be running in Public mode.</p>
|
||||
</section>
|
||||
<div id="onlineUsersList" class="glass rounded-xl p-4 mb-6 hidden">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm font-medium text-gray-300">全部WebSocket用户</h3>
|
||||
<span id="onlineUsersSummary" class="text-xs text-gray-500">0 个用户在线</span>
|
||||
</div>
|
||||
<div id="usersContainer" class="max-h-56 overflow-y-auto space-y-3">
|
||||
<!-- 在线用户将在这里动态生成 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WebSocket连接状态指示 -->
|
||||
<div id="wsStatus" class="mt-4 flex items-center justify-center gap-2 text-xs text-gray-500">
|
||||
<span id="wsStatusDot" class="w-2 h-2 bg-gray-500 rounded-full"></span>
|
||||
<span id="wsStatusText">未连接</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
============================================================
|
||||
call视图:视频通话界面(创建/加入房间后显示)
|
||||
============================================================
|
||||
-->
|
||||
<div id="callView" class="hidden h-full w-full flex flex-col">
|
||||
|
||||
<!--
|
||||
============================================================
|
||||
数据模型定义 (Data Models)
|
||||
============================================================
|
||||
|
||||
1. CallSession 通话会话
|
||||
interface CallSession {
|
||||
id: string; // 通话唯一ID [PRIMARY_KEY]
|
||||
type: 'video' | 'audio'; // 通话类型
|
||||
status: 'connecting' | 'ongoing' | 'ended' | 'failed';
|
||||
startTime: string; // ISO 8601 格式
|
||||
duration: number; // 已进行秒数
|
||||
isEncrypted: boolean; // 是否端到端加密
|
||||
localUser: LocalUser; // 本地用户信息
|
||||
remoteUser: RemoteUser; // 远端用户信息
|
||||
}
|
||||
|
||||
2. LocalUser 本地用户
|
||||
interface LocalUser {
|
||||
id: string; // 用户ID
|
||||
name: string; // 显示名称
|
||||
avatar: string; // 头像URL
|
||||
isHost: boolean; // 是否主持人
|
||||
mediaState: MediaState; // 媒体状态
|
||||
}
|
||||
|
||||
3. RemoteUser 远端用户
|
||||
interface RemoteUser {
|
||||
id: string; // 用户ID
|
||||
name: string; // 显示名称
|
||||
avatar: string; // 头像URL
|
||||
status: 'online' | 'offline' | 'connecting';
|
||||
mediaState: MediaState; // 媒体状态
|
||||
networkQuality: 'excellent' | 'good' | 'fair' | 'poor'; // 网络质量
|
||||
}
|
||||
|
||||
4. MediaState 媒体状态
|
||||
interface MediaState {
|
||||
audio: boolean; // 音频是否开启
|
||||
video: boolean; // 视频是否开启
|
||||
screenShare: boolean; // 是否屏幕共享
|
||||
isSpeaking: boolean; // 是否正在说话(VAD检测)
|
||||
}
|
||||
|
||||
5. ChatMessage 聊天消息
|
||||
interface ChatMessage {
|
||||
id: string; // 消息ID
|
||||
senderId: string; // 发送者ID
|
||||
senderName: string; // 发送者名称
|
||||
senderAvatar: string; // 发送者头像
|
||||
content: string; // 消息内容
|
||||
type: 'text' | 'file' | 'system';
|
||||
timestamp: string; // ISO 8601 格式
|
||||
isSelf: boolean; // 是否自己发送
|
||||
}
|
||||
|
||||
============================================================
|
||||
API 接口定义 (API Endpoints)
|
||||
============================================================
|
||||
|
||||
[GET] /api/call/:callId // 获取通话信息
|
||||
[POST] /api/call/:callId/join // 加入通话
|
||||
[POST] /api/call/:callId/leave // 离开通话
|
||||
[POST] /api/call/:callId/media // 更新媒体状态 {audio?: boolean, video?: boolean}
|
||||
[GET] /api/call/:callId/messages?limit=50&before=timestamp // 获取历史消息
|
||||
[POST] /api/call/:callId/message // 发送消息 {content: string, type: 'text'}
|
||||
|
||||
WebSocket Events:
|
||||
- connect: 连接建立
|
||||
- disconnect: 连接断开
|
||||
- user-joined: {userId, timestamp}
|
||||
- user-left: {userId, timestamp}
|
||||
- media-state-changed: {userId, audio, video, screenShare, isSpeaking}
|
||||
- message-received: {message: ChatMessage}
|
||||
- network-quality: {userId, quality: 'excellent' | 'good' | 'fair' | 'poor'}
|
||||
- call-ended: {reason: 'user_hangup' | 'network_error' | 'timeout'}
|
||||
-->
|
||||
|
||||
<!--
|
||||
============================================================
|
||||
区域: 顶部栏 (Header)
|
||||
数据源: CallSession
|
||||
更新频率: 实时 (WebSocket + 本地计时器)
|
||||
============================================================
|
||||
-->
|
||||
<header class="glass-strong h-16 flex items-center justify-between px-6 z-50 border-b border-white/10">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||
<i class="fas fa-video text-white text-lg"></i>
|
||||
</div>
|
||||
<div>
|
||||
<!-- [DATA_FIELD: callSession.remoteUser.name] [TYPE: string] [REQUIRED] -->
|
||||
<h1 class="font-bold text-lg tracking-tight" data-field="remoteUser.name" id="headerTitle">
|
||||
与 Sarah 的通话
|
||||
</h1>
|
||||
<div class="flex items-center gap-3 text-xs text-gray-400">
|
||||
<span id="remoteNetworkIndicator" class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
|
||||
<span id="remoteNetworkQuality" class="flex items-center gap-1">
|
||||
<i class="fas fa-signal"></i>
|
||||
<span>优秀</span>
|
||||
</span>
|
||||
<!-- [DATA_FIELD: callSession.duration] [TYPE: string] [FORMAT: MM:SS] [UPDATE: 每秒] -->
|
||||
<span data-field="callSession.duration" id="callDuration">00:00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- [CONDITIONAL_RENDER: callSession.isEncrypted === true] -->
|
||||
<div class="hidden md:flex items-center gap-2 px-4 py-2 glass rounded-full text-sm" id="encryptionBadge">
|
||||
<i class="fas fa-shield-alt text-green-400"></i>
|
||||
<span class="text-gray-300">端到端加密</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 flex overflow-hidden relative">
|
||||
|
||||
<!--
|
||||
============================================================
|
||||
区域: 视频区域 (Video Area)
|
||||
数据源: CallSession.remoteUser (对方) + CallSession.localUser (自己)
|
||||
更新频率: 实时 (WebRTC MediaStream + WebSocket 状态)
|
||||
============================================================
|
||||
-->
|
||||
<div class="flex-1 relative bg-black/40 overflow-hidden" id="videoArea">
|
||||
|
||||
<!--
|
||||
子区域: 多Participant视频网格(Host端显示)
|
||||
动态生成,每个participant一个视频格子
|
||||
-->
|
||||
<div id="participantGrid" class="hidden absolute inset-0 grid gap-3 p-3 auto-rows-fr" style="grid-template-columns: 1fr;">
|
||||
<!-- 动态生成的 participant 视频格子将插入这里 -->
|
||||
</div>
|
||||
|
||||
<!--
|
||||
子区域: 远端视频 (Remote Video) - 单路(Participant端显示Host画面)
|
||||
数据源: RemoteUser
|
||||
-->
|
||||
<div class="absolute inset-0 video-fade-in">
|
||||
<!-- [DATA_FIELD: remoteUser.videoStream] [TYPE: MediaStream | null] [BIND: srcObject] -->
|
||||
<!-- [FALLBACK: remoteUser.avatar] [TYPE: string] [URL] -->
|
||||
<video id="remoteVideo" alt="对方视频" class="w-full h-full object-contain" autoplay
|
||||
data-field="remoteUser.videoStream">
|
||||
</video>
|
||||
|
||||
<!-- 远端未连接时的占位背景 -->
|
||||
<div id="remoteVideoPlaceholder"
|
||||
class="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-indigo-900 to-purple-900 z-10">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="w-32 h-32 rounded-full bg-indigo-700/50 flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fas fa-user text-4xl text-white/70"></i>
|
||||
</div>
|
||||
<p class="text-white text-lg font-medium">等待对方连接...</p>
|
||||
<p class="text-sm text-gray-400 mt-2">请确保对方已加入通话</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 网络状态提示 -->
|
||||
<!-- [CONDITIONAL_RENDER: remoteUser.networkQuality !== 'excellent'] -->
|
||||
<div id="networkStatus"
|
||||
class="absolute top-6 right-6 glass px-3 py-1.5 rounded-full flex items-center gap-2 text-xs hidden"
|
||||
data-field="remoteUser.networkQuality">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-500"></i>
|
||||
<!-- [DATA_FIELD: remoteUser.networkQuality] [TYPE: string] [TRANSFORM: quality => text] -->
|
||||
<span class="text-gray-300" id="networkStatusText">网络不稳定</span>
|
||||
</div>
|
||||
|
||||
<!-- 连接中/重连提示 -->
|
||||
<!-- [CONDITIONAL_RENDER: callSession.status === 'connecting'] -->
|
||||
<div id="connectingOverlay" class="absolute inset-0 bg-black/60 flex items-center justify-center hidden"
|
||||
data-field="callSession.status">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="w-12 h-12 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-3">
|
||||
</div>
|
||||
<p class="text-white font-medium">正在连接...</p>
|
||||
<p class="text-sm text-gray-400 mt-1" id="connectingText">等待对方接受邀请</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!--
|
||||
子区域: 本地视频 (Local Video - Picture in Picture)
|
||||
数据源: LocalUser
|
||||
-->
|
||||
<div
|
||||
class="absolute bottom-6 right-6 w-64 h-48 rounded-2xl overflow-hidden shadow-2xl border-2 border-white/20 video-fade-in z-10">
|
||||
<!-- [DATA_FIELD: localUser.videoStream] [TYPE: MediaStream | null] [BIND: srcObject] -->
|
||||
<!-- [FALLBACK: localUser.avatar] [TYPE: string] [URL] -->
|
||||
<video id="localVideo"
|
||||
src="https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=300&fit=crop" alt="本地视频"
|
||||
class="w-full h-full object-cover" autoplay muted data-field="localUser.videoStream">
|
||||
</video>
|
||||
|
||||
<!-- [CONDITIONAL_RENDER: localUser.mediaState.video === false] -->
|
||||
<div id="localVideoPlaceholder"
|
||||
class="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-indigo-900 to-purple-900 hidden"
|
||||
data-field="localUser.videoOff">
|
||||
<span class="text-4xl font-bold" id="localInitials">我</span>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-3 left-3 glass px-2 py-1 rounded text-xs flex items-center gap-2">
|
||||
<span>我</span>
|
||||
<!-- [CONDITIONAL_RENDER: localUser.mediaState.isSpeaking === true] -->
|
||||
<div id="localAudioWave" class="audio-wave w-4 hidden" data-field="localUser.isSpeaking">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 本地视频悬停控制 -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black/40 opacity-0 hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||
<button onclick="toggleLocalVideo()"
|
||||
class="w-8 h-8 rounded-full bg-white/20 hover:bg-white/30 flex items-center justify-center transition-colors">
|
||||
<i class="fas fa-video text-xs" id="localVideoIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!--
|
||||
============================================================
|
||||
区域: 侧边栏 (Sidebar)
|
||||
数据源: ChatMessage[] + User[]
|
||||
更新频率: 实时 (WebSocket)
|
||||
============================================================
|
||||
-->
|
||||
<aside class="w-80 glass-strong border-l border-white/10 flex flex-col hidden" id="sidebar">
|
||||
|
||||
<!--
|
||||
子区域: 用户列表 (User List)
|
||||
数据源: [localUser, remoteUser]
|
||||
-->
|
||||
<div class="p-4 border-b border-white/10">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-3" id="userCountDisplay">
|
||||
通话成员 (1)
|
||||
</h3>
|
||||
<div class="space-y-2" id="userList">
|
||||
<!-- [LOOP_START: users as user] -->
|
||||
|
||||
<!-- 远端用户项 -->
|
||||
<div class="flex items-center gap-3 p-2 rounded-lg bg-white/5" data-user-id="remote">
|
||||
<div class="relative">
|
||||
<!-- [DATA_FIELD: remoteUser.avatar] -->
|
||||
<img src="" class="w-10 h-10 rounded-full object-cover" data-field="remoteUser.avatar">
|
||||
<!-- [CONDITIONAL_RENDER: remoteUser.status === 'online'] -->
|
||||
<div
|
||||
class="absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-slate-900">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<!-- [DATA_FIELD: remoteUser.name] -->
|
||||
<div class="text-sm font-medium" data-field="remoteUser.name">Sarah Chen</div>
|
||||
<!-- [DATA_FIELD: remoteUser.mediaState] [TRANSFORM: state => statusText] -->
|
||||
<div class="text-xs text-gray-500" data-field="remoteUser.mediaStatus">在线</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- [CONDITIONAL_RENDER: remoteUser.mediaState.audio === false] -->
|
||||
<i class="fas fa-microphone-slash text-gray-500 text-xs hidden"
|
||||
data-field="remoteUser.muteIcon"></i>
|
||||
<!-- [CONDITIONAL_RENDER: remoteUser.mediaState.isSpeaking === true] -->
|
||||
<div class="audio-wave w-6 hidden" data-field="remoteUser.speakingIndicator">
|
||||
<span></span><span></span><span></span><span></span><span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 本地用户项 -->
|
||||
<div class="flex items-center gap-3 p-2 rounded-lg hover:bg-white/5" data-user-id="local">
|
||||
<!-- [DATA_FIELD: localUser.avatar] -->
|
||||
<img src="" class="w-10 h-10 rounded-full object-cover" data-field="localUser.avatar">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium">
|
||||
<!-- [DATA_FIELD: localUser.name] -->我
|
||||
<!-- [CONDITIONAL_RENDER: localUser.isHost === true] -->
|
||||
<span class="text-xs bg-indigo-500 px-1.5 rounded ml-1">主持人</span>
|
||||
</div>
|
||||
<!-- [DATA_FIELD: localUser.mediaState] [TRANSFORM: state => statusText] -->
|
||||
<div class="text-xs text-gray-500" id="localMediaStatus" data-field="localUser.mediaStatus">
|
||||
静音中</div>
|
||||
</div>
|
||||
<!-- [CONDITIONAL_RENDER: localUser.mediaState.audio === false] -->
|
||||
<i class="fas fa-microphone-slash text-gray-500 text-xs" data-field="localUser.muteIcon"></i>
|
||||
</div>
|
||||
|
||||
<!-- [LOOP_END: users] -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
子区域: 聊天消息列表 (Chat Messages)
|
||||
数据源: ChatMessage[]
|
||||
排序: 按 timestamp 升序
|
||||
-->
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-4 custom-scrollbar" id="chatContent">
|
||||
<!-- [STATIC] 通话开始时间 -->
|
||||
<div class="text-center text-xs text-gray-500 my-4">
|
||||
通话开始 <!-- [DATA_FIELD: callSession.startTime] [FORMAT: HH:MM] -->14:30
|
||||
</div>
|
||||
|
||||
<!-- [LOOP_START: messages as message] -->
|
||||
|
||||
<!-- 消息模板 (对方) -->
|
||||
<!-- [CONDITIONAL_RENDER: message.isSelf === false] -->
|
||||
<div class="chat-bubble" data-message-id="${message.id}">
|
||||
<div class="flex gap-3">
|
||||
<!-- [DATA_FIELD: message.senderAvatar] -->
|
||||
<img src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=100&h=100&fit=crop"
|
||||
class="w-8 h-8 rounded-full object-cover" data-field="message.senderAvatar">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-baseline gap-2 mb-1">
|
||||
<!-- [DATA_FIELD: message.senderName] -->
|
||||
<span class="text-sm font-medium text-indigo-400" data-field="message.senderName">Sarah
|
||||
Chen</span>
|
||||
<!-- [DATA_FIELD: message.timestamp] [FORMAT: HH:MM] -->
|
||||
<span class="text-xs text-gray-500" data-field="message.time">14:32</span>
|
||||
</div>
|
||||
<!-- [DATA_FIELD: message.content] -->
|
||||
<div class="glass px-3 py-2 rounded-2xl rounded-tl-none text-sm text-gray-200"
|
||||
data-field="message.content">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息模板 (自己) -->
|
||||
<!-- [CONDITIONAL_RENDER: message.isSelf === true] -->
|
||||
<div class="chat-bubble">
|
||||
<div class="flex gap-3 flex-row-reverse">
|
||||
<!-- [DATA_FIELD: message.senderAvatar] -->
|
||||
<img src="https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&h=100&fit=crop"
|
||||
class="w-8 h-8 rounded-full object-cover" data-field="message.senderAvatar">
|
||||
<div class="flex-1 flex flex-col items-end">
|
||||
<div class="flex items-baseline gap-2 mb-1 flex-row-reverse">
|
||||
<span class="text-sm font-medium text-green-400">我</span>
|
||||
<span class="text-xs text-gray-500" data-field="message.time">14:32</span>
|
||||
</div>
|
||||
<div class="bg-indigo-600 px-3 py-2 rounded-2xl rounded-tr-none text-sm text-white"
|
||||
data-field="message.content">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- [LOOP_END: messages] -->
|
||||
</div>
|
||||
|
||||
<!--
|
||||
子区域: 消息输入 (Message Input)
|
||||
API: [POST] /api/call/:callId/message
|
||||
-->
|
||||
<div class="p-4 border-t border-white/10">
|
||||
<div class="glass rounded-2xl flex items-center gap-2 p-2">
|
||||
<button
|
||||
class="w-8 h-8 rounded-full hover:bg-white/10 flex items-center justify-center text-gray-400 transition-colors"
|
||||
onclick="openImagePicker()">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
<!-- 隐藏的文件输入元素 -->
|
||||
<input type="file" id="imageInput" accept="image/*" class="hidden"
|
||||
onchange="handleImageUpload(event)">
|
||||
<!-- [INPUT_FIELD] [BIND: inputValue] [EVENT: onEnter => sendMessage()] -->
|
||||
<input type="text" id="chatInput" placeholder="输入消息..."
|
||||
class="flex-1 bg-transparent border-none outline-none text-sm text-white placeholder-gray-500 px-2"
|
||||
data-field="chatInput" onkeypress="handleChatSubmit(event)">
|
||||
<!-- [BUTTON] [EVENT: onclick => sendMessage()] -->
|
||||
<button onclick="sendMessage()"
|
||||
class="w-8 h-8 rounded-full bg-indigo-600 hover:bg-indigo-700 flex items-center justify-center transition-colors">
|
||||
<i class="fas fa-paper-plane text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
</main>
|
||||
|
||||
<!--
|
||||
============================================================
|
||||
区域: 底部控制栏 (Control Bar)
|
||||
数据源: LocalUser.mediaState
|
||||
API: [POST] /api/call/:callId/media
|
||||
WebSocket: emit 'media-state-changed'
|
||||
============================================================
|
||||
-->
|
||||
<footer class="glass-strong h-20 border-t border-white/10 flex items-center justify-center px-6 gap-4 z-50">
|
||||
|
||||
<!-- 左侧连接信息 -->
|
||||
<div class="absolute left-6 hidden md:flex items-center gap-3">
|
||||
<div class="text-left">
|
||||
<div class="text-sm font-medium">一对一通话</div>
|
||||
<!-- [DATA_FIELD: remoteUser.networkQuality] [TRANSFORM: quality => displayText] -->
|
||||
<div class="text-xs text-gray-400" id="connectionQuality" data-field="connectionQualityText">
|
||||
连接质量: 优秀
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间控制按钮组 -->
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
<!-- 麦克风控制 -->
|
||||
<!-- [DATA_FIELD: localUser.mediaState.audio] [TYPE: boolean] -->
|
||||
<button
|
||||
class="control-btn w-12 h-12 rounded-full glass flex items-center justify-center text-white hover:bg-white/10 relative group"
|
||||
onclick="toggleMute(this)" id="micBtn" data-field="localUser.audio" data-active="false">
|
||||
<i class="fas fa-microphone text-lg" data-icon="default"></i>
|
||||
<i class="fas fa-microphone-slash text-lg hidden text-red-400" data-icon="active"></i>
|
||||
<span
|
||||
class="absolute -top-10 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-black/80 rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
静音 (Space)
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- 摄像头控制 -->
|
||||
<!-- [DATA_FIELD: localUser.mediaState.video] [TYPE: boolean] -->
|
||||
<button
|
||||
class="control-btn w-12 h-12 rounded-full glass flex items-center justify-center text-white hover:bg-white/10 relative group"
|
||||
onclick="toggleVideo(this)" id="videoBtn" data-field="localUser.video" data-active="false">
|
||||
<i class="fas fa-video text-lg" data-icon="default"></i>
|
||||
<i class="fas fa-video-slash text-lg hidden text-red-400" data-icon="active"></i>
|
||||
<span
|
||||
class="absolute -top-10 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-black/80 rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
关闭视频 (Ctrl+V)
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- 录屏控制 -->
|
||||
<!-- [DATA_FIELD: localUser.mediaState.recording] [TYPE: boolean] -->
|
||||
<button
|
||||
class="control-btn w-12 h-12 rounded-full glass flex items-center justify-center text-white hover:bg-white/10 relative group"
|
||||
onclick="toggleRecording(this)" id="recordBtn" data-field="localUser.recording" data-active="false">
|
||||
<i class="fas fa-circle text-lg" data-icon="default"></i>
|
||||
<i class="fas fa-stop text-lg hidden text-red-400" data-icon="active"></i>
|
||||
<span
|
||||
class="absolute -top-10 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-black/80 rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
录制
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- 更多选项 -->
|
||||
<div class="relative">
|
||||
<button id="moreOptionsBtn"
|
||||
class="control-btn w-12 h-12 rounded-full glass flex items-center justify-center text-white hover:bg-white/10 relative group">
|
||||
<i class="fas fa-ellipsis-h text-lg"></i>
|
||||
<span
|
||||
class="absolute -top-10 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-black/80 rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
更多选项
|
||||
</span>
|
||||
</button>
|
||||
<!-- 更多选项下拉菜单 -->
|
||||
<div id="moreOptionsMenu" class="hidden absolute bottom-full left-1/2 transform -translate-x-1/2 mb-3 glass rounded-xl shadow-lg w-52 z-50">
|
||||
<!-- 分辨率选项 -->
|
||||
<div class="p-3 border-b border-white/10">
|
||||
<h4 class="text-xs font-medium text-gray-400 mb-2 flex items-center gap-2">
|
||||
<i class="fas fa-desktop text-xs"></i>
|
||||
视频分辨率
|
||||
</h4>
|
||||
<div class="space-y-1" id="resolutionOptions">
|
||||
<button onclick="changeResolution(480, 270)" data-resolution="480"
|
||||
class="resolution-option w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-white/10 transition-colors">
|
||||
<span>流畅 480p</span>
|
||||
<span class="text-xs text-gray-500">省流量</span>
|
||||
</button>
|
||||
<button onclick="changeResolution(1280, 720)" data-resolution="720"
|
||||
class="resolution-option w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-white/10 transition-colors">
|
||||
<span>高清 720p</span>
|
||||
<span class="text-xs text-gray-500">推荐</span>
|
||||
</button>
|
||||
<button onclick="changeResolution(1920, 1080)" data-resolution="1080"
|
||||
class="resolution-option active w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-white/10 transition-colors">
|
||||
<span>超清 1080p</span>
|
||||
<span class="text-xs text-gray-500"></span>
|
||||
</button>
|
||||
<button onclick="changeResolution(2560, 1440)" data-resolution="1440"
|
||||
class="resolution-option w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-white/10 transition-colors">
|
||||
<span>2K 1440p</span>
|
||||
<span class="text-xs text-gray-500">最高画质</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 当前分辨率指示 -->
|
||||
<div class="px-3 py-2 flex items-center gap-2">
|
||||
<i class="fas fa-info-circle text-xs text-gray-500"></i>
|
||||
<span id="currentResolutionText" class="text-xs text-gray-500">当前: 1080p</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 结束通话 -->
|
||||
<!-- [EVENT: onclick => endCall()] [API: POST /api/call/:callId/leave] -->
|
||||
<button
|
||||
class="control-btn w-14 h-14 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center text-white end-call-pulse ml-4 relative group"
|
||||
onclick="endCall()">
|
||||
<i class="fas fa-phone-slash text-xl"></i>
|
||||
<span
|
||||
class="absolute -top-10 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-black/80 rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
结束通话
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 右侧聊天按钮 -->
|
||||
<div class="absolute right-6 flex items-center gap-3">
|
||||
<button
|
||||
class="control-btn w-10 h-10 rounded-full glass flex items-center justify-center text-gray-300 hover:text-white hover:bg-white/10 transition-colors relative"
|
||||
onclick="toggleSidebar()">
|
||||
<i class="fas fa-comment-alt"></i>
|
||||
<!-- 未读消息计数角标 -->
|
||||
<span id="unreadBadge"
|
||||
class="absolute -top-1 -right-1 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center text-xs font-bold text-white hidden">0</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</footer>
|
||||
|
||||
</div><!-- /callView -->
|
||||
|
||||
<!-- 通知组件 -->
|
||||
<div id="notification"
|
||||
class="fixed top-20 left-1/2 transform -translate-x-1/2 glass px-6 py-3 rounded-full flex items-center gap-3 opacity-0 pointer-events-none transition-all duration-300 z-50 translate-y-[-20px]">
|
||||
<i class="fas fa-info-circle text-indigo-400"></i>
|
||||
<span class="text-sm" id="notificationText">通知内容</span>
|
||||
</div>
|
||||
|
||||
<!-- 通话结束确认对话框 -->
|
||||
<div id="endCallDialog" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 hidden">
|
||||
<div class="glass rounded-2xl p-6 w-80 max-w-md">
|
||||
<div class="text-center mb-6">
|
||||
<div class="w-16 h-16 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fas fa-phone-slash text-red-500 text-2xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-2">结束通话</h3>
|
||||
<p class="text-gray-400 text-sm">确定要结束当前通话吗?</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button id="cancelEndCall" class="flex-1 py-2 rounded-lg glass hover:bg-white/10 transition-colors">
|
||||
取消
|
||||
</button>
|
||||
<button id="confirmEndCall"
|
||||
class="flex-1 py-2 rounded-lg bg-red-500 hover:bg-red-600 transition-colors">
|
||||
结束通话
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 通话请求弹窗 -->
|
||||
<div id="callRequestDialog" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 hidden">
|
||||
<div class="glass rounded-2xl p-6 w-80 max-w-md">
|
||||
<div class="text-center mb-6">
|
||||
<div class="w-16 h-16 bg-indigo-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fas fa-video text-indigo-500 text-2xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-2" id="callRequestName">Sarah Chen</h3>
|
||||
<p class="text-gray-400 text-sm" id="callRequestText">正在请求与您进行视频通话</p>
|
||||
<div class="mt-4 flex items-center justify-center gap-4">
|
||||
<img id="callRequestAvatar"
|
||||
src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=100&h=100&fit=crop"
|
||||
class="w-16 h-16 rounded-full object-cover border-4 border-indigo-500">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button id="rejectCall" class="flex-1 py-2 rounded-lg glass hover:bg-white/10 transition-colors">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<i class="fas fa-phone-slash"></i>
|
||||
<span>拒绝</span>
|
||||
</div>
|
||||
</button>
|
||||
<button id="acceptCall"
|
||||
class="flex-1 py-2 rounded-lg bg-green-500 hover:bg-green-600 transition-colors">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<i class="fas fa-phone"></i>
|
||||
<span>接受</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 引入模块化JavaScript文件 -->
|
||||
<script type="module" src="connectview.js"></script>
|
||||
<script type="module" src="main.js"></script>
|
||||
|
||||
<section>
|
||||
<a href="https://github.com/Unity-Technologies/UnityRenderStreaming/tree/develop/WebApp" title="View source for this page on GitHub" id="viewSource">View source on GitHub</a>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script type="module" src="js/main.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
body {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
#player {
|
||||
position: relative;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
background-color: #323232;
|
||||
}
|
||||
|
||||
#player:before {
|
||||
content: "";
|
||||
display: block;
|
||||
padding-top: 66%;
|
||||
}
|
||||
|
||||
#playButton {
|
||||
width: 15%;
|
||||
max-width: 200px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#Video {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#VideoThumbnail {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 30%;
|
||||
height: 30%;
|
||||
}
|
||||
|
||||
#greenButton {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
width: 160px;
|
||||
background-color: #4CAF50;
|
||||
/* Green */
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 15px 32px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#blueButton {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 180px;
|
||||
width: 160px;
|
||||
background-color: #447FAF;
|
||||
/* Blue */
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 15px 32px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#orangeButton {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 350px;
|
||||
width: 160px;
|
||||
background-color: #FF7700;
|
||||
/* Blue */
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 15px 32px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#fullscreenButton {
|
||||
position: absolute;
|
||||
top: 25px;
|
||||
right: 25px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<link rel="icon" href="../images/favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="../css/main.css" />
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
<title>Multiplay Sample</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div id="container">
|
||||
<h1>Multiplay Sample</h1>
|
||||
|
||||
<div id="warning" hidden="true"></div>
|
||||
|
||||
<div id="player"></div>
|
||||
|
||||
<div class="box">
|
||||
<span>Codec preferences:</span>
|
||||
<select id="codecPreferences" autocomplete="off" disabled>
|
||||
<option selected value="">Default</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<span>Lock Cursor to Player:</span>
|
||||
<input type="checkbox" id="lockMouseCheck" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<p>
|
||||
For more information about sample, see <a
|
||||
href="https://docs.unity3d.com/Packages/com.unity.renderstreaming@3.1/manual/sample-multiplay.html">Multiplay sample</a> document page.
|
||||
</p>
|
||||
|
||||
<div id="message"></div>
|
||||
|
||||
<section>
|
||||
<a href="https://github.com/Unity-Technologies/UnityRenderStreaming/tree/develop/WebApp/client/public/multiplay"
|
||||
title="View source for this page on GitHub" id="viewSource">View source on GitHub</a>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript" src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
|
||||
<script src="https://unpkg.com/event-target@latest/min.js"></script>
|
||||
<script src="https://unpkg.com/resize-observer-polyfill@1.5.0/dist/ResizeObserver.global.js"></script>
|
||||
<script type="module" src="js/main.js"></script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,203 +0,0 @@
|
||||
import { getServerConfig, getRTCConfiguration } from "../../js/config.js";
|
||||
import { createDisplayStringArray } from "../../js/stats.js";
|
||||
import { VideoPlayer } from "../../js/videoplayer.js";
|
||||
import { RenderStreaming } from "../../module/renderstreaming.js";
|
||||
import { Signaling, WebSocketSignaling } from "../../module/signaling.js";
|
||||
|
||||
/** @enum {number} */
|
||||
const ActionType = {
|
||||
ChangeLabel: 0
|
||||
};
|
||||
|
||||
/** @type {Element} */
|
||||
let playButton;
|
||||
/** @type {RenderStreaming} */
|
||||
let renderstreaming;
|
||||
/** @type {boolean} */
|
||||
let useWebSocket;
|
||||
/** @type {RTCDataChannel} */
|
||||
let multiplayChannel;
|
||||
|
||||
const codecPreferences = document.getElementById('codecPreferences');
|
||||
const supportsSetCodecPreferences = window.RTCRtpTransceiver &&
|
||||
'setCodecPreferences' in window.RTCRtpTransceiver.prototype;
|
||||
const messageDiv = document.getElementById('message');
|
||||
messageDiv.style.display = 'none';
|
||||
|
||||
const playerDiv = document.getElementById('player');
|
||||
const lockMouseCheck = document.getElementById('lockMouseCheck');
|
||||
const videoPlayer = new VideoPlayer();
|
||||
|
||||
setup();
|
||||
|
||||
window.document.oncontextmenu = function () {
|
||||
return false; // cancel default menu
|
||||
};
|
||||
|
||||
window.addEventListener('resize', function () {
|
||||
videoPlayer.resizeVideo();
|
||||
}, true);
|
||||
|
||||
window.addEventListener('beforeunload', async () => {
|
||||
if(!renderstreaming)
|
||||
return;
|
||||
await renderstreaming.stop();
|
||||
}, true);
|
||||
|
||||
async function setup() {
|
||||
const res = await getServerConfig();
|
||||
useWebSocket = res.useWebSocket;
|
||||
showWarningIfNeeded(res.startupMode);
|
||||
showCodecSelect();
|
||||
showPlayButton();
|
||||
}
|
||||
|
||||
function showWarningIfNeeded(startupMode) {
|
||||
const warningDiv = document.getElementById("warning");
|
||||
if (startupMode == "private") {
|
||||
warningDiv.innerHTML = "<h4>Warning</h4> This sample is not working on Private Mode.";
|
||||
warningDiv.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showPlayButton() {
|
||||
if (!document.getElementById('playButton')) {
|
||||
const elementPlayButton = document.createElement('img');
|
||||
elementPlayButton.id = 'playButton';
|
||||
elementPlayButton.src = '../../images/Play.png';
|
||||
elementPlayButton.alt = 'Start Streaming';
|
||||
playButton = document.getElementById('player').appendChild(elementPlayButton);
|
||||
playButton.addEventListener('click', onClickPlayButton);
|
||||
}
|
||||
}
|
||||
|
||||
function onClickPlayButton() {
|
||||
playButton.style.display = 'none';
|
||||
|
||||
// add video player
|
||||
videoPlayer.createPlayer(playerDiv, lockMouseCheck);
|
||||
setupRenderStreaming();
|
||||
}
|
||||
|
||||
async function setupRenderStreaming() {
|
||||
codecPreferences.disabled = true;
|
||||
|
||||
const signaling = useWebSocket ? new WebSocketSignaling() : new Signaling();
|
||||
const config = getRTCConfiguration();
|
||||
renderstreaming = new RenderStreaming(signaling, config);
|
||||
renderstreaming.onConnect = onConnect;
|
||||
renderstreaming.onDisconnect = onDisconnect;
|
||||
renderstreaming.onTrackEvent = (data) => videoPlayer.addTrack(data.track);
|
||||
renderstreaming.onGotOffer = setCodecPreferences;
|
||||
|
||||
await renderstreaming.start();
|
||||
await renderstreaming.createConnection();
|
||||
}
|
||||
|
||||
function onConnect() {
|
||||
const channel = renderstreaming.createDataChannel("input");
|
||||
videoPlayer.setupInput(channel);
|
||||
multiplayChannel = renderstreaming.createDataChannel("multiplay");
|
||||
multiplayChannel.onopen = onOpenMultiplayChannel;
|
||||
showStatsMessage();
|
||||
}
|
||||
|
||||
async function onOpenMultiplayChannel() {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
const num = Math.floor(Math.random() * 100000);
|
||||
const json = JSON.stringify({ type: ActionType.ChangeLabel, argument: String(num) });
|
||||
multiplayChannel.send(json);
|
||||
}
|
||||
|
||||
async function onDisconnect(connectionId) {
|
||||
clearStatsMessage();
|
||||
messageDiv.style.display = 'block';
|
||||
messageDiv.innerText = `Disconnect peer on ${connectionId}.`;
|
||||
|
||||
await renderstreaming.stop();
|
||||
renderstreaming = null;
|
||||
multiplayChannel = null;
|
||||
videoPlayer.deletePlayer();
|
||||
if (supportsSetCodecPreferences) {
|
||||
codecPreferences.disabled = false;
|
||||
}
|
||||
showPlayButton();
|
||||
}
|
||||
|
||||
function setCodecPreferences() {
|
||||
/** @type {RTCRtpCodecCapability[] | null} */
|
||||
let selectedCodecs = null;
|
||||
if (supportsSetCodecPreferences) {
|
||||
const preferredCodec = codecPreferences.options[codecPreferences.selectedIndex];
|
||||
if (preferredCodec.value !== '') {
|
||||
const [mimeType, sdpFmtpLine] = preferredCodec.value.split(' ');
|
||||
const { codecs } = RTCRtpSender.getCapabilities('video');
|
||||
const selectedCodecIndex = codecs.findIndex(c => c.mimeType === mimeType && c.sdpFmtpLine === sdpFmtpLine);
|
||||
const selectCodec = codecs[selectedCodecIndex];
|
||||
selectedCodecs = [selectCodec];
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedCodecs == null) {
|
||||
return;
|
||||
}
|
||||
const transceivers = renderstreaming.getTransceivers().filter(t => t.receiver.track.kind == "video");
|
||||
if (transceivers && transceivers.length > 0) {
|
||||
transceivers.forEach(t => t.setCodecPreferences(selectedCodecs));
|
||||
}
|
||||
}
|
||||
|
||||
function showCodecSelect() {
|
||||
if (!supportsSetCodecPreferences) {
|
||||
messageDiv.style.display = 'block';
|
||||
messageDiv.innerHTML = `Current Browser does not support <a href="https://developer.mozilla.org/en-US/docs/Web/API/RTCRtpTransceiver/setCodecPreferences">RTCRtpTransceiver.setCodecPreferences</a>.`;
|
||||
return;
|
||||
}
|
||||
|
||||
const codecs = RTCRtpSender.getCapabilities('video').codecs;
|
||||
codecs.forEach(codec => {
|
||||
if (['video/red', 'video/ulpfec', 'video/rtx'].includes(codec.mimeType)) {
|
||||
return;
|
||||
}
|
||||
const option = document.createElement('option');
|
||||
option.value = (codec.mimeType + ' ' + (codec.sdpFmtpLine || '')).trim();
|
||||
option.innerText = option.value;
|
||||
codecPreferences.appendChild(option);
|
||||
});
|
||||
codecPreferences.disabled = false;
|
||||
}
|
||||
|
||||
/** @type {RTCStatsReport} */
|
||||
let lastStats;
|
||||
/** @type {number} */
|
||||
let intervalId;
|
||||
|
||||
function showStatsMessage() {
|
||||
intervalId = setInterval(async () => {
|
||||
if (renderstreaming == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = await renderstreaming.getStats();
|
||||
if (stats == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const array = createDisplayStringArray(stats, lastStats);
|
||||
if (array.length) {
|
||||
messageDiv.style.display = 'block';
|
||||
messageDiv.innerHTML = array.join('<br>');
|
||||
}
|
||||
lastStats = stats;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function clearStatsMessage() {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
lastStats = null;
|
||||
intervalId = null;
|
||||
messageDiv.style.display = 'none';
|
||||
messageDiv.innerHTML = '';
|
||||
}
|
||||
@@ -1,731 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VideoCall - 一对一视频通话</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
|
||||
<body class="h-screen w-screen flex flex-col text-white bg-grid relative">
|
||||
<!--
|
||||
============================================================
|
||||
connect视图:初始连接界面(输入连接ID、创建/加入通话)
|
||||
WebSocket在此视图建立连接
|
||||
============================================================
|
||||
-->
|
||||
<div id="connectView" class="h-full w-full flex flex-col">
|
||||
<!-- 用户设置区域 -->
|
||||
<div class="absolute top-4 right-4 z-10">
|
||||
<button id="userSettingsBtn" class="flex items-center gap-2 glass px-3 py-2 rounded-full hover:bg-white/10 transition-colors">
|
||||
<img id="userAvatar" src="/images/p1.png" class="w-8 h-8 rounded-full object-cover">
|
||||
<span id="userName" class="text-sm font-medium">我</span>
|
||||
<i class="fas fa-chevron-down text-xs text-gray-400"></i>
|
||||
</button>
|
||||
|
||||
<!-- 设置菜单 -->
|
||||
<div id="settingsMenu" class="hidden absolute top-full right-0 mt-2 glass rounded-xl shadow-lg w-48 z-20">
|
||||
<div class="p-4 border-b border-white/10">
|
||||
<h3 class="text-sm font-medium mb-2">个人设置</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">昵称</label>
|
||||
<input type="text" id="nicknameInput" class="w-full bg-transparent border border-white/20 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" placeholder="输入昵称">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">头像</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<img id="avatarPreview" src="/images/p1.png" class="w-10 h-10 rounded-full object-cover">
|
||||
<input type="file" id="avatarInput" accept="image/*" class="hidden" onchange="handleAvatarUpload(event)">
|
||||
<button onclick="document.getElementById('avatarInput').click()" class="text-xs text-indigo-400 hover:text-indigo-300 transition-colors">更换头像</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">用户ID</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="text" id="userIdInput" class="w-full bg-transparent border border-white/20 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" readonly>
|
||||
<button onclick="copyUserId()" class="text-xs text-indigo-400 hover:text-indigo-300 transition-colors">复制</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<button onclick="saveSettings()" class="w-full px-4 py-2 text-sm text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors">保存设置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 连接表单 -->
|
||||
<div class="h-full w-full flex items-center justify-center bg-black/90">
|
||||
<div class="text-center max-w-md px-8">
|
||||
<div class="w-24 h-24 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-full flex items-center justify-center mx-auto mb-8 shadow-lg">
|
||||
<i class="fas fa-video text-white text-4xl"></i>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">VideoCall</h1>
|
||||
<p class="text-gray-400 mb-8">一对一视频通话</p>
|
||||
|
||||
<div class="space-y-4 mb-8">
|
||||
<div class="glass rounded-xl p-4">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">连接ID</label>
|
||||
<input type="text"
|
||||
id="connectionIdInput"
|
||||
placeholder="输入连接ID"
|
||||
class="w-full bg-transparent border border-white/20 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">
|
||||
连接ID是用于建立点对点通话的唯一标识,由发起方生成并分享给接收方。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center mb-6">
|
||||
<button id="connectBtn" class="flex-1 px-6 py-3 bg-indigo-600 hover:bg-indigo-700 rounded-xl transition-colors flex items-center justify-center gap-2">
|
||||
<i class="fas fa-phone"></i>
|
||||
<span>加入通话</span>
|
||||
</button>
|
||||
<button id="createCallBtn" class="flex-1 px-6 py-3 glass hover:bg-white/10 rounded-xl transition-colors flex items-center justify-center gap-2">
|
||||
<i class="fas fa-plus"></i>
|
||||
<span>创建通话</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 浏览全部ID按钮 -->
|
||||
<button id="browseIdsBtn" class="w-full px-6 py-3 glass hover:bg-white/10 rounded-xl transition-colors flex items-center justify-center gap-2 mb-4">
|
||||
<i class="fas fa-list"></i>
|
||||
<span>浏览全部ID</span>
|
||||
</button>
|
||||
|
||||
<!-- 连接ID列表 -->
|
||||
<div id="connectionIdsList" class="glass rounded-xl p-4 mb-6 hidden">
|
||||
<h3 class="text-sm font-medium text-gray-300 mb-2">可用的连接ID</h3>
|
||||
<div id="idsContainer" class="max-h-40 overflow-y-auto space-y-2">
|
||||
<!-- 连接ID将在这里动态生成 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="onlineUsersList" class="glass rounded-xl p-4 mb-6 hidden">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm font-medium text-gray-300">全部WebSocket用户</h3>
|
||||
<span id="onlineUsersSummary" class="text-xs text-gray-500">0 个用户在线</span>
|
||||
</div>
|
||||
<div id="usersContainer" class="max-h-56 overflow-y-auto space-y-3">
|
||||
<!-- 在线用户将在这里动态生成 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WebSocket连接状态指示 -->
|
||||
<div id="wsStatus" class="mt-4 flex items-center justify-center gap-2 text-xs text-gray-500">
|
||||
<span id="wsStatusDot" class="w-2 h-2 bg-gray-500 rounded-full"></span>
|
||||
<span id="wsStatusText">未连接</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
============================================================
|
||||
call视图:视频通话界面(创建/加入房间后显示)
|
||||
============================================================
|
||||
-->
|
||||
<div id="callView" class="hidden h-full w-full flex flex-col">
|
||||
|
||||
<!--
|
||||
============================================================
|
||||
数据模型定义 (Data Models)
|
||||
============================================================
|
||||
|
||||
1. CallSession 通话会话
|
||||
interface CallSession {
|
||||
id: string; // 通话唯一ID [PRIMARY_KEY]
|
||||
type: 'video' | 'audio'; // 通话类型
|
||||
status: 'connecting' | 'ongoing' | 'ended' | 'failed';
|
||||
startTime: string; // ISO 8601 格式
|
||||
duration: number; // 已进行秒数
|
||||
isEncrypted: boolean; // 是否端到端加密
|
||||
localUser: LocalUser; // 本地用户信息
|
||||
remoteUser: RemoteUser; // 远端用户信息
|
||||
}
|
||||
|
||||
2. LocalUser 本地用户
|
||||
interface LocalUser {
|
||||
id: string; // 用户ID
|
||||
name: string; // 显示名称
|
||||
avatar: string; // 头像URL
|
||||
isHost: boolean; // 是否主持人
|
||||
mediaState: MediaState; // 媒体状态
|
||||
}
|
||||
|
||||
3. RemoteUser 远端用户
|
||||
interface RemoteUser {
|
||||
id: string; // 用户ID
|
||||
name: string; // 显示名称
|
||||
avatar: string; // 头像URL
|
||||
status: 'online' | 'offline' | 'connecting';
|
||||
mediaState: MediaState; // 媒体状态
|
||||
networkQuality: 'excellent' | 'good' | 'fair' | 'poor'; // 网络质量
|
||||
}
|
||||
|
||||
4. MediaState 媒体状态
|
||||
interface MediaState {
|
||||
audio: boolean; // 音频是否开启
|
||||
video: boolean; // 视频是否开启
|
||||
screenShare: boolean; // 是否屏幕共享
|
||||
isSpeaking: boolean; // 是否正在说话(VAD检测)
|
||||
}
|
||||
|
||||
5. ChatMessage 聊天消息
|
||||
interface ChatMessage {
|
||||
id: string; // 消息ID
|
||||
senderId: string; // 发送者ID
|
||||
senderName: string; // 发送者名称
|
||||
senderAvatar: string; // 发送者头像
|
||||
content: string; // 消息内容
|
||||
type: 'text' | 'file' | 'system';
|
||||
timestamp: string; // ISO 8601 格式
|
||||
isSelf: boolean; // 是否自己发送
|
||||
}
|
||||
|
||||
============================================================
|
||||
API 接口定义 (API Endpoints)
|
||||
============================================================
|
||||
|
||||
[GET] /api/call/:callId // 获取通话信息
|
||||
[POST] /api/call/:callId/join // 加入通话
|
||||
[POST] /api/call/:callId/leave // 离开通话
|
||||
[POST] /api/call/:callId/media // 更新媒体状态 {audio?: boolean, video?: boolean}
|
||||
[GET] /api/call/:callId/messages?limit=50&before=timestamp // 获取历史消息
|
||||
[POST] /api/call/:callId/message // 发送消息 {content: string, type: 'text'}
|
||||
|
||||
WebSocket Events:
|
||||
- connect: 连接建立
|
||||
- disconnect: 连接断开
|
||||
- user-joined: {userId, timestamp}
|
||||
- user-left: {userId, timestamp}
|
||||
- media-state-changed: {userId, audio, video, screenShare, isSpeaking}
|
||||
- message-received: {message: ChatMessage}
|
||||
- network-quality: {userId, quality: 'excellent' | 'good' | 'fair' | 'poor'}
|
||||
- call-ended: {reason: 'user_hangup' | 'network_error' | 'timeout'}
|
||||
-->
|
||||
|
||||
<!--
|
||||
============================================================
|
||||
区域: 顶部栏 (Header)
|
||||
数据源: CallSession
|
||||
更新频率: 实时 (WebSocket + 本地计时器)
|
||||
============================================================
|
||||
-->
|
||||
<header class="glass-strong h-16 flex items-center justify-between px-6 z-50 border-b border-white/10">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||
<i class="fas fa-video text-white text-lg"></i>
|
||||
</div>
|
||||
<div>
|
||||
<!-- [DATA_FIELD: callSession.remoteUser.name] [TYPE: string] [REQUIRED] -->
|
||||
<h1 class="font-bold text-lg tracking-tight" data-field="remoteUser.name" id="headerTitle">
|
||||
与 Sarah 的通话
|
||||
</h1>
|
||||
<div class="flex items-center gap-3 text-xs text-gray-400">
|
||||
<span id="remoteNetworkIndicator" class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
|
||||
<span id="remoteNetworkQuality" class="flex items-center gap-1">
|
||||
<i class="fas fa-signal"></i>
|
||||
<span>优秀</span>
|
||||
</span>
|
||||
<!-- [DATA_FIELD: callSession.duration] [TYPE: string] [FORMAT: MM:SS] [UPDATE: 每秒] -->
|
||||
<span data-field="callSession.duration" id="callDuration">00:00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- [CONDITIONAL_RENDER: callSession.isEncrypted === true] -->
|
||||
<div class="hidden md:flex items-center gap-2 px-4 py-2 glass rounded-full text-sm" id="encryptionBadge">
|
||||
<i class="fas fa-shield-alt text-green-400"></i>
|
||||
<span class="text-gray-300">端到端加密</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 flex overflow-hidden relative">
|
||||
|
||||
<!--
|
||||
============================================================
|
||||
区域: 视频区域 (Video Area)
|
||||
数据源: CallSession.remoteUser (对方) + CallSession.localUser (自己)
|
||||
更新频率: 实时 (WebRTC MediaStream + WebSocket 状态)
|
||||
============================================================
|
||||
-->
|
||||
<div class="flex-1 relative bg-black/40 overflow-hidden" id="videoArea">
|
||||
|
||||
<!--
|
||||
子区域: 多Participant视频网格(Host端显示)
|
||||
动态生成,每个participant一个视频格子
|
||||
-->
|
||||
<div id="participantGrid" class="hidden absolute inset-0 grid gap-3 p-3 auto-rows-fr" style="grid-template-columns: 1fr;">
|
||||
<!-- 动态生成的 participant 视频格子将插入这里 -->
|
||||
</div>
|
||||
|
||||
<!--
|
||||
子区域: 远端视频 (Remote Video) - 单路(Participant端显示Host画面)
|
||||
数据源: RemoteUser
|
||||
-->
|
||||
<div class="absolute inset-0 video-fade-in">
|
||||
<!-- [DATA_FIELD: remoteUser.videoStream] [TYPE: MediaStream | null] [BIND: srcObject] -->
|
||||
<!-- [FALLBACK: remoteUser.avatar] [TYPE: string] [URL] -->
|
||||
<video id="remoteVideo" alt="对方视频" class="w-full h-full object-contain" autoplay
|
||||
data-field="remoteUser.videoStream">
|
||||
</video>
|
||||
|
||||
<!-- 远端未连接时的占位背景 -->
|
||||
<div id="remoteVideoPlaceholder"
|
||||
class="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-indigo-900 to-purple-900 z-10">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="w-32 h-32 rounded-full bg-indigo-700/50 flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fas fa-user text-4xl text-white/70"></i>
|
||||
</div>
|
||||
<p class="text-white text-lg font-medium">等待对方连接...</p>
|
||||
<p class="text-sm text-gray-400 mt-2">请确保对方已加入通话</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 网络状态提示 -->
|
||||
<!-- [CONDITIONAL_RENDER: remoteUser.networkQuality !== 'excellent'] -->
|
||||
<div id="networkStatus"
|
||||
class="absolute top-6 right-6 glass px-3 py-1.5 rounded-full flex items-center gap-2 text-xs hidden"
|
||||
data-field="remoteUser.networkQuality">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-500"></i>
|
||||
<!-- [DATA_FIELD: remoteUser.networkQuality] [TYPE: string] [TRANSFORM: quality => text] -->
|
||||
<span class="text-gray-300" id="networkStatusText">网络不稳定</span>
|
||||
</div>
|
||||
|
||||
<!-- 连接中/重连提示 -->
|
||||
<!-- [CONDITIONAL_RENDER: callSession.status === 'connecting'] -->
|
||||
<div id="connectingOverlay" class="absolute inset-0 bg-black/60 flex items-center justify-center hidden"
|
||||
data-field="callSession.status">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="w-12 h-12 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-3">
|
||||
</div>
|
||||
<p class="text-white font-medium">正在连接...</p>
|
||||
<p class="text-sm text-gray-400 mt-1" id="connectingText">等待对方接受邀请</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!--
|
||||
子区域: 本地视频 (Local Video - Picture in Picture)
|
||||
数据源: LocalUser
|
||||
-->
|
||||
<div
|
||||
class="absolute bottom-6 right-6 w-64 h-48 rounded-2xl overflow-hidden shadow-2xl border-2 border-white/20 video-fade-in z-10">
|
||||
<!-- [DATA_FIELD: localUser.videoStream] [TYPE: MediaStream | null] [BIND: srcObject] -->
|
||||
<!-- [FALLBACK: localUser.avatar] [TYPE: string] [URL] -->
|
||||
<video id="localVideo"
|
||||
src="https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=300&fit=crop" alt="本地视频"
|
||||
class="w-full h-full object-cover" autoplay muted data-field="localUser.videoStream">
|
||||
</video>
|
||||
|
||||
<!-- [CONDITIONAL_RENDER: localUser.mediaState.video === false] -->
|
||||
<div id="localVideoPlaceholder"
|
||||
class="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-indigo-900 to-purple-900 hidden"
|
||||
data-field="localUser.videoOff">
|
||||
<span class="text-4xl font-bold" id="localInitials">我</span>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-3 left-3 glass px-2 py-1 rounded text-xs flex items-center gap-2">
|
||||
<span>我</span>
|
||||
<!-- [CONDITIONAL_RENDER: localUser.mediaState.isSpeaking === true] -->
|
||||
<div id="localAudioWave" class="audio-wave w-4 hidden" data-field="localUser.isSpeaking">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 本地视频悬停控制 -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black/40 opacity-0 hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||
<button onclick="toggleLocalVideo()"
|
||||
class="w-8 h-8 rounded-full bg-white/20 hover:bg-white/30 flex items-center justify-center transition-colors">
|
||||
<i class="fas fa-video text-xs" id="localVideoIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!--
|
||||
============================================================
|
||||
区域: 侧边栏 (Sidebar)
|
||||
数据源: ChatMessage[] + User[]
|
||||
更新频率: 实时 (WebSocket)
|
||||
============================================================
|
||||
-->
|
||||
<aside class="w-80 glass-strong border-l border-white/10 flex flex-col hidden" id="sidebar">
|
||||
|
||||
<!--
|
||||
子区域: 用户列表 (User List)
|
||||
数据源: [localUser, remoteUser]
|
||||
-->
|
||||
<div class="p-4 border-b border-white/10">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-3" id="userCountDisplay">
|
||||
通话成员 (1)
|
||||
</h3>
|
||||
<div class="space-y-2" id="userList">
|
||||
<!-- [LOOP_START: users as user] -->
|
||||
|
||||
<!-- 远端用户项 -->
|
||||
<div class="flex items-center gap-3 p-2 rounded-lg bg-white/5" data-user-id="remote">
|
||||
<div class="relative">
|
||||
<!-- [DATA_FIELD: remoteUser.avatar] -->
|
||||
<img src="" class="w-10 h-10 rounded-full object-cover" data-field="remoteUser.avatar">
|
||||
<!-- [CONDITIONAL_RENDER: remoteUser.status === 'online'] -->
|
||||
<div
|
||||
class="absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-slate-900">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<!-- [DATA_FIELD: remoteUser.name] -->
|
||||
<div class="text-sm font-medium" data-field="remoteUser.name">Sarah Chen</div>
|
||||
<!-- [DATA_FIELD: remoteUser.mediaState] [TRANSFORM: state => statusText] -->
|
||||
<div class="text-xs text-gray-500" data-field="remoteUser.mediaStatus">在线</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- [CONDITIONAL_RENDER: remoteUser.mediaState.audio === false] -->
|
||||
<i class="fas fa-microphone-slash text-gray-500 text-xs hidden"
|
||||
data-field="remoteUser.muteIcon"></i>
|
||||
<!-- [CONDITIONAL_RENDER: remoteUser.mediaState.isSpeaking === true] -->
|
||||
<div class="audio-wave w-6 hidden" data-field="remoteUser.speakingIndicator">
|
||||
<span></span><span></span><span></span><span></span><span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 本地用户项 -->
|
||||
<div class="flex items-center gap-3 p-2 rounded-lg hover:bg-white/5" data-user-id="local">
|
||||
<!-- [DATA_FIELD: localUser.avatar] -->
|
||||
<img src="" class="w-10 h-10 rounded-full object-cover" data-field="localUser.avatar">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium">
|
||||
<!-- [DATA_FIELD: localUser.name] -->我
|
||||
<!-- [CONDITIONAL_RENDER: localUser.isHost === true] -->
|
||||
<span class="text-xs bg-indigo-500 px-1.5 rounded ml-1">主持人</span>
|
||||
</div>
|
||||
<!-- [DATA_FIELD: localUser.mediaState] [TRANSFORM: state => statusText] -->
|
||||
<div class="text-xs text-gray-500" id="localMediaStatus" data-field="localUser.mediaStatus">
|
||||
静音中</div>
|
||||
</div>
|
||||
<!-- [CONDITIONAL_RENDER: localUser.mediaState.audio === false] -->
|
||||
<i class="fas fa-microphone-slash text-gray-500 text-xs" data-field="localUser.muteIcon"></i>
|
||||
</div>
|
||||
|
||||
<!-- [LOOP_END: users] -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
子区域: 聊天消息列表 (Chat Messages)
|
||||
数据源: ChatMessage[]
|
||||
排序: 按 timestamp 升序
|
||||
-->
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-4 custom-scrollbar" id="chatContent">
|
||||
<!-- [STATIC] 通话开始时间 -->
|
||||
<div class="text-center text-xs text-gray-500 my-4">
|
||||
通话开始 <!-- [DATA_FIELD: callSession.startTime] [FORMAT: HH:MM] -->14:30
|
||||
</div>
|
||||
|
||||
<!-- [LOOP_START: messages as message] -->
|
||||
|
||||
<!-- 消息模板 (对方) -->
|
||||
<!-- [CONDITIONAL_RENDER: message.isSelf === false] -->
|
||||
<div class="chat-bubble" data-message-id="${message.id}">
|
||||
<div class="flex gap-3">
|
||||
<!-- [DATA_FIELD: message.senderAvatar] -->
|
||||
<img src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=100&h=100&fit=crop"
|
||||
class="w-8 h-8 rounded-full object-cover" data-field="message.senderAvatar">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-baseline gap-2 mb-1">
|
||||
<!-- [DATA_FIELD: message.senderName] -->
|
||||
<span class="text-sm font-medium text-indigo-400" data-field="message.senderName">Sarah
|
||||
Chen</span>
|
||||
<!-- [DATA_FIELD: message.timestamp] [FORMAT: HH:MM] -->
|
||||
<span class="text-xs text-gray-500" data-field="message.time">14:32</span>
|
||||
</div>
|
||||
<!-- [DATA_FIELD: message.content] -->
|
||||
<div class="glass px-3 py-2 rounded-2xl rounded-tl-none text-sm text-gray-200"
|
||||
data-field="message.content">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息模板 (自己) -->
|
||||
<!-- [CONDITIONAL_RENDER: message.isSelf === true] -->
|
||||
<div class="chat-bubble">
|
||||
<div class="flex gap-3 flex-row-reverse">
|
||||
<!-- [DATA_FIELD: message.senderAvatar] -->
|
||||
<img src="https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&h=100&fit=crop"
|
||||
class="w-8 h-8 rounded-full object-cover" data-field="message.senderAvatar">
|
||||
<div class="flex-1 flex flex-col items-end">
|
||||
<div class="flex items-baseline gap-2 mb-1 flex-row-reverse">
|
||||
<span class="text-sm font-medium text-green-400">我</span>
|
||||
<span class="text-xs text-gray-500" data-field="message.time">14:32</span>
|
||||
</div>
|
||||
<div class="bg-indigo-600 px-3 py-2 rounded-2xl rounded-tr-none text-sm text-white"
|
||||
data-field="message.content">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- [LOOP_END: messages] -->
|
||||
</div>
|
||||
|
||||
<!--
|
||||
子区域: 消息输入 (Message Input)
|
||||
API: [POST] /api/call/:callId/message
|
||||
-->
|
||||
<div class="p-4 border-t border-white/10">
|
||||
<div class="glass rounded-2xl flex items-center gap-2 p-2">
|
||||
<button
|
||||
class="w-8 h-8 rounded-full hover:bg-white/10 flex items-center justify-center text-gray-400 transition-colors"
|
||||
onclick="openImagePicker()">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
<!-- 隐藏的文件输入元素 -->
|
||||
<input type="file" id="imageInput" accept="image/*" class="hidden"
|
||||
onchange="handleImageUpload(event)">
|
||||
<!-- [INPUT_FIELD] [BIND: inputValue] [EVENT: onEnter => sendMessage()] -->
|
||||
<input type="text" id="chatInput" placeholder="输入消息..."
|
||||
class="flex-1 bg-transparent border-none outline-none text-sm text-white placeholder-gray-500 px-2"
|
||||
data-field="chatInput" onkeypress="handleChatSubmit(event)">
|
||||
<!-- [BUTTON] [EVENT: onclick => sendMessage()] -->
|
||||
<button onclick="sendMessage()"
|
||||
class="w-8 h-8 rounded-full bg-indigo-600 hover:bg-indigo-700 flex items-center justify-center transition-colors">
|
||||
<i class="fas fa-paper-plane text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
</main>
|
||||
|
||||
<!--
|
||||
============================================================
|
||||
区域: 底部控制栏 (Control Bar)
|
||||
数据源: LocalUser.mediaState
|
||||
API: [POST] /api/call/:callId/media
|
||||
WebSocket: emit 'media-state-changed'
|
||||
============================================================
|
||||
-->
|
||||
<footer class="glass-strong h-20 border-t border-white/10 flex items-center justify-center px-6 gap-4 z-50">
|
||||
|
||||
<!-- 左侧连接信息 -->
|
||||
<div class="absolute left-6 hidden md:flex items-center gap-3">
|
||||
<div class="text-left">
|
||||
<div class="text-sm font-medium">一对一通话</div>
|
||||
<!-- [DATA_FIELD: remoteUser.networkQuality] [TRANSFORM: quality => displayText] -->
|
||||
<div class="text-xs text-gray-400" id="connectionQuality" data-field="connectionQualityText">
|
||||
连接质量: 优秀
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间控制按钮组 -->
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
<!-- 麦克风控制 -->
|
||||
<!-- [DATA_FIELD: localUser.mediaState.audio] [TYPE: boolean] -->
|
||||
<button
|
||||
class="control-btn w-12 h-12 rounded-full glass flex items-center justify-center text-white hover:bg-white/10 relative group"
|
||||
onclick="toggleMute(this)" id="micBtn" data-field="localUser.audio" data-active="false">
|
||||
<i class="fas fa-microphone text-lg" data-icon="default"></i>
|
||||
<i class="fas fa-microphone-slash text-lg hidden text-red-400" data-icon="active"></i>
|
||||
<span
|
||||
class="absolute -top-10 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-black/80 rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
静音 (Space)
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- 摄像头控制 -->
|
||||
<!-- [DATA_FIELD: localUser.mediaState.video] [TYPE: boolean] -->
|
||||
<button
|
||||
class="control-btn w-12 h-12 rounded-full glass flex items-center justify-center text-white hover:bg-white/10 relative group"
|
||||
onclick="toggleVideo(this)" id="videoBtn" data-field="localUser.video" data-active="false">
|
||||
<i class="fas fa-video text-lg" data-icon="default"></i>
|
||||
<i class="fas fa-video-slash text-lg hidden text-red-400" data-icon="active"></i>
|
||||
<span
|
||||
class="absolute -top-10 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-black/80 rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
关闭视频 (Ctrl+V)
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- 录屏控制 -->
|
||||
<!-- [DATA_FIELD: localUser.mediaState.recording] [TYPE: boolean] -->
|
||||
<button
|
||||
class="control-btn w-12 h-12 rounded-full glass flex items-center justify-center text-white hover:bg-white/10 relative group"
|
||||
onclick="toggleRecording(this)" id="recordBtn" data-field="localUser.recording" data-active="false">
|
||||
<i class="fas fa-circle text-lg" data-icon="default"></i>
|
||||
<i class="fas fa-stop text-lg hidden text-red-400" data-icon="active"></i>
|
||||
<span
|
||||
class="absolute -top-10 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-black/80 rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
录制
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- 更多选项 -->
|
||||
<div class="relative">
|
||||
<button id="moreOptionsBtn"
|
||||
class="control-btn w-12 h-12 rounded-full glass flex items-center justify-center text-white hover:bg-white/10 relative group">
|
||||
<i class="fas fa-ellipsis-h text-lg"></i>
|
||||
<span
|
||||
class="absolute -top-10 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-black/80 rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
更多选项
|
||||
</span>
|
||||
</button>
|
||||
<!-- 更多选项下拉菜单 -->
|
||||
<div id="moreOptionsMenu" class="hidden absolute bottom-full left-1/2 transform -translate-x-1/2 mb-3 glass rounded-xl shadow-lg w-52 z-50">
|
||||
<!-- 分辨率选项 -->
|
||||
<div class="p-3 border-b border-white/10">
|
||||
<h4 class="text-xs font-medium text-gray-400 mb-2 flex items-center gap-2">
|
||||
<i class="fas fa-desktop text-xs"></i>
|
||||
视频分辨率
|
||||
</h4>
|
||||
<div class="space-y-1" id="resolutionOptions">
|
||||
<button onclick="changeResolution(480, 270)" data-resolution="480"
|
||||
class="resolution-option w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-white/10 transition-colors">
|
||||
<span>流畅 480p</span>
|
||||
<span class="text-xs text-gray-500">省流量</span>
|
||||
</button>
|
||||
<button onclick="changeResolution(1280, 720)" data-resolution="720"
|
||||
class="resolution-option w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-white/10 transition-colors">
|
||||
<span>高清 720p</span>
|
||||
<span class="text-xs text-gray-500">推荐</span>
|
||||
</button>
|
||||
<button onclick="changeResolution(1920, 1080)" data-resolution="1080"
|
||||
class="resolution-option active w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-white/10 transition-colors">
|
||||
<span>超清 1080p</span>
|
||||
<span class="text-xs text-gray-500"></span>
|
||||
</button>
|
||||
<button onclick="changeResolution(2560, 1440)" data-resolution="1440"
|
||||
class="resolution-option w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-white/10 transition-colors">
|
||||
<span>2K 1440p</span>
|
||||
<span class="text-xs text-gray-500">最高画质</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 当前分辨率指示 -->
|
||||
<div class="px-3 py-2 flex items-center gap-2">
|
||||
<i class="fas fa-info-circle text-xs text-gray-500"></i>
|
||||
<span id="currentResolutionText" class="text-xs text-gray-500">当前: 1080p</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 结束通话 -->
|
||||
<!-- [EVENT: onclick => endCall()] [API: POST /api/call/:callId/leave] -->
|
||||
<button
|
||||
class="control-btn w-14 h-14 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center text-white end-call-pulse ml-4 relative group"
|
||||
onclick="endCall()">
|
||||
<i class="fas fa-phone-slash text-xl"></i>
|
||||
<span
|
||||
class="absolute -top-10 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-black/80 rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
结束通话
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 右侧聊天按钮 -->
|
||||
<div class="absolute right-6 flex items-center gap-3">
|
||||
<button
|
||||
class="control-btn w-10 h-10 rounded-full glass flex items-center justify-center text-gray-300 hover:text-white hover:bg-white/10 transition-colors relative"
|
||||
onclick="toggleSidebar()">
|
||||
<i class="fas fa-comment-alt"></i>
|
||||
<!-- 未读消息计数角标 -->
|
||||
<span id="unreadBadge"
|
||||
class="absolute -top-1 -right-1 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center text-xs font-bold text-white hidden">0</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</footer>
|
||||
|
||||
</div><!-- /callView -->
|
||||
|
||||
<!-- 通知组件 -->
|
||||
<div id="notification"
|
||||
class="fixed top-20 left-1/2 transform -translate-x-1/2 glass px-6 py-3 rounded-full flex items-center gap-3 opacity-0 pointer-events-none transition-all duration-300 z-50 translate-y-[-20px]">
|
||||
<i class="fas fa-info-circle text-indigo-400"></i>
|
||||
<span class="text-sm" id="notificationText">通知内容</span>
|
||||
</div>
|
||||
|
||||
<!-- 通话结束确认对话框 -->
|
||||
<div id="endCallDialog" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 hidden">
|
||||
<div class="glass rounded-2xl p-6 w-80 max-w-md">
|
||||
<div class="text-center mb-6">
|
||||
<div class="w-16 h-16 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fas fa-phone-slash text-red-500 text-2xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-2">结束通话</h3>
|
||||
<p class="text-gray-400 text-sm">确定要结束当前通话吗?</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button id="cancelEndCall" class="flex-1 py-2 rounded-lg glass hover:bg-white/10 transition-colors">
|
||||
取消
|
||||
</button>
|
||||
<button id="confirmEndCall"
|
||||
class="flex-1 py-2 rounded-lg bg-red-500 hover:bg-red-600 transition-colors">
|
||||
结束通话
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 通话请求弹窗 -->
|
||||
<div id="callRequestDialog" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 hidden">
|
||||
<div class="glass rounded-2xl p-6 w-80 max-w-md">
|
||||
<div class="text-center mb-6">
|
||||
<div class="w-16 h-16 bg-indigo-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fas fa-video text-indigo-500 text-2xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-2" id="callRequestName">Sarah Chen</h3>
|
||||
<p class="text-gray-400 text-sm" id="callRequestText">正在请求与您进行视频通话</p>
|
||||
<div class="mt-4 flex items-center justify-center gap-4">
|
||||
<img id="callRequestAvatar"
|
||||
src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=100&h=100&fit=crop"
|
||||
class="w-16 h-16 rounded-full object-cover border-4 border-indigo-500">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button id="rejectCall" class="flex-1 py-2 rounded-lg glass hover:bg-white/10 transition-colors">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<i class="fas fa-phone-slash"></i>
|
||||
<span>拒绝</span>
|
||||
</div>
|
||||
</button>
|
||||
<button id="acceptCall"
|
||||
class="flex-1 py-2 rounded-lg bg-green-500 hover:bg-green-600 transition-colors">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<i class="fas fa-phone"></i>
|
||||
<span>接受</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 引入模块化JavaScript文件 -->
|
||||
<script type="module" src="connectview.js"></script>
|
||||
<script type="module" src="main.js"></script>
|
||||
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,43 +0,0 @@
|
||||
body {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
#player {
|
||||
position: relative;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
background-color: #323232;
|
||||
}
|
||||
|
||||
#player:before {
|
||||
content: "";
|
||||
display: block;
|
||||
padding-top: 66%;
|
||||
}
|
||||
|
||||
#playButton {
|
||||
width: 15%;
|
||||
max-width: 200px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#Video {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#fullscreenButton {
|
||||
position: absolute;
|
||||
top: 25px;
|
||||
right: 25px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<link rel="icon" href="../images/favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="../css/main.css" />
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
<title>Receiver Sample</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div id="container">
|
||||
<h1>Receiver Sample</h1>
|
||||
|
||||
<div id="warning" hidden="true"></div>
|
||||
|
||||
<div id="player"></div>
|
||||
|
||||
<div class="box">
|
||||
<span>Codec preferences:</span>
|
||||
<select id="codecPreferences" autocomplete="off" disabled>
|
||||
<option selected value="">Default</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<span>Lock Cursor to Player:</span>
|
||||
<input type="checkbox" id="lockMouseCheck" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<p>
|
||||
For more information about sample, see
|
||||
<a href="https://docs.unity3d.com/Packages/com.unity.renderstreaming@3.1/manual/sample-broadcast.html">Broadcast sample</a> document page.
|
||||
</p>
|
||||
|
||||
<div id="message"></div>
|
||||
|
||||
<section>
|
||||
<a href="https://github.com/Unity-Technologies/UnityRenderStreaming/tree/develop/WebApp/client/public/receiver"
|
||||
title="View source for this page on GitHub" id="viewSource">View source on GitHub</a>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript" src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
|
||||
<script src="https://unpkg.com/event-target@latest/min.js"></script>
|
||||
<script src="https://unpkg.com/resize-observer-polyfill@1.5.0/dist/ResizeObserver.global.js"></script>
|
||||
<script type="module" src="js/main.js"></script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,186 +0,0 @@
|
||||
import { getServerConfig, getRTCConfiguration } from "../../js/config.js";
|
||||
import { createDisplayStringArray } from "../../js/stats.js";
|
||||
import { VideoPlayer } from "../../js/videoplayer.js";
|
||||
import { RenderStreaming } from "../../module/renderstreaming.js";
|
||||
import { Signaling, WebSocketSignaling } from "../../module/signaling.js";
|
||||
|
||||
/** @type {Element} */
|
||||
let playButton;
|
||||
/** @type {RenderStreaming} */
|
||||
let renderstreaming;
|
||||
/** @type {boolean} */
|
||||
let useWebSocket;
|
||||
|
||||
const codecPreferences = document.getElementById('codecPreferences');
|
||||
const supportsSetCodecPreferences = window.RTCRtpTransceiver &&
|
||||
'setCodecPreferences' in window.RTCRtpTransceiver.prototype;
|
||||
const messageDiv = document.getElementById('message');
|
||||
messageDiv.style.display = 'none';
|
||||
|
||||
const playerDiv = document.getElementById('player');
|
||||
const lockMouseCheck = document.getElementById('lockMouseCheck');
|
||||
const videoPlayer = new VideoPlayer();
|
||||
|
||||
setup();
|
||||
|
||||
window.document.oncontextmenu = function () {
|
||||
return false; // cancel default menu
|
||||
};
|
||||
|
||||
window.addEventListener('resize', function () {
|
||||
videoPlayer.resizeVideo();
|
||||
}, true);
|
||||
|
||||
window.addEventListener('beforeunload', async () => {
|
||||
if(!renderstreaming)
|
||||
return;
|
||||
await renderstreaming.stop();
|
||||
}, true);
|
||||
|
||||
async function setup() {
|
||||
const res = await getServerConfig();
|
||||
useWebSocket = res.useWebSocket;
|
||||
showWarningIfNeeded(res.startupMode);
|
||||
showCodecSelect();
|
||||
showPlayButton();
|
||||
}
|
||||
|
||||
function showWarningIfNeeded(startupMode) {
|
||||
const warningDiv = document.getElementById("warning");
|
||||
if (startupMode == "private") {
|
||||
warningDiv.innerHTML = "<h4>Warning</h4> This sample is not working on Private Mode.";
|
||||
warningDiv.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showPlayButton() {
|
||||
if (!document.getElementById('playButton')) {
|
||||
const elementPlayButton = document.createElement('img');
|
||||
elementPlayButton.id = 'playButton';
|
||||
elementPlayButton.src = '../../images/Play.png';
|
||||
elementPlayButton.alt = 'Start Streaming';
|
||||
playButton = document.getElementById('player').appendChild(elementPlayButton);
|
||||
playButton.addEventListener('click', onClickPlayButton);
|
||||
}
|
||||
}
|
||||
|
||||
function onClickPlayButton() {
|
||||
playButton.style.display = 'none';
|
||||
|
||||
// add video player
|
||||
videoPlayer.createPlayer(playerDiv, lockMouseCheck);
|
||||
setupRenderStreaming();
|
||||
}
|
||||
|
||||
async function setupRenderStreaming() {
|
||||
codecPreferences.disabled = true;
|
||||
|
||||
const signaling = useWebSocket ? new WebSocketSignaling() : new Signaling();
|
||||
const config = getRTCConfiguration();
|
||||
renderstreaming = new RenderStreaming(signaling, config);
|
||||
renderstreaming.onConnect = onConnect;
|
||||
renderstreaming.onDisconnect = onDisconnect;
|
||||
renderstreaming.onTrackEvent = (data) => videoPlayer.addTrack(data.track);
|
||||
renderstreaming.onGotOffer = setCodecPreferences;
|
||||
|
||||
await renderstreaming.start();
|
||||
await renderstreaming.createConnection();
|
||||
}
|
||||
|
||||
function onConnect() {
|
||||
const channel = renderstreaming.createDataChannel("input");
|
||||
videoPlayer.setupInput(channel);
|
||||
showStatsMessage();
|
||||
}
|
||||
|
||||
async function onDisconnect(connectionId) {
|
||||
clearStatsMessage();
|
||||
messageDiv.style.display = 'block';
|
||||
messageDiv.innerText = `Disconnect peer on ${connectionId}.`;
|
||||
|
||||
await renderstreaming.stop();
|
||||
renderstreaming = null;
|
||||
videoPlayer.deletePlayer();
|
||||
if (supportsSetCodecPreferences) {
|
||||
codecPreferences.disabled = false;
|
||||
}
|
||||
showPlayButton();
|
||||
}
|
||||
|
||||
function setCodecPreferences() {
|
||||
/** @type {RTCRtpCodecCapability[] | null} */
|
||||
let selectedCodecs = null;
|
||||
if (supportsSetCodecPreferences) {
|
||||
const preferredCodec = codecPreferences.options[codecPreferences.selectedIndex];
|
||||
if (preferredCodec.value !== '') {
|
||||
const [mimeType, sdpFmtpLine] = preferredCodec.value.split(' ');
|
||||
const { codecs } = RTCRtpSender.getCapabilities('video');
|
||||
const selectedCodecIndex = codecs.findIndex(c => c.mimeType === mimeType && c.sdpFmtpLine === sdpFmtpLine);
|
||||
const selectCodec = codecs[selectedCodecIndex];
|
||||
selectedCodecs = [selectCodec];
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedCodecs == null) {
|
||||
return;
|
||||
}
|
||||
const transceivers = renderstreaming.getTransceivers().filter(t => t.receiver.track.kind == "video");
|
||||
if (transceivers && transceivers.length > 0) {
|
||||
transceivers.forEach(t => t.setCodecPreferences(selectedCodecs));
|
||||
}
|
||||
}
|
||||
|
||||
function showCodecSelect() {
|
||||
if (!supportsSetCodecPreferences) {
|
||||
messageDiv.style.display = 'block';
|
||||
messageDiv.innerHTML = `Current Browser does not support <a href="https://developer.mozilla.org/en-US/docs/Web/API/RTCRtpTransceiver/setCodecPreferences">RTCRtpTransceiver.setCodecPreferences</a>.`;
|
||||
return;
|
||||
}
|
||||
|
||||
const codecs = RTCRtpSender.getCapabilities('video').codecs;
|
||||
codecs.forEach(codec => {
|
||||
if (['video/red', 'video/ulpfec', 'video/rtx'].includes(codec.mimeType)) {
|
||||
return;
|
||||
}
|
||||
const option = document.createElement('option');
|
||||
option.value = (codec.mimeType + ' ' + (codec.sdpFmtpLine || '')).trim();
|
||||
option.innerText = option.value;
|
||||
codecPreferences.appendChild(option);
|
||||
});
|
||||
codecPreferences.disabled = false;
|
||||
}
|
||||
|
||||
/** @type {RTCStatsReport} */
|
||||
let lastStats;
|
||||
/** @type {number} */
|
||||
let intervalId;
|
||||
|
||||
function showStatsMessage() {
|
||||
intervalId = setInterval(async () => {
|
||||
if (renderstreaming == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = await renderstreaming.getStats();
|
||||
if (stats == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const array = createDisplayStringArray(stats, lastStats);
|
||||
if (array.length) {
|
||||
messageDiv.style.display = 'block';
|
||||
messageDiv.innerHTML = array.join('<br>');
|
||||
}
|
||||
lastStats = stats;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function clearStatsMessage() {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
lastStats = null;
|
||||
intervalId = null;
|
||||
messageDiv.style.display = 'none';
|
||||
messageDiv.innerHTML = '';
|
||||
}
|
||||
BIN
client/public/uploads/avatars/user_XPMwGa7W.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
@@ -1,103 +0,0 @@
|
||||
|
||||
body {
|
||||
margin: 0px;
|
||||
}
|
||||
button#muteButton {
|
||||
margin: 5px 0;
|
||||
width: auto;
|
||||
}
|
||||
#player {
|
||||
position: relative;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
background-color: #323232;
|
||||
}
|
||||
|
||||
#player:before {
|
||||
content: "";
|
||||
display: block;
|
||||
padding-top: 66%;
|
||||
}
|
||||
|
||||
#playButton {
|
||||
width: 15%;
|
||||
max-width: 200px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#Video {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#VideoThumbnail {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 30%;
|
||||
height: 30%;
|
||||
}
|
||||
|
||||
#greenButton {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
width: 160px;
|
||||
background-color: #4CAF50;
|
||||
/* Green */
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 15px 32px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#blueButton {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 180px;
|
||||
width: 160px;
|
||||
background-color: #447FAF;
|
||||
/* Blue */
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 15px 32px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#orangeButton {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 350px;
|
||||
width: 160px;
|
||||
background-color: #FF7700;
|
||||
/* Blue */
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 15px 32px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#fullscreenButton {
|
||||
position: absolute;
|
||||
top: 25px;
|
||||
right: 25px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 472 KiB |
@@ -1,37 +0,0 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<link rel="icon" href="../images/favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="../css/main.css" />
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
<title>VideoPlayer Sample</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div id="container">
|
||||
<h1>VideoPlayer Sample</h1>
|
||||
|
||||
<div id="warning" hidden=true></div>
|
||||
|
||||
<div id="player"></div>
|
||||
|
||||
<p>For more information about <code>WebBrowserInput</code> sample, see <a href="https://docs.unity3d.com/Packages/com.unity.renderstreaming@latest/sample-browserinput.html">WebBrowserInput
|
||||
sample</a> document page.</p>
|
||||
|
||||
<section>
|
||||
<a href="https://github.com/Unity-Technologies/UnityRenderStreaming/tree/develop/WebApp/public/videoplayer"
|
||||
title="View source for this page on GitHub" id="viewSource">View source on GitHub</a>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript" src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
|
||||
<script src="https://unpkg.com/event-target@latest/min.js"></script>
|
||||
<script src="https://unpkg.com/resize-observer-polyfill@1.5.0/dist/ResizeObserver.global.js"></script>
|
||||
<script type="module" src="js/main.js"></script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,146 +0,0 @@
|
||||
import * as Logger from "../../module/logger.js";
|
||||
|
||||
const _e = 0.09;
|
||||
const _gameloopInterval = 16.67; //in milliseconds, 60 times a second
|
||||
var gameloop = null;
|
||||
var gamepadsPreviousButtonsStates = {};
|
||||
var gamepadsPreviousAxesStates = {};
|
||||
var gamepadsConnectedTimeStamp = {};
|
||||
const _axisOffset = 100;
|
||||
const _axisMultiplier = 1;
|
||||
const _axisYInverted = -1;
|
||||
|
||||
class GamepadButtonEvent extends Event {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.index = arguments[1].index;
|
||||
this.id = arguments[1].id;
|
||||
this.value = arguments[1].value;
|
||||
}
|
||||
}
|
||||
|
||||
class GamepadAxisEvent extends Event {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.index = arguments[1].index;
|
||||
this.x = arguments[1].x;
|
||||
this.y = arguments[1].y;
|
||||
this.id = arguments[1].id;
|
||||
}
|
||||
}
|
||||
|
||||
function storePreviousState(gamepad) {
|
||||
gamepadsPreviousButtonsStates[gamepad.index] = {};
|
||||
gamepad.buttons.forEach(function (button, index) {
|
||||
gamepadsPreviousButtonsStates[gamepad.index][index] = { value: button.value, pressed: button.pressed };
|
||||
});
|
||||
|
||||
gamepadsPreviousAxesStates[gamepad.index] = [gamepad.axes.length];
|
||||
for (var index = 0; index < gamepad.axes.length; index++)
|
||||
gamepadsPreviousAxesStates[gamepad.index][index] = gamepad.axes[index];
|
||||
}
|
||||
|
||||
function checkAxes(gamepad, previousGamePad) {
|
||||
for (var i = 0; i < gamepad.axes.length; i += 2) {
|
||||
var absX = Math.abs(gamepad.axes[i]);
|
||||
var absY = Math.abs(gamepad.axes[i + 1]);
|
||||
var event = null;
|
||||
if ((absX > _e) ||
|
||||
(absY > _e)) {
|
||||
|
||||
event = new GamepadAxisEvent('gamepadAxis', { id: gamepadsConnectedTimeStamp[gamepad.index], index: i / 2 + _axisOffset, x: gamepad.axes[i] * _axisMultiplier, y: gamepad.axes[i + 1] * _axisMultiplier * _axisYInverted });
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
else {
|
||||
var previousAbsX = Math.abs(previousGamePad[i]);
|
||||
var previousAbsY = Math.abs(previousGamePad[i + 1]);
|
||||
|
||||
//have to send if previously was moved
|
||||
if ((previousAbsX > _e) ||
|
||||
(previousAbsY > _e)) {
|
||||
event = new GamepadAxisEvent('gamepadAxis', { id: gamepadsConnectedTimeStamp[gamepad.index], index: i / 2 + _axisOffset, x: 0.0, y: 0.0 });
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function gameLoop() {
|
||||
Object.keys(gamepadsPreviousAxesStates).forEach(function (gamepadIndex) {
|
||||
var gamepad = navigator.webkitGetGamepads ? navigator.webkitGetGamepads()[gamepadIndex] : navigator.getGamepads()[gamepadIndex];
|
||||
var previousButtons = gamepadsPreviousButtonsStates[gamepadIndex];
|
||||
gamepad.buttons.forEach(function (button, index) {
|
||||
var buttonStatus = navigator.webkitGetGamepads ? button == 1 : (button.value > 0 || button.pressed == true);
|
||||
var previousButtonStatus = navigator.webkitGetGamepads ? previousButtons[index].value == 1 : (previousButtons[index].value > 0 || previousButtons[index].pressed == true);
|
||||
var event;
|
||||
if (buttonStatus != previousButtonStatus) {
|
||||
if (buttonStatus) {
|
||||
event = new GamepadButtonEvent('gamepadButtonDown', { id: gamepadsConnectedTimeStamp[gamepad.index], index: index, value: button.value });
|
||||
}
|
||||
else {
|
||||
event = new GamepadButtonEvent('gamepadButtonUp', { id: gamepadsConnectedTimeStamp[gamepad.index], index: index, value: 0 });
|
||||
}
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
else if (buttonStatus) {
|
||||
event = new GamepadButtonEvent('gamepadButtonPressed', { id: gamepadsConnectedTimeStamp[gamepad.index], index: index, value: button.value });
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
});
|
||||
checkAxes(gamepad, gamepadsPreviousAxesStates[gamepadIndex]);
|
||||
storePreviousState(gamepad);
|
||||
});
|
||||
}
|
||||
|
||||
function getCookie(cname) {
|
||||
var name = cname + "=";
|
||||
var decodedCookie = decodeURIComponent(document.cookie);
|
||||
var ca = decodedCookie.split(';');
|
||||
for (var i = 0; i < ca.length; i++) {
|
||||
var c = ca[i];
|
||||
while (c.charAt(0) == ' ') {
|
||||
c = c.substring(1);
|
||||
}
|
||||
if (c.indexOf(name) == 0) {
|
||||
return c.substring(name.length, c.length);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function gamepadHandler(event, connecting) {
|
||||
var gamepad = event.gamepad;
|
||||
|
||||
var key = gamepad.id.replace(/\s/g, '');
|
||||
var cookieTimeStamp = getCookie(key);
|
||||
|
||||
if (connecting) {
|
||||
storePreviousState(gamepad);
|
||||
if (Object.keys(gamepadsPreviousAxesStates).length == 1) {
|
||||
gameloop = setInterval(gameLoop, _gameloopInterval);
|
||||
}
|
||||
|
||||
//try to find the timestamp
|
||||
//need to strip the : from the id
|
||||
|
||||
if (cookieTimeStamp == "") {
|
||||
document.cookie = key + "=" + gamepad.timestamp;
|
||||
gamepadsConnectedTimeStamp[gamepad.index] = gamepad.timestamp;
|
||||
}
|
||||
else {
|
||||
gamepadsConnectedTimeStamp[gamepad.index] = cookieTimeStamp;
|
||||
}
|
||||
|
||||
Logger.log("connected: " + gamepadsConnectedTimeStamp[gamepad.index]);
|
||||
|
||||
} else {
|
||||
delete gamepadsPreviousAxesStates[gamepad.index];
|
||||
delete gamepadsPreviousButtonsStates[gamepad.index];
|
||||
if (Object.keys(gamepadsPreviousAxesStates).length == 0) {
|
||||
clearInterval(gameloop);
|
||||
gameloop = null;
|
||||
}
|
||||
Logger.log("disconnected: " + gamepad.id);
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import { VideoPlayer } from "./video-player.js";
|
||||
import { registerGamepadEvents, registerKeyboardEvents, registerMouseEvents, sendClickEvent } from "./register-events.js";
|
||||
import { getServerConfig } from "../../js/config.js";
|
||||
|
||||
setup();
|
||||
|
||||
let playButton;
|
||||
let videoPlayer;
|
||||
let useWebSocket;
|
||||
|
||||
window.document.oncontextmenu = function () {
|
||||
return false; // cancel default menu
|
||||
};
|
||||
|
||||
window.addEventListener('resize', function () {
|
||||
videoPlayer.resizeVideo();
|
||||
}, true);
|
||||
|
||||
window.addEventListener('beforeunload', async () => {
|
||||
await videoPlayer.stop();
|
||||
}, true);
|
||||
|
||||
async function setup() {
|
||||
const res = await getServerConfig();
|
||||
useWebSocket = res.useWebSocket;
|
||||
showWarningIfNeeded(res.startupMode);
|
||||
showPlayButton();
|
||||
}
|
||||
|
||||
function showWarningIfNeeded(startupMode) {
|
||||
const warningDiv = document.getElementById("warning");
|
||||
if (startupMode == "private") {
|
||||
warningDiv.innerHTML = "<h4>Warning</h4> This sample is not working on Private Mode.";
|
||||
warningDiv.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showPlayButton() {
|
||||
if (!document.getElementById('playButton')) {
|
||||
let elementPlayButton = document.createElement('img');
|
||||
elementPlayButton.id = 'playButton';
|
||||
elementPlayButton.src = 'images/Play.png';
|
||||
elementPlayButton.alt = 'Start Streaming';
|
||||
playButton = document.getElementById('player').appendChild(elementPlayButton);
|
||||
playButton.addEventListener('click', onClickPlayButton);
|
||||
}
|
||||
}
|
||||
|
||||
function onClickPlayButton() {
|
||||
|
||||
playButton.style.display = 'none';
|
||||
|
||||
const playerDiv = document.getElementById('player');
|
||||
|
||||
// add video player
|
||||
const elementVideo = document.createElement('video');
|
||||
elementVideo.id = 'Video';
|
||||
elementVideo.style.touchAction = 'none';
|
||||
playerDiv.appendChild(elementVideo);
|
||||
|
||||
// add video thumbnail
|
||||
const elementVideoThumb = document.createElement('video');
|
||||
elementVideoThumb.id = 'VideoThumbnail';
|
||||
elementVideoThumb.style.touchAction = 'none';
|
||||
playerDiv.appendChild(elementVideoThumb);
|
||||
|
||||
setupVideoPlayer([elementVideo, elementVideoThumb]).then(value => videoPlayer = value);
|
||||
|
||||
// add blue button
|
||||
const elementBlueButton = document.createElement('button');
|
||||
elementBlueButton.id = "blueButton";
|
||||
elementBlueButton.innerHTML = "Light on";
|
||||
playerDiv.appendChild(elementBlueButton);
|
||||
elementBlueButton.addEventListener("click", function () {
|
||||
sendClickEvent(videoPlayer, 1);
|
||||
});
|
||||
|
||||
// add green button
|
||||
const elementGreenButton = document.createElement('button');
|
||||
elementGreenButton.id = "greenButton";
|
||||
elementGreenButton.innerHTML = "Light off";
|
||||
playerDiv.appendChild(elementGreenButton);
|
||||
elementGreenButton.addEventListener("click", function () {
|
||||
sendClickEvent(videoPlayer, 2);
|
||||
});
|
||||
|
||||
// add orange button
|
||||
const elementOrangeButton = document.createElement('button');
|
||||
elementOrangeButton.id = "orangeButton";
|
||||
elementOrangeButton.innerHTML = "Play audio";
|
||||
playerDiv.appendChild(elementOrangeButton);
|
||||
elementOrangeButton.addEventListener("click", function () {
|
||||
sendClickEvent(videoPlayer, 3);
|
||||
});
|
||||
|
||||
// add fullscreen button
|
||||
const elementFullscreenButton = document.createElement('img');
|
||||
elementFullscreenButton.id = 'fullscreenButton';
|
||||
elementFullscreenButton.src = 'images/FullScreen.png';
|
||||
playerDiv.appendChild(elementFullscreenButton);
|
||||
elementFullscreenButton.addEventListener("click", function () {
|
||||
if (!document.fullscreenElement || !document.webkitFullscreenElement) {
|
||||
if (document.documentElement.requestFullscreen) {
|
||||
document.documentElement.requestFullscreen();
|
||||
}
|
||||
else if (document.documentElement.webkitRequestFullscreen) {
|
||||
document.documentElement.webkitRequestFullscreen(Element.ALLOW_KEYBOARD_INPUT);
|
||||
} else {
|
||||
if (playerDiv.style.position == "absolute") {
|
||||
playerDiv.style.position = "relative";
|
||||
} else {
|
||||
playerDiv.style.position = "absolute";
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
document.addEventListener('webkitfullscreenchange', onFullscreenChange);
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange);
|
||||
|
||||
function onFullscreenChange() {
|
||||
if (document.webkitFullscreenElement || document.fullscreenElement) {
|
||||
playerDiv.style.position = "absolute";
|
||||
elementFullscreenButton.style.display = 'none';
|
||||
}
|
||||
else {
|
||||
playerDiv.style.position = "relative";
|
||||
elementFullscreenButton.style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function setupVideoPlayer(elements) {
|
||||
const videoPlayer = new VideoPlayer(elements);
|
||||
await videoPlayer.setupConnection(useWebSocket);
|
||||
|
||||
videoPlayer.ondisconnect = onDisconnect;
|
||||
registerGamepadEvents(videoPlayer);
|
||||
registerKeyboardEvents(videoPlayer);
|
||||
registerMouseEvents(videoPlayer, elements[0]);
|
||||
|
||||
return videoPlayer;
|
||||
}
|
||||
|
||||
function onDisconnect() {
|
||||
const playerDiv = document.getElementById('player');
|
||||
clearChildren(playerDiv);
|
||||
videoPlayer.stop();
|
||||
videoPlayer = null;
|
||||
showPlayButton();
|
||||
}
|
||||
|
||||
function clearChildren(element) {
|
||||
while (element.firstChild) {
|
||||
element.removeChild(element.firstChild);
|
||||
}
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
import { gamepadHandler } from "./gamepadEvents.js";
|
||||
import * as Logger from "../../module/logger.js";
|
||||
import { Keymap } from "../../module/keymap.js";
|
||||
|
||||
const InputEvent = {
|
||||
Keyboard: 0,
|
||||
Mouse: 1,
|
||||
MouseWheel: 2,
|
||||
Touch: 3,
|
||||
ButtonClick: 4,
|
||||
Gamepad: 5
|
||||
};
|
||||
|
||||
const KeyboardEventType = {
|
||||
Up: 0,
|
||||
Down: 1
|
||||
};
|
||||
|
||||
const GamepadEventType = {
|
||||
ButtonUp: 0,
|
||||
ButtonDown: 1,
|
||||
ButtonPressed: 2,
|
||||
Axis: 3
|
||||
};
|
||||
|
||||
const PointerPhase = {
|
||||
None: 0,
|
||||
Began: 1,
|
||||
Moved: 2,
|
||||
Ended: 3,
|
||||
Canceled: 4,
|
||||
Stationary: 5
|
||||
};
|
||||
|
||||
let sendGamepadButtonDown = undefined;
|
||||
let sendGamepadButtonUp = undefined;
|
||||
let sendGamepadButtonPressed;
|
||||
let gamepadAxisChange = undefined;
|
||||
let gamepadConnected = undefined;
|
||||
let gamepadDisconnected = undefined;
|
||||
|
||||
export function registerGamepadEvents(videoPlayer) {
|
||||
|
||||
const _videoPlayer = videoPlayer;
|
||||
|
||||
sendGamepadButtonDown = (e) => {
|
||||
Logger.log("gamepad id: " + e.id + " button index: " + e.index + " value " + e.value + " down");
|
||||
let data = new DataView(new ArrayBuffer(19));
|
||||
data.setUint8(0, InputEvent.Gamepad);
|
||||
data.setUint8(1, GamepadEventType.ButtonDown);
|
||||
data.setUint8(2, e.index);
|
||||
data.setFloat64(3, e.value, true);
|
||||
|
||||
_videoPlayer && _videoPlayer.sendMsg(data.buffer);
|
||||
};
|
||||
|
||||
sendGamepadButtonUp = (e) => {
|
||||
Logger.log("gamepad id: " + e.id + " button index: " + e.index + " value " + e.value + " up");
|
||||
let data = new DataView(new ArrayBuffer(19));
|
||||
data.setUint8(0, InputEvent.Gamepad);
|
||||
data.setUint8(1, GamepadEventType.ButtonUp);
|
||||
data.setUint8(2, e.index);
|
||||
data.setFloat64(3, e.value, true);
|
||||
|
||||
_videoPlayer && _videoPlayer.sendMsg(data.buffer);
|
||||
};
|
||||
|
||||
sendGamepadButtonPressed = (e) => {
|
||||
Logger.log("gamepad id: " + e.id + " button index: " + e.index + " value " + e.value + " pressed");
|
||||
let data = new DataView(new ArrayBuffer(19));
|
||||
data.setUint8(0, InputEvent.Gamepad);
|
||||
data.setUint8(1, GamepadEventType.ButtonPressed);
|
||||
data.setUint8(2, e.index);
|
||||
data.setFloat64(3, e.value, true);
|
||||
|
||||
_videoPlayer && _videoPlayer.sendMsg(data.buffer);
|
||||
};
|
||||
|
||||
gamepadAxisChange = (e) => {
|
||||
Logger.log("gamepad id: " + e.id + " axis: " + e.index + " value " + e.value + " x:" + e.x + " y:" + e.y);
|
||||
let data = new DataView(new ArrayBuffer(27));
|
||||
data.setUint8(0, InputEvent.Gamepad);
|
||||
data.setUint8(1, GamepadEventType.Axis);
|
||||
data.setUint8(2, e.index);
|
||||
data.setFloat64(3, e.x, true);
|
||||
data.setFloat64(11, e.y, true);
|
||||
_videoPlayer && _videoPlayer.sendMsg(data.buffer);
|
||||
};
|
||||
|
||||
gamepadConnected = (e) => { gamepadHandler(e, true); };
|
||||
gamepadDisconnected = (e) => { gamepadHandler(e, false); };
|
||||
|
||||
|
||||
document.addEventListener("gamepadButtonDown", sendGamepadButtonDown, false);
|
||||
document.addEventListener("gamepadButtonUp", sendGamepadButtonUp, false);
|
||||
document.addEventListener("gamepadButtonPressed", sendGamepadButtonPressed, false);
|
||||
document.addEventListener("gamepadAxis", gamepadAxisChange, false);
|
||||
|
||||
window.addEventListener("gamepadconnected", gamepadConnected, false);
|
||||
window.addEventListener("gamepaddisconnected", gamepadDisconnected, false);
|
||||
}
|
||||
|
||||
export function unregisterGamepadEvents() {
|
||||
|
||||
document.removeEventListener("gamepadButtonDown", sendGamepadButtonDown, false);
|
||||
document.removeEventListener("gamepadButtonUp", sendGamepadButtonUp, false);
|
||||
document.removeEventListener("gamepadButtonPressed", sendGamepadButtonPressed, false);
|
||||
document.removeEventListener("gamepadAxis", gamepadAxisChange, false);
|
||||
|
||||
window.removeEventListener("gamepadconnected", gamepadConnected, false);
|
||||
window.removeEventListener("gamepaddisconnected", gamepadDisconnected, false);
|
||||
|
||||
}
|
||||
|
||||
|
||||
let sendKeyUp = undefined;
|
||||
let sendKeyDown = undefined;
|
||||
|
||||
|
||||
export function registerKeyboardEvents(videoPlayer) {
|
||||
|
||||
const _videoPlayer = videoPlayer;
|
||||
|
||||
function sendKey(e, type) {
|
||||
const key = Keymap[e.code];
|
||||
const character = e.key.length === 1 ? e.key.charCodeAt(0) : 0;
|
||||
Logger.log("key down " + key + ", repeat = " + e.repeat + ", character = " + character);
|
||||
_videoPlayer && _videoPlayer.sendMsg(new Uint8Array([InputEvent.Keyboard, type, e.repeat, key, character]).buffer);
|
||||
}
|
||||
|
||||
|
||||
sendKeyUp = (e) => {
|
||||
sendKey(e, KeyboardEventType.Up);
|
||||
};
|
||||
|
||||
sendKeyDown = (e) => {
|
||||
sendKey(e, KeyboardEventType.Down);
|
||||
};
|
||||
|
||||
document.addEventListener('keyup', sendKeyUp, false);
|
||||
document.addEventListener('keydown', sendKeyDown, false);
|
||||
}
|
||||
|
||||
|
||||
export function unregisterKeyboardEvents() {
|
||||
|
||||
//Stop listening to keyboard events
|
||||
document.removeEventListener('keyup', sendKeyUp, false);
|
||||
document.removeEventListener('keydown', sendKeyDown, false);
|
||||
}
|
||||
|
||||
|
||||
let sendMouse = undefined;
|
||||
let sendMouseWheel = undefined;
|
||||
let sendTouchEnd = undefined;
|
||||
let sendTouchStart = undefined;
|
||||
let sendTouchCancel = undefined;
|
||||
let sendTouchMove = undefined;
|
||||
|
||||
|
||||
export function registerMouseEvents(videoPlayer, playerElement) {
|
||||
|
||||
const _videoPlayer = videoPlayer;
|
||||
|
||||
function sendTouch(e, phase) {
|
||||
const changedTouches = Array.from(e.changedTouches);
|
||||
const touches = Array.from(e.touches);
|
||||
const phrases = [];
|
||||
|
||||
for (let i = 0; i < changedTouches.length; i++) {
|
||||
if (touches.find(function (t) {
|
||||
return t.identifier === changedTouches[i].identifier;
|
||||
}) === undefined) {
|
||||
touches.push(changedTouches[i]);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < touches.length; i++) {
|
||||
touches[i].identifier;
|
||||
phrases[i] = changedTouches.find(
|
||||
function (e) {
|
||||
return e.identifier === touches[i].identifier;
|
||||
}) === undefined ? PointerPhase.Stationary : phase;
|
||||
}
|
||||
|
||||
Logger.log("touch phase:" + phase + " length:" + changedTouches.length + " pageX" + changedTouches[0].pageX + ", pageX: " + changedTouches[0].pageY + ", force:" + changedTouches[0].force);
|
||||
|
||||
let data = new DataView(new ArrayBuffer(2 + 13 * touches.length));
|
||||
data.setUint8(0, InputEvent.Touch);
|
||||
data.setUint8(1, touches.length);
|
||||
let byteOffset = 2;
|
||||
for (let i = 0; i < touches.length; i++) {
|
||||
|
||||
const scale = _videoPlayer.videoScale;
|
||||
const originX = _videoPlayer.videoOriginX;
|
||||
const originY = _videoPlayer.videoOriginY;
|
||||
|
||||
const x = (touches[i].pageX - originX) / scale;
|
||||
// According to Unity Coordinate system
|
||||
// const y = (touches[i].pageX - originY) / scale;
|
||||
const y = _videoPlayer.videoHeight - (touches[i].pageY - originY) / scale;
|
||||
|
||||
data.setInt32(byteOffset, touches[i].identifier, true);
|
||||
byteOffset += 4;
|
||||
data.setUint8(byteOffset, phrases[i]);
|
||||
byteOffset += 1;
|
||||
data.setInt16(byteOffset, x, true);
|
||||
byteOffset += 2;
|
||||
data.setInt16(byteOffset, y, true);
|
||||
byteOffset += 2;
|
||||
data.setFloat32(byteOffset, touches[i].force, true);
|
||||
byteOffset += 4;
|
||||
}
|
||||
_videoPlayer && _videoPlayer.sendMsg(data.buffer);
|
||||
}
|
||||
|
||||
sendTouchMove = (e) => {
|
||||
sendTouch(e, PointerPhase.Moved);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
sendTouchStart = (e) => {
|
||||
sendTouch(e, PointerPhase.Began);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
sendTouchEnd = (e) => {
|
||||
sendTouch(e, PointerPhase.Ended);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
sendTouchCancel = (e) => {
|
||||
sendTouch(e, PointerPhase.Canceled);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
sendMouse = (e) => {
|
||||
const scale = _videoPlayer.videoScale;
|
||||
const originX = _videoPlayer.videoOriginX;
|
||||
const originY = _videoPlayer.videoOriginY;
|
||||
|
||||
const x = (e.clientX - originX) / scale;
|
||||
// According to Unity Coordinate system
|
||||
// const y = (e.clientY - originY) / scale;
|
||||
const y = _videoPlayer.videoHeight - (e.clientY - originY) / scale;
|
||||
|
||||
Logger.log("x: " + x + ", y: " + y + ", scale: " + scale + ", originX: " + originX + ", originY: " + originY + " mouse button:" + e.buttons);
|
||||
let data = new DataView(new ArrayBuffer(6));
|
||||
data.setUint8(0, InputEvent.Mouse);
|
||||
data.setInt16(1, x, true);
|
||||
data.setInt16(3, y, true);
|
||||
data.setUint8(5, e.buttons);
|
||||
_videoPlayer && _videoPlayer.sendMsg(data.buffer);
|
||||
};
|
||||
|
||||
function sendMouseWheel(e) {
|
||||
Logger.log("mouse wheel with delta " + e.wheelDelta);
|
||||
let data = new DataView(new ArrayBuffer(9));
|
||||
data.setUint8(0, InputEvent.MouseWheel);
|
||||
data.setFloat32(1, e.deltaX, true);
|
||||
data.setFloat32(5, e.deltaY, true);
|
||||
_videoPlayer && _videoPlayer.sendMsg(data.buffer);
|
||||
}
|
||||
|
||||
// Listen to mouse events
|
||||
playerElement.addEventListener('click', sendMouse, false);
|
||||
playerElement.addEventListener('mousedown', sendMouse, false);
|
||||
playerElement.addEventListener('mouseup', sendMouse, false);
|
||||
playerElement.addEventListener('mousemove', sendMouse, false);
|
||||
playerElement.addEventListener('wheel', sendMouseWheel, false);
|
||||
|
||||
// Listen to touch events based on "Touch Events Level1" TR.
|
||||
//
|
||||
// Touch event Level1 https://www.w3.org/TR/touch-events/
|
||||
// Touch event Level2 https://w3c.github.io/touch-events/
|
||||
//
|
||||
playerElement.addEventListener('touchend', sendTouchEnd, false);
|
||||
playerElement.addEventListener('touchstart', sendTouchStart, false);
|
||||
playerElement.addEventListener('touchcancel', sendTouchCancel, false);
|
||||
playerElement.addEventListener('touchmove', sendTouchMove, false);
|
||||
}
|
||||
|
||||
|
||||
export function unregisterMouseEvents(playerElement) {
|
||||
|
||||
// Stop listening to mouse events
|
||||
playerElement.removeEventListener('click', sendMouse, false);
|
||||
playerElement.removeEventListener('mousedown', sendMouse, false);
|
||||
playerElement.removeEventListener('mouseup', sendMouse, false);
|
||||
playerElement.removeEventListener('mousemove', sendMouse, false);
|
||||
playerElement.removeEventListener('wheel', sendMouseWheel, false);
|
||||
|
||||
// Stop listening to touch events based on "Touch Events Level1" TR.
|
||||
playerElement.removeEventListener('touchend', sendTouchEnd, false);
|
||||
playerElement.removeEventListener('touchstart', sendTouchStart, false);
|
||||
playerElement.removeEventListener('touchcancel', sendTouchCancel, false);
|
||||
playerElement.removeEventListener('touchmove', sendTouchMove, false);
|
||||
|
||||
}
|
||||
|
||||
|
||||
export function sendClickEvent(videoPlayer, elementId) {
|
||||
let data = new DataView(new ArrayBuffer(3));
|
||||
data.setUint8(0, InputEvent.ButtonClick);
|
||||
data.setInt16(1, elementId, true);
|
||||
videoPlayer && videoPlayer.sendMsg(data.buffer);
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
import { Signaling, WebSocketSignaling } from "../../module/signaling.js";
|
||||
import Peer from "../../module/peer.js";
|
||||
import * as Logger from "../../module/logger.js";
|
||||
|
||||
|
||||
// enum type of event sending from Unity
|
||||
var UnityEventType = {
|
||||
SWITCH_VIDEO: 0
|
||||
};
|
||||
|
||||
function uuid4() {
|
||||
var temp_url = URL.createObjectURL(new Blob());
|
||||
var uuid = temp_url.toString();
|
||||
URL.revokeObjectURL(temp_url);
|
||||
return uuid.split(/[:/]/g).pop().toLowerCase(); // remove prefixes
|
||||
}
|
||||
|
||||
export class VideoPlayer {
|
||||
constructor(elements) {
|
||||
const _this = this;
|
||||
this.pc = null;
|
||||
this.channel = null;
|
||||
this.connectionId = null;
|
||||
|
||||
// main video
|
||||
this.localStream = new MediaStream();
|
||||
this.video = elements[0];
|
||||
this.video.playsInline = true;
|
||||
this.video.addEventListener('loadedmetadata', function () {
|
||||
_this.video.play();
|
||||
_this.resizeVideo();
|
||||
}, true);
|
||||
|
||||
// secondly video
|
||||
this.localStream2 = new MediaStream();
|
||||
this.videoThumb = elements[1];
|
||||
this.videoThumb.playsInline = true;
|
||||
this.videoThumb.addEventListener('loadedmetadata', function () {
|
||||
_this.videoThumb.play();
|
||||
}, true);
|
||||
|
||||
this.videoTrackList = [];
|
||||
this.videoTrackIndex = 0;
|
||||
this.maxVideoTrackLength = 2;
|
||||
|
||||
this.ondisconnect = function () { };
|
||||
}
|
||||
|
||||
async setupConnection(useWebSocket) {
|
||||
const _this = this;
|
||||
// close current RTCPeerConnection
|
||||
if (this.pc) {
|
||||
Logger.log('Close current PeerConnection');
|
||||
this.pc.close();
|
||||
this.pc = null;
|
||||
}
|
||||
|
||||
if (useWebSocket) {
|
||||
this.signaling = new WebSocketSignaling();
|
||||
} else {
|
||||
this.signaling = new Signaling();
|
||||
}
|
||||
|
||||
this.connectionId = uuid4();
|
||||
|
||||
// Create peerConnection with proxy server and set up handlers
|
||||
this.pc = new Peer(this.connectionId, true);
|
||||
this.pc.addEventListener('disconnect', () => {
|
||||
_this.ondisconnect();
|
||||
});
|
||||
this.pc.addEventListener('trackevent', (e) => {
|
||||
const data = e.detail;
|
||||
if (data.track.kind == 'video') {
|
||||
_this.videoTrackList.push(data.track);
|
||||
}
|
||||
if (data.track.kind == 'audio') {
|
||||
_this.localStream.addTrack(data.track);
|
||||
}
|
||||
if (_this.videoTrackList.length == _this.maxVideoTrackLength) {
|
||||
_this.switchVideo(_this.videoTrackIndex);
|
||||
}
|
||||
});
|
||||
this.pc.addEventListener('sendoffer', (e) => {
|
||||
const offer = e.detail;
|
||||
_this.signaling.sendOffer(offer.connectionId, offer.sdp);
|
||||
});
|
||||
this.pc.addEventListener('sendanswer', (e) => {
|
||||
const answer = e.detail;
|
||||
_this.signaling.sendAnswer(answer.connectionId, answer.sdp);
|
||||
});
|
||||
this.pc.addEventListener('sendcandidate', (e) => {
|
||||
const candidate = e.detail;
|
||||
_this.signaling.sendCandidate(candidate.connectionId, candidate.candidate, candidate.sdpMid, candidate.sdpMLineIndex);
|
||||
});
|
||||
|
||||
this.signaling.addEventListener('disconnect', async (e) => {
|
||||
const data = e.detail;
|
||||
if (_this.pc != null && _this.pc.connectionId == data.connectionId) {
|
||||
_this.ondisconnect();
|
||||
}
|
||||
});
|
||||
this.signaling.addEventListener('offer', async (e) => {
|
||||
const offer = e.detail;
|
||||
const desc = new RTCSessionDescription({ sdp: offer.sdp, type: "offer" });
|
||||
if (_this.pc != null) {
|
||||
await _this.pc.onGotDescription(offer.connectionId, desc);
|
||||
}
|
||||
});
|
||||
this.signaling.addEventListener('answer', async (e) => {
|
||||
const answer = e.detail;
|
||||
const desc = new RTCSessionDescription({ sdp: answer.sdp, type: "answer" });
|
||||
if (_this.pc != null) {
|
||||
await _this.pc.onGotDescription(answer.connectionId, desc);
|
||||
}
|
||||
});
|
||||
this.signaling.addEventListener('candidate', async (e) => {
|
||||
const candidate = e.detail;
|
||||
const iceCandidate = new RTCIceCandidate({ candidate: candidate.candidate, sdpMid: candidate.sdpMid, sdpMLineIndex: candidate.sdpMLineIndex });
|
||||
if (_this.pc != null) {
|
||||
await _this.pc.onGotCandidate(candidate.connectionId, iceCandidate);
|
||||
}
|
||||
});
|
||||
|
||||
// setup signaling
|
||||
await this.signaling.start();
|
||||
|
||||
// Create data channel with proxy server and set up handlers
|
||||
this.channel = this.pc.createDataChannel(this.connectionId, 'data');
|
||||
this.channel.onopen = function () {
|
||||
Logger.log('Datachannel connected.');
|
||||
};
|
||||
this.channel.onerror = function (e) {
|
||||
Logger.log("The error " + e.error.message + " occurred\n while handling data with proxy server.");
|
||||
};
|
||||
this.channel.onclose = function () {
|
||||
Logger.log('Datachannel disconnected.');
|
||||
};
|
||||
this.channel.onmessage = async (msg) => {
|
||||
// receive message from unity and operate message
|
||||
let data;
|
||||
// receive message data type is blob only on Firefox
|
||||
if (navigator.userAgent.indexOf('Firefox') != -1) {
|
||||
data = await msg.data.arrayBuffer();
|
||||
} else {
|
||||
data = msg.data;
|
||||
}
|
||||
const bytes = new Uint8Array(data);
|
||||
_this.videoTrackIndex = bytes[1];
|
||||
switch (bytes[0]) {
|
||||
case UnityEventType.SWITCH_VIDEO:
|
||||
_this.switchVideo(_this.videoTrackIndex);
|
||||
break;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
resizeVideo() {
|
||||
const clientRect = this.video.getBoundingClientRect();
|
||||
const videoRatio = this.videoWidth / this.videoHeight;
|
||||
const clientRatio = clientRect.width / clientRect.height;
|
||||
|
||||
this._videoScale = videoRatio > clientRatio ? clientRect.width / this.videoWidth : clientRect.height / this.videoHeight;
|
||||
const videoOffsetX = videoRatio > clientRatio ? 0 : (clientRect.width - this.videoWidth * this._videoScale) * 0.5;
|
||||
const videoOffsetY = videoRatio > clientRatio ? (clientRect.height - this.videoHeight * this._videoScale) * 0.5 : 0;
|
||||
this._videoOriginX = clientRect.left + videoOffsetX;
|
||||
this._videoOriginY = clientRect.top + videoOffsetY;
|
||||
}
|
||||
|
||||
// switch streaming destination main video and secondly video
|
||||
switchVideo(indexVideoTrack) {
|
||||
this.video.srcObject = this.localStream;
|
||||
this.videoThumb.srcObject = this.localStream2;
|
||||
|
||||
if (indexVideoTrack == 0) {
|
||||
this.replaceTrack(this.localStream, this.videoTrackList[0]);
|
||||
this.replaceTrack(this.localStream2, this.videoTrackList[1]);
|
||||
}
|
||||
else {
|
||||
this.replaceTrack(this.localStream, this.videoTrackList[1]);
|
||||
this.replaceTrack(this.localStream2, this.videoTrackList[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// replace video track related the MediaStream
|
||||
replaceTrack(stream, newTrack) {
|
||||
const tracks = stream.getVideoTracks();
|
||||
for (const track of tracks) {
|
||||
if (track.kind == 'video') {
|
||||
stream.removeTrack(track);
|
||||
}
|
||||
}
|
||||
stream.addTrack(newTrack);
|
||||
}
|
||||
|
||||
get videoWidth() {
|
||||
return this.video.videoWidth;
|
||||
}
|
||||
|
||||
get videoHeight() {
|
||||
return this.video.videoHeight;
|
||||
}
|
||||
|
||||
get videoOriginX() {
|
||||
return this._videoOriginX;
|
||||
}
|
||||
|
||||
get videoOriginY() {
|
||||
return this._videoOriginY;
|
||||
}
|
||||
|
||||
get videoScale() {
|
||||
return this._videoScale;
|
||||
}
|
||||
|
||||
sendMsg(msg) {
|
||||
if (this.channel == null) {
|
||||
return;
|
||||
}
|
||||
switch (this.channel.readyState) {
|
||||
case 'connecting':
|
||||
Logger.log('Connection not ready');
|
||||
break;
|
||||
case 'open':
|
||||
this.channel.send(msg);
|
||||
break;
|
||||
case 'closing':
|
||||
Logger.log('Attempt to sendMsg message while closing');
|
||||
break;
|
||||
case 'closed':
|
||||
Logger.log('Attempt to sendMsg message while connection closed.');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (this.signaling) {
|
||||
await this.signaling.stop();
|
||||
this.signaling = null;
|
||||
}
|
||||
|
||||
if (this.pc) {
|
||||
this.pc.close();
|
||||
this.pc = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,7 @@ function add(ws: WebSocket): void {
|
||||
clients.set(ws, id);
|
||||
(ws as any).socketId = (ws as any).socketId || `ws_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
// 记录添加WebSocket连接的日志
|
||||
log(LogLevel.log, `Add WebSocket: ${ws.url}`);
|
||||
log(LogLevel.log, `Add WebSocket: ${(ws as any).socketId.toString() }`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||