32 Commits

Author SHA1 Message Date
d74a0c8121 优化 2026-05-25 22:58:11 +08:00
e6dfb28ef2 房间成员信息刷新 2026-05-25 22:21:50 +08:00
ad93ef342b 完成新页面开发 2026-05-25 21:57:58 +08:00
40fd7f7e08 优化目录结构 2026-05-25 20:37:36 +08:00
bbe7e71274 新增视频管理 2026-05-25 17:39:57 +08:00
254d9337bf 优化信息 2026-05-25 16:58:41 +08:00
cc734790ef 视频录制开发 2026-05-25 16:39:13 +08:00
eb0106d296 ++ 2026-05-24 22:26:57 +08:00
518f8a94b3 log优化 2026-05-24 14:16:28 +08:00
e48a6eae3c 打包目标优化 2026-05-24 14:05:51 +08:00
e00192daf9 ++ 分辨率设置拆除 2026-05-24 13:56:53 +08:00
c89b22d320 ++ 2026-05-24 13:29:54 +08:00
20760a2668 ++ 2026-05-24 13:03:22 +08:00
a37fba5519 ++ 2026-05-24 12:56:50 +08:00
9c05c6a9d9 通话模块拆分 2026-05-24 12:43:16 +08:00
554bb5d9ee 解决乱码问题 2026-05-24 01:54:47 +08:00
68712fba8c ++ 2026-05-24 01:46:57 +08:00
ac16fa85e9 ++ 2026-05-24 01:29:34 +08:00
a30c74f8da ++ 2026-05-24 01:18:27 +08:00
0d8a567c95 拆分媒体 2026-05-24 01:01:28 +08:00
44f4b30313 拆分part 2026-05-24 00:54:58 +08:00
a413c56a6f 优化代码 2026-05-23 23:49:47 +08:00
5fdc70c645 优化 2026-05-23 22:47:34 +08:00
690ebac266 message修改为data 2026-05-21 20:40:39 +08:00
ffaf527721 密钥修改 2026-05-19 22:39:53 +08:00
aebdf72867 【m】增加请求理由 2026-05-19 13:22:01 +08:00
52b5faf5a7 调起通话完成 2026-05-18 23:29:48 +08:00
85c0b0226d 消息模块开发完成 2026-05-18 23:03:28 +08:00
2c6a7af31b 邀请弹窗 2026-05-18 21:18:55 +08:00
66656c961c 【m】删除无用代码 2026-05-18 21:12:05 +08:00
a8ceb194f6 完成http、https修改 2026-05-17 22:07:07 +08:00
7ff53c2cfd 房间id生成修改 2026-05-17 20:59:34 +08:00
141 changed files with 7584 additions and 6460 deletions

5
.gitignore vendored
View File

@@ -43,6 +43,9 @@ node_modules/
# Coverage
coverage/
recordings/
!client/public/recordings/
!client/public/recordings/**
*.lcov
.nyc_output
@@ -184,4 +187,4 @@ out/
.history/
# Built Visual Studio Code Extensions
*.vsix
*.vsix

View File

@@ -1,8 +1,8 @@
/* eslint-disable no-undef */
import fetch from 'node-fetch';
import { TextEncoder, TextDecoder } from 'util';
import { PeerConnectionMock, SessionDescriptionMock, IceCandidateMock } from './test/peerconnectionmock';
import ResizeObserverMock from './test/resizeobservermock';
import { PeerConnectionMock, SessionDescriptionMock, IceCandidateMock } from './test/mocks/peerconnectionmock.js';
import ResizeObserverMock from './test/helpers/resizeobservermock.js';
// note: If set testEnvironment `jest-environment-jsdom`, below classes are not defined.
@@ -32,4 +32,4 @@ if (!window.RTCIceCandidate) {
if (!window.ResizeObserver) {
window.ResizeObserver = ResizeObserverMock;
}
}

View File

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

Before

Width:  |  Height:  |  Size: 472 KiB

After

Width:  |  Height:  |  Size: 472 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 166 KiB

View File

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 152 KiB

View File

Before

Width:  |  Height:  |  Size: 274 KiB

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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 = '';
}

View File

@@ -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);
}
}

View File

