Compare commits
22 Commits
a413c56a6f
...
本地音视频合并
| Author | SHA1 | Date | |
|---|---|---|---|
| 83cf098c5f | |||
| d74a0c8121 | |||
| e6dfb28ef2 | |||
| ad93ef342b | |||
| 40fd7f7e08 | |||
| bbe7e71274 | |||
| 254d9337bf | |||
| cc734790ef | |||
| eb0106d296 | |||
| 518f8a94b3 | |||
| e48a6eae3c | |||
| e00192daf9 | |||
| c89b22d320 | |||
| 20760a2668 | |||
| a37fba5519 | |||
| 9c05c6a9d9 | |||
| 554bb5d9ee | |||
| 68712fba8c | |||
| ac16fa85e9 | |||
| a30c74f8da | |||
| 0d8a567c95 | |||
| 44f4b30313 |
3
.gitignore
vendored
@@ -43,6 +43,9 @@ node_modules/
|
|||||||
|
|
||||||
# Coverage
|
# Coverage
|
||||||
coverage/
|
coverage/
|
||||||
|
recordings/
|
||||||
|
!client/public/recordings/
|
||||||
|
!client/public/recordings/**
|
||||||
*.lcov
|
*.lcov
|
||||||
.nyc_output
|
.nyc_output
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/* eslint-disable no-undef */
|
/* eslint-disable no-undef */
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
import { TextEncoder, TextDecoder } from 'util';
|
import { TextEncoder, TextDecoder } from 'util';
|
||||||
import { PeerConnectionMock, SessionDescriptionMock, IceCandidateMock } from './test/peerconnectionmock';
|
import { PeerConnectionMock, SessionDescriptionMock, IceCandidateMock } from './test/mocks/peerconnectionmock.js';
|
||||||
import ResizeObserverMock from './test/resizeobservermock';
|
import ResizeObserverMock from './test/helpers/resizeobservermock.js';
|
||||||
|
|
||||||
// note: If set testEnvironment `jest-environment-jsdom`, below classes are not defined.
|
// note: If set testEnvironment `jest-environment-jsdom`, below classes are not defined.
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 472 KiB After Width: | Height: | Size: 472 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 274 KiB After Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 13 KiB |
BIN
client/public/assets/uploads/avatars/user_XPMwGa7W.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
@@ -1,10 +1,13 @@
|
|||||||
|
import { createLogger } from '../../shared/logger.js';
|
||||||
|
|
||||||
|
const logger = createLogger('chat');
|
||||||
/**
|
/**
|
||||||
* 消息模块
|
* 消息模块
|
||||||
* 处理聊天消息的发送、接收和显示
|
* 处理聊天消息的发送、接收和显示
|
||||||
*/
|
*/
|
||||||
import { showNotification, generateId } from './utils.js';
|
import { showNotification, generateId } from '../../shared/utils.js';
|
||||||
import store from './store.js';
|
import store from '../store.js';
|
||||||
import { mockMessages } from './models.js';
|
import { mockMessages } from '../models.js';
|
||||||
|
|
||||||
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB
|
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
|
|
||||||
@@ -72,7 +75,7 @@ function sendChatMessage(message) {
|
|||||||
* @param {Object} data - 消息数据
|
* @param {Object} data - 消息数据
|
||||||
*/
|
*/
|
||||||
function handleChatMessage(data) {
|
function handleChatMessage(data) {
|
||||||
console.log('处理聊天:', data);
|
logger.debug('处理聊天:', data);
|
||||||
addMessage(data);
|
addMessage(data);
|
||||||
|
|
||||||
const isImage = data.content && data.content.startsWith('data:image/');
|
const isImage = data.content && data.content.startsWith('data:image/');
|
||||||
77
client/public/call/chat/renderer-chat.js
Normal 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;
|
||||||
|
}
|
||||||
211
client/public/call/connectview.js
Normal 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;
|
||||||
136
client/public/call/controllers/call-view-controller.js
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
190
client/public/call/controllers/invite-controller.js
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
220
client/public/call/controllers/profile-settings.js
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<title>VideoCall - 一对一视频通话</title>
|
<title>VideoCall - 一对一视频通话</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<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 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>
|
</head>
|
||||||
|
|
||||||
<body class="h-screen w-screen flex flex-col text-white bg-grid relative">
|
<body class="h-screen w-screen flex flex-col text-white bg-grid relative">
|
||||||
@@ -726,8 +726,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 引入模块化JavaScript文件 -->
|
<!-- 引入模块化JavaScript文件 -->
|
||||||
<script type="module" src="connectview.js"></script>
|
<script type="module" src="/call/connectview.js"></script>
|
||||||
<script type="module" src="main.js"></script>
|
<script type="module" src="/call/main.js"></script>
|
||||||
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
129
client/public/call/main.js
Normal 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 };
|
||||||
80
client/public/call/media/media-config.js
Normal 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;
|
||||||
|
}
|
||||||
62
client/public/call/media/media-monitoring.js
Normal 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`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
336
client/public/call/media/meeting-recorder.js
Normal 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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
191
client/public/call/media/renderer-media.js
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
96
client/public/call/media/webrtc-stats.js
Normal 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';
|
||||||
|
}
|
||||||
77
client/public/call/participants/participants.js
Normal 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;
|
||||||
|
}
|
||||||
73
client/public/call/participants/renderer-participant-grid.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
225
client/public/call/renderers/renderer-ui.js
Normal 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;
|
||||||
|
}
|
||||||
541
client/public/call/renderers/renderer.js
Normal 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;
|
||||||
172
client/public/call/signaling/connect-directory.js
Normal 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');
|
||||||
|
}
|
||||||
111
client/public/call/signaling/signaling-session.js
Normal 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
@@ -1,9 +1,12 @@
|
|||||||
|
import { createLogger } from '../shared/logger.js';
|
||||||
|
|
||||||
|
const logger = createLogger('legacy-connect');
|
||||||
/**
|
/**
|
||||||
* 连接界面逻辑
|
* 连接界面逻辑
|
||||||
* 处理初始连接、创建通话和加入通话的功能
|
* 处理初始连接、创建通话和加入通话的功能
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { showNotification, randomMeetingId } from '../utils.js';
|
import { showNotification, randomMeetingId } from '../shared/utils.js';
|
||||||
|
|
||||||
const MAX_AVATAR_SIZE = 2 * 1024 * 1024; // 2MB
|
const MAX_AVATAR_SIZE = 2 * 1024 * 1024; // 2MB
|
||||||
|
|
||||||
@@ -17,7 +20,7 @@ function joinCall() {
|
|||||||
localStorage.setItem('connectionId', connectionId);
|
localStorage.setItem('connectionId', connectionId);
|
||||||
|
|
||||||
// 跳转到通话界面
|
// 跳转到通话界面
|
||||||
window.location.href = '../index.html';
|
window.location.href = '/';
|
||||||
} else {
|
} else {
|
||||||
showNotification('请输入连接ID', 'error');
|
showNotification('请输入连接ID', 'error');
|
||||||
}
|
}
|
||||||
@@ -36,7 +39,7 @@ function createCall() {
|
|||||||
localStorage.setItem('connectionId', connectionId);
|
localStorage.setItem('connectionId', connectionId);
|
||||||
|
|
||||||
// 跳转到通话界面
|
// 跳转到通话界面
|
||||||
window.location.href = '../index.html';
|
window.location.href = '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -51,7 +54,7 @@ async function getAllConnectionIds() {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
displayConnectionIds(data.connectionIds);
|
displayConnectionIds(data.connectionIds);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching connection IDs:', error);
|
logger.error('Error fetching connection IDs:', error);
|
||||||
showNotification('获取连接ID失败', 'error');
|
showNotification('获取连接ID失败', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,7 +167,7 @@ function loadUserSettings() {
|
|||||||
document.getElementById('userAvatar').src = avatar;
|
document.getElementById('userAvatar').src = avatar;
|
||||||
document.getElementById('avatarPreview').src = avatar;
|
document.getElementById('avatarPreview').src = avatar;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading user settings:', error);
|
logger.error('Error loading user settings:', error);
|
||||||
// 加载失败时使用默认头像
|
// 加载失败时使用默认头像
|
||||||
const defaultAvatar = '/images/p1.png';
|
const defaultAvatar = '/images/p1.png';
|
||||||
document.getElementById('userAvatar').src = defaultAvatar;
|
document.getElementById('userAvatar').src = defaultAvatar;
|
||||||
@@ -254,7 +257,7 @@ function handleAvatarUpload(event) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('Error uploading avatar:', error);
|
logger.error('Error uploading avatar:', error);
|
||||||
showNotification('头像上传失败,请重试', 'error');
|
showNotification('头像上传失败,请重试', 'error');
|
||||||
|
|
||||||
// 上传失败时,使用默认头像
|
// 上传失败时,使用默认头像
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<title>VideoCall - 重定向</title>
|
<title>VideoCall - 重定向</title>
|
||||||
<script>
|
<script>
|
||||||
// 重定向到SPA入口页面(index.html)
|
// 重定向到SPA入口页面(index.html)
|
||||||
window.location.href = '../index.html';
|
window.location.href = '/';
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -1,500 +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, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCurrentUserId() {
|
|
||||||
try {
|
|
||||||
const settings = JSON.parse(localStorage.getItem('userSettings') || '{}');
|
|
||||||
return settings.userId || settings.id || '';
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing current user settings:', error);
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 显示全部在线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 isSelf = Boolean(user.userId) && user.userId === getCurrentUserId();
|
|
||||||
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>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<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>
|
|
||||||
${isSelf ? '<span class="text-xs text-gray-500">自己</span>' : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
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;
|
|
||||||
@@ -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); }
|
|
||||||
}
|
|
||||||
@@ -3,14 +3,14 @@
|
|||||||
* 处理通话结束后的操作,如重新连接或返回连接界面
|
* 处理通话结束后的操作,如重新连接或返回连接界面
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { showNotification } from '../utils.js';
|
import { showNotification } from '../shared/utils.js';
|
||||||
|
|
||||||
// 重新连接
|
// 重新连接
|
||||||
function reconnectCall() {
|
function reconnectCall() {
|
||||||
showNotification('正在重新连接...');
|
showNotification('正在重新连接...');
|
||||||
|
|
||||||
// 跳转到通话界面
|
// 跳转到通话界面
|
||||||
window.location.href = '../index.html';
|
window.location.href = '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 离开
|
// 离开
|
||||||
@@ -19,7 +19,7 @@ function leaveCall() {
|
|||||||
localStorage.removeItem('connectionId');
|
localStorage.removeItem('connectionId');
|
||||||
|
|
||||||
// 跳转到连接界面
|
// 跳转到连接界面
|
||||||
window.location.href = '../connect/connect.html';
|
window.location.href = '/connect/';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 绑定事件监听器
|
// 绑定事件监听器
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<title>VideoCall - 通话结束</title>
|
<title>VideoCall - 通话结束</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<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 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>
|
</head>
|
||||||
<body class="h-screen w-screen flex flex-col text-white bg-grid relative">
|
<body class="h-screen w-screen flex flex-col text-white bg-grid relative">
|
||||||
<!--
|
<!--
|
||||||
@@ -1,411 +0,0 @@
|
|||||||
/**
|
|
||||||
* 主入口文件
|
|
||||||
* 初始化应用,连接各个模块
|
|
||||||
* SPA架构:connect视图和call视图在同一页面切换
|
|
||||||
*/
|
|
||||||
import store from './store.js';
|
|
||||||
import UIRenderer from './renderer.js';
|
|
||||||
import { showNotification, randomMeetingId } from './utils.js';
|
|
||||||
import chatMessage from './chatmessage.js';
|
|
||||||
import {
|
|
||||||
bindConnectViewEvents,
|
|
||||||
initWebSocket,
|
|
||||||
loadUserSettings
|
|
||||||
} from './connectview.js';
|
|
||||||
// 全局变量
|
|
||||||
let connectionId = "";
|
|
||||||
// 当前视图状态:'connect' 或 'call'(可用于未来扩展)
|
|
||||||
let currentView = 'connect';
|
|
||||||
let pendingIncomingInvite = null;
|
|
||||||
let inviteHandlersBound = false;
|
|
||||||
|
|
||||||
function getInvitePayloadFromUrl() {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
if (params.get('invite') !== '1') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: params.get('callerName') || '邀请方',
|
|
||||||
avatar: params.get('callerAvatar') || '/images/p2.png',
|
|
||||||
connectionId: params.get('connectionId') || ''
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function showCallRequestDialog(caller = {}) {
|
|
||||||
const dialog = document.getElementById('callRequestDialog');
|
|
||||||
if (!dialog) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const callerName = caller.name || '邀请方';
|
|
||||||
const callerAvatar = caller.avatar || '/images/p2.png';
|
|
||||||
const targetConnectionId = caller.connectionId || '';
|
|
||||||
const applyReason = caller.applyReason || caller.reason || '未填写';
|
|
||||||
pendingIncomingInvite = caller;
|
|
||||||
|
|
||||||
if (document.getElementById('callRequestName')) {
|
|
||||||
document.getElementById('callRequestName').textContent = callerName;
|
|
||||||
}
|
|
||||||
if (document.getElementById('callRequestAvatar')) {
|
|
||||||
document.getElementById('callRequestAvatar').src = callerAvatar;
|
|
||||||
}
|
|
||||||
if (document.getElementById('callRequestText')) {
|
|
||||||
document.getElementById('callRequestText').textContent = targetConnectionId
|
|
||||||
? `正在邀请您加入通话 (${targetConnectionId})`
|
|
||||||
: '正在请求与您进行视频通话';
|
|
||||||
}
|
|
||||||
if (document.getElementById('callRequestReason')) {
|
|
||||||
document.getElementById('callRequestReason').textContent = applyReason;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetConnectionId) {
|
|
||||||
connectionId = targetConnectionId;
|
|
||||||
localStorage.setItem('connectionId', targetConnectionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCurrentUserProfile() {
|
|
||||||
try {
|
|
||||||
const settings = JSON.parse(localStorage.getItem('userSettings') || '{}');
|
|
||||||
return {
|
|
||||||
userId: settings.userId || settings.id || '',
|
|
||||||
name: settings.name || '我',
|
|
||||||
avatar: settings.avatar || '/images/p1.png'
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing user settings:', error);
|
|
||||||
return {
|
|
||||||
userId: '',
|
|
||||||
name: '我',
|
|
||||||
avatar: '/images/p1.png'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindInviteSignalHandlers() {
|
|
||||||
if (inviteHandlersBound) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
store.onSocketEvent('invite-call', (payload) => {
|
|
||||||
pendingIncomingInvite = {
|
|
||||||
connectionId: payload.connectionId,
|
|
||||||
inviterSocketId: payload.inviterSocketId,
|
|
||||||
inviterUserId: payload.inviterUserId,
|
|
||||||
name: payload.inviterName || '邀请方',
|
|
||||||
avatar: payload.inviterAvatar || '/images/p2.png',
|
|
||||||
applyReason: payload.applyReason || payload.reason || ''
|
|
||||||
};
|
|
||||||
showCallRequestDialog(pendingIncomingInvite);
|
|
||||||
showNotification(`${pendingIncomingInvite.name} 邀请你加入通话`);
|
|
||||||
});
|
|
||||||
|
|
||||||
inviteHandlersBound = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindInviteDialogEvents() {
|
|
||||||
window.showCallRequest = function (caller) {
|
|
||||||
showCallRequestDialog(caller);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.rejectCall = function () {
|
|
||||||
const dialog = document.getElementById('callRequestDialog');
|
|
||||||
if (dialog) {
|
|
||||||
dialog.classList.add('hidden');
|
|
||||||
}
|
|
||||||
if (pendingIncomingInvite) {
|
|
||||||
try {
|
|
||||||
store.sendInviteRejected({
|
|
||||||
connectionId: pendingIncomingInvite.connectionId || '',
|
|
||||||
targetSocketId: pendingIncomingInvite.inviterSocketId || '',
|
|
||||||
targetUserId: pendingIncomingInvite.inviterUserId || ''
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error rejecting invite:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pendingIncomingInvite = null;
|
|
||||||
showNotification('已拒绝通话请求');
|
|
||||||
};
|
|
||||||
|
|
||||||
window.acceptCall = async function () {
|
|
||||||
const dialog = document.getElementById('callRequestDialog');
|
|
||||||
if (dialog) {
|
|
||||||
dialog.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetConnectionId =
|
|
||||||
(pendingIncomingInvite && pendingIncomingInvite.connectionId) ||
|
|
||||||
connectionId ||
|
|
||||||
localStorage.getItem('connectionId') ||
|
|
||||||
new URLSearchParams(window.location.search).get('connectionId');
|
|
||||||
|
|
||||||
if (!targetConnectionId) {
|
|
||||||
showNotification('缺少连接ID,无法接受邀请', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
connectionId = targetConnectionId;
|
|
||||||
localStorage.setItem('connectionId', targetConnectionId);
|
|
||||||
|
|
||||||
if (pendingIncomingInvite) {
|
|
||||||
try {
|
|
||||||
store.sendInviteAccepted({
|
|
||||||
connectionId: targetConnectionId,
|
|
||||||
targetSocketId: pendingIncomingInvite.inviterSocketId || '',
|
|
||||||
targetUserId: pendingIncomingInvite.inviterUserId || ''
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error accepting invite:', error);
|
|
||||||
showNotification('接受邀请失败,请稍后重试', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
showNotification('已接受通话请求');
|
|
||||||
pendingIncomingInvite = null;
|
|
||||||
|
|
||||||
if (currentView !== 'call') {
|
|
||||||
await switchToCallView(targetConnectionId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const rejectCall = document.getElementById('rejectCall');
|
|
||||||
const acceptCall = document.getElementById('acceptCall');
|
|
||||||
if (rejectCall && !rejectCall.dataset.bound) {
|
|
||||||
rejectCall.addEventListener('click', window.rejectCall);
|
|
||||||
rejectCall.dataset.bound = 'true';
|
|
||||||
}
|
|
||||||
if (acceptCall && !acceptCall.dataset.bound) {
|
|
||||||
acceptCall.addEventListener('click', window.acceptCall);
|
|
||||||
acceptCall.dataset.bound = 'true';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 切换到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');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error initializing app:', error);
|
|
||||||
showNotification('初始化失败,请刷新页面重试', 'error');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理加入通话
|
|
||||||
* @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);
|
|
||||||
|
|
||||||
const connectionId = randomMeetingId();
|
|
||||||
localStorage.setItem('connectionId', connectionId);
|
|
||||||
await switchToCallView(connectionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 绑定call视图DOM事件
|
|
||||||
*/
|
|
||||||
function bindCallViewDomEvents() {
|
|
||||||
// 切换侧边栏
|
|
||||||
window.toggleSidebar = function () {
|
|
||||||
chatMessage.toggleSidebar();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 切换麦克风
|
|
||||||
window.toggleMute = function () {
|
|
||||||
const state = store.getState();
|
|
||||||
const currentState = state.session.localUser.mediaState.audio;
|
|
||||||
store.updateLocalMedia('audio', !currentState);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 切换视频
|
|
||||||
window.toggleVideo = function () {
|
|
||||||
const state = store.getState();
|
|
||||||
const currentState = state.session.localUser.mediaState.video;
|
|
||||||
store.updateLocalMedia('video', !currentState);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 切换本地视频(用于悬停控制)
|
|
||||||
window.toggleLocalVideo = function () {
|
|
||||||
window.toggleVideo();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 切换录屏
|
|
||||||
window.toggleRecording = function () {
|
|
||||||
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('通话已结束');
|
|
||||||
};
|
|
||||||
|
|
||||||
// 绑定消息相关事件
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
bindInviteDialogEvents();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 页面加载完成后初始化(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();
|
|
||||||
bindInviteSignalHandlers();
|
|
||||||
|
|
||||||
// 绑定connect视图事件(加入通话、创建通话等)
|
|
||||||
bindConnectViewEvents(handleJoinCall, handleCreateCall);
|
|
||||||
bindInviteDialogEvents();
|
|
||||||
|
|
||||||
// 检查是否有保存的连接ID,填入输入框
|
|
||||||
const savedConnectionId = localStorage.getItem('connectionId');
|
|
||||||
if (savedConnectionId) {
|
|
||||||
connectionId = savedConnectionId;
|
|
||||||
const connectionIdInput = document.getElementById('connectionIdInput');
|
|
||||||
if (connectionIdInput) connectionIdInput.value = savedConnectionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const invitePayload = getInvitePayloadFromUrl();
|
|
||||||
if (invitePayload) {
|
|
||||||
window.showCallRequest(invitePayload);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('SPA initialized, showing connect view');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error initializing SPA:', error);
|
|
||||||
showNotification('初始化失败,请刷新页面重试', 'error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 导出全局变量
|
|
||||||
export { store };
|
|
||||||
227
client/public/recordings/index.html
Normal 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>
|
||||||
530
client/public/recordings/recordings-admin.js
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Observer, Sender } from "../module/sender.js";
|
import { Observer, Sender } from "/module/core/sender.js";
|
||||||
import { InputRemoting } from "../module/inputremoting.js";
|
import { InputRemoting } from "/module/input/inputremoting.js";
|
||||||
|
|
||||||
export class VideoPlayer {
|
export class VideoPlayer {
|
||||||
constructor() {
|
constructor() {
|
||||||
18
client/public/shared/dom.js
Normal 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;
|
||||||
|
}
|
||||||
84
client/public/shared/logger.js
Normal 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;
|
||||||
|
}
|
||||||
914
client/public/styles/style.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as Logger from "./logger.js";
|
import * as Logger from "../utils/logger.js";
|
||||||
|
|
||||||
export default class Peer extends EventTarget {
|
export default class Peer extends EventTarget {
|
||||||
constructor(connectionId, polite, config, resendIntervalMsec = 5000) {
|
constructor(connectionId, polite, config, resendIntervalMsec = 5000) {
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import Peer from "./peer.js";
|
import Peer from "./peer.js";
|
||||||
import * as Logger from "./logger.js";
|
import * as Logger from "../utils/logger.js";
|
||||||
|
|
||||||
function uuid4() {
|
function uuid4() {
|
||||||
var temp_url = URL.createObjectURL(new Blob());
|
var temp_url = URL.createObjectURL(new Blob());
|
||||||
@@ -5,11 +5,11 @@ import {
|
|||||||
Touchscreen,
|
Touchscreen,
|
||||||
StateEvent,
|
StateEvent,
|
||||||
TextEvent
|
TextEvent
|
||||||
} from "./inputdevice.js";
|
} from "../input/inputdevice.js";
|
||||||
|
|
||||||
import { LocalInputManager } from "./inputremoting.js";
|
import { LocalInputManager } from "../input/inputremoting.js";
|
||||||
import { GamepadHandler } from "./gamepadhandler.js";
|
import { GamepadHandler } from "../input/gamepadhandler.js";
|
||||||
import { PointerCorrector } from "./pointercorrect.js";
|
import { PointerCorrector } from "../input/pointercorrect.js";
|
||||||
|
|
||||||
export class Sender extends LocalInputManager {
|
export class Sender extends LocalInputManager {
|
||||||
constructor(elem) {
|
constructor(elem) {
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import * as Logger from "./logger.js";
|
import * as Logger from "../utils/logger.js";
|
||||||
|
|
||||||
export class Signaling extends EventTarget {
|
export class Signaling extends EventTarget {
|
||||||
|
|
||||||
constructor(interval = 1000) {
|
constructor(interval = 1000, baseUrl = null) {
|
||||||
super();
|
super();
|
||||||
this.running = false;
|
this.running = false;
|
||||||
this.interval = interval;
|
this.interval = interval;
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
this.sleep = msec => new Promise(resolve => setTimeout(resolve, msec));
|
this.sleep = msec => new Promise(resolve => setTimeout(resolve, msec));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ export class Signaling extends EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
url(method, parameter = '') {
|
url(method, parameter = '') {
|
||||||
let ret = location.origin + '/signaling';
|
let ret = (this.baseUrl || location.origin) + '/signaling';
|
||||||
if (method)
|
if (method)
|
||||||
ret += '/' + method;
|
ret += '/' + method;
|
||||||
if (parameter)
|
if (parameter)
|
||||||
@@ -151,16 +152,17 @@ export class Signaling extends EventTarget {
|
|||||||
|
|
||||||
export class WebSocketSignaling extends EventTarget {
|
export class WebSocketSignaling extends EventTarget {
|
||||||
|
|
||||||
constructor(interval = 1000) {
|
constructor(interval = 1000, websocketUrl = null) {
|
||||||
super();
|
super();
|
||||||
this.interval = interval;
|
this.interval = interval;
|
||||||
this.sleep = msec => new Promise(resolve => setTimeout(resolve, msec));
|
this.sleep = msec => new Promise(resolve => setTimeout(resolve, msec));
|
||||||
|
|
||||||
let websocketUrl;
|
if (!websocketUrl) {
|
||||||
if (location.protocol === "https:") {
|
if (location.protocol === "https:") {
|
||||||
websocketUrl = "wss://" + location.host;
|
websocketUrl = "wss://" + location.host;
|
||||||
} else {
|
} else {
|
||||||
websocketUrl = "ws://" + location.host;
|
websocketUrl = "ws://" + location.host;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.websocket = new WebSocket(websocketUrl);
|
this.websocket = new WebSocket(websocketUrl);
|
||||||
@@ -301,6 +303,12 @@ export class WebSocketSignaling extends EventTarget {
|
|||||||
this.websocket.send(sendJson);
|
this.websocket.send(sendJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendUserInfo(payload) {
|
||||||
|
const sendJson = JSON.stringify({ type: 'host-userInfo', data: payload });
|
||||||
|
Logger.log(sendJson);
|
||||||
|
this.websocket.send(sendJson);
|
||||||
|
}
|
||||||
|
|
||||||
sendInviteCall(payload) {
|
sendInviteCall(payload) {
|
||||||
const sendJson = JSON.stringify({ type: 'invite-call', data: payload });
|
const sendJson = JSON.stringify({ type: 'invite-call', data: payload });
|
||||||
Logger.log(sendJson);
|
Logger.log(sendJson);
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
MemoryHelper,
|
MemoryHelper,
|
||||||
} from "./memoryhelper.js";
|
} from "../utils/memoryhelper.js";
|
||||||
|
|
||||||
import { CharNumber } from "./charnumber.js";
|
import { CharNumber } from "../utils/charnumber.js";
|
||||||
import { Keymap } from "./keymap.js";
|
import { Keymap } from "./keymap.js";
|
||||||
import { MouseButton } from "./mousebutton.js";
|
import { MouseButton } from "./mousebutton.js";
|
||||||
import { GamepadButton } from "./gamepadbutton.js";
|
import { GamepadButton } from "./gamepadbutton.js";
|
||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
MemoryHelper
|
MemoryHelper
|
||||||
} from "./memoryhelper.js";
|
} from "../utils/memoryhelper.js";
|
||||||
|
|
||||||
export class LocalInputManager {
|
export class LocalInputManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { sleep } from "./testutils";
|
import { sleep } from "../helpers/testutils.js";
|
||||||
|
|
||||||
/** @type {MockPrivateSignalingManager | MockPublicSignalingManager} */
|
/** @type {MockPrivateSignalingManager | MockPublicSignalingManager} */
|
||||||
let manager;
|
let manager;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { sleep, getUniqueId } from './testutils';
|
import { sleep, getUniqueId } from '../helpers/testutils.js';
|
||||||
|
|
||||||
export class PeerConnectionMock extends EventTarget {
|
export class PeerConnectionMock extends EventTarget {
|
||||||
constructor(config) {
|
constructor(config) {
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
StateEvent,
|
StateEvent,
|
||||||
InputEvent,
|
InputEvent,
|
||||||
TextEvent
|
TextEvent
|
||||||
} from "../src/inputdevice.js";
|
} from "../../src/input/inputdevice.js";
|
||||||
|
|
||||||
describe(`FourCC`, () => {
|
describe(`FourCC`, () => {
|
||||||
test('toInt32', () => {
|
test('toInt32', () => {
|
||||||