Compare commits
6 Commits
554bb5d9ee
...
e48a6eae3c
| Author | SHA1 | Date | |
|---|---|---|---|
| e48a6eae3c | |||
| e00192daf9 | |||
| c89b22d320 | |||
| 20760a2668 | |||
| a37fba5519 | |||
| 9c05c6a9d9 |
138
client/public/call-view-controller.js
Normal file
138
client/public/call-view-controller.js
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRecording() {
|
||||||
|
const state = store.getState();
|
||||||
|
const currentState = state.session.localUser.mediaState.recording || false;
|
||||||
|
store.updateLocalMedia('recording', !currentState);
|
||||||
|
|
||||||
|
if (!currentState) {
|
||||||
|
notify('\u5f00\u59cb\u5f55\u5236');
|
||||||
|
} else {
|
||||||
|
notify('\u505c\u6b62\u5f55\u5236');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
159
client/public/connect-directory.js
Normal file
159
client/public/connect-directory.js
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
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 escapeHtml(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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.innerHTML = `
|
||||||
|
<span class="text-sm font-medium text-white">${escapeHtml(groupName)}</span>
|
||||||
|
<span class="text-xs text-gray-400">${roomUsers.length} ${USER_COUNT_SUFFIX}</span>
|
||||||
|
`;
|
||||||
|
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';
|
||||||
|
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(identity)}</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">${SELF_LABEL}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
roomList.appendChild(userItem);
|
||||||
|
});
|
||||||
|
|
||||||
|
section.appendChild(roomList);
|
||||||
|
usersContainer.appendChild(section);
|
||||||
|
});
|
||||||
|
|
||||||
|
onlineUsersList.classList.remove('hidden');
|
||||||
|
}
|
||||||
@@ -1,29 +1,25 @@
|
|||||||
/**
|
|
||||||
* connect视图逻辑
|
|
||||||
* 处理初始连接界面的UI、用户设置、WebSocket连接状态显示
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { showNotification } from './utils.js';
|
import { showNotification } from './utils.js';
|
||||||
import store from './store.js';
|
import store from './store.js';
|
||||||
|
import {
|
||||||
|
fetchConnectionDirectory,
|
||||||
|
fetchOnlineUsers,
|
||||||
|
renderConnectionIds,
|
||||||
|
renderOnlineUsers
|
||||||
|
} from './connect-directory.js';
|
||||||
|
import { createProfileSettingsController } from './profile-settings.js';
|
||||||
|
|
||||||
const MAX_AVATAR_SIZE = 2 * 1024 * 1024; // 2MB
|
|
||||||
|
|
||||||
// WebSocket连接状态更新回调
|
|
||||||
let onWsStatusChange = null;
|
let onWsStatusChange = null;
|
||||||
let cachedOnlineUsers = [];
|
let cachedOnlineUsers = [];
|
||||||
|
|
||||||
/**
|
const profileSettingsController = createProfileSettingsController({
|
||||||
* 设置WebSocket状态变化回调
|
store,
|
||||||
* @param {function} callback - 回调函数(connected: boolean)
|
notify: showNotification
|
||||||
*/
|
});
|
||||||
|
|
||||||
export function setWsStatusCallback(callback) {
|
export function setWsStatusCallback(callback) {
|
||||||
onWsStatusChange = callback;
|
onWsStatusChange = callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新WebSocket状态显示
|
|
||||||
* @param {boolean} connected - 是否已连接
|
|
||||||
*/
|
|
||||||
export function updateWsStatus(connected) {
|
export function updateWsStatus(connected) {
|
||||||
const wsStatusDot = document.getElementById('wsStatusDot');
|
const wsStatusDot = document.getElementById('wsStatusDot');
|
||||||
const wsStatusText = document.getElementById('wsStatusText');
|
const wsStatusText = document.getElementById('wsStatusText');
|
||||||
@@ -43,9 +39,6 @@ export function updateWsStatus(connected) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化WebSocket连接(页面加载时调用)
|
|
||||||
*/
|
|
||||||
export async function initWebSocket() {
|
export async function initWebSocket() {
|
||||||
try {
|
try {
|
||||||
await store.connectSignaling();
|
await store.connectSignaling();
|
||||||
@@ -60,19 +53,11 @@ export async function initWebSocket() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取全部在线WebSocket用户
|
|
||||||
* @param {boolean} silent - 是否静默刷新
|
|
||||||
*/
|
|
||||||
async function refreshOnlineUsers(silent = true) {
|
async function refreshOnlineUsers(silent = true) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/signaling/users');
|
cachedOnlineUsers = await fetchOnlineUsers();
|
||||||
if (!response.ok) {
|
updateOnlineUsersList(cachedOnlineUsers);
|
||||||
throw new Error('Failed to fetch online users');
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
cachedOnlineUsers = Array.isArray(data.users) ? data.users : [];
|
|
||||||
displayOnlineUsers(cachedOnlineUsers);
|
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
showNotification(`当前共有 ${cachedOnlineUsers.length} 个WebSocket用户在线`);
|
showNotification(`当前共有 ${cachedOnlineUsers.length} 个WebSocket用户在线`);
|
||||||
}
|
}
|
||||||
@@ -84,83 +69,36 @@ async function refreshOnlineUsers(silent = true) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取所有连接ID
|
|
||||||
*/
|
|
||||||
async function getAllConnectionIds() {
|
async function getAllConnectionIds() {
|
||||||
showNotification('正在获取连接ID和在线用户...');
|
showNotification('正在获取连接ID和在线用户...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [connectionResponse, usersResponse] = await Promise.all([
|
const { connectionIds, users } = await fetchConnectionDirectory();
|
||||||
fetch('/signaling/connection-ids'),
|
cachedOnlineUsers = users;
|
||||||
fetch('/signaling/users')
|
updateConnectionIdList(connectionIds);
|
||||||
]);
|
updateOnlineUsersList(cachedOnlineUsers);
|
||||||
|
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error('Error fetching connection IDs:', error);
|
console.error('Error fetching connection IDs:', error);
|
||||||
showNotification('获取连接信息失败', 'error');
|
showNotification('获取连接信息失败', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function updateConnectionIdList(connectionIds) {
|
||||||
* 显示连接ID列表
|
|
||||||
* @param {string[]} connectionIds - 连接ID数组
|
|
||||||
*/
|
|
||||||
function displayConnectionIds(connectionIds) {
|
|
||||||
const idsContainer = document.getElementById('idsContainer');
|
const idsContainer = document.getElementById('idsContainer');
|
||||||
const connectionIdsList = document.getElementById('connectionIdsList');
|
const connectionIdsList = document.getElementById('connectionIdsList');
|
||||||
|
|
||||||
if (idsContainer) {
|
renderConnectionIds({
|
||||||
idsContainer.innerHTML = '';
|
connectionIds,
|
||||||
|
idsContainer,
|
||||||
if (connectionIds.length === 0) {
|
connectionIdsList,
|
||||||
idsContainer.innerHTML = '<p class="text-gray-500 text-sm">暂无可用的连接ID</p>';
|
onSelectConnectionId: selectConnectionId
|
||||||
} 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');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (idsContainer) {
|
||||||
showNotification(`找到 ${connectionIds.length} 个连接ID`);
|
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() {
|
function getCurrentUserId() {
|
||||||
try {
|
try {
|
||||||
const settings = JSON.parse(localStorage.getItem('userSettings') || '{}');
|
const settings = JSON.parse(localStorage.getItem('userSettings') || '{}');
|
||||||
@@ -171,330 +109,100 @@ function getCurrentUserId() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function updateOnlineUsersList(users) {
|
||||||
* 显示全部在线WebSocket用户
|
|
||||||
* @param {Array} users - 在线用户列表
|
|
||||||
*/
|
|
||||||
function displayOnlineUsers(users) {
|
|
||||||
const onlineUsersList = document.getElementById('onlineUsersList');
|
const onlineUsersList = document.getElementById('onlineUsersList');
|
||||||
const usersContainer = document.getElementById('usersContainer');
|
const usersContainer = document.getElementById('usersContainer');
|
||||||
const onlineUsersSummary = document.getElementById('onlineUsersSummary');
|
const onlineUsersSummary = document.getElementById('onlineUsersSummary');
|
||||||
|
|
||||||
if (!onlineUsersList || !usersContainer || !onlineUsersSummary) {
|
renderOnlineUsers({
|
||||||
return;
|
users,
|
||||||
}
|
currentUserId: getCurrentUserId(),
|
||||||
|
onlineUsersList,
|
||||||
onlineUsersSummary.textContent = `${users.length} 个WebSocket用户在线`;
|
usersContainer,
|
||||||
|
onlineUsersSummary
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function selectConnectionId(connectionId) {
|
||||||
* 选择连接ID
|
|
||||||
* @param {string} id - 连接ID
|
|
||||||
*/
|
|
||||||
function selectConnectionId(id) {
|
|
||||||
const connectionIdInput = document.getElementById('connectionIdInput');
|
const connectionIdInput = document.getElementById('connectionIdInput');
|
||||||
if (connectionIdInput) {
|
if (connectionIdInput) {
|
||||||
connectionIdInput.value = id;
|
connectionIdInput.value = connectionId;
|
||||||
showNotification(`已选择连接ID: ${id}`);
|
showNotification(`已选择连接ID: ${connectionId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成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() {
|
export function loadUserSettings() {
|
||||||
const defaultAvatar = '/images/p1.png';
|
profileSettingsController.loadUserSettings();
|
||||||
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() {
|
export function saveSettings() {
|
||||||
const defaultAvatar = '/images/p1.png';
|
profileSettingsController.saveSettings();
|
||||||
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) {
|
export function handleAvatarUpload(event) {
|
||||||
const file = event.target.files[0];
|
profileSettingsController.handleAvatarUpload(event);
|
||||||
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() {
|
export function copyUserId() {
|
||||||
const userIdInput = document.getElementById('userIdInput');
|
profileSettingsController.copyUserId();
|
||||||
if (userIdInput) {
|
|
||||||
userIdInput.select();
|
|
||||||
document.execCommand('copy');
|
|
||||||
showNotification('用户ID已复制到剪贴板', 'success');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 切换设置菜单
|
|
||||||
*/
|
|
||||||
export function toggleSettingsMenu() {
|
export function toggleSettingsMenu() {
|
||||||
const settingsMenu = document.getElementById('settingsMenu');
|
profileSettingsController.toggleSettingsMenu();
|
||||||
if (settingsMenu) {
|
|
||||||
settingsMenu.classList.toggle('hidden');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 绑定connect视图事件
|
|
||||||
* @param {function} onJoinCall - 加入通话回调(connectionId: string)
|
|
||||||
* @param {function} onCreateCall - 创建通话回调()
|
|
||||||
*/
|
|
||||||
export function bindConnectViewEvents(onJoinCall, onCreateCall) {
|
export function bindConnectViewEvents(onJoinCall, onCreateCall) {
|
||||||
// 加入通话按钮
|
|
||||||
const connectBtn = document.getElementById('connectBtn');
|
const connectBtn = document.getElementById('connectBtn');
|
||||||
if (connectBtn) {
|
if (connectBtn && !connectBtn.dataset.bound) {
|
||||||
connectBtn.addEventListener('click', () => {
|
connectBtn.addEventListener('click', () => {
|
||||||
const connectionIdInput = document.getElementById('connectionIdInput');
|
const connectionIdInput = document.getElementById('connectionIdInput');
|
||||||
const connectionId = connectionIdInput ? connectionIdInput.value.trim() : '';
|
const connectionId = connectionIdInput ? connectionIdInput.value.trim() : '';
|
||||||
|
|
||||||
if (connectionId) {
|
if (connectionId) {
|
||||||
onJoinCall(connectionId);
|
onJoinCall(connectionId);
|
||||||
} else {
|
} else {
|
||||||
showNotification('请输入连接ID', 'error');
|
showNotification('请输入连接ID', 'error');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
connectBtn.dataset.bound = 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建通话按钮
|
|
||||||
const createCallBtn = document.getElementById('createCallBtn');
|
const createCallBtn = document.getElementById('createCallBtn');
|
||||||
if (createCallBtn) {
|
if (createCallBtn && !createCallBtn.dataset.bound) {
|
||||||
createCallBtn.addEventListener('click', onCreateCall);
|
createCallBtn.addEventListener('click', onCreateCall);
|
||||||
|
createCallBtn.dataset.bound = 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 浏览全部ID按钮
|
|
||||||
const browseIdsBtn = document.getElementById('browseIdsBtn');
|
const browseIdsBtn = document.getElementById('browseIdsBtn');
|
||||||
if (browseIdsBtn) {
|
if (browseIdsBtn && !browseIdsBtn.dataset.bound) {
|
||||||
browseIdsBtn.addEventListener('click', getAllConnectionIds);
|
browseIdsBtn.addEventListener('click', getAllConnectionIds);
|
||||||
|
browseIdsBtn.dataset.bound = 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 输入框回车事件
|
|
||||||
const connectionIdInput = document.getElementById('connectionIdInput');
|
const connectionIdInput = document.getElementById('connectionIdInput');
|
||||||
if (connectionIdInput) {
|
if (connectionIdInput && !connectionIdInput.dataset.bound) {
|
||||||
connectionIdInput.addEventListener('keypress', (e) => {
|
connectionIdInput.addEventListener('keypress', (event) => {
|
||||||
if (e.key === 'Enter') {
|
if (event.key !== 'Enter') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const connectionId = connectionIdInput.value.trim();
|
const connectionId = connectionIdInput.value.trim();
|
||||||
if (connectionId) {
|
if (connectionId) {
|
||||||
onJoinCall(connectionId);
|
onJoinCall(connectionId);
|
||||||
} else {
|
} else {
|
||||||
showNotification('请输入连接ID', 'error');
|
showNotification('请输入连接ID', 'error');
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
connectionIdInput.dataset.bound = 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用户设置按钮
|
|
||||||
const userSettingsBtn = document.getElementById('userSettingsBtn');
|
const userSettingsBtn = document.getElementById('userSettingsBtn');
|
||||||
if (userSettingsBtn) {
|
if (userSettingsBtn && !userSettingsBtn.dataset.bound) {
|
||||||
userSettingsBtn.addEventListener('click', toggleSettingsMenu);
|
userSettingsBtn.addEventListener('click', toggleSettingsMenu);
|
||||||
|
userSettingsBtn.dataset.bound = 'true';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 点击外部关闭设置菜单
|
profileSettingsController.bindDocumentEvents();
|
||||||
document.addEventListener('click', function(event) {
|
profileSettingsController.bindWindowHandlers();
|
||||||
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.selectConnectionId = selectConnectionId;
|
||||||
window.saveSettings = saveSettings;
|
|
||||||
window.handleAvatarUpload = handleAvatarUpload;
|
|
||||||
window.copyUserId = copyUserId;
|
|
||||||
window.toggleSettingsMenu = toggleSettingsMenu;
|
|
||||||
|
|||||||
187
client/public/invite-controller.js
Normal file
187
client/public/invite-controller.js
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
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) {
|
||||||
|
console.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) {
|
||||||
|
console.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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,217 +1,47 @@
|
|||||||
/**
|
|
||||||
* 主入口文件
|
|
||||||
* 初始化应用,连接各个模块
|
|
||||||
* SPA架构:connect视图和call视图在同一页面切换
|
|
||||||
*/
|
|
||||||
import store from './store.js';
|
import store from './store.js';
|
||||||
import UIRenderer from './renderer.js';
|
import UIRenderer from './renderer.js';
|
||||||
import { showNotification, randomMeetingId } from './utils.js';
|
import { showNotification, randomMeetingId } from './utils.js';
|
||||||
import chatMessage from './chatmessage.js';
|
import chatMessage from './chatmessage.js';
|
||||||
|
import { createCallViewController } from './call-view-controller.js';
|
||||||
import {
|
import {
|
||||||
bindConnectViewEvents,
|
bindConnectViewEvents,
|
||||||
initWebSocket,
|
initWebSocket,
|
||||||
loadUserSettings
|
loadUserSettings
|
||||||
} from './connectview.js';
|
} from './connectview.js';
|
||||||
// 全局变量
|
import { createInviteController } from './invite-controller.js';
|
||||||
let connectionId = "";
|
|
||||||
// 当前视图状态:'connect' 或 'call'(可用于未来扩展)
|
let connectionId = '';
|
||||||
let currentView = 'connect';
|
let currentView = 'connect';
|
||||||
let pendingIncomingInvite = null;
|
|
||||||
let inviteHandlersBound = false;
|
|
||||||
|
|
||||||
function getInvitePayloadFromUrl() {
|
function updateConnectionId(nextConnectionId) {
|
||||||
const params = new URLSearchParams(window.location.search);
|
connectionId = nextConnectionId || '';
|
||||||
if (params.get('invite') !== '1') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
if (connectionId) {
|
||||||
name: params.get('callerName') || '邀请方',
|
localStorage.setItem('connectionId', connectionId);
|
||||||
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() {
|
async function switchToCallView(targetConnectionId) {
|
||||||
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 connectView = document.getElementById('connectView');
|
||||||
const callView = document.getElementById('callView');
|
const callView = document.getElementById('callView');
|
||||||
|
|
||||||
if (connectView) connectView.classList.add('hidden');
|
if (connectView) {
|
||||||
if (callView) callView.classList.remove('hidden');
|
connectView.classList.add('hidden');
|
||||||
|
}
|
||||||
|
if (callView) {
|
||||||
|
callView.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
currentView = 'call';
|
currentView = 'call';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 初始化渲染器
|
|
||||||
const renderer = new UIRenderer(store);
|
const renderer = new UIRenderer(store);
|
||||||
|
|
||||||
// 加入通话
|
await store.joinCall(targetConnectionId);
|
||||||
await store.joinCall(connectionId);
|
await store.setUp(targetConnectionId);
|
||||||
|
|
||||||
// 设置WebRTC连接
|
|
||||||
await store.setUp(connectionId);
|
|
||||||
|
|
||||||
renderer.renderHeaderTitle();
|
renderer.renderHeaderTitle();
|
||||||
|
callViewController.bindDomEvents();
|
||||||
// 绑定DOM事件
|
|
||||||
bindCallViewDomEvents();
|
|
||||||
|
|
||||||
console.log('Video call app initialized successfully');
|
console.log('Video call app initialized successfully');
|
||||||
return true;
|
return true;
|
||||||
@@ -222,182 +52,68 @@ async function switchToCallView(connectionId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const inviteController = createInviteController({
|
||||||
* 处理加入通话
|
store,
|
||||||
* @param {string} connectionId - 连接ID
|
notify: showNotification,
|
||||||
*/
|
onAcceptConnection: switchToCallView,
|
||||||
async function handleJoinCall(connectionId) {
|
getCurrentView: () => currentView,
|
||||||
showNotification(`正在加入通话 (${connectionId})`);
|
getConnectionId: () => connectionId,
|
||||||
localStorage.setItem('connectionId', connectionId);
|
setConnectionId: updateConnectionId
|
||||||
await switchToCallView(connectionId);
|
});
|
||||||
|
|
||||||
|
const callViewController = createCallViewController({
|
||||||
|
store,
|
||||||
|
chatMessage,
|
||||||
|
notify: showNotification
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleJoinCall(targetConnectionId) {
|
||||||
|
showNotification(`正在加入通话 (${targetConnectionId})`);
|
||||||
|
updateConnectionId(targetConnectionId);
|
||||||
|
await switchToCallView(targetConnectionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理创建通话
|
|
||||||
*/
|
|
||||||
async function handleCreateCall() {
|
async function handleCreateCall() {
|
||||||
showNotification('正在创建通话...');
|
showNotification('正在创建通话...');
|
||||||
//const connectionId = 'conn_' + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
|
||||||
|
|
||||||
const connectionId = randomMeetingId();
|
const nextConnectionId = randomMeetingId();
|
||||||
localStorage.setItem('connectionId', connectionId);
|
updateConnectionId(nextConnectionId);
|
||||||
await switchToCallView(connectionId);
|
await switchToCallView(nextConnectionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 绑定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 () => {
|
window.addEventListener('DOMContentLoaded', async () => {
|
||||||
try {
|
try {
|
||||||
// 显示connect视图,隐藏call视图
|
|
||||||
const connectView = document.getElementById('connectView');
|
const connectView = document.getElementById('connectView');
|
||||||
const callView = document.getElementById('callView');
|
const callView = document.getElementById('callView');
|
||||||
if (connectView) connectView.classList.remove('hidden');
|
|
||||||
if (callView) callView.classList.add('hidden');
|
|
||||||
currentView = 'connect';
|
|
||||||
|
|
||||||
// 加载用户设置
|
if (connectView) {
|
||||||
loadUserSettings();
|
connectView.classList.remove('hidden');
|
||||||
|
}
|
||||||
// 初始化WebSocket连接(在connect视图就建立WebSocket)
|
if (callView) {
|
||||||
await initWebSocket();
|
callView.classList.add('hidden');
|
||||||
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();
|
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) {
|
if (invitePayload) {
|
||||||
window.showCallRequest(invitePayload);
|
inviteController.showCallRequestDialog(invitePayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('SPA initialized, showing connect view');
|
console.log('SPA initialized, showing connect view');
|
||||||
@@ -407,5 +123,4 @@ window.addEventListener('DOMContentLoaded', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 导出全局变量
|
|
||||||
export { store };
|
export { store };
|
||||||
|
|||||||
217
client/public/profile-settings.js
Normal file
217
client/public/profile-settings.js
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
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) {
|
||||||
|
console.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) {
|
||||||
|
console.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
|
||||||
|
};
|
||||||
|
}
|
||||||
61
client/public/renderer-chat.js
Normal file
61
client/public/renderer-chat.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
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 contentHTML = message.type === 'file' && message.content.startsWith('data:image/')
|
||||||
|
? `
|
||||||
|
<div class="message-image-container">
|
||||||
|
<img src="${message.content}" class="message-image" alt="${message.fileName || '\u56fe\u7247'}">
|
||||||
|
${message.fileName ? `<div class="message-image-name">${message.fileName}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: `
|
||||||
|
<div class="message-text">
|
||||||
|
${message.content}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
messageDiv.innerHTML = `
|
||||||
|
<div class="message-header">
|
||||||
|
<img src="${message.senderAvatar}" class="message-avatar">
|
||||||
|
<div>
|
||||||
|
<span class="message-sender">${message.senderName}</span>
|
||||||
|
<span class="message-time">${formatTimestamp(message.timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="message-content">
|
||||||
|
${contentHTML}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
188
client/public/renderer-media.js
Normal file
188
client/public/renderer-media.js
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { createParticipantTile, getParticipantTile } from './renderer-participant-grid.js';
|
||||||
|
|
||||||
|
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);
|
||||||
|
console.log(`Created participant video tile for ${connectionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const video = tile.querySelector('video');
|
||||||
|
if (video && stream) {
|
||||||
|
if (video.srcObject === stream) {
|
||||||
|
console.log(`Same stream for participant ${connectionId}, ensuring playback`);
|
||||||
|
video.play().catch(error => console.log('Auto-play prevented:', error.message));
|
||||||
|
} else {
|
||||||
|
video.srcObject = stream;
|
||||||
|
video.play().catch(error => console.log('Auto-play prevented:', error.message));
|
||||||
|
console.log(`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) {
|
||||||
|
console.error('Either remoteVideo element or stream is missing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Rendering remote stream:', stream, 'tracks:', stream.getTracks().map(track => `${track.kind}(${track.readyState})`));
|
||||||
|
|
||||||
|
if (remoteVideo.srcObject === stream) {
|
||||||
|
console.log('Same stream object, track added - ensuring playback');
|
||||||
|
remoteVideo.play().catch(error => console.log('Auto-play prevented:', error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
remoteVideo.srcObject = stream;
|
||||||
|
remoteVideo.autoplay = true;
|
||||||
|
remoteVideo.playsinline = true;
|
||||||
|
remoteVideo.muted = false;
|
||||||
|
remoteVideo.play().catch(error => {
|
||||||
|
console.log('Auto-play prevented, will retry on interaction:', error.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (disconnectedOverlay) {
|
||||||
|
disconnectedOverlay.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoTracks = stream.getVideoTracks();
|
||||||
|
const audioTracks = stream.getAudioTracks();
|
||||||
|
console.log(`Stream has ${videoTracks.length} video tracks, ${audioTracks.length} audio tracks`);
|
||||||
|
|
||||||
|
if (videoTracks.length === 0) {
|
||||||
|
console.log('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();
|
||||||
|
console.log(`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';
|
||||||
|
}
|
||||||
|
}
|
||||||
64
client/public/renderer-participant-grid.js
Normal file
64
client/public/renderer-participant-grid.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
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 = 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_${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.innerHTML = `<i class="fas fa-user text-purple-400"></i><span>${displayName || '\u53c2\u4e0e\u8005'}</span>`;
|
||||||
|
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';
|
||||||
|
liveTag.innerHTML = `<span class="w-1.5 h-1.5 bg-white rounded-full animate-pulse"></span><span>\u5728\u7ebf</span>`;
|
||||||
|
tile.appendChild(liveTag);
|
||||||
|
|
||||||
|
return tile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getParticipantTile(grid, participantId) {
|
||||||
|
return grid?.querySelector(`[data-participant-id="${participantId}"]`) || 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
189
client/public/renderer-ui.js
Normal file
189
client/public/renderer-ui.js
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
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 getRoleTagMarkup(user, role) {
|
||||||
|
if (role === 'local') {
|
||||||
|
return user.isHost
|
||||||
|
? '<span class="text-xs bg-indigo-500 px-1.5 rounded ml-1">\u4e3b\u6301\u4eba</span>'
|
||||||
|
: '<span class="text-xs bg-purple-500 px-1.5 rounded ml-1">\u53c2\u4e0e\u8005</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === 'participant') {
|
||||||
|
return '<span class="text-xs bg-purple-500 px-1.5 rounded ml-1">\u53c2\u4e0e\u8005</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<span class="text-xs bg-indigo-500 px-1.5 rounded ml-1">\u4e3b\u6301\u4eba</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 getAvatarMarkup(user, role) {
|
||||||
|
if (role === 'local') {
|
||||||
|
return `<img src="${user.avatar}" class="w-10 h-10 rounded-full object-cover">`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="relative">
|
||||||
|
<img src="${user.avatar}" class="w-10 h-10 rounded-full object-cover">
|
||||||
|
<div class="absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-slate-900"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRightMarkup(mediaState, role, muteIconMarkup) {
|
||||||
|
if (role !== 'participant') {
|
||||||
|
return muteIconMarkup;
|
||||||
|
}
|
||||||
|
|
||||||
|
const speakingMarkup = (mediaState.isSpeaking && mediaState.audio)
|
||||||
|
? '<div class="audio-wave w-6"><span></span><span></span><span></span><span></span><span></span></div>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
${muteIconMarkup}
|
||||||
|
${speakingMarkup}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 muteIconMarkup = mediaMeta.showMuteIcon
|
||||||
|
? `<i class="${mediaMeta.muteIconClass}"></i>`
|
||||||
|
: '';
|
||||||
|
const baseClass = 'flex items-center gap-3 p-2 rounded-lg';
|
||||||
|
const dataFieldAttr = role === 'local' ? ' data-field="localUser.mediaStatus"' : '';
|
||||||
|
|
||||||
|
entry.className = role === 'local'
|
||||||
|
? `${baseClass} hover:bg-white/5`
|
||||||
|
: `${baseClass} bg-white/5`;
|
||||||
|
entry.dataset.userId = getDatasetUserId(role, id);
|
||||||
|
entry.innerHTML = `
|
||||||
|
${getAvatarMarkup(user, role)}
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="text-sm font-medium">
|
||||||
|
${user.name}
|
||||||
|
${getRoleTagMarkup(user, role)}
|
||||||
|
</div>
|
||||||
|
<div class="${mediaMeta.className}"${dataFieldAttr}>${mediaMeta.text}</div>
|
||||||
|
</div>
|
||||||
|
${getRightMarkup(user.mediaState, role, muteIconMarkup)}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -54,7 +54,7 @@
|
|||||||
"client/src/**/*"
|
"client/src/**/*"
|
||||||
],
|
],
|
||||||
"targets": [
|
"targets": [
|
||||||
"node10"
|
"node18"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user