@@ -1,10 +1,13 @@
import { createLogger } from '../../shared/logger.js';
const logger = createLogger('chat');
/**
* 消息模块
* 处理聊天消息的发送接收和显示
*/
import { showNotification, generateId } from './utils.js';
import store from './store.js';
import { mockMessages } from './models.js';
import { showNotification, generateId } from '../../shared/utils.js';
import store from '../store.js';
import { mockMessages } from '../models.js';
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB
@@ -62,7 +65,7 @@ function sendChatMessage(message) {
if (store.getRenderStreaming()) {
store.getRenderStreaming().sendMessage({
type: 'chat-message',
message: message,
data: message,
});
}
}
@@ -72,7 +75,7 @@ function sendChatMessage(message) {
* @param {Object} data - 消息数据
*/
function handleChatMessage(data) {
console.log('处理聊天:', data);
logger.debug('处理聊天:', data);
addMessage(data);
const isImage = data.content && data.content.startsWith('data:image/');

View File

@@ -0,0 +1,77 @@
import { createTextElement, textValue } from '../../shared/dom.js';
export function createMessageElement(message, formatTimestamp) {
const messageDiv = document.createElement('div');
let messageClass = 'chat-bubble';
if (message.type === 'system') {
messageClass += ' message-system';
} else if (message.isSelf) {
messageClass += ' message-self';
} else {
messageClass += ' message-other';
}
messageDiv.className = messageClass;
messageDiv.dataset.messageId = message.id;
const header = document.createElement('div');
header.className = 'message-header';
const avatar = document.createElement('img');
avatar.className = 'message-avatar';
avatar.src = textValue(message.senderAvatar);
avatar.alt = textValue(message.senderName, '\u7528\u6237');
header.appendChild(avatar);
const headerText = document.createElement('div');
headerText.appendChild(createTextElement('span', 'message-sender', message.senderName));
headerText.appendChild(createTextElement('span', 'message-time', formatTimestamp(message.timestamp)));
header.appendChild(headerText);
const content = document.createElement('div');
content.className = 'message-content';
const rawContent = textValue(message.content);
if (message.type === 'file' && rawContent.startsWith('data:image/')) {
const imageContainer = document.createElement('div');
imageContainer.className = 'message-image-container';
const image = document.createElement('img');
image.src = rawContent;
image.className = 'message-image';
image.alt = textValue(message.fileName, '\u56fe\u7247');
imageContainer.appendChild(image);
if (message.fileName) {
imageContainer.appendChild(createTextElement('div', 'message-image-name', message.fileName));
}
content.appendChild(imageContainer);
} else {
content.appendChild(createTextElement('div', 'message-text', rawContent));
}
messageDiv.appendChild(header);
messageDiv.appendChild(content);
return messageDiv;
}
export function renderChatMessagesInto(container, messages, formatTimestamp) {
if (!container) return;
container.innerHTML = '';
const startTimeElement = document.createElement('div');
startTimeElement.className = 'text-center text-xs text-gray-500 my-4';
const startTime = messages[0]?.timestamp || new Date().toISOString();
startTimeElement.textContent = `\u901a\u8bdd\u5f00\u59cb ${formatTimestamp(startTime)}`;
container.appendChild(startTimeElement);
messages.forEach(message => {
container.appendChild(createMessageElement(message, formatTimestamp));
});
container.scrollTop = container.scrollHeight;
}

View File

@@ -0,0 +1,211 @@
import { showNotification } from '../shared/utils.js';
import store from './store.js';
import {
fetchConnectionDirectory,
fetchOnlineUsers,
renderConnectionIds,
renderOnlineUsers
} from './signaling/connect-directory.js';
import { createProfileSettingsController } from './controllers/profile-settings.js';
import { createLogger } from '../shared/logger.js';
const logger = createLogger('connectview');
let onWsStatusChange = null;
let cachedOnlineUsers = [];
const profileSettingsController = createProfileSettingsController({
store,
notify: showNotification
});
export function setWsStatusCallback(callback) {
onWsStatusChange = callback;
}
export function updateWsStatus(connected) {
const wsStatusDot = document.getElementById('wsStatusDot');
const wsStatusText = document.getElementById('wsStatusText');
if (wsStatusDot && wsStatusText) {
if (connected) {
wsStatusDot.className = 'w-2 h-2 bg-green-500 rounded-full animate-pulse';
wsStatusText.textContent = 'WebSocket已连接';
} else {
wsStatusDot.className = 'w-2 h-2 bg-gray-500 rounded-full';
wsStatusText.textContent = '未连接';
}
}
if (onWsStatusChange) {
onWsStatusChange(connected);
}
}
export async function initWebSocket() {
try {
await store.connectSignaling();
store.syncSocketUserInfo();
updateWsStatus(true);
await refreshOnlineUsers();
logger.debug('WebSocket initialized from connectview');
} catch (error) {
logger.error('Failed to initialize WebSocket:', error);
updateWsStatus(false);
showNotification('WebSocket连接失败请刷新页面重试', 'error');
}
}
async function refreshOnlineUsers(silent = true) {
try {
cachedOnlineUsers = await fetchOnlineUsers();
updateOnlineUsersList(cachedOnlineUsers);
if (!silent) {
showNotification(`当前共有 ${cachedOnlineUsers.length} 个WebSocket用户在线`);
}
} catch (error) {
logger.error('Error fetching online users:', error);
if (!silent) {
showNotification('获取在线用户失败', 'error');
}
}
}
async function getAllConnectionIds() {
showNotification('正在获取连接ID和在线用户...');
try {
const { connectionIds, users } = await fetchConnectionDirectory();
cachedOnlineUsers = users;
updateConnectionIdList(connectionIds);
updateOnlineUsersList(cachedOnlineUsers);
} catch (error) {
logger.error('Error fetching connection IDs:', error);
showNotification('获取连接信息失败', 'error');
}
}
function updateConnectionIdList(connectionIds) {
const idsContainer = document.getElementById('idsContainer');
const connectionIdsList = document.getElementById('connectionIdsList');
renderConnectionIds({
connectionIds,
idsContainer,
connectionIdsList,
onSelectConnectionId: selectConnectionId
});
if (idsContainer) {
showNotification(`找到 ${connectionIds.length} 个连接ID`);
}
}
function getCurrentUserId() {
try {
const settings = JSON.parse(localStorage.getItem('userSettings') || '{}');
return settings.userId || settings.id || '';
} catch (error) {
logger.error('Error parsing current user settings:', error);
return '';
}
}
function updateOnlineUsersList(users) {
const onlineUsersList = document.getElementById('onlineUsersList');
const usersContainer = document.getElementById('usersContainer');
const onlineUsersSummary = document.getElementById('onlineUsersSummary');
renderOnlineUsers({
users,
currentUserId: getCurrentUserId(),
onlineUsersList,
usersContainer,
onlineUsersSummary
});
}
function selectConnectionId(connectionId) {
const connectionIdInput = document.getElementById('connectionIdInput');
if (connectionIdInput) {
connectionIdInput.value = connectionId;
showNotification(`已选择连接ID: ${connectionId}`);
}
}
export function loadUserSettings() {
profileSettingsController.loadUserSettings();
}
export function saveSettings() {
profileSettingsController.saveSettings();
}
export function handleAvatarUpload(event) {
profileSettingsController.handleAvatarUpload(event);
}
export function copyUserId() {
profileSettingsController.copyUserId();
}
export function toggleSettingsMenu() {
profileSettingsController.toggleSettingsMenu();
}
export function bindConnectViewEvents(onJoinCall, onCreateCall) {
const connectBtn = document.getElementById('connectBtn');
if (connectBtn && !connectBtn.dataset.bound) {
connectBtn.addEventListener('click', () => {
const connectionIdInput = document.getElementById('connectionIdInput');
const connectionId = connectionIdInput ? connectionIdInput.value.trim() : '';
if (connectionId) {
onJoinCall(connectionId);
} else {
showNotification('请输入连接ID', 'error');
}
});
connectBtn.dataset.bound = 'true';
}
const createCallBtn = document.getElementById('createCallBtn');
if (createCallBtn && !createCallBtn.dataset.bound) {
createCallBtn.addEventListener('click', onCreateCall);
createCallBtn.dataset.bound = 'true';
}
const browseIdsBtn = document.getElementById('browseIdsBtn');
if (browseIdsBtn && !browseIdsBtn.dataset.bound) {
browseIdsBtn.addEventListener('click', getAllConnectionIds);
browseIdsBtn.dataset.bound = 'true';
}
const connectionIdInput = document.getElementById('connectionIdInput');
if (connectionIdInput && !connectionIdInput.dataset.bound) {
connectionIdInput.addEventListener('keypress', (event) => {
if (event.key !== 'Enter') {
return;
}
const connectionId = connectionIdInput.value.trim();
if (connectionId) {
onJoinCall(connectionId);
} else {
showNotification('请输入连接ID', 'error');
}
});
connectionIdInput.dataset.bound = 'true';
}
const userSettingsBtn = document.getElementById('userSettingsBtn');
if (userSettingsBtn && !userSettingsBtn.dataset.bound) {
userSettingsBtn.addEventListener('click', toggleSettingsMenu);
userSettingsBtn.dataset.bound = 'true';
}
}
profileSettingsController.bindDocumentEvents();
profileSettingsController.bindWindowHandlers();
window.selectConnectionId = selectConnectionId;

View File

@@ -0,0 +1,136 @@
export function createCallViewController({ store, chatMessage, notify }) {
let isBound = false;
function toggleSidebar() {
chatMessage.toggleSidebar();
}
function toggleMute() {
const state = store.getState();
const currentState = state.session.localUser.mediaState.audio;
store.updateLocalMedia('audio', !currentState);
}
function toggleVideo() {
const state = store.getState();
const currentState = state.session.localUser.mediaState.video;
store.updateLocalMedia('video', !currentState);
}
function toggleLocalVideo() {
toggleVideo();
}
async function toggleRecording() {
try {
const result = await store.toggleRecording();
notify(result.message);
}
catch (error) {
notify(error.message || '\u5f55\u5236\u5931\u8d25');
}
}
function toggleMoreOptions() {
const menu = document.getElementById('moreOptionsMenu');
if (menu) {
menu.classList.toggle('hidden');
}
}
function changeResolution(width, height) {
store.changeResolution(width, height);
const menu = document.getElementById('moreOptionsMenu');
if (menu) {
menu.classList.add('hidden');
}
}
function endCall() {
const dialog = document.getElementById('endCallDialog');
if (dialog) {
dialog.classList.remove('hidden');
}
}
function cancelEndCall() {
const dialog = document.getElementById('endCallDialog');
if (dialog) {
dialog.classList.add('hidden');
}
}
function confirmEndCall() {
cancelEndCall();
store.endCall();
notify('\u901a\u8bdd\u5df2\u7ed3\u675f');
}
function handleKeydown(event) {
if (event.code === 'Space' && !event.target.matches('input, textarea')) {
event.preventDefault();
toggleMute();
}
if (event.ctrlKey && event.key === 'v') {
event.preventDefault();
toggleVideo();
}
}
function handleDocumentClick(event) {
const moreOptionsMenu = document.getElementById('moreOptionsMenu');
const moreOptionsButton = document.getElementById('moreOptionsBtn');
if (
moreOptionsMenu &&
moreOptionsButton &&
!moreOptionsMenu.contains(event.target) &&
!moreOptionsButton.contains(event.target)
) {
moreOptionsMenu.classList.add('hidden');
}
}
function bindButton(buttonId, handler) {
const button = document.getElementById(buttonId);
if (button && !button.dataset.bound) {
button.addEventListener('click', handler);
button.dataset.bound = 'true';
}
}
function exposeWindowHandlers() {
window.toggleSidebar = toggleSidebar;
window.toggleMute = toggleMute;
window.toggleVideo = toggleVideo;
window.toggleLocalVideo = toggleLocalVideo;
window.toggleRecording = toggleRecording;
window.toggleMoreOptions = toggleMoreOptions;
window.changeResolution = changeResolution;
window.endCall = endCall;
window.cancelEndCall = cancelEndCall;
window.confirmEndCall = confirmEndCall;
}
function bindDomEvents() {
exposeWindowHandlers();
if (isBound) {
return;
}
chatMessage.bindMessageEvents();
document.addEventListener('keydown', handleKeydown);
document.addEventListener('click', handleDocumentClick);
bindButton('cancelEndCall', cancelEndCall);
bindButton('confirmEndCall', confirmEndCall);
bindButton('moreOptionsBtn', toggleMoreOptions);
isBound = true;
}
return {
bindDomEvents
};
}

View File

@@ -0,0 +1,190 @@
import { createLogger } from '../../shared/logger.js';
const logger = createLogger('invite');
const DEFAULT_CALLER_NAME = '\u9080\u8bf7\u65b9';
const DEFAULT_CALLER_AVATAR = '/images/p2.png';
const DEFAULT_APPLY_REASON = '\u672a\u586b\u5199';
function readConnectionIdFromSearch(search) {
return new URLSearchParams(search).get('connectionId') || '';
}
function hideInviteDialog() {
const dialog = document.getElementById('callRequestDialog');
if (dialog) {
dialog.classList.add('hidden');
}
}
function normalizeInviteCaller(caller = {}) {
return {
connectionId: caller.connectionId || '',
inviterSocketId: caller.inviterSocketId || '',
inviterUserId: caller.inviterUserId || '',
name: caller.name || caller.inviterName || DEFAULT_CALLER_NAME,
avatar: caller.avatar || caller.inviterAvatar || DEFAULT_CALLER_AVATAR,
applyReason: caller.applyReason || caller.reason || DEFAULT_APPLY_REASON
};
}
function renderInviteDialog(caller) {
const dialog = document.getElementById('callRequestDialog');
if (!dialog) {
return false;
}
const callRequestName = document.getElementById('callRequestName');
const callRequestAvatar = document.getElementById('callRequestAvatar');
const callRequestText = document.getElementById('callRequestText');
const callRequestReason = document.getElementById('callRequestReason');
if (callRequestName) {
callRequestName.textContent = caller.name;
}
if (callRequestAvatar) {
callRequestAvatar.src = caller.avatar;
}
if (callRequestText) {
callRequestText.textContent = caller.connectionId
? `\u6b63\u5728\u9080\u8bf7\u60a8\u52a0\u5165\u901a\u8bdd (${caller.connectionId})`
: '\u6b63\u5728\u8bf7\u6c42\u4e0e\u60a8\u8fdb\u884c\u89c6\u9891\u901a\u8bdd';
}
if (callRequestReason) {
callRequestReason.textContent = caller.applyReason;
}
dialog.classList.remove('hidden');
return true;
}
export function createInviteController({
store,
notify,
onAcceptConnection,
getCurrentView,
getConnectionId,
setConnectionId
}) {
let pendingInvite = null;
let signalHandlersBound = false;
function showCallRequestDialog(caller = {}) {
const normalizedCaller = normalizeInviteCaller(caller);
pendingInvite = normalizedCaller;
if (normalizedCaller.connectionId) {
setConnectionId(normalizedCaller.connectionId);
}
return renderInviteDialog(normalizedCaller);
}
function getInvitePayloadFromUrl(search = window.location.search) {
const params = new URLSearchParams(search);
if (params.get('invite') !== '1') {
return null;
}
return normalizeInviteCaller({
name: params.get('callerName'),
avatar: params.get('callerAvatar'),
connectionId: params.get('connectionId')
});
}
function bindSignalHandlers() {
if (signalHandlersBound) {
return;
}
store.onSocketEvent('invite-call', (payload) => {
const caller = normalizeInviteCaller(payload);
showCallRequestDialog(caller);
notify(`${caller.name} \u9080\u8bf7\u4f60\u52a0\u5165\u901a\u8bdd`);
});
signalHandlersBound = true;
}
async function acceptInvite() {
hideInviteDialog();
const targetConnectionId =
(pendingInvite && pendingInvite.connectionId) ||
getConnectionId() ||
localStorage.getItem('connectionId') ||
readConnectionIdFromSearch(window.location.search);
if (!targetConnectionId) {
notify('\u7f3a\u5c11\u8fde\u63a5ID\uff0c\u65e0\u6cd5\u63a5\u53d7\u9080\u8bf7', 'error');
return;
}
setConnectionId(targetConnectionId);
if (pendingInvite) {
try {
store.sendInviteAccepted({
connectionId: targetConnectionId,
targetSocketId: pendingInvite.inviterSocketId,
targetUserId: pendingInvite.inviterUserId
});
} catch (error) {
logger.error('Error accepting invite:', error);
notify('\u63a5\u53d7\u9080\u8bf7\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5', 'error');
return;
}
}
pendingInvite = null;
notify('\u5df2\u63a5\u53d7\u901a\u8bdd\u8bf7\u6c42');
if (getCurrentView() !== 'call') {
await onAcceptConnection(targetConnectionId);
}
}
function rejectInvite() {
hideInviteDialog();
if (pendingInvite) {
try {
store.sendInviteRejected({
connectionId: pendingInvite.connectionId,
targetSocketId: pendingInvite.inviterSocketId,
targetUserId: pendingInvite.inviterUserId
});
} catch (error) {
logger.error('Error rejecting invite:', error);
}
}
pendingInvite = null;
notify('\u5df2\u62d2\u7edd\u901a\u8bdd\u8bf7\u6c42');
}
function bindDialogEvents() {
window.showCallRequest = showCallRequestDialog;
window.rejectCall = rejectInvite;
window.acceptCall = acceptInvite;
const rejectCallButton = document.getElementById('rejectCall');
const acceptCallButton = document.getElementById('acceptCall');
if (rejectCallButton && !rejectCallButton.dataset.bound) {
rejectCallButton.addEventListener('click', rejectInvite);
rejectCallButton.dataset.bound = 'true';
}
if (acceptCallButton && !acceptCallButton.dataset.bound) {
acceptCallButton.addEventListener('click', acceptInvite);
acceptCallButton.dataset.bound = 'true';
}
}
return {
bindDialogEvents,
bindSignalHandlers,
getInvitePayloadFromUrl,
showCallRequestDialog
};
}

View File

@@ -0,0 +1,220 @@
import { createLogger } from '../../shared/logger.js';
const logger = createLogger('profile');
const DEFAULT_AVATAR = '/images/p1.png';
const MAX_AVATAR_SIZE = 2 * 1024 * 1024;
const USER_ID_PREFIX = 'user_';
const USER_ID_LENGTH = 8;
const USER_ID_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
function getElement(id) {
return document.getElementById(id);
}
function setAvatarPreview(avatarUrl) {
const userAvatar = getElement('userAvatar');
const avatarPreview = getElement('avatarPreview');
if (userAvatar) {
userAvatar.src = avatarUrl;
}
if (avatarPreview) {
avatarPreview.src = avatarUrl;
}
}
function updateUserName(name) {
const userName = getElement('userName');
if (userName) {
userName.textContent = name;
}
}
function generateUserId() {
let result = USER_ID_PREFIX;
for (let i = 0; i < USER_ID_LENGTH; i++) {
result += USER_ID_CHARS.charAt(Math.floor(Math.random() * USER_ID_CHARS.length));
}
return result;
}
function readStoredSettings() {
const rawSettings = localStorage.getItem('userSettings');
if (!rawSettings) {
return null;
}
return JSON.parse(rawSettings);
}
function getCurrentSettingsPayload() {
const nicknameInput = getElement('nicknameInput');
const userIdInput = getElement('userIdInput');
const avatarPreview = getElement('avatarPreview');
return {
userId: userIdInput ? userIdInput.value : generateUserId(),
name: nicknameInput ? (nicknameInput.value || '\u6211') : '\u6211',
avatar: avatarPreview ? (avatarPreview.src || DEFAULT_AVATAR) : DEFAULT_AVATAR
};
}
async function uploadAvatar(formData) {
const response = await fetch('/api/upload/avatar', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('\u4e0a\u4f20\u5931\u8d25');
}
return response.json();
}
export function createProfileSettingsController({ store, notify }) {
let documentEventsBound = false;
function loadUserSettings() {
try {
const settings = readStoredSettings();
if (!settings) {
const nextUserId = generateUserId();
const userIdInput = getElement('userIdInput');
if (userIdInput) {
userIdInput.value = nextUserId;
}
setAvatarPreview(DEFAULT_AVATAR);
saveSettings();
return;
}
const userIdInput = getElement('userIdInput');
const nicknameInput = getElement('nicknameInput');
if (settings.userId && userIdInput) {
userIdInput.value = settings.userId;
}
if (settings.name && nicknameInput) {
nicknameInput.value = settings.name;
}
updateUserName(settings.name || '\u6211');
setAvatarPreview(settings.avatar || DEFAULT_AVATAR);
} catch (error) {
logger.error('Error loading user settings:', error);
setAvatarPreview(DEFAULT_AVATAR);
}
}
function saveSettings() {
const settings = getCurrentSettingsPayload();
localStorage.setItem('userSettings', JSON.stringify(settings));
store.syncSocketUserInfo(settings);
updateUserName(settings.name);
setAvatarPreview(settings.avatar);
notify('\u8bbe\u7f6e\u5df2\u4fdd\u5b58', 'success');
}
async function handleAvatarUpload(event) {
const file = event.target.files[0];
if (!file) {
return;
}
if (!file.type.startsWith('image/')) {
notify('\u8bf7\u9009\u62e9\u56fe\u7247\u6587\u4ef6', 'error');
return;
}
if (file.size > MAX_AVATAR_SIZE) {
notify('\u56fe\u7247\u5927\u5c0f\u4e0d\u80fd\u8d85\u8fc72MB', 'error');
return;
}
const formData = new FormData();
formData.append('avatar', file);
const userIdInput = getElement('userIdInput');
if (userIdInput) {
formData.append('userId', userIdInput.value);
}
notify('\u6b63\u5728\u4e0a\u4f20\u5934\u50cf...');
try {
const data = await uploadAvatar(formData);
if (!data.success || !data.avatarUrl) {
throw new Error(data.message || '\u672a\u77e5\u9519\u8bef');
}
setAvatarPreview(data.avatarUrl);
saveSettings();
notify('\u5934\u50cf\u4e0a\u4f20\u6210\u529f', 'success');
} catch (error) {
logger.error('Error uploading avatar:', error);
setAvatarPreview(DEFAULT_AVATAR);
notify('\u5934\u50cf\u4e0a\u4f20\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5', 'error');
}
}
function copyUserId() {
const userIdInput = getElement('userIdInput');
if (!userIdInput) {
return;
}
userIdInput.select();
document.execCommand('copy');
notify('\u7528\u6237ID\u5df2\u590d\u5236\u5230\u526a\u8d34\u677f', 'success');
}
function toggleSettingsMenu() {
const settingsMenu = getElement('settingsMenu');
if (settingsMenu) {
settingsMenu.classList.toggle('hidden');
}
}
function bindDocumentEvents() {
if (documentEventsBound) {
return;
}
document.addEventListener('click', (event) => {
const settingsMenu = getElement('settingsMenu');
const userSettingsButton = getElement('userSettingsBtn');
if (
settingsMenu &&
userSettingsButton &&
!settingsMenu.contains(event.target) &&
!userSettingsButton.contains(event.target)
) {
settingsMenu.classList.add('hidden');
}
});
documentEventsBound = true;
}
function bindWindowHandlers() {
window.saveSettings = saveSettings;
window.handleAvatarUpload = handleAvatarUpload;
window.copyUserId = copyUserId;
window.toggleSettingsMenu = toggleSettingsMenu;
}
return {
bindDocumentEvents,
bindWindowHandlers,
copyUserId,
handleAvatarUpload,
loadUserSettings,
saveSettings,
toggleSettingsMenu
};
}

View File

@@ -7,7 +7,7 @@
<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">
<link rel="stylesheet" href="/styles/style.css">
</head>
<body class="h-screen w-screen flex flex-col text-white bg-grid relative">
@@ -697,6 +697,10 @@
</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-3 rounded-lg bg-white/5 px-3 py-2 text-left">
<div class="text-xs text-gray-500 mb-1">申请理由</div>
<div class="text-sm text-gray-200 break-words" id="callRequestReason">未填写</div>
</div>
<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"
@@ -722,8 +726,8 @@
</div>
<!-- 引入模块化JavaScript文件 -->
<script type="module" src="connectview.js"></script>
<script type="module" src="main.js"></script>
<script type="module" src="/call/connectview.js"></script>
<script type="module" src="/call/main.js"></script>
</body>

129
client/public/call/main.js Normal file
View File

@@ -0,0 +1,129 @@
import store from './store.js';
import UIRenderer from './renderers/renderer.js';
import { showNotification, randomMeetingId } from '../shared/utils.js';
import chatMessage from './chat/chatmessage.js';
import { createCallViewController } from './controllers/call-view-controller.js';
import {
bindConnectViewEvents,
initWebSocket,
loadUserSettings
} from './connectview.js';
import { createInviteController } from './controllers/invite-controller.js';
import { createLogger } from '../shared/logger.js';
const logger = createLogger('main');
let connectionId = '';
let currentView = 'connect';
function updateConnectionId(nextConnectionId) {
connectionId = nextConnectionId || '';
if (connectionId) {
localStorage.setItem('connectionId', connectionId);
}
}
async function switchToCallView(targetConnectionId) {
const connectView = document.getElementById('connectView');
const callView = document.getElementById('callView');
if (connectView) {
connectView.classList.add('hidden');
}
if (callView) {
callView.classList.remove('hidden');
}
currentView = 'call';
try {
const renderer = new UIRenderer(store);
await store.joinCall(targetConnectionId);
await store.setUp(targetConnectionId);
renderer.renderHeaderTitle();
callViewController.bindDomEvents();
logger.debug('Video call app initialized successfully');
return true;
} catch (error) {
logger.error('Error initializing app:', error);
showNotification('初始化失败,请刷新页面重试', 'error');
return false;
}
}
const inviteController = createInviteController({
store,
notify: showNotification,
onAcceptConnection: switchToCallView,
getCurrentView: () => currentView,
getConnectionId: () => connectionId,
setConnectionId: updateConnectionId
});
const callViewController = createCallViewController({
store,
chatMessage,
notify: showNotification
});
async function handleJoinCall(targetConnectionId) {
showNotification(`正在加入通话 (${targetConnectionId})`);
updateConnectionId(targetConnectionId);
await switchToCallView(targetConnectionId);
}
async function handleCreateCall() {
showNotification('正在创建通话...');
const nextConnectionId = randomMeetingId();
updateConnectionId(nextConnectionId);
await switchToCallView(nextConnectionId);
}
window.addEventListener('DOMContentLoaded', async () => {
try {
const connectView = document.getElementById('connectView');
const callView = document.getElementById('callView');
if (connectView) {
connectView.classList.remove('hidden');
}
if (callView) {
callView.classList.add('hidden');
}
currentView = 'connect';
loadUserSettings();
await initWebSocket();
inviteController.bindSignalHandlers();
inviteController.bindDialogEvents();
bindConnectViewEvents(handleJoinCall, handleCreateCall);
const savedConnectionId = localStorage.getItem('connectionId');
if (savedConnectionId) {
updateConnectionId(savedConnectionId);
const connectionIdInput = document.getElementById('connectionIdInput');
if (connectionIdInput) {
connectionIdInput.value = savedConnectionId;
}
}
const invitePayload = inviteController.getInvitePayloadFromUrl();
if (invitePayload) {
inviteController.showCallRequestDialog(invitePayload);
}
logger.debug('SPA initialized, showing connect view');
} catch (error) {
logger.error('Error initializing SPA:', error);
showNotification('初始化失败,请刷新页面重试', 'error');
}
});
export { store };

View File

@@ -0,0 +1,80 @@
export const AUDIO_CONFIG = {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
};
export const VAD_CONFIG = {
threshold: 15,
debounceTime: 500,
fftSize: 256
};
export const MEDIA_CONSTRAINTS = {
video: {
width: { ideal: 1920, max: 1920 },
height: { ideal: 1080, max: 1080 },
frameRate: { ideal: 30, max: 30 }
},
audio: AUDIO_CONFIG
};
export const VIDEO_ONLY_CONSTRAINT = {
video: {
width: { ideal: 1920, max: 1920 },
height: { ideal: 1080, max: 1080 },
frameRate: { ideal: 30, max: 30 }
},
audio: false
};
const TARGET_RESOLUTION_BITRATE_MAP = {
270: 1000000,
480: 1500000,
720: 2500000,
1080: 4000000,
1440: 6000000
};
export function buildVideoConstraints(savedResolution) {
if (!savedResolution) {
return MEDIA_CONSTRAINTS.video;
}
return {
width: { ideal: savedResolution.width, max: savedResolution.width },
height: { ideal: savedResolution.height, max: savedResolution.height },
frameRate: { ideal: 30, max: 30 }
};
}
export function getResolutionLabel(height) {
if (height >= 1440) {
return '2K 1440p';
}
if (height >= 1080) {
return '1080p 超清';
}
if (height >= 720) {
return '720p 高清';
}
return '480p 流畅';
}
export function getTargetResolutionBitrate(height) {
return TARGET_RESOLUTION_BITRATE_MAP[height] || 2500000;
}
export function getAdaptiveVideoBitrate(height) {
const supportedHeights = Object.keys(TARGET_RESOLUTION_BITRATE_MAP).map(Number).sort((a, b) => a - b);
let maxBitrate = TARGET_RESOLUTION_BITRATE_MAP[1080];
for (const supportedHeight of supportedHeights) {
if (height <= supportedHeight) {
return TARGET_RESOLUTION_BITRATE_MAP[supportedHeight];
}
maxBitrate = TARGET_RESOLUTION_BITRATE_MAP[supportedHeight];
}
return maxBitrate;
}

View File

@@ -0,0 +1,62 @@
export function createAudioAnalyser(stream, fftSize) {
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
const audioContext = new AudioContextCtor();
const source = audioContext.createMediaStreamSource(stream);
const analyser = audioContext.createAnalyser();
analyser.fftSize = fftSize;
source.connect(analyser);
return {
audioContext,
analyser,
dataArray: new Uint8Array(analyser.frequencyBinCount)
};
}
export function getAudioLevel(analyser, dataArray) {
analyser.getByteTimeDomainData(dataArray);
let sum = 0;
for (let i = 0; i < dataArray.length; i++) {
const amplitude = dataArray[i] - 128;
sum += amplitude * amplitude;
}
const rms = Math.sqrt(sum / dataArray.length);
return rms / 128;
}
function formatPacketLossRate(packetsLost, packetsReceived) {
if (packetsReceived <= 0) {
return '0%';
}
return `${((packetsLost / (packetsLost + packetsReceived)) * 100).toFixed(2)}%`;
}
export function buildStatsLogPayload(networkQuality, statsSummary) {
return {
networkQuality,
video: {
'Packets Lost': statsSummary.video.packetsLost,
'Packets Received': statsSummary.video.packetsReceived,
'Packet Loss Rate': formatPacketLossRate(
statsSummary.video.packetsLost,
statsSummary.video.packetsReceived
),
'Jitter': `${(statsSummary.video.jitter * 1000).toFixed(2)}ms`,
'Round Trip Time': `${(statsSummary.video.roundTripTime * 1000).toFixed(2)}ms`,
'FPS': statsSummary.video.fps.toFixed(1),
'Bitrate': `${statsSummary.video.bitrate}kbps`
},
audio: {
'Packets Lost': statsSummary.audio.packetsLost,
'Packets Received': statsSummary.audio.packetsReceived,
'Packet Loss Rate': formatPacketLossRate(
statsSummary.audio.packetsLost,
statsSummary.audio.packetsReceived
),
'Jitter': `${(statsSummary.audio.jitter * 1000).toFixed(2)}ms`
}
};
}

View File

@@ -0,0 +1,336 @@
const DEFAULT_WIDTH = 1280;
const DEFAULT_HEIGHT = 720;
const DEFAULT_FPS = 30;
const MIME_TYPE_CANDIDATES = [
{ mimeType: 'video/mp4;codecs=avc1.42E01E,mp4a.40.2', extension: 'mp4' },
{ mimeType: 'video/mp4', extension: 'mp4' },
{ mimeType: 'video/webm;codecs=vp9,opus', extension: 'webm' },
{ mimeType: 'video/webm;codecs=vp8,opus', extension: 'webm' },
{ mimeType: 'video/webm', extension: 'webm' }
];
function getSupportedFormat(mediaRecorderCtor) {
if (!mediaRecorderCtor || typeof mediaRecorderCtor.isTypeSupported !== 'function') {
return {
mimeType: '',
extension: 'webm'
};
}
return MIME_TYPE_CANDIDATES.find(format => mediaRecorderCtor.isTypeSupported(format.mimeType)) || {
mimeType: '',
extension: 'webm'
};
}
function isElementVisible(element) {
if (!element || element.classList.contains('hidden')) {
return false;
}
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function drawVideoCover(context, video, x, y, width, height) {
const videoWidth = video.videoWidth || width;
const videoHeight = video.videoHeight || height;
const sourceRatio = videoWidth / videoHeight;
const targetRatio = width / height;
let sourceX = 0;
let sourceY = 0;
let sourceWidth = videoWidth;
let sourceHeight = videoHeight;
if (sourceRatio > targetRatio) {
sourceWidth = videoHeight * targetRatio;
sourceX = (videoWidth - sourceWidth) / 2;
} else {
sourceHeight = videoWidth / targetRatio;
sourceY = (videoHeight - sourceHeight) / 2;
}
context.drawImage(video, sourceX, sourceY, sourceWidth, sourceHeight, x, y, width, height);
}
function drawEmptyFrame(context, canvas) {
context.fillStyle = '#111827';
context.fillRect(0, 0, canvas.width, canvas.height);
context.fillStyle = '#9ca3af';
context.font = '24px sans-serif';
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText('等待会议画面...', canvas.width / 2, canvas.height / 2);
}
function drawGrid(context, videos, canvas) {
const columns = Math.ceil(Math.sqrt(videos.length));
const rows = Math.ceil(videos.length / columns);
const gap = 8;
const tileWidth = (canvas.width - gap * (columns - 1)) / columns;
const tileHeight = (canvas.height - gap * (rows - 1)) / rows;
videos.forEach((video, index) => {
const column = index % columns;
const row = Math.floor(index / columns);
const x = column * (tileWidth + gap);
const y = row * (tileHeight + gap);
drawVideoCover(context, video, x, y, tileWidth, tileHeight);
});
}
function drawLocalPreview(context, localVideo, canvas) {
const previewWidth = Math.floor(canvas.width * 0.22);
const previewHeight = Math.floor(previewWidth * 9 / 16);
const margin = 24;
const x = canvas.width - previewWidth - margin;
const y = canvas.height - previewHeight - margin;
context.fillStyle = 'rgba(0, 0, 0, 0.4)';
context.fillRect(x - 4, y - 4, previewWidth + 8, previewHeight + 8);
drawVideoCover(context, localVideo, x, y, previewWidth, previewHeight);
}
function collectStreams({ localStream, remoteStream, remoteStreams } = {}) {
return [
localStream,
remoteStream,
...Object.values(remoteStreams || {})
].filter(Boolean);
}
function collectLiveAudioTracks(streams) {
return streams.flatMap(stream => stream.getAudioTracks())
.filter(track => track.readyState !== 'ended');
}
export class MeetingRecorder {
constructor({
documentRef = document,
windowRef = window,
width = DEFAULT_WIDTH,
height = DEFAULT_HEIGHT,
fps = DEFAULT_FPS
} = {}) {
this.document = documentRef;
this.window = windowRef;
this.width = width;
this.height = height;
this.fps = fps;
this.mediaRecorder = null;
this.chunks = [];
this.animationFrameId = null;
this.audioContext = null;
this.audioSources = [];
this.recordingStream = null;
this.connectionId = '';
}
isSupported() {
return Boolean(
this.window.MediaRecorder &&
this.document.createElement('canvas').captureStream
);
}
isRecording() {
return Boolean(this.mediaRecorder && this.mediaRecorder.state !== 'inactive');
}
async start({ localStream, remoteStream, remoteStreams, connectionId } = {}) {
if (this.isRecording()) {
throw new Error('会议正在录制中');
}
if (!this.isSupported()) {
throw new Error('当前浏览器不支持会议录制');
}
const canvas = this.document.createElement('canvas');
canvas.width = this.width;
canvas.height = this.height;
const context = canvas.getContext('2d');
if (!context) {
throw new Error('无法创建录制画布');
}
this.connectionId = connectionId || '';
this.chunks = [];
this.canvas = canvas;
this.context = context;
const canvasStream = canvas.captureStream(this.fps);
const streams = collectStreams({ localStream, remoteStream, remoteStreams });
const audioTrack = this.createMixedAudioTrack(streams);
const tracks = [
...canvasStream.getVideoTracks(),
...(audioTrack ? [audioTrack] : [])
];
this.recordingStream = new this.window.MediaStream(tracks);
try {
this.startDrawing();
this.startMediaRecorder(this.recordingStream);
}
catch (error) {
this.cleanup();
throw error;
}
}
stop() {
if (!this.isRecording()) {
return Promise.resolve(null);
}
return new Promise((resolve, reject) => {
this.pendingStop = { resolve, reject };
this.mediaRecorder.stop();
});
}
createMixedAudioTrack(streams) {
const audioTracks = collectLiveAudioTracks(streams);
if (audioTracks.length === 0) {
return null;
}
const AudioContextCtor = this.window.AudioContext || this.window.webkitAudioContext;
if (!AudioContextCtor) {
return audioTracks[0].clone ? audioTracks[0].clone() : audioTracks[0];
}
this.audioContext = new AudioContextCtor();
const destination = this.audioContext.createMediaStreamDestination();
audioTracks.forEach(track => {
const sourceStream = new this.window.MediaStream([track]);
const source = this.audioContext.createMediaStreamSource(sourceStream);
source.connect(destination);
this.audioSources.push(source);
});
return destination.stream.getAudioTracks()[0] || null;
}
startMediaRecorder(stream) {
const MediaRecorderCtor = this.window.MediaRecorder;
const format = getSupportedFormat(MediaRecorderCtor);
const options = format.mimeType ? { mimeType: format.mimeType } : {};
this.fileExtension = format.extension;
this.mediaRecorder = new MediaRecorderCtor(stream, options);
this.mediaRecorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
this.chunks.push(event.data);
}
};
this.mediaRecorder.onerror = (event) => {
if (this.pendingStop) {
this.pendingStop.reject(event.error || new Error('录制失败'));
this.pendingStop = null;
}
this.cleanup();
};
this.mediaRecorder.onstop = () => {
const blob = new Blob(this.chunks, { type: this.mediaRecorder.mimeType || 'video/webm' });
const filename = this.buildFilename();
const mimeType = blob.type || this.mediaRecorder.mimeType || 'video/webm';
this.cleanup();
if (this.pendingStop) {
this.pendingStop.resolve({ blob, filename, mimeType });
this.pendingStop = null;
}
};
this.mediaRecorder.start(1000);
}
startDrawing() {
const draw = () => {
this.drawFrame();
this.animationFrameId = this.window.requestAnimationFrame(draw);
};
draw();
}
drawFrame() {
const context = this.context;
const canvas = this.canvas;
const videos = this.getRecordableVideos();
const localVideo = videos.find(video => video.id === 'localVideo');
const remoteVideos = videos.filter(video => video !== localVideo);
context.fillStyle = '#020617';
context.fillRect(0, 0, canvas.width, canvas.height);
if (remoteVideos.length > 0) {
drawGrid(context, remoteVideos, canvas);
if (localVideo) {
drawLocalPreview(context, localVideo, canvas);
}
return;
}
if (localVideo) {
drawVideoCover(context, localVideo, 0, 0, canvas.width, canvas.height);
return;
}
drawEmptyFrame(context, canvas);
}
getRecordableVideos() {
return Array.from(this.document.querySelectorAll('#participantGrid video, #remoteVideo, #localVideo'))
.filter(video => video.srcObject && isElementVisible(video) && video.readyState >= 2);
}
download(blob, filename = this.buildFilename()) {
const url = this.window.URL.createObjectURL(blob);
const link = this.document.createElement('a');
link.href = url;
link.download = filename;
link.style.display = 'none';
this.document.body.appendChild(link);
link.click();
link.remove();
this.window.setTimeout(() => {
this.window.URL.revokeObjectURL(url);
}, 1000);
return filename;
}
buildFilename() {
const datePart = new Date().toISOString().replace(/[:.]/g, '-');
const meetingPart = this.connectionId ? `-${this.connectionId}` : '';
return `meeting-recording${meetingPart}-${datePart}.${this.fileExtension || 'webm'}`;
}
cleanup() {
if (this.animationFrameId) {
this.window.cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
if (this.recordingStream) {
this.recordingStream.getTracks().forEach(track => track.stop());
this.recordingStream = null;
}
if (this.audioContext) {
this.audioContext.close();
this.audioContext = null;
}
this.audioSources = [];
this.mediaRecorder = null;
this.canvas = null;
this.context = null;
this.chunks = [];
}
}

View File

@@ -0,0 +1,191 @@
import { createParticipantTile, getParticipantTile } from '../participants/renderer-participant-grid.js';
import { createLogger } from '../../shared/logger.js';
const logger = createLogger('renderer-media');
export function getVideoResolution(track) {
if (track && track.getSettings) {
const settings = track.getSettings();
return {
width: settings.width || 640,
height: settings.height || 480
};
}
return { width: 640, height: 480 };
}
export function adjustVideoSize(videoElement) {
if (!videoElement) return;
const container = videoElement.parentElement;
if (!container) return;
videoElement.style.transform = 'translateZ(0)';
videoElement.style.willChange = 'transform';
container.style.display = 'flex';
container.style.alignItems = 'center';
container.style.justifyContent = 'center';
videoElement.style.imageRendering = 'auto';
videoElement.style.maxWidth = '100%';
videoElement.style.maxHeight = '100%';
videoElement.style.objectFit = 'contain';
}
export function renderParticipantStreamMedia({
grid,
stream,
connectionId,
displayName,
getGridTemplateColumns,
remoteVideo,
connectingOverlay,
remoteVideoPlaceholder
}) {
if (!grid) return;
grid.classList.remove('hidden');
let tile = getParticipantTile(grid, connectionId);
if (!tile) {
tile = createParticipantTile(connectionId, displayName);
grid.appendChild(tile);
logger.debug(`Created participant video tile for ${connectionId}`);
}
const video = tile.querySelector('video');
if (video && stream) {
if (video.srcObject === stream) {
logger.debug(`Same stream for participant ${connectionId}, ensuring playback`);
video.play().catch(error => logger.debug('Auto-play prevented:', error.message));
} else {
video.srcObject = stream;
video.play().catch(error => logger.debug('Auto-play prevented:', error.message));
logger.debug(`Set remote stream for participant tile ${connectionId}`);
}
}
const remoteVideoContainer = remoteVideo?.closest('.absolute.inset-0.video-fade-in');
if (remoteVideoContainer) {
remoteVideoContainer.classList.add('hidden');
}
const tileCount = grid.querySelectorAll('[data-participant-id]').length;
grid.style.gridTemplateColumns = getGridTemplateColumns(tileCount);
if (connectingOverlay) {
connectingOverlay.classList.add('hidden');
}
if (remoteVideoPlaceholder) {
remoteVideoPlaceholder.classList.add('hidden');
}
}
export function renderSingleRemoteStreamMedia({
remoteVideo,
stream,
disconnectedOverlay,
remoteVideoPlaceholder,
connectingOverlay
}) {
if (!remoteVideo || !stream) {
logger.error('Either remoteVideo element or stream is missing');
return;
}
logger.debug('Rendering remote stream:', stream, 'tracks:', stream.getTracks().map(track => `${track.kind}(${track.readyState})`));
if (remoteVideo.srcObject === stream) {
logger.debug('Same stream object, track added - ensuring playback');
remoteVideo.play().catch(error => logger.debug('Auto-play prevented:', error.message));
return;
}
remoteVideo.srcObject = stream;
remoteVideo.autoplay = true;
remoteVideo.playsinline = true;
remoteVideo.muted = false;
remoteVideo.play().catch(error => {
logger.debug('Auto-play prevented, will retry on interaction:', error.message);
});
if (disconnectedOverlay) {
disconnectedOverlay.classList.add('hidden');
}
const videoTracks = stream.getVideoTracks();
const audioTracks = stream.getAudioTracks();
logger.debug(`Stream has ${videoTracks.length} video tracks, ${audioTracks.length} audio tracks`);
if (videoTracks.length === 0) {
logger.debug('Audio-only stream, waiting for video track...');
return;
}
if (remoteVideoPlaceholder) {
remoteVideoPlaceholder.classList.add('hidden');
}
if (connectingOverlay) {
connectingOverlay.classList.add('hidden');
}
const activeVideoTrack = videoTracks.find(track => track.readyState === 'live');
if (!activeVideoTrack) return;
adjustVideoSize(remoteVideo, getVideoResolution(activeVideoTrack));
activeVideoTrack.addEventListener('resize', () => {
adjustVideoSize(remoteVideo, getVideoResolution(activeVideoTrack));
});
}
export function clearParticipantGrid(grid) {
if (!grid) return;
grid.querySelectorAll('[data-participant-id]').forEach(tile => {
const video = tile.querySelector('video');
if (video) {
video.srcObject = null;
}
tile.remove();
});
grid.classList.add('hidden');
}
export function removeParticipantTile({
grid,
connectionId,
getGridTemplateColumns,
remoteVideo,
remoteVideoPlaceholder,
remoteNetworkIndicator
}) {
if (!grid) return;
const tile = getParticipantTile(grid, connectionId);
if (tile) {
const video = tile.querySelector('video');
if (video) {
video.srcObject = null;
}
tile.remove();
logger.debug(`Removed participant video tile for ${connectionId}`);
}
const remainingTiles = grid.querySelectorAll('[data-participant-id]');
if (remainingTiles.length === 0) {
grid.classList.add('hidden');
const remoteVideoContainer = remoteVideo?.closest('.absolute.inset-0.video-fade-in');
if (remoteVideoContainer) {
remoteVideoContainer.classList.remove('hidden');
}
if (remoteVideoPlaceholder) {
remoteVideoPlaceholder.classList.remove('hidden');
}
} else {
grid.style.gridTemplateColumns = getGridTemplateColumns(remainingTiles.length);
}
if (remoteNetworkIndicator) {
remoteNetworkIndicator.className = 'w-2 h-2 bg-gray-500 rounded-full';
}
}

View File

@@ -0,0 +1,96 @@
function createStatsSummary() {
return {
video: {
packetsLost: 0,
packetsReceived: 0,
bytesReceived: 0,
jitter: 0,
roundTripTime: 0,
fps: 0,
bitrate: 0
},
audio: {
packetsLost: 0,
packetsReceived: 0,
bytesReceived: 0,
jitter: 0
},
network: {
totalPacketsLost: 0,
totalPacketsReceived: 0,
inboundRtpCount: 0,
jitter: 0,
roundTripTime: 0
}
};
}
export function summarizeInboundStats(stats) {
const summary = createStatsSummary();
stats.forEach((report) => {
if (report.type === 'inbound-rtp' && report.mediaType === 'video') {
summary.network.inboundRtpCount++;
if (report.packetsLost !== undefined && report.packetsReceived !== undefined) {
summary.network.totalPacketsLost += report.packetsLost;
summary.network.totalPacketsReceived += report.packetsReceived;
}
if (report.jitter !== undefined) {
summary.network.jitter = Math.max(summary.network.jitter, report.jitter);
}
if (report.roundTripTime !== undefined) {
summary.network.roundTripTime = Math.max(summary.network.roundTripTime, report.roundTripTime);
}
summary.video.packetsLost = report.packetsLost || 0;
summary.video.packetsReceived = report.packetsReceived || 0;
summary.video.bytesReceived = report.bytesReceived || 0;
summary.video.jitter = report.jitter || 0;
summary.video.roundTripTime = report.roundTripTime || 0;
summary.video.fps = report.framesPerSecond || 0;
if (report.bytesReceived && report.timestamp) {
const duration = report.timestamp / 1000;
summary.video.bitrate = duration > 0
? Math.round((report.bytesReceived * 8) / (duration * 1000))
: 0;
}
} else if (report.type === 'inbound-rtp' && report.mediaType === 'audio') {
summary.audio.packetsLost = report.packetsLost || 0;
summary.audio.packetsReceived = report.packetsReceived || 0;
summary.audio.bytesReceived = report.bytesReceived || 0;
summary.audio.jitter = report.jitter || 0;
}
});
return summary;
}
export function getNetworkQualityFromSummary(summary) {
const { totalPacketsLost, totalPacketsReceived, inboundRtpCount, jitter, roundTripTime } = summary.network;
if (inboundRtpCount === 0) {
return 'no_signal';
}
const packetLossRate = totalPacketsReceived > 0
? totalPacketsLost / (totalPacketsLost + totalPacketsReceived)
: 0;
const jitterMs = jitter * 1000;
const rttMs = roundTripTime * 1000;
if (packetLossRate > 0.05 || jitterMs > 100 || rttMs > 300) {
return 'poor';
}
if (packetLossRate > 0.02 || jitterMs > 50 || rttMs > 150) {
return 'fair';
}
if (packetLossRate > 0.01 || jitterMs > 30 || rttMs > 100) {
return 'good';
}
return 'excellent';
}

View File

@@ -0,0 +1,77 @@
export const DEFAULT_PARTICIPANT_NAME = '参与者';
export const DEFAULT_PARTICIPANT_AVATAR = '/images/p2.png';
const DEFAULT_MEDIA_STATE = Object.freeze({
audio: false,
video: true,
isSpeaking: false
});
function createParticipantRecord(current = {}, patch = {}) {
return {
id: '',
name: DEFAULT_PARTICIPANT_NAME,
avatar: DEFAULT_PARTICIPANT_AVATAR,
mediaState: { ...DEFAULT_MEDIA_STATE },
status: 'online',
...current,
...patch,
mediaState: {
...DEFAULT_MEDIA_STATE,
...(current.mediaState || {}),
...(patch.mediaState || {})
}
};
}
export function upsertParticipant(participants, participantId, patch = {}) {
if (!participantId) {
return null;
}
participants[participantId] = createParticipantRecord(participants[participantId], patch);
return participants[participantId];
}
export function removeParticipant(participants, participantId) {
if (!participantId || !participants[participantId]) {
return false;
}
delete participants[participantId];
return true;
}
export function omitParticipant(participants, excludedParticipantId) {
const filtered = {};
for (const [participantId, participant] of Object.entries(participants || {})) {
if (participantId !== excludedParticipantId) {
filtered[participantId] = participant;
}
}
return filtered;
}
export function buildParticipantsSyncData(localUser, participants) {
const memberList = {
host: {
id: localUser.id,
name: localUser.name,
avatar: localUser.avatar,
mediaState: { ...localUser.mediaState },
status: 'online',
role: 'host'
}
};
for (const [participantId, participant] of Object.entries(participants || {})) {
memberList[participantId] = {
...createParticipantRecord(participant),
role: 'participant'
};
}
return memberList;
}

View File

@@ -0,0 +1,73 @@
import { createIconElement, createTextElement, textValue } from '../../shared/dom.js';
function createParticipantPlaceholder() {
const placeholder = document.createElement('div');
placeholder.className = 'participant-video-placeholder absolute inset-0 flex items-center justify-center bg-gradient-to-br from-indigo-900/80 to-purple-900/80 hidden';
placeholder.innerHTML = `
<div class="text-center">
<div class="w-20 h-20 rounded-full bg-indigo-700/50 flex items-center justify-center mx-auto mb-3">
<i class="fas fa-video-slash text-2xl text-white/70"></i>
</div>
<p class="text-white text-sm font-medium">\u6444\u50cf\u5934\u5df2\u5173\u95ed</p>
</div>
`;
return placeholder;
}
export function createParticipantTile(connectionId, displayName) {
const tile = document.createElement('div');
tile.className = 'relative bg-black/60 rounded-xl overflow-hidden flex items-center justify-center';
tile.dataset.participantId = textValue(connectionId);
const video = document.createElement('video');
video.className = 'w-full h-full object-contain';
video.autoplay = true;
video.playsinline = true;
video.muted = false;
video.id = `participantVideo_${textValue(connectionId)}`;
tile.appendChild(video);
tile.appendChild(createParticipantPlaceholder());
const label = document.createElement('div');
label.className = 'absolute bottom-3 left-3 glass px-3 py-1 rounded-full text-xs flex items-center gap-2';
label.appendChild(createIconElement('fas fa-user text-purple-400'));
label.appendChild(createTextElement('span', '', displayName, '\u53c2\u4e0e\u8005'));
tile.appendChild(label);
const liveTag = document.createElement('div');
liveTag.className = 'absolute top-3 right-3 bg-green-500/80 px-2 py-0.5 rounded-full text-xs flex items-center gap-1';
const pulse = document.createElement('span');
pulse.className = 'w-1.5 h-1.5 bg-white rounded-full animate-pulse';
liveTag.appendChild(pulse);
liveTag.appendChild(createTextElement('span', '', '\u5728\u7ebf'));
tile.appendChild(liveTag);
return tile;
}
export function getParticipantTile(grid, participantId) {
if (!grid) return null;
const expectedId = textValue(participantId);
return Array.from(grid.querySelectorAll('[data-participant-id]'))
.find(tile => tile.dataset.participantId === expectedId) || null;
}
export function updateParticipantTilePlaceholder(grid, participantId, showPlaceholder) {
const tile = getParticipantTile(grid, participantId);
if (!tile) return;
const placeholder = tile.querySelector('.participant-video-placeholder');
if (placeholder) {
placeholder.classList.toggle('hidden', !showPlaceholder);
}
}
export function updateParticipantTileName(grid, participantId, name) {
const tile = getParticipantTile(grid, participantId);
if (!tile) return;
const label = tile.querySelector('.absolute.bottom-3 span');
if (label && name) {
label.textContent = name;
}
}

View File

@@ -0,0 +1,225 @@
import { createIconElement, createTextElement, textValue } from '../../shared/dom.js';
const DEFAULT_NETWORK_QUALITY = {
label: '\u672a\u77e5',
statusIconClass: 'fas fa-question-circle text-gray-400',
statusTextClass: 'text-gray-400',
headerIconClass: 'fas fa-signal text-gray-400',
indicatorClass: 'w-2 h-2 bg-green-500 rounded-full animate-pulse',
connectionTextClass: 'text-gray-400'
};
const NETWORK_QUALITY_DISPLAY = {
excellent: {
label: '\u4f18\u79c0',
statusIconClass: 'fas fa-check-circle text-green-400',
statusTextClass: 'text-green-400',
headerIconClass: 'fas fa-signal text-green-400',
indicatorClass: 'w-2 h-2 bg-green-500 rounded-full animate-pulse',
connectionTextClass: 'text-green-400'
},
good: {
label: '\u826f\u597d',
statusIconClass: 'fas fa-signal text-blue-400',
statusTextClass: 'text-blue-400',
headerIconClass: 'fas fa-signal text-green-500',
indicatorClass: 'w-2 h-2 bg-green-500 rounded-full animate-pulse',
connectionTextClass: 'text-blue-400'
},
fair: {
label: '\u4e00\u822c',
statusIconClass: 'fas fa-exclamation-circle text-yellow-500',
statusTextClass: 'text-yellow-500',
headerIconClass: 'fas fa-signal text-yellow-400',
indicatorClass: 'w-2 h-2 bg-green-500 rounded-full animate-pulse',
connectionTextClass: 'text-yellow-500'
},
poor: {
label: '\u8f83\u5dee',
statusIconClass: 'fas fa-exclamation-triangle text-red-500',
statusTextClass: 'text-red-500',
headerIconClass: 'fas fa-signal text-red-400',
indicatorClass: 'w-2 h-2 bg-green-500 rounded-full animate-pulse',
connectionTextClass: 'text-red-500'
},
no_signal: {
label: '\u65e0\u4fe1\u53f7',
statusIconClass: 'fas fa-times-circle text-gray-500',
statusTextClass: 'text-gray-500',
headerIconClass: 'fas fa-signal text-gray-400',
indicatorClass: 'w-2 h-2 bg-gray-500 rounded-full',
connectionTextClass: 'text-gray-500'
}
};
function getRoleTagMeta(user, role) {
if (role === 'local') {
return user.isHost
? { className: 'text-xs bg-indigo-500 px-1.5 rounded ml-1', label: '\u4e3b\u6301\u4eba' }
: { className: 'text-xs bg-purple-500 px-1.5 rounded ml-1', label: '\u53c2\u4e0e\u8005' };
}
if (role === 'participant') {
return { className: 'text-xs bg-purple-500 px-1.5 rounded ml-1', label: '\u53c2\u4e0e\u8005' };
}
return { className: 'text-xs bg-indigo-500 px-1.5 rounded ml-1', label: '\u4e3b\u6301\u4eba' };
}
function getDatasetUserId(role, id) {
switch (role) {
case 'local':
return 'local';
case 'remote':
return 'remote';
case 'host':
return `host_${id}`;
case 'participant':
return `participant_${id}`;
default:
return role;
}
}
function createAvatarImage(user) {
const image = document.createElement('img');
image.src = textValue(user.avatar);
image.alt = textValue(user.name, '\u7528\u6237');
image.className = 'w-10 h-10 rounded-full object-cover';
return image;
}
function createAvatarElement(user, role) {
if (role === 'local') {
return createAvatarImage(user);
}
const wrapper = document.createElement('div');
wrapper.className = 'relative';
wrapper.appendChild(createAvatarImage(user));
const statusDot = document.createElement('div');
statusDot.className = 'absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-slate-900';
wrapper.appendChild(statusDot);
return wrapper;
}
function createAudioWaveElement() {
const wave = document.createElement('div');
wave.className = 'audio-wave w-6';
for (let i = 0; i < 5; i += 1) {
wave.appendChild(document.createElement('span'));
}
return wave;
}
function createRightElement(mediaState, role, muteIcon) {
if (role !== 'participant') {
return muteIcon;
}
const right = document.createElement('div');
right.className = 'flex items-center gap-2';
if (muteIcon) {
right.appendChild(muteIcon);
}
if (mediaState.isSpeaking && mediaState.audio) {
right.appendChild(createAudioWaveElement());
}
return right.childNodes.length > 0 ? right : null;
}
export function getCallTitle(connectionId) {
return `\u901a\u8bdd (${connectionId || ''})`;
}
export function getRemoteVideoPlaceholderText(isVideoEnabled) {
return isVideoEnabled
? {
title: '\u7b49\u5f85\u5bf9\u65b9\u8fde\u63a5...',
subtitle: '\u8bf7\u786e\u8ba4\u5bf9\u65b9\u5df2\u52a0\u5165\u901a\u8bdd'
}
: {
title: '\u5bf9\u65b9\u6444\u50cf\u5934\u5df2\u5173\u95ed',
subtitle: '\u5bf9\u65b9\u6682\u65f6\u5173\u95ed\u4e86\u89c6\u9891'
};
}
export function getNetworkQualityDisplay(quality) {
return NETWORK_QUALITY_DISPLAY[quality] || DEFAULT_NETWORK_QUALITY;
}
export function getMediaStatusMeta(mediaState) {
if (!mediaState.audio) {
return {
text: '\u9759\u97f3\u4e2d',
className: 'text-xs text-gray-500',
muteIconClass: 'fas fa-microphone-slash text-gray-500 text-xs',
showMuteIcon: true
};
}
if (!mediaState.video) {
return {
text: '\u89c6\u9891\u5173\u95ed',
className: 'text-xs text-gray-500',
muteIconClass: 'fas fa-microphone-slash text-gray-500 text-xs',
showMuteIcon: false
};
}
return {
text: '\u5728\u7ebf',
className: 'text-xs text-green-400',
muteIconClass: 'fas fa-microphone-slash text-gray-500 text-xs',
showMuteIcon: false
};
}
export function buildUserCountLabel(userCount) {
return `\u901a\u8bdd\u6210\u5458 (${userCount})`;
}
export function createUserEntryElement({ user, role, id }) {
const entry = document.createElement('div');
const mediaMeta = getMediaStatusMeta(user.mediaState);
const muteIcon = mediaMeta.showMuteIcon
? createIconElement(mediaMeta.muteIconClass)
: '';
const baseClass = 'flex items-center gap-3 p-2 rounded-lg';
entry.className = role === 'local'
? `${baseClass} hover:bg-white/5`
: `${baseClass} bg-white/5`;
entry.dataset.userId = getDatasetUserId(role, id);
entry.appendChild(createAvatarElement(user, role));
const details = document.createElement('div');
details.className = 'flex-1';
const nameRow = document.createElement('div');
nameRow.className = 'text-sm font-medium';
nameRow.appendChild(document.createTextNode(textValue(user.name)));
const roleTag = getRoleTagMeta(user, role);
nameRow.appendChild(createTextElement('span', roleTag.className, roleTag.label));
details.appendChild(nameRow);
const mediaStatus = createTextElement('div', mediaMeta.className, mediaMeta.text);
if (role === 'local') {
mediaStatus.dataset.field = 'localUser.mediaStatus';
}
details.appendChild(mediaStatus);
entry.appendChild(details);
const right = createRightElement(user.mediaState, role, muteIcon || null);
if (right) {
entry.appendChild(right);
}
return entry;
}

View File

@@ -0,0 +1,541 @@
import { formatTime, formatTimestamp, toggleElement, toggleButtonState } from '../../shared/utils.js';
import { mockCallSession } from '../models.js';
import chatMessage from '../chat/chatmessage.js';
import store from '../store.js';
import {
buildUserCountLabel,
createUserEntryElement,
getCallTitle,
getMediaStatusMeta,
getNetworkQualityDisplay,
getRemoteVideoPlaceholderText
} from './renderer-ui.js';
import { renderChatMessagesInto } from '../chat/renderer-chat.js';
import {
updateParticipantTileName as syncParticipantTileName,
updateParticipantTilePlaceholder
} from '../participants/renderer-participant-grid.js';
import {
adjustVideoSize,
clearParticipantGrid,
getVideoResolution,
removeParticipantTile,
renderParticipantStreamMedia,
renderSingleRemoteStreamMedia
} from '../media/renderer-media.js';
import { createLogger } from '../../shared/logger.js';
const logger = createLogger('renderer');
const GRID_LAYOUT = {
maxColumns: 3,
breakpoints: [
{ maxParticipants: 1, template: '1fr' },
{ maxParticipants: 4, template: 'repeat(2, 1fr)' }
],
defaultTemplate: 'repeat(3, 1fr)'
};
function getGridTemplateColumns(participantCount) {
for (const bp of GRID_LAYOUT.breakpoints) {
if (participantCount <= bp.maxParticipants) {
return bp.template;
}
}
return GRID_LAYOUT.defaultTemplate;
}
class UIRenderer {
constructor(stateManager) {
this.stateManager = stateManager;
this.unsubscribe = stateManager.subscribe(this.render.bind(this));
this.elements = {
header: document.querySelector('header'),
footer: document.querySelector('footer'),
participantGrid: document.getElementById('participantGrid'),
headerTitle: document.getElementById('headerTitle'),
callDuration: document.getElementById('callDuration'),
encryptionBadge: document.getElementById('encryptionBadge'),
unreadBadge: document.getElementById('unreadBadge'),
remoteNetworkIndicator: document.getElementById('remoteNetworkIndicator'),
remoteNetworkQuality: document.getElementById('remoteNetworkQuality'),
remoteVideo: document.getElementById('remoteVideo'),
remoteVideoPlaceholder: document.getElementById('remoteVideoPlaceholder'),
networkStatus: document.getElementById('networkStatus'),
networkStatusText: document.getElementById('networkStatusText'),
connectingOverlay: document.getElementById('connectingOverlay'),
localVideo: document.getElementById('localVideo'),
localVideoPlaceholder: document.getElementById('localVideoPlaceholder'),
localAudioWave: document.getElementById('localAudioWave'),
localInitials: document.getElementById('localInitials'),
sidebar: document.getElementById('sidebar'),
chatContent: document.getElementById('chatContent'),
userList: document.getElementById('userList'),
localMediaStatus: document.getElementById('localMediaStatus'),
localMuteIcon: document.querySelector('[data-field="localUser.muteIcon"]'),
userCountDisplay: document.getElementById('userCountDisplay'),
micBtn: document.getElementById('micBtn'),
videoBtn: document.getElementById('videoBtn'),
recordBtn: document.getElementById('recordBtn'),
connectionQuality: document.getElementById('connectionQuality')
};
this.bindEventListeners();
this.unsubscribe = stateManager.subscribe(this.render.bind(this));
this.messageUnsubscribe = chatMessage.subscribe(this.renderMessageState.bind(this));
this.render(this.stateManager.getState(), { type: 'INIT' });
window.addEventListener('resize', () => {
if (this.elements.remoteVideo && this.elements.remoteVideo.srcObject) {
const stream = this.elements.remoteVideo.srcObject;
const videoTracks = stream.getVideoTracks();
if (videoTracks.length > 0) {
adjustVideoSize(this.elements.remoteVideo, getVideoResolution(videoTracks[0]));
}
}
});
}
renderMessageState(messageState, changes) {
switch (changes.type) {
case 'NEW_MESSAGE':
this.renderChatMessages(messageState.messages);
this.renderUnreadCount(changes.unreadCount);
break;
case 'SIDEBAR_TOGGLE':
this.renderSidebar(changes.isOpen);
if (changes.isOpen) {
this.renderUnreadCount(0);
} else {
this.renderUnreadCount(changes.unreadCount);
}
break;
}
}
bindEventListeners() {
}
/**
* 婵炴挸寮堕悡瀣棘鐟欏嫮銆?- 闁哄秷顫夊畵渚€鎮╅懜纰樺亾娴g缍侀柛鏍ㄧ墬濞插潡寮惂鐕?
* @param {Object} state - 鐟滅増鎸告晶鐘虫償閺冨倹鏆忛柣妯垮煐閳?
* @param {Object} changes - 闁绘鍩栭埀顑跨瑜板宕犻弽褜鍤犻悹?
*/
render(state, changes) {
switch (changes.type) {
case 'INIT':
this.renderRemoteVideo(state.session.remoteUser);
this.renderLocalVideo(state.session.localUser, state.localStream);
this.renderControlButtons(state.session.localUser.mediaState);
this.renderChatMessages(chatMessage.getMessageState().messages);
this.renderUserList(state.session.localUser, state.session.remoteUser, state.participants);
this.renderHeader(state.session);
if (this.elements.remoteVideoPlaceholder) {
if (state.remoteStream) {
this.elements.remoteVideoPlaceholder.classList.add('hidden');
} else {
this.elements.remoteVideoPlaceholder.classList.remove('hidden');
}
}
break;
case 'DURATION_UPDATE':
this.renderCallDuration(changes.duration);
break;
case 'LOCAL_MEDIA_CHANGE':
this.renderControlButtons(state.session.localUser.mediaState);
this.renderLocalVideo(state.session.localUser, state.localStream);
this.renderLocalUserStatus(state.session.localUser);
this.renderUserList(state.session.localUser, state.session.remoteUser, state.participants);
break;
case 'LOCAL_STREAM_OBTAINED':
this.renderLocalStream(state.localStream);
this.renderLocalVideo(state.session.localUser, state.localStream);
break;
case 'REMOTE_STREAM_OBTAINED':
this.renderRemoteStream(changes.stream, changes.connectionId, changes.isHost);
if (this.elements.connectingOverlay) {
this.elements.connectingOverlay.classList.add('hidden');
}
break;
case 'REMOTE_MEDIA_CHANGE':
this.renderRemoteVideo(state.session.remoteUser);
this.renderUserList(state.session.localUser, state.session.remoteUser, state.participants);
if (changes.participantId && state.session.localUser.isHost) {
const pInfo = state.participants[changes.participantId];
const showPlaceholder = pInfo ? !pInfo.mediaState.video : true;
this.renderParticipantVideoPlaceholder(changes.participantId, showPlaceholder);
}
break;
case 'USER_LIST_UPDATE':
this.renderUserList(changes.localUser, changes.remoteUser, state.participants);
break;
case 'PARTICIPANTS_UPDATE':
this.renderUserList(state.session.localUser, state.session.remoteUser, changes.participants || state.participants);
this.syncParticipantTileNames(changes.participants || state.participants);
break;
case 'NETWORK_CHANGE':
this.renderNetworkStatus(changes.quality);
break;
case 'CALL_STATUS_CHANGE':
this.renderCallStatus(changes.status);
break;
case 'CALL_ENDED':
this.renderCallEnded();
break;
case 'PARTICIPANT_LEFT':
this.renderParticipantLeft(changes.connectionId);
break;
case 'RESOLUTION_CHANGED':
this.renderResolutionChanged(changes.resolution);
break;
}
}
renderCallStatus(status) {
if (this.elements.connectingOverlay) {
if (status === 'connecting') {
this.elements.connectingOverlay.classList.remove('hidden');
} else {
this.elements.connectingOverlay.classList.add('hidden');
}
}
}
renderHeader(session) {
this.renderHeaderTitle();
if (this.elements.encryptionBadge) {
toggleElement(this.elements.encryptionBadge, session.isEncrypted);
}
if (this.elements.remoteNetworkIndicator) {
this.elements.remoteNetworkIndicator.classList.remove('hidden');
}
if (this.elements.remoteNetworkQuality) {
this.elements.remoteNetworkQuality.classList.remove('hidden');
}
this.renderCallDuration(session.duration);
}
renderHeaderTitle() {
if (this.elements.headerTitle) {
const connectionId = store.getConnectionId() || '';
this.elements.headerTitle.textContent = getCallTitle(connectionId);
}
}
renderCallDuration(duration) {
if (this.elements.callDuration) {
this.elements.callDuration.textContent = formatTime(duration);
}
}
renderResolutionChanged(resolution) {
if (!resolution) return;
const options = document.querySelectorAll('.resolution-option');
options.forEach(btn => {
const btnRes = btn.dataset.resolution;
const isActive = (resolution.height >= 1440 && btnRes === '1440') ||
(resolution.height >= 1080 && resolution.height < 1440 && btnRes === '1080') ||
(resolution.height >= 720 && resolution.height < 1080 && btnRes === '720') ||
(resolution.height < 720 && btnRes === '480');
btn.classList.toggle('active', isActive);
});
const currentResText = document.getElementById('currentResolutionText');
if (currentResText) {
currentResText.textContent = `当前: ${resolution.label}`;
}
}
renderRemoteVideo(remoteUser) {
this.renderUserList(
this.stateManager.getState().session.localUser,
remoteUser,
this.stateManager.getState().participants
);
if (this.elements.remoteVideoPlaceholder) {
const shouldShowPlaceholder = !remoteUser.mediaState.video;
const placeholderText = getRemoteVideoPlaceholderText(!shouldShowPlaceholder);
toggleElement(this.elements.remoteVideoPlaceholder, shouldShowPlaceholder);
if (this.elements.remoteVideo) {
this.elements.remoteVideo.style.opacity = shouldShowPlaceholder ? '0' : '1';
}
const placeholderContent = this.elements.remoteVideoPlaceholder.querySelector('.text-center');
if (placeholderContent) {
const titleElement = placeholderContent.querySelector('p.text-white.text-lg.font-medium');
const subtitleElement = placeholderContent.querySelector('p.text-sm.text-gray-400');
if (titleElement) {
titleElement.textContent = placeholderText.title;
}
if (subtitleElement) {
subtitleElement.textContent = placeholderText.subtitle;
}
}
}
this.renderNetworkStatus(remoteUser.networkQuality);
this.renderHeaderNetworkStatus(remoteUser.networkQuality);
}
renderHeaderNetworkStatus(networkQuality) {
if (this.elements.remoteNetworkQuality) {
const textElement = this.elements.remoteNetworkQuality.querySelector('span');
const iconElement = this.elements.remoteNetworkQuality.querySelector('i');
if (textElement && iconElement) {
const display = getNetworkQualityDisplay(networkQuality);
textElement.textContent = display.label;
iconElement.className = display.headerIconClass;
}
}
}
renderLocalVideo(localUser, localStream) {
if (this.elements.localVideoPlaceholder) {
const shouldShowPlaceholder = !localStream || !localUser.mediaState.video;
toggleElement(this.elements.localVideoPlaceholder, shouldShowPlaceholder);
}
if (this.elements.localAudioWave) {
toggleElement(this.elements.localAudioWave, localUser.mediaState.isSpeaking);
}
this.renderLocalUserStatus(localUser);
}
renderLocalStream(stream) {
if (this.elements.localVideo && stream) {
this.elements.localVideo.srcObject = stream;
this.elements.localVideo.autoplay = true;
this.elements.localVideo.muted = true;
logger.debug('srcObject set successfully:', this.elements.localVideo.srcObject);
if (this.elements.disconnectedOverlay) {
this.elements.disconnectedOverlay.classList.add('hidden');
}
} else {
logger.error('Either localVideo element or stream is missing');
}
}
renderRemoteStream(stream, connectionId, isHost) {
if (isHost && connectionId) {
this.renderParticipantStream(stream, connectionId);
} else {
this.renderSingleRemoteStream(stream);
}
}
renderParticipantStream(stream, connectionId) {
const participantInfo = this.stateManager.getState().participants[connectionId];
renderParticipantStreamMedia({
grid: this.elements.participantGrid,
stream,
connectionId,
displayName: participantInfo?.name,
getGridTemplateColumns,
remoteVideo: this.elements.remoteVideo,
connectingOverlay: this.elements.connectingOverlay,
remoteVideoPlaceholder: this.elements.remoteVideoPlaceholder
});
}
renderParticipantVideoPlaceholder(participantId, showPlaceholder) {
const grid = this.elements.participantGrid;
if (!grid) return;
updateParticipantTilePlaceholder(grid, participantId, showPlaceholder);
logger.debug(`Updated placeholder for participant ${participantId}: ${showPlaceholder ? 'shown' : 'hidden'}`);
}
syncParticipantTileNames(participants) {
if (!participants) return;
const grid = this.elements.participantGrid;
if (!grid) return;
for (const [participantId, pInfo] of Object.entries(participants)) {
this.updateParticipantTileName(participantId, pInfo.name);
}
}
updateParticipantTileName(participantId, name) {
const grid = this.elements.participantGrid;
if (!grid) return;
syncParticipantTileName(grid, participantId, name);
if (name) {
logger.debug(`Updated tile name for participant ${participantId}: ${name}`);
}
}
renderSingleRemoteStream(stream) {
renderSingleRemoteStreamMedia({
remoteVideo: this.elements.remoteVideo,
stream,
disconnectedOverlay: this.elements.disconnectedOverlay,
remoteVideoPlaceholder: this.elements.remoteVideoPlaceholder,
connectingOverlay: this.elements.connectingOverlay
});
}
renderLocalUserStatus(localUser) {
const mediaMeta = getMediaStatusMeta(localUser.mediaState);
if (this.elements.localMediaStatus) {
this.elements.localMediaStatus.textContent = mediaMeta.text;
this.elements.localMediaStatus.className = mediaMeta.className;
}
if (this.elements.localMuteIcon) {
if (mediaMeta.showMuteIcon) {
this.elements.localMuteIcon.classList.remove('hidden');
this.elements.localMuteIcon.className = mediaMeta.muteIconClass;
} else {
this.elements.localMuteIcon.classList.add('hidden');
}
}
}
renderUserList(localUser, remoteUser, participants) {
if (!this.elements.userList) return;
const participantsMap = participants || {};
const participantCount = Object.keys(participantsMap).length;
const userCount = 1 + participantCount;
if (this.elements.userCountDisplay) {
this.elements.userCountDisplay.textContent = buildUserCountLabel(userCount);
}
this.elements.userList.innerHTML = '';
this.elements.userList.appendChild(createUserEntryElement({
user: localUser,
role: 'local'
}));
if (participantCount > 0) {
for (const [participantId, participant] of Object.entries(participantsMap)) {
this.elements.userList.appendChild(createUserEntryElement({
user: participant,
role: participant.role === 'host' ? 'host' : 'participant',
id: participantId
}));
}
} else if (remoteUser.status !== 'offline') {
this.elements.userList.appendChild(createUserEntryElement({
user: remoteUser,
role: 'remote'
}));
}
}
renderControlButtons(mediaState) {
if (this.elements.micBtn) {
toggleButtonState(this.elements.micBtn, !mediaState.audio);
}
if (this.elements.videoBtn) {
toggleButtonState(this.elements.videoBtn, !mediaState.video);
}
if (this.elements.recordBtn) {
toggleButtonState(this.elements.recordBtn, mediaState.recording);
}
}
renderChatMessages(messages) {
renderChatMessagesInto(this.elements.chatContent, messages, formatTimestamp);
}
renderUnreadCount(count) {
if (this.elements.unreadBadge) {
if (count > 0) {
this.elements.unreadBadge.textContent = count;
this.elements.unreadBadge.classList.remove('hidden');
} else {
this.elements.unreadBadge.classList.add('hidden');
}
}
}
renderSidebar(isOpen) {
if (this.elements.sidebar) {
if (isOpen) {
this.elements.sidebar.classList.remove('hidden');
} else {
this.elements.sidebar.classList.add('hidden');
}
}
}
renderNetworkStatus(quality) {
const display = getNetworkQualityDisplay(quality);
if (this.elements.networkStatus && this.elements.networkStatusText) {
toggleElement(this.elements.networkStatus, true);
const networkStatus = this.elements.networkStatus;
const networkStatusText = this.elements.networkStatusText;
const existingIcon = networkStatus.querySelector('i');
if (existingIcon) {
existingIcon.remove();
}
const icon = document.createElement('i');
icon.className = display.statusIconClass;
networkStatusText.textContent = display.label;
networkStatusText.className = display.statusTextClass;
networkStatus.insertBefore(icon, networkStatusText);
}
if (this.elements.connectionQuality) {
this.elements.connectionQuality.textContent = `连接质量: ${display.label}`;
this.elements.connectionQuality.className = `text-xs ${display.connectionTextClass}`;
}
this.updateHeaderNetworkIndicator(quality);
this.renderHeaderNetworkStatus(quality);
}
updateHeaderNetworkIndicator(networkQuality) {
if (!this.elements.remoteNetworkIndicator) return;
this.elements.remoteNetworkIndicator.className = getNetworkQualityDisplay(networkQuality).indicatorClass;
}
renderCallEnded() {
logger.debug('Call ended');
clearParticipantGrid(this.elements.participantGrid);
window.location.href = '/endcall/';
}
renderParticipantLeft(connectionId) {
logger.debug(`Participant left: ${connectionId}, updating UI`);
removeParticipantTile({
grid: this.elements.participantGrid,
connectionId,
getGridTemplateColumns,
remoteVideo: this.elements.remoteVideo,
remoteVideoPlaceholder: this.elements.remoteVideoPlaceholder,
remoteNetworkIndicator: this.elements.remoteNetworkIndicator
});
}
destroy() {
if (this.unsubscribe) {
this.unsubscribe();
}
if (this.messageUnsubscribe) {
this.messageUnsubscribe();
}
}
}
export default UIRenderer;

View File

@@ -0,0 +1,172 @@
import { createTextElement, textValue } from '../../shared/dom.js';
const EMPTY_CONNECTION_IDS_HTML = '<p class="text-gray-500 text-sm">\u6682\u65e0\u53ef\u7528\u7684\u8fde\u63a5ID</p>';
const EMPTY_USERS_HTML = '<p class="text-gray-500 text-sm">\u6682\u65e0\u5728\u7ebf\u7528\u6237</p>';
const HALL_LABEL = '\u5927\u5385\uff08\u672a\u52a0\u5165\u623f\u95f4\uff09';
const HOST_LABEL = '\u623f\u4e3b';
const PARTICIPANT_LABEL = '\u6210\u5458';
const UNKNOWN_USER_LABEL = '\u533f\u540d\u7528\u6237';
const UNSET_USER_ID_LABEL = '\u672a\u8bbe\u7f6eID';
const SELF_LABEL = '\u81ea\u5df1';
const SELECT_LABEL = '\u9009\u62e9';
const USER_COUNT_SUFFIX = '\u4eba';
const ONLINE_USERS_SUMMARY_SUFFIX = ' \u4e2aWebSocket\u7528\u6237\u5728\u7ebf';
function getRoleTagClass(role) {
if (role === 'host') {
return 'text-xs px-2 py-1 rounded-full bg-indigo-500/20 text-indigo-300';
}
if (role === 'participant') {
return 'text-xs px-2 py-1 rounded-full bg-white/10 text-gray-300';
}
return 'text-xs px-2 py-1 rounded-full bg-emerald-500/20 text-emerald-300';
}
export async function fetchOnlineUsers() {
const response = await fetch('/signaling/users');
if (!response.ok) {
throw new Error('Failed to fetch online users');
}
const data = await response.json();
return Array.isArray(data.users) ? data.users : [];
}
export async function fetchConnectionDirectory() {
const [connectionResponse, usersResponse] = await Promise.all([
fetch('/signaling/connection-ids'),
fetch('/signaling/users')
]);
if (!connectionResponse.ok) {
throw new Error('Failed to fetch connection IDs');
}
if (!usersResponse.ok) {
throw new Error('Failed to fetch online users');
}
const connectionData = await connectionResponse.json();
const usersData = await usersResponse.json();
return {
connectionIds: connectionData.connectionIds || [],
users: Array.isArray(usersData.users) ? usersData.users : []
};
}
export function renderConnectionIds({ connectionIds, idsContainer, connectionIdsList, onSelectConnectionId }) {
if (!idsContainer) {
return;
}
idsContainer.innerHTML = '';
if (connectionIds.length === 0) {
idsContainer.innerHTML = EMPTY_CONNECTION_IDS_HTML;
} else {
connectionIds.forEach((connectionId) => {
const item = document.createElement('div');
item.className = 'flex items-center justify-between p-2 bg-white/5 rounded-lg hover:bg-white/10 cursor-pointer transition-colors';
const label = document.createElement('span');
label.className = 'text-sm';
label.textContent = connectionId;
const button = document.createElement('button');
button.className = 'text-xs bg-indigo-600 hover:bg-indigo-700 px-2 py-1 rounded';
button.type = 'button';
button.textContent = SELECT_LABEL;
button.addEventListener('click', () => onSelectConnectionId(connectionId));
item.appendChild(label);
item.appendChild(button);
idsContainer.appendChild(item);
});
}
if (connectionIdsList) {
connectionIdsList.classList.remove('hidden');
}
}
export function renderOnlineUsers({ users, currentUserId, onlineUsersList, usersContainer, onlineUsersSummary }) {
if (!onlineUsersList || !usersContainer || !onlineUsersSummary) {
return;
}
onlineUsersSummary.textContent = `${users.length}${ONLINE_USERS_SUMMARY_SUFFIX}`;
usersContainer.innerHTML = '';
if (users.length === 0) {
usersContainer.innerHTML = EMPTY_USERS_HTML;
onlineUsersList.classList.remove('hidden');
return;
}
const groupedUsers = users.reduce((groups, user) => {
const groupName = user.connectionId ? `\u623f\u95f4 ${user.connectionId}` : HALL_LABEL;
if (!groups[groupName]) {
groups[groupName] = [];
}
groups[groupName].push(user);
return groups;
}, {});
Object.entries(groupedUsers).forEach(([groupName, roomUsers]) => {
const section = document.createElement('div');
section.className = 'rounded-lg border border-white/10 bg-white/5 p-3';
const roomTitle = document.createElement('div');
roomTitle.className = 'flex items-center justify-between mb-2';
roomTitle.appendChild(createTextElement('span', 'text-sm font-medium text-white', groupName));
roomTitle.appendChild(createTextElement('span', 'text-xs text-gray-400', `${roomUsers.length} ${USER_COUNT_SUFFIX}`));
section.appendChild(roomTitle);
const roomList = document.createElement('div');
roomList.className = 'space-y-2';
roomUsers.forEach((user) => {
const userName = user.name || user.userId || UNKNOWN_USER_LABEL;
const avatar = user.avatar || '/images/p2.png';
const roleLabel = user.role === 'host'
? HOST_LABEL
: (user.role === 'participant' ? PARTICIPANT_LABEL : HALL_LABEL);
const isSelf = Boolean(user.userId) && user.userId === currentUserId;
const identity = user.userId || user.socketId || user.participantId || UNSET_USER_ID_LABEL;
const userItem = document.createElement('div');
userItem.className = 'flex items-center justify-between rounded-lg bg-black/20 px-3 py-2';
const profile = document.createElement('div');
profile.className = 'flex items-center gap-3 min-w-0';
const avatarImage = document.createElement('img');
avatarImage.src = textValue(avatar);
avatarImage.alt = textValue(userName);
avatarImage.className = 'w-8 h-8 rounded-full object-cover';
profile.appendChild(avatarImage);
const info = document.createElement('div');
info.className = 'min-w-0';
info.appendChild(createTextElement('div', 'text-sm text-white truncate', userName));
info.appendChild(createTextElement('div', 'text-xs text-gray-400 truncate', identity));
profile.appendChild(info);
const status = document.createElement('div');
status.className = 'flex items-center gap-2';
status.appendChild(createTextElement('span', getRoleTagClass(user.role), roleLabel));
if (isSelf) {
status.appendChild(createTextElement('span', 'text-xs text-gray-500', SELF_LABEL));
}
userItem.appendChild(profile);
userItem.appendChild(status);
roomList.appendChild(userItem);
});
section.appendChild(roomList);
usersContainer.appendChild(section);
});
onlineUsersList.classList.remove('hidden');
}

View File

@@ -0,0 +1,111 @@
import { Signaling, WebSocketSignaling } from '/module/core/signaling.js';
import { createLogger } from '../../shared/logger.js';
const logger = createLogger('signaling');
const INVITE_EVENT_NAMES = Object.freeze([
'invite-call',
'invite-accepted',
'invite-rejected',
'invite-failed'
]);
const DEFAULT_SOCKET_USER_NAME = '?';
const DEFAULT_SOCKET_USER_AVATAR = '/images/p1.png';
export function createSignalingInstance(useWebSocket) {
return useWebSocket ? new WebSocketSignaling() : new Signaling();
}
export async function ensureSignalingStarted(existingSignaling, useWebSocket) {
if (existingSignaling) {
return {
signaling: existingSignaling,
reused: true
};
}
const signaling = createSignalingInstance(useWebSocket);
await signaling.start();
return {
signaling,
reused: false
};
}
export function bindInviteSocketEvents(signaling, eventHandlers, boundSignaling = null) {
if (!signaling || signaling === boundSignaling || typeof signaling.addEventListener !== 'function') {
return boundSignaling;
}
INVITE_EVENT_NAMES.forEach((eventName) => {
signaling.addEventListener(eventName, (event) => {
const handler = eventHandlers[eventName];
if (typeof handler === 'function') {
handler(event.detail);
}
});
});
return signaling;
}
export function getActiveSignalingInstance(preconnectedSignaling, renderstreaming) {
if (preconnectedSignaling) {
return preconnectedSignaling;
}
if (renderstreaming && renderstreaming._signaling) {
return renderstreaming._signaling;
}
return null;
}
export function sendInviteSignal(signaling, methodName, payload) {
if (!signaling || typeof signaling[methodName] !== 'function') {
throw new Error('Invite signaling is not ready');
}
signaling[methodName](payload);
}
function readStoredSocketUserInfo() {
try {
return JSON.parse(localStorage.getItem('userSettings') || '{}');
} catch (error) {
logger.error('Error parsing user settings:', error);
return {};
}
}
export function buildSocketUserInfoPayload(userInfo, localUser) {
const settings = userInfo || readStoredSocketUserInfo();
return {
id: settings.id || settings.userId || localUser.id || '',
name: settings.name || localUser.name || DEFAULT_SOCKET_USER_NAME,
avatar: settings.avatar || localUser.avatar || DEFAULT_SOCKET_USER_AVATAR
};
}
export function sendSocketUserInfo(signaling, payload) {
if (!signaling) {
return;
}
if (typeof signaling.sendUserInfo === 'function') {
signaling.sendUserInfo(payload);
return;
}
if (typeof signaling.sendMessage !== 'function') {
return;
}
signaling.sendMessage('', {
type: 'user-info',
data: payload
});
}

1143
client/public/call/store.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,12 @@
import { createLogger } from '../shared/logger.js';
const logger = createLogger('legacy-connect');
/**
* 连接界面逻辑
* 处理初始连接创建通话和加入通话的功能
*/
import { showNotification } from '../utils.js';
import { showNotification, randomMeetingId } from '../shared/utils.js';
const MAX_AVATAR_SIZE = 2 * 1024 * 1024; // 2MB
@@ -17,7 +20,7 @@ function joinCall() {
localStorage.setItem('connectionId', connectionId);
// 跳转到通话界面
window.location.href = '../index.html';
window.location.href = '/';
} else {
showNotification('请输入连接ID', 'error');
}
@@ -28,13 +31,15 @@ function createCall() {
showNotification('正在创建通话...');
// 生成随机连接ID
const connectionId = 'conn_' + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
//const connectionId = 'conn_' + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
const connectionId = randomMeetingId();
showNotification(`已创建通话 (${connectionId})`);
// 保存连接ID到本地存储
localStorage.setItem('connectionId', connectionId);
// 跳转到通话界面
window.location.href = '../index.html';
window.location.href = '/';
}
@@ -49,7 +54,7 @@ async function getAllConnectionIds() {
const data = await response.json();
displayConnectionIds(data.connectionIds);
} catch (error) {
console.error('Error fetching connection IDs:', error);
logger.error('Error fetching connection IDs:', error);
showNotification('获取连接ID失败', 'error');
}
}
@@ -162,7 +167,7 @@ function loadUserSettings() {
document.getElementById('userAvatar').src = avatar;
document.getElementById('avatarPreview').src = avatar;
} catch (error) {
console.error('Error loading user settings:', error);
logger.error('Error loading user settings:', error);
// 加载失败时使用默认头像
const defaultAvatar = '/images/p1.png';
document.getElementById('userAvatar').src = defaultAvatar;
@@ -252,7 +257,7 @@ function handleAvatarUpload(event) {
}
})
.catch(error => {
console.error('Error uploading avatar:', error);
logger.error('Error uploading avatar:', error);
showNotification('头像上传失败,请重试', 'error');
// 上传失败时,使用默认头像

View File

@@ -6,7 +6,7 @@
<title>VideoCall - 重定向</title>
<script>
// 重定向到SPA入口页面index.html
window.location.href = '../index.html';
window.location.href = '/';
</script>
</head>
<body>

View File

@@ -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;
}
}

View File

@@ -3,14 +3,14 @@
* 处理通话结束后的操作如重新连接或返回连接界面
*/
import { showNotification } from '../utils.js';
import { showNotification } from '../shared/utils.js';
// 重新连接
function reconnectCall() {
showNotification('正在重新连接...');
// 跳转到通话界面
window.location.href = '../index.html';
window.location.href = '/';
}
// 离开
@@ -19,7 +19,7 @@ function leaveCall() {
localStorage.removeItem('connectionId');
// 跳转到连接界面
window.location.href = '../connect/connect.html';
window.location.href = '/connect/';
}
// 绑定事件监听器

View File

@@ -6,7 +6,7 @@
<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">
<link rel="stylesheet" href="/styles/style.css">
</head>
<body class="h-screen w-screen flex flex-col text-white bg-grid relative">
<!--

View File

@@ -1,77 +0,0 @@
<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>
</head>
<body>
<div id="container">
<h1>Unity Render Streaming Samples</h1>
<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>
<section>
<h2>Server Configuration</h2>
<div id="startup"></div>
</section>
<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>
<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>
<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>
<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>
<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>
<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>

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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 = '';
}

View File

@@ -1,486 +0,0 @@
/**
* connect视图逻辑
* 处理初始连接界面的UI、用户设置、WebSocket连接状态显示
*/
import { showNotification } from './utils.js';
import store from './store.js';
const MAX_AVATAR_SIZE = 2 * 1024 * 1024; // 2MB
// WebSocket连接状态更新回调
let onWsStatusChange = null;
let cachedOnlineUsers = [];
/**
* 设置WebSocket状态变化回调
* @param {function} callback - 回调函数(connected: boolean)
*/
export function setWsStatusCallback(callback) {
onWsStatusChange = callback;
}
/**
* 更新WebSocket状态显示
* @param {boolean} connected - 是否已连接
*/
export function updateWsStatus(connected) {
const wsStatusDot = document.getElementById('wsStatusDot');
const wsStatusText = document.getElementById('wsStatusText');
if (wsStatusDot && wsStatusText) {
if (connected) {
wsStatusDot.className = 'w-2 h-2 bg-green-500 rounded-full animate-pulse';
wsStatusText.textContent = 'WebSocket已连接';
} else {
wsStatusDot.className = 'w-2 h-2 bg-gray-500 rounded-full';
wsStatusText.textContent = '未连接';
}
}
if (onWsStatusChange) {
onWsStatusChange(connected);
}
}
/**
* 初始化WebSocket连接页面加载时调用
*/
export async function initWebSocket() {
try {
await store.connectSignaling();
store.syncSocketUserInfo();
updateWsStatus(true);
await refreshOnlineUsers();
console.log('WebSocket initialized from connectview');
} catch (error) {
console.error('Failed to initialize WebSocket:', error);
updateWsStatus(false);
showNotification('WebSocket连接失败请刷新页面重试', 'error');
}
}
/**
* 获取全部在线WebSocket用户
* @param {boolean} silent - 是否静默刷新
*/
async function refreshOnlineUsers(silent = true) {
try {
const response = await fetch('/signaling/users');
if (!response.ok) {
throw new Error('Failed to fetch online users');
}
const data = await response.json();
cachedOnlineUsers = Array.isArray(data.users) ? data.users : [];
displayOnlineUsers(cachedOnlineUsers);
if (!silent) {
showNotification(`当前共有 ${cachedOnlineUsers.length} 个WebSocket用户在线`);
}
} catch (error) {
console.error('Error fetching online users:', error);
if (!silent) {
showNotification('获取在线用户失败', 'error');
}
}
}
/**
* 获取所有连接ID
*/
async function getAllConnectionIds() {
showNotification('正在获取连接ID和在线用户...');
try {
const [connectionResponse, usersResponse] = await Promise.all([
fetch('/signaling/connection-ids'),
fetch('/signaling/users')
]);
if (!connectionResponse.ok) {
throw new Error('Failed to fetch connection IDs');
}
if (!usersResponse.ok) {
throw new Error('Failed to fetch online users');
}
const connectionData = await connectionResponse.json();
const usersData = await usersResponse.json();
cachedOnlineUsers = Array.isArray(usersData.users) ? usersData.users : [];
displayConnectionIds(connectionData.connectionIds || []);
displayOnlineUsers(cachedOnlineUsers);
} catch (error) {
console.error('Error fetching connection IDs:', error);
showNotification('获取连接信息失败', 'error');
}
}
/**
* 显示连接ID列表
* @param {string[]} connectionIds - 连接ID数组
*/
function displayConnectionIds(connectionIds) {
const idsContainer = document.getElementById('idsContainer');
const connectionIdsList = document.getElementById('connectionIdsList');
if (idsContainer) {
idsContainer.innerHTML = '';
if (connectionIds.length === 0) {
idsContainer.innerHTML = '<p class="text-gray-500 text-sm">暂无可用的连接ID</p>';
} else {
connectionIds.forEach(id => {
const idElement = document.createElement('div');
idElement.className = 'flex items-center justify-between p-2 bg-white/5 rounded-lg hover:bg-white/10 cursor-pointer transition-colors';
idElement.innerHTML = `
<span class="text-sm">${id}</span>
<button class="text-xs bg-indigo-600 hover:bg-indigo-700 px-2 py-1 rounded" onclick="selectConnectionId('${id}')">选择</button>
`;
idsContainer.appendChild(idElement);
});
}
if (connectionIdsList) {
connectionIdsList.classList.remove('hidden');
}
showNotification(`找到 ${connectionIds.length} 个连接ID`);
}
}
/**
* 转义HTML特殊字符
* @param {string} value - 原始字符串
* @returns {string} 安全字符串
*/
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* 显示全部在线WebSocket用户
* @param {Array} users - 在线用户列表
*/
function displayOnlineUsers(users) {
const onlineUsersList = document.getElementById('onlineUsersList');
const usersContainer = document.getElementById('usersContainer');
const onlineUsersSummary = document.getElementById('onlineUsersSummary');
if (!onlineUsersList || !usersContainer || !onlineUsersSummary) {
return;
}
onlineUsersSummary.textContent = `${users.length} 个WebSocket用户在线`;
usersContainer.innerHTML = '';
if (users.length === 0) {
usersContainer.innerHTML = '<p class="text-gray-500 text-sm">暂无在线用户</p>';
onlineUsersList.classList.remove('hidden');
return;
}
const groupedUsers = users.reduce((groups, user) => {
const groupName = user.connectionId ? `房间 ${user.connectionId}` : '大厅(未加入房间)';
if (!groups[groupName]) {
groups[groupName] = [];
}
groups[groupName].push(user);
return groups;
}, {});
Object.entries(groupedUsers).forEach(([groupName, roomUsers]) => {
const section = document.createElement('div');
section.className = 'rounded-lg border border-white/10 bg-white/5 p-3';
const roomTitle = document.createElement('div');
roomTitle.className = 'flex items-center justify-between mb-2';
roomTitle.innerHTML = `
<span class="text-sm font-medium text-white">${escapeHtml(groupName)}</span>
<span class="text-xs text-gray-400">${roomUsers.length} 人</span>
`;
section.appendChild(roomTitle);
const roomList = document.createElement('div');
roomList.className = 'space-y-2';
roomUsers.forEach((user) => {
const userName = user.name || user.userId || '匿名用户';
const avatar = user.avatar || '/images/p2.png';
const roleLabel = user.role === 'host' ? '房主' : (user.role === 'participant' ? '成员' : '大厅');
const userItem = document.createElement('div');
userItem.className = 'flex items-center justify-between rounded-lg bg-black/20 px-3 py-2';
userItem.innerHTML = `
<div class="flex items-center gap-3 min-w-0">
<img src="${escapeHtml(avatar)}" alt="${escapeHtml(userName)}" class="w-8 h-8 rounded-full object-cover">
<div class="min-w-0">
<div class="text-sm text-white truncate">${escapeHtml(userName)}</div>
<div class="text-xs text-gray-400 truncate">${escapeHtml(user.userId || user.socketId || user.participantId || '未设置ID')}</div>
</div>
</div>
<span class="text-xs px-2 py-1 rounded-full ${user.role === 'host' ? 'bg-indigo-500/20 text-indigo-300' : (user.role === 'participant' ? 'bg-white/10 text-gray-300' : 'bg-emerald-500/20 text-emerald-300')}">${roleLabel}</span>
`;
roomList.appendChild(userItem);
});
section.appendChild(roomList);
usersContainer.appendChild(section);
});
onlineUsersList.classList.remove('hidden');
}
/**
* 选择连接ID
* @param {string} id - 连接ID
*/
function selectConnectionId(id) {
const connectionIdInput = document.getElementById('connectionIdInput');
if (connectionIdInput) {
connectionIdInput.value = id;
showNotification(`已选择连接ID: ${id}`);
}
}
/**
* 生成8位用户ID
* @returns {string} 用户ID
*/
function generateUserId() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = 'user_';
for (let i = 0; i < 8; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
/**
* 加载用户设置
*/
export function loadUserSettings() {
const defaultAvatar = '/images/p1.png';
const userSettings = localStorage.getItem('userSettings');
if (userSettings) {
try {
const settings = JSON.parse(userSettings);
if (settings.userId) {
const userIdInput = document.getElementById('userIdInput');
if (userIdInput) userIdInput.value = settings.userId;
}
if (settings.name) {
const nicknameInput = document.getElementById('nicknameInput');
const userName = document.getElementById('userName');
if (nicknameInput) nicknameInput.value = settings.name;
if (userName) userName.textContent = settings.name;
}
const avatar = settings.avatar || defaultAvatar;
const userAvatar = document.getElementById('userAvatar');
const avatarPreview = document.getElementById('avatarPreview');
if (userAvatar) userAvatar.src = avatar;
if (avatarPreview) avatarPreview.src = avatar;
} catch (error) {
console.error('Error loading user settings:', error);
const userAvatar = document.getElementById('userAvatar');
const avatarPreview = document.getElementById('avatarPreview');
if (userAvatar) userAvatar.src = defaultAvatar;
if (avatarPreview) avatarPreview.src = defaultAvatar;
}
} else {
const newUserId = generateUserId();
const userIdInput = document.getElementById('userIdInput');
if (userIdInput) userIdInput.value = newUserId;
const userAvatar = document.getElementById('userAvatar');
const avatarPreview = document.getElementById('avatarPreview');
if (userAvatar) userAvatar.src = defaultAvatar;
if (avatarPreview) avatarPreview.src = defaultAvatar;
saveSettings();
}
}
/**
* 保存用户设置
*/
export function saveSettings() {
const defaultAvatar = '/images/p1.png';
const nicknameInput = document.getElementById('nicknameInput');
const userIdInput = document.getElementById('userIdInput');
const avatarPreview = document.getElementById('avatarPreview');
const userName = document.getElementById('userName');
const userAvatar = document.getElementById('userAvatar');
const settings = {
userId: userIdInput ? userIdInput.value : generateUserId(),
name: nicknameInput ? (nicknameInput.value || '我') : '我',
avatar: avatarPreview ? (avatarPreview.src || defaultAvatar) : defaultAvatar
};
localStorage.setItem('userSettings', JSON.stringify(settings));
store.syncSocketUserInfo(settings);
if (userName) userName.textContent = settings.name;
if (userAvatar) userAvatar.src = settings.avatar;
showNotification('设置已保存', 'success');
}
/**
* 处理头像上传
* @param {Event} event - 文件选择事件
*/
export function handleAvatarUpload(event) {
const file = event.target.files[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
showNotification('请选择图片文件', 'error');
return;
}
if (file.size > MAX_AVATAR_SIZE) {
showNotification('图片大小不能超过2MB', 'error');
return;
}
const formData = new FormData();
formData.append('avatar', file);
const userIdInput = document.getElementById('userIdInput');
if (userIdInput) {
formData.append('userId', userIdInput.value);
}
showNotification('正在上传头像...');
fetch('/api/upload/avatar', {
method: 'POST',
body: formData
})
.then(response => {
if (!response.ok) throw new Error('上传失败');
return response.json();
})
.then(data => {
if (data.success && data.avatarUrl) {
const avatarUrl = data.avatarUrl;
const avatarPreview = document.getElementById('avatarPreview');
const userAvatar = document.getElementById('userAvatar');
if (avatarPreview) avatarPreview.src = avatarUrl;
if (userAvatar) userAvatar.src = avatarUrl;
saveSettings();
showNotification('头像上传成功', 'success');
} else {
throw new Error('上传失败:' + (data.message || '未知错误'));
}
})
.catch(error => {
console.error('Error uploading avatar:', error);
showNotification('头像上传失败,请重试', 'error');
const defaultAvatar = '/images/p1.png';
const avatarPreview = document.getElementById('avatarPreview');
if (avatarPreview) avatarPreview.src = defaultAvatar;
});
}
/**
* 复制用户ID到剪贴板
*/
export function copyUserId() {
const userIdInput = document.getElementById('userIdInput');
if (userIdInput) {
userIdInput.select();
document.execCommand('copy');
showNotification('用户ID已复制到剪贴板', 'success');
}
}
/**
* 切换设置菜单
*/
export function toggleSettingsMenu() {
const settingsMenu = document.getElementById('settingsMenu');
if (settingsMenu) {
settingsMenu.classList.toggle('hidden');
}
}
/**
* 绑定connect视图事件
* @param {function} onJoinCall - 加入通话回调(connectionId: string)
* @param {function} onCreateCall - 创建通话回调()
*/
export function bindConnectViewEvents(onJoinCall, onCreateCall) {
// 加入通话按钮
const connectBtn = document.getElementById('connectBtn');
if (connectBtn) {
connectBtn.addEventListener('click', () => {
const connectionIdInput = document.getElementById('connectionIdInput');
const connectionId = connectionIdInput ? connectionIdInput.value.trim() : '';
if (connectionId) {
onJoinCall(connectionId);
} else {
showNotification('请输入连接ID', 'error');
}
});
}
// 创建通话按钮
const createCallBtn = document.getElementById('createCallBtn');
if (createCallBtn) {
createCallBtn.addEventListener('click', onCreateCall);
}
// 浏览全部ID按钮
const browseIdsBtn = document.getElementById('browseIdsBtn');
if (browseIdsBtn) {
browseIdsBtn.addEventListener('click', getAllConnectionIds);
}
// 输入框回车事件
const connectionIdInput = document.getElementById('connectionIdInput');
if (connectionIdInput) {
connectionIdInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
const connectionId = connectionIdInput.value.trim();
if (connectionId) {
onJoinCall(connectionId);
} else {
showNotification('请输入连接ID', 'error');
}
}
});
}
// 用户设置按钮
const userSettingsBtn = document.getElementById('userSettingsBtn');
if (userSettingsBtn) {
userSettingsBtn.addEventListener('click', toggleSettingsMenu);
}
}
// 点击外部关闭设置菜单
document.addEventListener('click', function(event) {
const settingsMenu = document.getElementById('settingsMenu');
const userSettingsBtn = document.getElementById('userSettingsBtn');
if (settingsMenu && userSettingsBtn &&
!settingsMenu.contains(event.target) &&
!userSettingsBtn.contains(event.target)) {
settingsMenu.classList.add('hidden');
}
});
// 导出全局函数供HTML onclick使用
window.selectConnectionId = selectConnectionId;
window.saveSettings = saveSettings;
window.handleAvatarUpload = handleAvatarUpload;
window.copyUserId = copyUserId;
window.toggleSettingsMenu = toggleSettingsMenu;

View File

@@ -1,255 +0,0 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', sans-serif;
background: #0f172a;
overflow: hidden;
}
.bg-grid {
background-image:
linear-gradient(rgba(99, 102, 241, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(99, 102, 241, 0.1) 1px, transparent 1px);
background-size: 40px 40px;
}
@keyframes gridMove {
0% { transform: translate(0, 0); }
100% { transform: translate(20px, 20px); }
}
.glass {
background: rgba(30, 41, 59, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.glass-strong {
background: rgba(15, 23, 42, 0.9);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.control-btn {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.control-btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3);
}
.end-call-pulse {
animation: pulse-red 2s infinite;
}
@keyframes pulse-red {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); }
50% { box-shadow: 0 0 0 15px rgba(239, 68, 68, 0); }
}
.chat-bubble {
animation: messageSlide 0.3s ease-out;
margin-bottom: 12px;
}
@keyframes messageSlide {
from { opacity: 0; transform: translateX(-10px); }
to { opacity: 1; transform: translateX(0); }
}
/* 消息样式 */
.message-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.message-header img {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
}
.message-sender {
font-size: 12px;
font-weight: 500;
}
.message-time {
font-size: 12px;
color: #94a3b8;
}
.message-content {
padding: 8px 16px;
border-radius: 8px;
font-size: 14px;
max-width: 70%;
}
/* 系统消息 */
.message-system .message-sender {
color: #60a5fa;
}
.message-system .message-content {
background-color: rgba(30, 64, 175, 0.3);
color: #ffffff;
}
/* 对方消息 */
.message-other .message-sender {
color: #a5b4fc;
}
.message-other .message-content {
background-color: #1e293b;
color: #ffffff;
border-top-left-radius: 0;
}
/* 自己的消息 */
.message-self {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.message-self .message-header {
flex-direction: row-reverse;
}
.message-self .message-sender {
color: #4ade80;
}
.message-self .message-content {
background-color: #4f46e5;
color: #ffffff;
border-top-right-radius: 0;
}
/* 图片消息样式 */
.message-image-container {
display: flex;
flex-direction: column;
gap: 8px;
}
.message-image {
max-width: 200px;
max-height: 200px;
border-radius: 8px;
object-fit: cover;
}
.message-image-name {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
text-align: center;
}
.message-text {
word-wrap: break-word;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
}
.audio-wave {
display: flex;
align-items: center;
gap: 3px;
height: 20px;
}
.audio-wave span {
width: 3px;
background: #10b981;
border-radius: 2px;
animation: wave 1s ease-in-out infinite;
}
.audio-wave span:nth-child(1) { animation-delay: 0s; height: 40%; }
.audio-wave span:nth-child(2) { animation-delay: 0.1s; height: 70%; }
.audio-wave span:nth-child(3) { animation-delay: 0.2s; height: 100%; }
.audio-wave span:nth-child(4) { animation-delay: 0.3s; height: 60%; }
.audio-wave span:nth-child(5) { animation-delay: 0.4s; height: 30%; }
@keyframes wave {
0%, 100% { transform: scaleY(0.5); }
50% { transform: scaleY(1); }
}
.video-fade-in {
animation: videoFadeIn 0.5s ease-out;
}
@keyframes videoFadeIn {
from { opacity: 0; transform: scale(1.05); }
to { opacity: 1; transform: scale(1); }
}
/* 数据绑定标记 - 开发调试时显示
[data-field]::after {
content: attr(data-field);
position: absolute;
top: -18px;
right: 0;
background: #f59e0b;
color: #000;
font-size: 9px;
padding: 1px 4px;
border-radius: 3px;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
z-index: 1000;
white-space: nowrap;
}
[data-field]:hover::after { opacity: 1; }
[data-field] { position: relative; }*/
/* 分辨率选项样式 */
.resolution-option {
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
transition: all 0.2s;
}
.resolution-option:hover {
color: white;
}
.resolution-option.active {
background: rgba(99, 102, 241, 0.3);
color: white;
}
.resolution-option.active::before {
content: '\f00c';
font-family: 'Font Awesome 6 Free';
font-weight: 900;
font-size: 10px;
margin-right: 6px;
color: #818cf8;
}
/* 更多选项菜单动画 */
#moreOptionsMenu {
animation: menuFadeIn 0.15s ease-out;
}
@keyframes menuFadeIn {
from { opacity: 0; transform: translateX(-50%) translateY(8px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}

View File

@@ -1,275 +0,0 @@
/**
* 主入口文件
* 初始化应用,连接各个模块
* SPA架构connect视图和call视图在同一页面切换
*/
import store from './store.js';
import UIRenderer from './renderer.js';
import { showNotification } from './utils.js';
import chatMessage from './chatmessage.js';
import {
bindConnectViewEvents,
initWebSocket,
loadUserSettings
} from './connectview.js';
// 全局变量
let connectionId = "";
// 当前视图状态:'connect' 或 'call'(可用于未来扩展)
let currentView = 'connect';
/**
* 切换到call视图创建/加入通话后)
* @param {string} connectionId - 连接ID
*/
async function switchToCallView(connectionId) {
const connectView = document.getElementById('connectView');
const callView = document.getElementById('callView');
if (connectView) connectView.classList.add('hidden');
if (callView) callView.classList.remove('hidden');
currentView = 'call';
try {
// 初始化渲染器
const renderer = new UIRenderer(store);
// 加入通话
await store.joinCall(connectionId);
// 设置WebRTC连接
await store.setUp(connectionId);
renderer.renderHeaderTitle();
// 绑定DOM事件
bindCallViewDomEvents();
console.log('Video call app initialized successfully');
} catch (error) {
console.error('Error initializing app:', error);
showNotification('初始化失败,请刷新页面重试', 'error');
}
}
/**
* 处理加入通话
* @param {string} connectionId - 连接ID
*/
async function handleJoinCall(connectionId) {
showNotification(`正在加入通话 (${connectionId})`);
localStorage.setItem('connectionId', connectionId);
await switchToCallView(connectionId);
}
/**
* 处理创建通话
*/
async function handleCreateCall() {
showNotification('正在创建通话...');
const connectionId = 'conn_' + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
localStorage.setItem('connectionId', connectionId);
await switchToCallView(connectionId);
}
/**
* 绑定call视图DOM事件
*/
function bindCallViewDomEvents() {
// 切换侧边栏
window.toggleSidebar = function () {
chatMessage.toggleSidebar();
};
// 切换麦克风
window.toggleMute = function (button) {
const state = store.getState();
const currentState = state.session.localUser.mediaState.audio;
store.updateLocalMedia('audio', !currentState);
};
// 切换视频
window.toggleVideo = function (button) {
const state = store.getState();
const currentState = state.session.localUser.mediaState.video;
store.updateLocalMedia('video', !currentState);
};
// 切换本地视频(用于悬停控制)
window.toggleLocalVideo = function () {
window.toggleVideo();
};
// 切换录屏
window.toggleRecording = function (button) {
const state = store.getState();
const currentState = state.session.localUser.mediaState.recording || false;
store.updateLocalMedia('recording', !currentState);
// 显示录制状态通知
if (!currentState) {
showNotification('开始录制');
} else {
showNotification('停止录制');
}
};
// 更多选项菜单切换
window.toggleMoreOptions = function () {
const menu = document.getElementById('moreOptionsMenu');
if (menu) {
menu.classList.toggle('hidden');
}
};
// 切换视频分辨率
window.changeResolution = function (width, height) {
store.changeResolution(width, height);
// 关闭菜单
const menu = document.getElementById('moreOptionsMenu');
if (menu) {
menu.classList.add('hidden');
}
};
// 结束通话
window.endCall = function () {
// 显示确认对话框
document.getElementById('endCallDialog').classList.remove('hidden');
};
// 取消结束通话
window.cancelEndCall = function () {
document.getElementById('endCallDialog').classList.add('hidden');
};
// 确认结束通话
window.confirmEndCall = function () {
document.getElementById('endCallDialog').classList.add('hidden');
store.endCall();
showNotification('通话已结束');
};
// 显示通话请求弹窗
window.showCallRequest = function (caller) {
const dialog = document.getElementById('callRequestDialog');
if (dialog) {
// 设置通话请求信息
if (document.getElementById('callRequestName')) {
document.getElementById('callRequestName').textContent = caller.name;
}
if (document.getElementById('callRequestAvatar')) {
document.getElementById('callRequestAvatar').src = caller.avatar;
}
// 显示弹窗
dialog.classList.remove('hidden');
}
};
// 拒绝通话
window.rejectCall = function () {
const dialog = document.getElementById('callRequestDialog');
if (dialog) {
dialog.classList.add('hidden');
}
showNotification('已拒绝通话请求');
// 可以在这里添加发送拒绝通话请求到服务器的逻辑
};
// 接受通话
window.acceptCall = function () {
const dialog = document.getElementById('callRequestDialog');
if (dialog) {
dialog.classList.add('hidden');
}
showNotification('已接受通话请求');
// 可以在这里添加发送接受通话请求到服务器的逻辑
// 然后初始化通话
store.initCall();
store.setUp(connectionId);
};
// 绑定消息相关事件
chatMessage.bindMessageEvents();
// 键盘快捷键
document.addEventListener('keydown', (event) => {
// 空格键静音
if (event.code === 'Space' && !event.target.matches('input, textarea')) {
event.preventDefault();
window.toggleMute();
}
// Ctrl+V 切换视频
if (event.ctrlKey && event.key === 'v') {
event.preventDefault();
window.toggleVideo();
}
});
// 绑定对话框事件
const cancelEndCall = document.getElementById('cancelEndCall');
const confirmEndCall = document.getElementById('confirmEndCall');
if (cancelEndCall) cancelEndCall.addEventListener('click', window.cancelEndCall);
if (confirmEndCall) confirmEndCall.addEventListener('click', window.confirmEndCall);
// 更多选项按钮事件
const moreOptionsBtn = document.getElementById('moreOptionsBtn');
if (moreOptionsBtn) {
moreOptionsBtn.addEventListener('click', window.toggleMoreOptions);
}
// 点击外部关闭更多选项菜单
document.addEventListener('click', function(event) {
const moreOptionsMenu = document.getElementById('moreOptionsMenu');
const moreOptionsBtnEl = document.getElementById('moreOptionsBtn');
if (moreOptionsMenu && moreOptionsBtnEl &&
!moreOptionsMenu.contains(event.target) &&
!moreOptionsBtnEl.contains(event.target)) {
moreOptionsMenu.classList.add('hidden');
}
});
// 绑定通话请求对话框事件
const rejectCall = document.getElementById('rejectCall');
const acceptCall = document.getElementById('acceptCall');
if (rejectCall) rejectCall.addEventListener('click', window.rejectCall);
if (acceptCall) acceptCall.addEventListener('click', window.acceptCall);
}
// 页面加载完成后初始化SPA入口
window.addEventListener('DOMContentLoaded', async () => {
try {
// 显示connect视图隐藏call视图
const connectView = document.getElementById('connectView');
const callView = document.getElementById('callView');
if (connectView) connectView.classList.remove('hidden');
if (callView) callView.classList.add('hidden');
currentView = 'connect';
// 加载用户设置
loadUserSettings();
// 初始化WebSocket连接在connect视图就建立WebSocket
await initWebSocket();
// 绑定connect视图事件加入通话、创建通话等
bindConnectViewEvents(handleJoinCall, handleCreateCall);
// 检查是否有保存的连接ID填入输入框
const savedConnectionId = localStorage.getItem('connectionId');
if (savedConnectionId) {
const connectionIdInput = document.getElementById('connectionIdInput');
if (connectionIdInput) connectionIdInput.value = savedConnectionId;
}
console.log('SPA initialized, showing connect view');
} catch (error) {
console.error('Error initializing SPA:', error);
showNotification('初始化失败,请刷新页面重试', 'error');
}
});
// 导出全局变量
export { store };

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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 = '';
}

View File

@@ -0,0 +1,227 @@
<!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="/styles/style.css">
</head>
<body class="min-h-screen w-screen text-white bg-grid recordings-page">
<div class="min-h-screen bg-black/70 flex flex-col">
<header class="glass-strong h-16 flex items-center justify-between px-6 border-b border-white/10">
<div class="flex items-center gap-3 min-w-0">
<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 class="min-w-0">
<h1 class="font-bold text-lg tracking-tight">录制管理后台</h1>
<div class="flex items-center gap-3 text-xs text-gray-400">
<span class="w-2 h-2 bg-green-500 rounded-full"></span>
<span id="recordingRootText" class="truncate max-w-[60vw]">recordings</span>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<a href="/" class="recordings-icon-btn glass" title="返回视频通话">
<i class="fas fa-phone"></i>
</a>
<button id="refreshBtn" class="recordings-icon-btn glass" title="刷新列表">
<i class="fas fa-rotate-right"></i>
</button>
</div>
</header>
<main class="recordings-shell flex-1 overflow-hidden">
<section class="recordings-toolbar glass">
<div class="recordings-stat">
<span class="recordings-stat-value" id="totalCount">0</span>
<span class="recordings-stat-label">总录制</span>
</div>
<div class="recordings-stat">
<span class="recordings-stat-value" id="meetingCount">0</span>
<span class="recordings-stat-label">会议数</span>
</div>
<div class="recordings-stat">
<span class="recordings-stat-value" id="storageSize">0 MB</span>
<span class="recordings-stat-label">占用空间</span>
</div>
<div class="recordings-search">
<i class="fas fa-magnifying-glass text-gray-500"></i>
<input id="searchInput" type="search" placeholder="搜索会议、文件或用户" autocomplete="off">
</div>
<div class="recordings-format-filter" id="typeFilterControl">
<select id="typeFilter" class="recordings-select-native" aria-hidden="true" tabindex="-1">
<option value="all">全部格式</option>
<option value="mp4">MP4</option>
<option value="webm">WebM</option>
</select>
<button id="typeFilterButton" class="recordings-filter-trigger" type="button" aria-haspopup="listbox" aria-expanded="false">
<span id="typeFilterText">全部格式</span>
<i class="fas fa-chevron-down"></i>
</button>
<div id="typeFilterMenu" class="recordings-filter-menu hidden" role="listbox" aria-label="录制格式筛选">
<button class="recordings-filter-option is-active" type="button" role="option" aria-selected="true" data-type-value="all">
<span>全部格式</span>
<small>所有录制</small>
</button>
<button class="recordings-filter-option" type="button" role="option" aria-selected="false" data-type-value="mp4">
<span>MP4</span>
<small>标准视频</small>
</button>
<button class="recordings-filter-option" type="button" role="option" aria-selected="false" data-type-value="webm">
<span>WebM</span>
<small>网页录制</small>
</button>
</div>
</div>
</section>
<section class="recordings-content">
<aside class="recordings-upload glass">
<div class="flex items-center justify-between mb-5">
<div>
<h2 class="text-base font-semibold">新增录制</h2>
<p class="text-xs text-gray-500 mt-1">上传到 recordings 目录</p>
</div>
<div class="w-10 h-10 rounded-xl bg-indigo-500/20 flex items-center justify-center text-indigo-300">
<i class="fas fa-cloud-arrow-up"></i>
</div>
</div>
<form id="uploadForm" class="space-y-4">
<label class="recordings-dropzone" for="recordingFile">
<input id="recordingFile" type="file" accept="video/mp4,video/webm" class="hidden" required>
<i class="fas fa-file-video text-2xl text-indigo-300"></i>
<span id="fileNameText">选择 MP4 或 WebM 文件</span>
</label>
<div>
<label class="recordings-label" for="uploadMeetingId">会议 ID</label>
<input id="uploadMeetingId" class="recordings-input" type="text" placeholder="例如 665-261-326" required>
</div>
<div>
<label class="recordings-label" for="uploadUserId">用户 ID</label>
<input id="uploadUserId" class="recordings-input" type="text" placeholder="可选">
</div>
<button id="uploadBtn" class="recordings-primary-btn" type="submit">
<i class="fas fa-plus"></i>
<span>上传录制</span>
</button>
</form>
</aside>
<section class="recordings-list glass">
<div class="recordings-list-head">
<div>
<h2 class="text-base font-semibold">录制文件</h2>
<p class="text-xs text-gray-500 mt-1" id="listSummary">等待加载</p>
</div>
<button id="clearSearchBtn" class="recordings-text-btn hidden">
<i class="fas fa-xmark"></i>
<span>清空筛选</span>
</button>
</div>
<div id="loadingState" class="recordings-empty">
<div class="w-10 h-10 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
<span>正在读取 recordings 目录...</span>
</div>
<div id="emptyState" class="recordings-empty hidden">
<i class="fas fa-folder-open text-3xl text-gray-500"></i>
<span>还没有录制文件</span>
</div>
<div id="recordingsTableWrap" class="recordings-table-wrap hidden">
<table class="recordings-table">
<thead>
<tr>
<th>文件</th>
<th>会议</th>
<th>房主</th>
<th>参与者</th>
<th>大小</th>
<th>上传时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="recordingsTableBody"></tbody>
</table>
</div>
</section>
<aside class="recordings-preview glass">
<div class="recordings-preview-video">
<video id="previewVideo" controls playsinline></video>
<div id="previewPlaceholder" class="recordings-preview-placeholder">
<i class="fas fa-play text-2xl"></i>
<span>选择一条录制预览</span>
</div>
</div>
<div class="recordings-preview-meta">
<h2 id="previewTitle">未选择录制</h2>
<div id="previewDetails" class="space-y-3 text-sm text-gray-400">
<p>从左侧列表选择文件后,可播放、下载、编辑或删除。</p>
</div>
</div>
</aside>
</section>
</main>
</div>
<div id="editDialog" class="fixed inset-0 bg-black/70 hidden items-center justify-center z-50">
<form id="editForm" class="recordings-dialog glass">
<div class="flex items-center justify-between mb-5">
<div>
<h2 class="text-lg font-semibold">编辑录制信息</h2>
<p class="text-xs text-gray-500 mt-1" id="editFilenameText"></p>
</div>
<button type="button" id="closeEditBtn" class="recordings-icon-btn" title="关闭">
<i class="fas fa-xmark"></i>
</button>
</div>
<div class="space-y-4">
<div>
<label class="recordings-label" for="editMeetingId">会议 ID</label>
<input id="editMeetingId" class="recordings-input" type="text" required>
</div>
<div>
<label class="recordings-label" for="editOriginalFilename">显示名称</label>
<input id="editOriginalFilename" class="recordings-input" type="text" required>
</div>
<div>
<label class="recordings-label" for="editUserId">房主用户 ID</label>
<input id="editUserId" class="recordings-input" type="text">
</div>
</div>
<div class="flex gap-3 mt-6">
<button type="button" id="cancelEditBtn" class="recordings-secondary-btn">取消</button>
<button type="submit" class="recordings-primary-btn">
<i class="fas fa-floppy-disk"></i>
<span>保存</span>
</button>
</div>
</form>
</div>
<div id="notification" class="recordings-notification glass">
<i class="fas fa-info-circle text-indigo-400"></i>
<span id="notificationText"></span>
</div>
<script type="module" src="/recordings/recordings-admin.js"></script>
</body>
</html>

View File

@@ -0,0 +1,530 @@
const state = {
recordings: [],
filtered: [],
selectedKey: '',
editing: null
};
const elements = {
refreshBtn: document.getElementById('refreshBtn'),
searchInput: document.getElementById('searchInput'),
typeFilter: document.getElementById('typeFilter'),
typeFilterControl: document.getElementById('typeFilterControl'),
typeFilterButton: document.getElementById('typeFilterButton'),
typeFilterText: document.getElementById('typeFilterText'),
typeFilterMenu: document.getElementById('typeFilterMenu'),
clearSearchBtn: document.getElementById('clearSearchBtn'),
uploadForm: document.getElementById('uploadForm'),
uploadBtn: document.getElementById('uploadBtn'),
recordingFile: document.getElementById('recordingFile'),
fileNameText: document.getElementById('fileNameText'),
uploadMeetingId: document.getElementById('uploadMeetingId'),
uploadUserId: document.getElementById('uploadUserId'),
totalCount: document.getElementById('totalCount'),
meetingCount: document.getElementById('meetingCount'),
storageSize: document.getElementById('storageSize'),
recordingRootText: document.getElementById('recordingRootText'),
listSummary: document.getElementById('listSummary'),
loadingState: document.getElementById('loadingState'),
emptyState: document.getElementById('emptyState'),
recordingsTableWrap: document.getElementById('recordingsTableWrap'),
recordingsTableBody: document.getElementById('recordingsTableBody'),
previewVideo: document.getElementById('previewVideo'),
previewPlaceholder: document.getElementById('previewPlaceholder'),
previewTitle: document.getElementById('previewTitle'),
previewDetails: document.getElementById('previewDetails'),
editDialog: document.getElementById('editDialog'),
editForm: document.getElementById('editForm'),
editFilenameText: document.getElementById('editFilenameText'),
editMeetingId: document.getElementById('editMeetingId'),
editOriginalFilename: document.getElementById('editOriginalFilename'),
editUserId: document.getElementById('editUserId'),
closeEditBtn: document.getElementById('closeEditBtn'),
cancelEditBtn: document.getElementById('cancelEditBtn'),
notification: document.getElementById('notification'),
notificationText: document.getElementById('notificationText')
};
const typeFilterLabels = {
all: '全部格式',
mp4: 'MP4',
webm: 'WebM'
};
function recordingKey(recording) {
return `${recording.meetingId}/${recording.filename}`;
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function formatBytes(bytes) {
const value = Number(bytes) || 0;
if (value < 1024) {
return `${value} B`;
}
const units = ['KB', 'MB', 'GB', 'TB'];
let size = value / 1024;
let index = 0;
while (size >= 1024 && index < units.length - 1) {
size /= 1024;
index += 1;
}
return `${size.toFixed(size >= 10 ? 1 : 2)} ${units[index]}`;
}
function formatDate(value) {
if (!value) {
return '-';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '-';
}
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
function getPersonId(person) {
return person?.userId || person?.id || '';
}
function getPersonName(person) {
return person?.name || getPersonId(person) || '-';
}
function getRecordingHost(recording) {
return recording.host || (recording.userId ? {
userId: recording.userId,
id: recording.userId,
role: 'host'
} : null);
}
function getRecordingParticipants(recording) {
return Array.isArray(recording.participants) ? recording.participants : [];
}
function getPeopleSearchText(recording) {
const host = getRecordingHost(recording);
const participants = getRecordingParticipants(recording);
return [
host?.participantId,
host?.userId,
host?.id,
host?.name,
...participants.flatMap(participant => [
participant.participantId,
participant.userId,
participant.id,
participant.name
])
].filter(Boolean).join(' ');
}
function renderPersonSummary(person) {
if (!person) {
return '-';
}
const name = getPersonName(person);
const id = getPersonId(person);
return id && id !== name ? `${name} (${id})` : name;
}
function renderPeopleList(people) {
if (!people.length) {
return '<div class="recordings-person-empty">暂无参与者</div>';
}
return people.map((person) => `
<div class="recordings-person">
<img src="${escapeHtml(person.avatar || '/images/p2.png')}" alt="">
<div>
<strong>${escapeHtml(getPersonName(person))}</strong>
<span>${escapeHtml(getPersonId(person) || person.participantId || '-')}</span>
</div>
</div>
`).join('');
}
function setTypeFilter(value) {
const nextValue = typeFilterLabels[value] ? value : 'all';
elements.typeFilter.value = nextValue;
elements.typeFilterText.textContent = typeFilterLabels[nextValue];
elements.typeFilterMenu.querySelectorAll('[data-type-value]').forEach((option) => {
const isActive = option.dataset.typeValue === nextValue;
option.classList.toggle('is-active', isActive);
option.setAttribute('aria-selected', String(isActive));
});
}
function setTypeFilterMenuOpen(isOpen) {
elements.typeFilterControl.classList.toggle('is-open', isOpen);
elements.typeFilterMenu.classList.toggle('hidden', !isOpen);
elements.typeFilterButton.setAttribute('aria-expanded', String(isOpen));
}
function showNotification(message, isError = false) {
elements.notificationText.textContent = message;
elements.notification.classList.toggle('recordings-notification-error', isError);
elements.notification.classList.add('recordings-notification-visible');
window.clearTimeout(showNotification.timer);
showNotification.timer = window.setTimeout(() => {
elements.notification.classList.remove('recordings-notification-visible');
}, 2600);
}
async function requestJson(url, options = {}) {
const response = await fetch(url, options);
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload.success === false) {
throw new Error(payload.message || `请求失败: ${response.status}`);
}
return payload;
}
function setLoading(isLoading) {
elements.loadingState.classList.toggle('hidden', !isLoading);
elements.recordingsTableWrap.classList.toggle('hidden', isLoading || state.filtered.length === 0);
elements.emptyState.classList.toggle('hidden', isLoading || state.filtered.length > 0);
}
async function loadRecordings() {
setLoading(true);
try {
const payload = await requestJson('/api/recordings');
state.recordings = payload.recordings || [];
elements.recordingRootText.textContent = payload.root || 'recordings';
applyFilters();
const selected = state.filtered.find(item => recordingKey(item) === state.selectedKey) || state.filtered[0];
selectRecording(selected || null);
} catch (error) {
state.recordings = [];
applyFilters();
selectRecording(null);
showNotification(error.message, true);
} finally {
setLoading(false);
}
}
function applyFilters() {
const query = elements.searchInput.value.trim().toLowerCase();
const type = elements.typeFilter.value;
state.filtered = state.recordings.filter((recording) => {
const extension = (recording.filename || '').split('.').pop().toLowerCase();
const haystack = [
recording.meetingId,
recording.filename,
recording.originalFilename,
recording.userId,
getPeopleSearchText(recording)
].join(' ').toLowerCase();
return (type === 'all' || extension === type) && (!query || haystack.includes(query));
});
elements.clearSearchBtn.classList.toggle('hidden', !query && type === 'all');
renderSummary();
renderTable();
setLoading(false);
}
function renderSummary() {
const meetings = new Set(state.recordings.map(recording => recording.meetingId));
const totalSize = state.recordings.reduce((sum, recording) => sum + (Number(recording.size) || 0), 0);
elements.totalCount.textContent = state.recordings.length;
elements.meetingCount.textContent = meetings.size;
elements.storageSize.textContent = formatBytes(totalSize);
elements.listSummary.textContent = `当前显示 ${state.filtered.length} 条,共 ${state.recordings.length}`;
}
function renderTable() {
elements.recordingsTableBody.innerHTML = state.filtered.map((recording) => {
const key = recordingKey(recording);
const active = key === state.selectedKey ? 'recordings-row-active' : '';
const ext = escapeHtml((recording.filename || '').split('.').pop().toUpperCase());
const host = getRecordingHost(recording);
const participants = getRecordingParticipants(recording);
return `
<tr class="${active}" data-key="${escapeHtml(key)}">
<td>
<button class="recordings-file-cell" data-action="select" data-key="${escapeHtml(key)}">
<span class="recordings-file-icon">${ext}</span>
<span class="min-w-0">
<span class="recordings-file-name">${escapeHtml(recording.originalFilename || recording.filename)}</span>
<span class="recordings-file-sub">${escapeHtml(recording.filename)}</span>
</span>
</button>
</td>
<td>${escapeHtml(recording.meetingId)}</td>
<td>${escapeHtml(renderPersonSummary(host))}</td>
<td>${participants.length}</td>
<td>${formatBytes(recording.size)}</td>
<td>${formatDate(recording.uploadedAt)}</td>
<td>
<div class="recordings-actions">
<button class="recordings-icon-btn" data-action="preview" data-key="${escapeHtml(key)}" title="预览">
<i class="fas fa-play"></i>
</button>
<a class="recordings-icon-btn" href="${escapeHtml(recording.downloadUrl)}" title="下载">
<i class="fas fa-download"></i>
</a>
<button class="recordings-icon-btn" data-action="edit" data-key="${escapeHtml(key)}" title="编辑">
<i class="fas fa-pen"></i>
</button>
<button class="recordings-icon-btn recordings-danger" data-action="delete" data-key="${escapeHtml(key)}" title="删除">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
`;
}).join('');
}
function findRecording(key) {
return state.recordings.find(recording => recordingKey(recording) === key);
}
function selectRecording(recording) {
if (!recording) {
state.selectedKey = '';
elements.previewVideo.removeAttribute('src');
elements.previewVideo.load();
elements.previewPlaceholder.classList.remove('hidden');
elements.previewTitle.textContent = '未选择录制';
elements.previewDetails.innerHTML = '<p>从左侧列表选择文件后,可播放、下载、编辑或删除。</p>';
renderTable();
return;
}
state.selectedKey = recordingKey(recording);
const host = getRecordingHost(recording);
const participants = getRecordingParticipants(recording);
elements.previewVideo.src = recording.streamUrl;
elements.previewPlaceholder.classList.add('hidden');
elements.previewTitle.textContent = recording.originalFilename || recording.filename;
elements.previewDetails.innerHTML = `
<div class="recordings-detail-row"><span>会议 ID</span><strong>${escapeHtml(recording.meetingId)}</strong></div>
<div class="recordings-detail-row"><span>文件名</span><strong>${escapeHtml(recording.filename)}</strong></div>
<div class="recordings-detail-row"><span>格式</span><strong>${escapeHtml(recording.mimetype)}</strong></div>
<div class="recordings-detail-row"><span>大小</span><strong>${formatBytes(recording.size)}</strong></div>
<div class="recordings-detail-row"><span>用户 ID</span><strong>${escapeHtml(recording.userId || '-')}</strong></div>
<div class="recordings-detail-row"><span>上传时间</span><strong>${formatDate(recording.uploadedAt)}</strong></div>
<div class="recordings-people-section">
<div class="recordings-people-title">房主</div>
${renderPeopleList(host ? [host] : [])}
</div>
<div class="recordings-people-section">
<div class="recordings-people-title">参与者 (${participants.length})</div>
${renderPeopleList(participants)}
</div>
<div class="recordings-preview-actions">
<a class="recordings-primary-btn" href="${escapeHtml(recording.downloadUrl)}">
<i class="fas fa-download"></i>
<span>下载</span>
</a>
<button class="recordings-secondary-btn" type="button" data-preview-action="edit">编辑</button>
</div>
`;
renderTable();
}
function openEdit(recording) {
if (!recording) {
return;
}
state.editing = recording;
elements.editFilenameText.textContent = recording.filename;
elements.editMeetingId.value = recording.meetingId || '';
elements.editOriginalFilename.value = recording.originalFilename || recording.filename || '';
elements.editUserId.value = recording.userId || '';
elements.editDialog.classList.remove('hidden');
elements.editDialog.classList.add('flex');
elements.editMeetingId.focus();
}
function closeEdit() {
state.editing = null;
elements.editDialog.classList.add('hidden');
elements.editDialog.classList.remove('flex');
}
async function handleUpload(event) {
event.preventDefault();
const file = elements.recordingFile.files[0];
if (!file) {
showNotification('请选择录制文件', true);
return;
}
const formData = new FormData();
formData.append('recording', file, file.name);
formData.append('filename', file.name);
formData.append('meetingId', elements.uploadMeetingId.value.trim());
formData.append('userId', elements.uploadUserId.value.trim());
elements.uploadBtn.disabled = true;
try {
await requestJson('/api/recordings', {
method: 'POST',
body: formData
});
elements.uploadForm.reset();
elements.fileNameText.textContent = '选择 MP4 或 WebM 文件';
showNotification('录制已上传');
await loadRecordings();
} catch (error) {
showNotification(error.message, true);
} finally {
elements.uploadBtn.disabled = false;
}
}
async function handleEdit(event) {
event.preventDefault();
if (!state.editing) {
return;
}
const recording = state.editing;
try {
const payload = await requestJson(`/api/recordings/${encodeURIComponent(recording.meetingId)}/${encodeURIComponent(recording.filename)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
meetingId: elements.editMeetingId.value.trim(),
originalFilename: elements.editOriginalFilename.value.trim(),
userId: elements.editUserId.value.trim()
})
});
state.selectedKey = recordingKey(payload.recording);
closeEdit();
showNotification('录制信息已更新');
await loadRecordings();
} catch (error) {
showNotification(error.message, true);
}
}
async function deleteRecording(recording) {
if (!recording) {
return;
}
const confirmed = window.confirm(`确定删除录制文件 "${recording.originalFilename || recording.filename}" 吗?`);
if (!confirmed) {
return;
}
try {
await requestJson(`/api/recordings/${encodeURIComponent(recording.meetingId)}/${encodeURIComponent(recording.filename)}`, {
method: 'DELETE'
});
if (state.selectedKey === recordingKey(recording)) {
state.selectedKey = '';
}
showNotification('录制已删除');
await loadRecordings();
} catch (error) {
showNotification(error.message, true);
}
}
function bindEvents() {
elements.refreshBtn.addEventListener('click', loadRecordings);
elements.searchInput.addEventListener('input', applyFilters);
elements.typeFilter.addEventListener('change', applyFilters);
elements.typeFilterButton.addEventListener('click', () => {
setTypeFilterMenuOpen(!elements.typeFilterControl.classList.contains('is-open'));
});
elements.typeFilterMenu.addEventListener('click', (event) => {
const option = event.target.closest('[data-type-value]');
if (!option) {
return;
}
setTypeFilter(option.dataset.typeValue);
setTypeFilterMenuOpen(false);
applyFilters();
});
document.addEventListener('click', (event) => {
if (!elements.typeFilterControl.contains(event.target)) {
setTypeFilterMenuOpen(false);
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
setTypeFilterMenuOpen(false);
}
});
elements.clearSearchBtn.addEventListener('click', () => {
elements.searchInput.value = '';
setTypeFilter('all');
applyFilters();
});
elements.recordingFile.addEventListener('change', () => {
const file = elements.recordingFile.files[0];
elements.fileNameText.textContent = file ? file.name : '选择 MP4 或 WebM 文件';
});
elements.uploadForm.addEventListener('submit', handleUpload);
elements.editForm.addEventListener('submit', handleEdit);
elements.closeEditBtn.addEventListener('click', closeEdit);
elements.cancelEditBtn.addEventListener('click', closeEdit);
elements.editDialog.addEventListener('click', (event) => {
if (event.target === elements.editDialog) {
closeEdit();
}
});
elements.recordingsTableBody.addEventListener('click', (event) => {
const control = event.target.closest('[data-action]');
if (!control) {
return;
}
const recording = findRecording(control.dataset.key);
const action = control.dataset.action;
if (action === 'select' || action === 'preview') {
selectRecording(recording);
if (action === 'preview') {
elements.previewVideo.play().catch(() => undefined);
}
} else if (action === 'edit') {
openEdit(recording);
} else if (action === 'delete') {
deleteRecording(recording);
}
});
elements.previewDetails.addEventListener('click', (event) => {
const control = event.target.closest('[data-preview-action]');
if (control && control.dataset.previewAction === 'edit') {
openEdit(findRecording(state.selectedKey));
}
});
}
bindEvents();
setTypeFilter(elements.typeFilter.value);
loadRecordings();

View File

@@ -1,5 +1,5 @@
import { Observer, Sender } from "../module/sender.js";
import { InputRemoting } from "../module/inputremoting.js";
import { Observer, Sender } from "/module/core/sender.js";
import { InputRemoting } from "/module/input/inputremoting.js";
export class VideoPlayer {
constructor() {
@@ -210,4 +210,4 @@ export class VideoPlayer {
await new Promise(resolve => setTimeout(resolve, 100));
this.inputRemoting.startSending();
}
}
}

View File

@@ -0,0 +1,18 @@
export function textValue(value, fallback = '') {
return value == null || value === '' ? fallback : String(value);
}
export function createTextElement(tagName, className, value, fallback = '') {
const element = document.createElement(tagName);
if (className) {
element.className = className;
}
element.textContent = textValue(value, fallback);
return element;
}
export function createIconElement(className) {
const icon = document.createElement('i');
icon.className = className;
return icon;
}

View File

@@ -0,0 +1,84 @@
const LOG_LEVELS = {
debug: 10,
info: 20,
warn: 30,
error: 40,
silent: 50
};
const STORAGE_KEY = 'video_socket_log_level';
const DEFAULT_LEVEL = 'warn';
function normalizeLevel(level) {
if (!level) {
return DEFAULT_LEVEL;
}
const normalized = String(level).toLowerCase();
return Object.prototype.hasOwnProperty.call(LOG_LEVELS, normalized)
? normalized
: DEFAULT_LEVEL;
}
function getConfiguredLevel() {
try {
const queryLevel = new URLSearchParams(window.location.search).get('logLevel');
if (queryLevel) {
return normalizeLevel(queryLevel);
}
} catch (_error) {
}
try {
const storageLevel = localStorage.getItem(STORAGE_KEY);
if (storageLevel) {
return normalizeLevel(storageLevel);
}
} catch (_error) {
}
return DEFAULT_LEVEL;
}
function shouldLog(level) {
return LOG_LEVELS[level] >= LOG_LEVELS[getConfiguredLevel()];
}
function getConsoleMethod(level) {
switch (level) {
case 'debug':
return console.debug;
case 'info':
return console.info;
case 'warn':
return console.warn;
case 'error':
return console.error;
default:
return console.log;
}
}
function emit(level, scope, args) {
if (!shouldLog(level)) {
return;
}
const prefix = scope ? `[${scope}]` : '[app]';
getConsoleMethod(level)(prefix, ...args);
}
export function createLogger(scope) {
return {
debug: (...args) => emit('debug', scope, args),
info: (...args) => emit('info', scope, args),
warn: (...args) => emit('warn', scope, args),
error: (...args) => emit('error', scope, args)
};
}
export function setBrowserLogLevel(level) {
const normalized = normalizeLevel(level);
localStorage.setItem(STORAGE_KEY, normalized);
return normalized;
}

View File

@@ -92,3 +92,12 @@ export function toggleButtonState(button, active) {
}
}
}
/**
* 生成随机会议ID
* @returns {string} 随机会议ID
*/
export function randomMeetingId() {
const part = () => Math.floor(100 + Math.random() * 900);
return `${part()}-${part()}-${part()}`;
}

View File

@@ -0,0 +1,914 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', sans-serif;
background: #0f172a;
overflow: hidden;
}
.bg-grid {
background-image:
linear-gradient(rgba(99, 102, 241, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(99, 102, 241, 0.1) 1px, transparent 1px);
background-size: 40px 40px;
}
@keyframes gridMove {
0% { transform: translate(0, 0); }
100% { transform: translate(20px, 20px); }
}
.glass {
background: rgba(30, 41, 59, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.glass-strong {
background: rgba(15, 23, 42, 0.9);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.control-btn {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.control-btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3);
}
.end-call-pulse {
animation: pulse-red 2s infinite;
}
@keyframes pulse-red {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); }
50% { box-shadow: 0 0 0 15px rgba(239, 68, 68, 0); }
}
.chat-bubble {
animation: messageSlide 0.3s ease-out;
margin-bottom: 12px;
}
@keyframes messageSlide {
from { opacity: 0; transform: translateX(-10px); }
to { opacity: 1; transform: translateX(0); }
}
/* 消息样式 */
.message-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.message-header img {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
}
.message-sender {
font-size: 12px;
font-weight: 500;
}
.message-time {
font-size: 12px;
color: #94a3b8;
}
.message-content {
padding: 8px 16px;
border-radius: 8px;
font-size: 14px;
max-width: 70%;
}
/* 系统消息 */
.message-system .message-sender {
color: #60a5fa;
}
.message-system .message-content {
background-color: rgba(30, 64, 175, 0.3);
color: #ffffff;
}
/* 对方消息 */
.message-other .message-sender {
color: #a5b4fc;
}
.message-other .message-content {
background-color: #1e293b;
color: #ffffff;
border-top-left-radius: 0;
}
/* 自己的消息 */
.message-self {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.message-self .message-header {
flex-direction: row-reverse;
}
.message-self .message-sender {
color: #4ade80;
}
.message-self .message-content {
background-color: #4f46e5;
color: #ffffff;
border-top-right-radius: 0;
}
/* 图片消息样式 */
.message-image-container {
display: flex;
flex-direction: column;
gap: 8px;
}
.message-image {
max-width: 200px;
max-height: 200px;
border-radius: 8px;
object-fit: cover;
}
.message-image-name {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
text-align: center;
}
.message-text {
word-wrap: break-word;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
}
.audio-wave {
display: flex;
align-items: center;
gap: 3px;
height: 20px;
}
.audio-wave span {
width: 3px;
background: #10b981;
border-radius: 2px;
animation: wave 1s ease-in-out infinite;
}
.audio-wave span:nth-child(1) { animation-delay: 0s; height: 40%; }
.audio-wave span:nth-child(2) { animation-delay: 0.1s; height: 70%; }
.audio-wave span:nth-child(3) { animation-delay: 0.2s; height: 100%; }
.audio-wave span:nth-child(4) { animation-delay: 0.3s; height: 60%; }
.audio-wave span:nth-child(5) { animation-delay: 0.4s; height: 30%; }
@keyframes wave {
0%, 100% { transform: scaleY(0.5); }
50% { transform: scaleY(1); }
}
.video-fade-in {
animation: videoFadeIn 0.5s ease-out;
}
@keyframes videoFadeIn {
from { opacity: 0; transform: scale(1.05); }
to { opacity: 1; transform: scale(1); }
}
/* 数据绑定标记 - 开发调试时显示
[data-field]::after {
content: attr(data-field);
position: absolute;
top: -18px;
right: 0;
background: #f59e0b;
color: #000;
font-size: 9px;
padding: 1px 4px;
border-radius: 3px;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
z-index: 1000;
white-space: nowrap;
}
[data-field]:hover::after { opacity: 1; }
[data-field] { position: relative; }*/
/* 分辨率选项样式 */
.resolution-option {
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
transition: all 0.2s;
}
.resolution-option:hover {
color: white;
}
.resolution-option.active {
background: rgba(99, 102, 241, 0.3);
color: white;
}
.resolution-option.active::before {
content: '\f00c';
font-family: 'Font Awesome 6 Free';
font-weight: 900;
font-size: 10px;
margin-right: 6px;
color: #818cf8;
}
/* 更多选项菜单动画 */
#moreOptionsMenu {
animation: menuFadeIn 0.15s ease-out;
}
@keyframes menuFadeIn {
from { opacity: 0; transform: translateX(-50%) translateY(8px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
.recordings-page {
overflow: hidden;
}
.recordings-shell {
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.recordings-toolbar {
position: relative;
z-index: 50;
min-height: 76px;
border-radius: 16px;
padding: 14px;
display: grid;
grid-template-columns: repeat(3, minmax(112px, max-content)) minmax(220px, 1fr) 152px;
align-items: center;
gap: 12px;
}
.recordings-stat {
min-width: 112px;
padding: 6px 10px;
}
.recordings-stat-value {
display: block;
font-size: 20px;
line-height: 1.1;
font-weight: 700;
color: #ffffff;
}
.recordings-stat-label {
display: block;
margin-top: 4px;
font-size: 12px;
color: #94a3b8;
}
.recordings-search,
.recordings-input {
height: 42px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(15, 23, 42, 0.58);
color: #ffffff;
}
.recordings-search {
display: flex;
align-items: center;
gap: 10px;
padding: 0 14px;
}
.recordings-search input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: #ffffff;
font-size: 14px;
}
.recordings-search input::placeholder,
.recordings-input::placeholder {
color: #64748b;
}
.recordings-format-filter {
position: relative;
z-index: 20;
min-width: 152px;
}
.recordings-select-native {
position: absolute;
inset: 0;
width: 100%;
height: 42px;
opacity: 0;
pointer-events: none;
}
.recordings-filter-trigger {
width: 100%;
height: 42px;
padding: 0 12px 0 14px;
border: 1px solid rgba(129, 140, 248, 0.45);
border-radius: 12px;
background: linear-gradient(180deg, rgba(30, 41, 59, 0.96), rgba(15, 23, 42, 0.94));
color: #e5e7eb;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: 14px;
font-weight: 700;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 10px 24px rgba(2, 6, 23, 0.24);
}
.recordings-filter-trigger i {
color: #c7d2fe;
font-size: 12px;
transition: transform 0.2s ease;
}
.recordings-filter-trigger:hover,
.recordings-format-filter.is-open .recordings-filter-trigger {
border-color: rgba(165, 180, 252, 0.9);
background: linear-gradient(180deg, rgba(49, 46, 129, 0.72), rgba(30, 41, 59, 0.96));
}
.recordings-filter-trigger:focus-visible {
border-color: rgba(129, 140, 248, 0.95);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.recordings-format-filter.is-open .recordings-filter-trigger i {
transform: rotate(180deg);
}
.recordings-filter-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
left: 0;
z-index: 40;
padding: 6px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 14px;
background: rgba(15, 23, 42, 0.98);
box-shadow: 0 18px 40px rgba(2, 6, 23, 0.42), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.recordings-filter-option {
position: relative;
width: 100%;
min-height: 50px;
padding: 8px 34px 8px 10px;
border: 0;
border-radius: 10px;
background: transparent;
color: #e2e8f0;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 2px;
text-align: left;
cursor: pointer;
transition: background 0.18s, color 0.18s;
}
.recordings-filter-option span {
font-size: 14px;
font-weight: 700;
}
.recordings-filter-option small {
color: #64748b;
font-size: 11px;
line-height: 1;
}
.recordings-filter-option:hover,
.recordings-filter-option.is-active {
background: rgba(79, 70, 229, 0.22);
color: #ffffff;
}
.recordings-filter-option.is-active::after {
content: "\f00c";
position: absolute;
top: 50%;
right: 12px;
transform: translateY(-50%);
color: #a5b4fc;
font-family: "Font Awesome 6 Free";
font-size: 12px;
font-weight: 900;
}
.recordings-content {
min-height: 0;
flex: 1;
display: grid;
grid-template-columns: 280px minmax(420px, 1fr) 360px;
gap: 16px;
}
.recordings-upload,
.recordings-list,
.recordings-preview {
min-height: 0;
border-radius: 16px;
}
.recordings-upload {
padding: 18px;
}
.recordings-list {
padding: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.recordings-list-head {
min-height: 72px;
padding: 18px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.recordings-preview {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
overflow: hidden;
}
.recordings-dropzone {
min-height: 132px;
border-radius: 14px;
border: 1px dashed rgba(129, 140, 248, 0.55);
background: rgba(79, 70, 229, 0.08);
color: #c7d2fe;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
text-align: center;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.recordings-dropzone:hover {
background: rgba(79, 70, 229, 0.16);
border-color: rgba(165, 180, 252, 0.8);
}
.recordings-label {
display: block;
margin-bottom: 8px;
font-size: 12px;
color: #94a3b8;
}
.recordings-input {
width: 100%;
padding: 0 12px;
outline: 0;
}
.recordings-input:focus,
.recordings-search:focus-within {
border-color: rgba(129, 140, 248, 0.9);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.22);
}
.recordings-primary-btn,
.recordings-secondary-btn,
.recordings-text-btn,
.recordings-icon-btn {
border: 0;
outline: 0;
cursor: pointer;
transition: transform 0.2s, background 0.2s, color 0.2s, border-color 0.2s;
}
.recordings-primary-btn,
.recordings-secondary-btn {
min-height: 42px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
}
.recordings-primary-btn {
width: 100%;
background: #4f46e5;
color: #ffffff;
}
.recordings-primary-btn:hover {
background: #4338ca;
}
.recordings-primary-btn:disabled {
opacity: 0.55;
cursor: wait;
}
.recordings-secondary-btn {
width: 100%;
background: rgba(255, 255, 255, 0.08);
color: #e2e8f0;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.recordings-secondary-btn:hover {
background: rgba(255, 255, 255, 0.14);
}
.recordings-text-btn {
display: inline-flex;
align-items: center;
gap: 8px;
color: #c7d2fe;
background: transparent;
font-size: 13px;
}
.recordings-icon-btn {
width: 38px;
height: 38px;
border-radius: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #cbd5e1;
background: rgba(255, 255, 255, 0.06);
}
.recordings-icon-btn:hover {
transform: translateY(-1px);
color: #ffffff;
background: rgba(255, 255, 255, 0.12);
}
.recordings-danger {
color: #fca5a5;
}
.recordings-danger:hover {
color: #ffffff;
background: rgba(239, 68, 68, 0.75);
}
.recordings-table-wrap {
flex: 1;
min-height: 0;
overflow: auto;
}
.recordings-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.recordings-table th,
.recordings-table td {
padding: 13px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
text-align: left;
vertical-align: middle;
}
.recordings-table th {
position: sticky;
top: 0;
z-index: 1;
background: rgba(15, 23, 42, 0.95);
color: #94a3b8;
font-size: 12px;
font-weight: 600;
}
.recordings-table th:nth-child(1) { width: 28%; }
.recordings-table th:nth-child(2) { width: 12%; }
.recordings-table th:nth-child(3) { width: 15%; }
.recordings-table th:nth-child(4) { width: 8%; }
.recordings-table th:nth-child(5) { width: 9%; }
.recordings-table th:nth-child(6) { width: 14%; }
.recordings-table th:nth-child(7) { width: 14%; }
.recordings-table tbody tr {
transition: background 0.2s;
}
.recordings-table tbody tr:hover,
.recordings-row-active {
background: rgba(99, 102, 241, 0.14);
}
.recordings-file-cell {
max-width: 100%;
display: flex;
align-items: center;
gap: 10px;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.recordings-file-icon {
flex: 0 0 auto;
width: 46px;
height: 30px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(16, 185, 129, 0.16);
color: #6ee7b7;
font-size: 11px;
font-weight: 700;
}
.recordings-file-name,
.recordings-file-sub {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recordings-file-name {
color: #ffffff;
font-size: 14px;
font-weight: 600;
}
.recordings-file-sub {
margin-top: 3px;
color: #64748b;
font-size: 12px;
}
.recordings-actions {
display: flex;
align-items: center;
gap: 6px;
}
.recordings-empty {
flex: 1;
min-height: 260px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
color: #94a3b8;
}
.recordings-preview-video {
position: relative;
aspect-ratio: 16 / 9;
border-radius: 14px;
overflow: hidden;
background: #020617;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.recordings-preview-video video {
width: 100%;
height: 100%;
object-fit: contain;
}
.recordings-preview-placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
color: #94a3b8;
background: linear-gradient(135deg, rgba(49, 46, 129, 0.55), rgba(8, 47, 73, 0.45));
}
.recordings-preview-meta {
min-height: 0;
overflow: auto;
}
.recordings-preview-meta h2 {
margin-bottom: 14px;
font-size: 17px;
font-weight: 700;
word-break: break-word;
}
.recordings-detail-row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 10px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.recordings-detail-row span {
color: #94a3b8;
}
.recordings-detail-row strong {
color: #e2e8f0;
font-weight: 600;
text-align: right;
word-break: break-all;
}
.recordings-people-section {
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.recordings-people-title {
margin-bottom: 10px;
color: #94a3b8;
font-size: 13px;
font-weight: 600;
}
.recordings-person {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
}
.recordings-person img {
width: 34px;
height: 34px;
border-radius: 999px;
object-fit: cover;
background: rgba(255, 255, 255, 0.08);
}
.recordings-person strong,
.recordings-person span {
display: block;
max-width: 230px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recordings-person strong {
color: #ffffff;
font-size: 13px;
}
.recordings-person span,
.recordings-person-empty {
color: #64748b;
font-size: 12px;
}
.recordings-preview-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 16px;
}
.recordings-dialog {
width: min(440px, calc(100vw - 32px));
border-radius: 18px;
padding: 22px;
}
.recordings-notification {
position: fixed;
top: 82px;
left: 50%;
transform: translate(-50%, -16px);
z-index: 60;
min-height: 44px;
padding: 0 18px;
border-radius: 999px;
display: flex;
align-items: center;
gap: 10px;
color: #ffffff;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s, transform 0.2s;
}
.recordings-notification-visible {
opacity: 1;
transform: translate(-50%, 0);
}
.recordings-notification-error i {
color: #f87171;
}
@media (max-width: 1180px) {
.recordings-content {
grid-template-columns: 260px minmax(420px, 1fr);
}
.recordings-preview {
grid-column: 1 / -1;
min-height: 360px;
display: grid;
grid-template-columns: minmax(360px, 0.8fr) 1fr;
}
}
@media (max-width: 860px) {
.recordings-page {
overflow: auto;
}
.recordings-shell {
padding: 14px;
overflow: visible;
}
.recordings-toolbar {
grid-template-columns: 1fr 1fr;
}
.recordings-search,
.recordings-format-filter {
grid-column: 1 / -1;
}
.recordings-content,
.recordings-preview {
display: flex;
flex-direction: column;
}
.recordings-list,
.recordings-upload,
.recordings-preview {
flex: none;
}
.recordings-table {
min-width: 920px;
}
}

View File

@@ -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;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 472 KiB

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}
}

View File

@@ -1,4 +1,4 @@
import * as Logger from "./logger.js";
import * as Logger from "../utils/logger.js";
export default class Peer extends EventTarget {
constructor(connectionId, polite, config, resendIntervalMsec = 5000) {

View File

@@ -1,5 +1,5 @@
import Peer from "./peer.js";
import * as Logger from "./logger.js";
import * as Logger from "../utils/logger.js";
function uuid4() {
var temp_url = URL.createObjectURL(new Blob());
@@ -314,4 +314,4 @@ export class RenderStreaming {
this._signaling = null;
}
}
}
}

View File

@@ -5,11 +5,11 @@ import {
Touchscreen,
StateEvent,
TextEvent
} from "./inputdevice.js";
} from "../input/inputdevice.js";
import { LocalInputManager } from "./inputremoting.js";
import { GamepadHandler } from "./gamepadhandler.js";
import { PointerCorrector } from "./pointercorrect.js";
import { LocalInputManager } from "../input/inputremoting.js";
import { GamepadHandler } from "../input/gamepadhandler.js";
import { PointerCorrector } from "../input/pointercorrect.js";
export class Sender extends LocalInputManager {
constructor(elem) {

Some files were not shown because too many files have changed in this diff Show More