This commit is contained in:
宋居成 2025-08-06 01:01:41 +08:00
parent 9d4bbc182c
commit dfcab8e6a7
24 changed files with 145 additions and 445 deletions

3
scene_state.json Normal file
View File

@ -0,0 +1,3 @@
{
"currentSceneIndex": 2
}

123
server.js
View File

@ -86,27 +86,58 @@ app.delete('/api/messages/clear', async (req, res) => {
const connectedClients = new Map();
// 场景轮询系统
// 场景轮询系统
// 场景轮询系统 - 添加持久化
// 删除这行const fs = require('fs'); // 重复声明,需要删除
const sceneStateFile = path.join(__dirname, 'scene_state.json');
// 从文件加载场景状态
function loadSceneState() {
try {
if (fs.existsSync(sceneStateFile)) {
const data = fs.readFileSync(sceneStateFile, 'utf8');
const state = JSON.parse(data);
currentSceneIndex = state.currentSceneIndex || 0;
console.log(`从文件加载场景状态: ${currentSceneIndex} (${scenes[currentSceneIndex].name})`);
} else {
console.log('场景状态文件不存在,使用默认值: 0');
}
} catch (error) {
console.error('加载场景状态失败:', error);
currentSceneIndex = 0;
}
}
// 保存场景状态到文件
function saveSceneState() {
try {
const state = { currentSceneIndex };
fs.writeFileSync(sceneStateFile, JSON.stringify(state, null, 2));
console.log(`场景状态已保存: ${currentSceneIndex}`);
} catch (error) {
console.error('保存场景状态失败:', error);
}
}
let currentSceneIndex = 0;
const scenes = [
{
name: '起床',
defaultVideo: '8-4-bd-2.mp4',
interactionVideo: '8-4-sh.mp4',
defaultVideo: 'qc-bd-4.mp4',
interactionVideo: 'qc-sh-4.mp4',
tag: 'wakeup',
apiKey: 'bot-20250724150616-xqpz8' // 起床场景的API key
},
{
name: '开车',
defaultVideo: '8-4-kc-bd.mp4',
interactionVideo: '8-4-kc-sh.mp4',
defaultVideo: 'kc-bd-3.mp4',
interactionVideo: 'kc-sh-3.mp4',
tag: 'driving',
apiKey: 'bot-20250623140339-r8f8b' // 开车场景的API key
},
{
name: '喝茶',
defaultVideo: '8-4-hc-bd.mp4',
interactionVideo: '8-4-hc-sh.mp4',
defaultVideo: 'hc-bd-3.mp4',
interactionVideo: 'hc-sh-3(1).mp4',
tag: 'tea',
apiKey: 'bot-20250804180724-4dgtk' // 喝茶场景的API key
}
@ -117,11 +148,34 @@ function getCurrentScene() {
return scenes[currentSceneIndex];
}
// 切换到下一个场景
// 切换到下一个场景 - 改进版
function switchToNextScene() {
const previousIndex = currentSceneIndex;
const previousScene = scenes[previousIndex].name;
currentSceneIndex = (currentSceneIndex + 1) % scenes.length;
console.log(`切换到场景: ${getCurrentScene().name}`);
return getCurrentScene();
const newScene = getCurrentScene();
console.log(`场景切换: ${previousScene}(${previousIndex}) → ${newScene.name}(${currentSceneIndex})`);
// 保存状态到文件
saveSceneState();
return newScene;
}
// 在服务器启动时加载场景状态
async function initializeServer() {
try {
// 加载场景状态
loadSceneState();
await messageHistory.initialize();
console.log('消息历史初始化完成');
console.log(`当前场景: ${getCurrentScene().name} (索引: ${currentSceneIndex})`);
} catch (error) {
console.error('初始化服务器失败:', error);
}
}
// 视频映射配置 - 动态更新
@ -185,8 +239,13 @@ app.get('/api/current-scene', (req, res) => {
// 获取视频映射
app.get('/api/video-mapping', (req, res) => {
// videoMapping = getCurrentScene()
res.json({ mapping: videoMapping });
const currentMapping = getVideoMapping();
const dynamicMapping = {
'default': currentMapping.defaultVideo,
'8-4-sh': currentMapping.interactionVideo,
'tag': currentMapping.tag
};
res.json({ mapping: dynamicMapping });
});
// 获取默认视频
@ -203,7 +262,8 @@ io.on('connection', (socket) => {
connectedClients.set(socket.id, {
socket: socket,
currentVideo: getDefaultVideo(),
isInInteraction: false
isInInteraction: false,
hasTriggeredSceneSwitch: false // 添加这个标志
});
// 处理WebRTC信令 - 用于传输视频流
@ -344,11 +404,25 @@ io.on('connection', (socket) => {
// 处理用户关闭连接事件
socket.on('user-disconnect', () => {
console.log('=== 场景切换开始 ===');
console.log('用户主动关闭连接:', socket.id);
console.log('切换前场景:', getCurrentScene().name, '(索引:', currentSceneIndex, ')');
// 切换到下一个场景
const newScene = switchToNextScene();
console.log(`场景已切换到: ${newScene.name}`);
console.log('切换后场景:', newScene.name, '(索引:', currentSceneIndex, ')');
// 检查是否已经处理过场景切换
const client = connectedClients.get(socket.id);
if (client && client.hasTriggeredSceneSwitch) {
console.log('场景切换已处理,跳过重复触发');
return;
}
// 标记已处理场景切换
if (client) {
client.hasTriggeredSceneSwitch = true;
}
// 更新videoMapping
const newMapping = getVideoMapping();
@ -356,20 +430,17 @@ io.on('connection', (socket) => {
videoMapping['8-4-sh'] = newMapping.interactionVideo;
videoMapping['tag'] = newMapping.tag;
console.log('videoMapping已更新:', videoMapping);
// 清理客户端状态
const client = connectedClients.get(socket.id);
if (client) {
client.currentVideo = newMapping.defaultVideo;
client.isInInteraction = false;
}
// 广播场景切换事件给所有客户端
io.emit('scene-switched', {
scene: newScene,
mapping: newMapping,
from: socket.id
scene: newScene,
mapping: {
defaultVideo: newMapping.defaultVideo,
interactionVideo: newMapping.interactionVideo,
tag: newMapping.tag,
'default': newMapping.defaultVideo,
'8-4-sh': newMapping.interactionVideo
},
from: socket.id
});
});

View File

@ -1,408 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Soulmate In Parallels - 壹和零人工智能</title>
<link rel="stylesheet" href="styles.css">
<link rel="icon" type="image/png" sizes="48x48" href="favicon.png" />
<style>
/* 全屏视频样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
overflow: hidden;
background: linear-gradient(135deg, #87CEEB 0%, #B0E0E6 100%); /* 浅蓝色渐变背景 */
}
.container {
width: 100vw;
height: 100vh;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
position: relative;
}
.main-content {
flex: 1;
background: transparent;
border-radius: 0;
padding: 0;
box-shadow: none;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.recorded-video-section {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
position: relative;
/* 确保视频区域固定高度并居中 */
min-height: 100vh;
max-height: 100vh;
}
/* 视频容器样式 - 支持双缓冲固定9:16比例 */
.video-container {
position: relative;
width: 56.25vh; /* 9:16比例与视频宽度保持一致 */
height: 100vh;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto; /* 水平居中 */
}
#recordedVideo, #recordedVideoBuffer {
position: absolute;
width: 56.25vh; /* 9:16比例高度为100vh时宽度为100vh * 9/16 = 56.25vh */
height: 100vh;
object-fit: cover;
border-radius: 0;
box-shadow: none;
transition: opacity 0.5s ease-in-out;
/* 确保视频始终居中 */
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
/* 主视频默认显示 */
#recordedVideo {
opacity: 1;
z-index: 2;
}
/* 缓冲视频默认隐藏 */
#recordedVideoBuffer {
opacity: 0;
z-index: 1;
}
/* 切换状态 */
#recordedVideo.switching {
opacity: 0;
}
#recordedVideoBuffer.switching {
opacity: 1;
}
/* 加载状态 */
.video-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 10;
color: white;
font-size: 18px;
opacity: 0;
transition: opacity 0.3s ease;
}
.video-loading.show {
opacity: 1;
}
/* 加载动画 */
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top: 3px solid white;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 10px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 响应式设计 - 确保在不同屏幕尺寸下视频容器保持9:16比例 */
@media (max-width: 768px) {
.video-container {
height: 100vh;
width: 56.25vh; /* 9:16比例与视频宽度保持一致 */
}
#recordedVideo, #recordedVideoBuffer {
width: 56.25vh; /* 9:16比例 */
height: 100vh;
object-fit: cover;
}
}
@media (min-width: 769px) {
.video-container {
height: 100vh;
width: 56.25vh; /* 9:16比例与视频宽度保持一致 */
}
#recordedVideo, #recordedVideoBuffer {
width: 56.25vh; /* 9:16比例 */
height: 100vh;
object-fit: cover;
}
}
/* 横屏模式优化 */
@media (orientation: landscape) and (max-height: 500px) {
.video-container {
height: 100vh;
width: 56.25vh; /* 9:16比例与视频宽度保持一致 */
}
.controls {
bottom: 20px;
}
}
/* 竖屏模式优化 */
@media (orientation: portrait) {
.video-container {
height: 100vh;
width: 56.25vh; /* 9:16比例与视频宽度保持一致 */
}
}
.controls {
position: absolute;
bottom: 50px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
display: flex;
justify-content: center;
gap: 20px;
}
#startButton {
width: 60px;
height: 60px;
border-radius: 50%;
background: rgba(34, 197, 94, 0.9);
backdrop-filter: blur(10px);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(34, 197, 94, 0.3);
min-width: auto;
padding: 15px 30px;
font-size: 1.1rem;
border-radius: 25px;
min-width: 200px;
}
#startButton:hover:not(:disabled) {
background: rgba(22, 163, 74, 0.95);
transform: scale(1.1);
box-shadow: 0 6px 20px rgba(34, 197, 94, 0.5);
}
#startButton.connecting {
background: rgba(255, 193, 7, 0.9);
cursor: not-allowed;
}
#startButton.connecting:hover {
background: rgba(255, 193, 7, 0.9);
transform: none;
}
#startButton.calling {
background: rgba(255, 193, 7, 0.9);
animation: pulse 2s infinite;
}
#startButton.calling:hover {
background: rgba(255, 193, 7, 0.95);
transform: scale(1.05);
}
@keyframes pulse {
0% {
box-shadow: 0 4px 15px rgba(255, 193, 7, 0.3);
}
50% {
box-shadow: 0 6px 25px rgba(255, 193, 7, 0.6);
}
100% {
box-shadow: 0 4px 15px rgba(255, 193, 7, 0.3);
}
}
.audio-status {
position: absolute;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
z-index: 1000;
transition: all 0.3s ease;
}
.audio-status.connecting {
background: rgba(255, 193, 7, 0.9);
color: #000;
}
.audio-status.connected {
background: rgba(40, 167, 69, 0.9);
color: white;
}
.audio-status.error {
background: rgba(220, 53, 69, 0.9);
color: white;
}
#startButton svg {
width: 24px;
height: 24px;
fill: white;
}
#startButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#stopButton svg {
width: 24px;
height: 24px;
fill: white;
}
#stopButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
</head>
<body>
<div class="container">
<!-- 隐藏的header -->
<header style="display: none;">
<h1>WebRTC 音频通话</h1>
<p>实时播放录制视频,支持文本和语音输入</p>
</header>
<div class="main-content">
<!-- 音频状态显示 - 显示状态文本 -->
<div class="audio-status">
<div class="status-indicator">
<span id="audioStatus">未连接</span>
</div>
</div>
<!-- 录制视频播放区域 - 全屏显示 -->
<div class="recorded-video-section">
<div class="video-container">
<!-- 主视频元素 -->
<video id="recordedVideo" autoplay muted>
<source src="" type="video/mp4">
您的浏览器不支持视频播放
</video>
<!-- 缓冲视频元素 -->
<video id="recordedVideoBuffer" autoplay muted>
<source src="" type="video/mp4">
您的浏览器不支持视频播放
</video>
<!-- 加载指示器 -->
<div class="video-loading" id="videoLoading">
<div class="loading-spinner"></div>
<div>正在切换视频...</div>
</div>
</div>
<div class="video-info" style="display: none;">
<span id="currentVideoName">未选择视频</span>
</div>
</div>
<!-- 控制按钮 - 悬浮在视频上方 -->
<div class="controls">
<button id="startButton" class="btn btn-primary" title="开始通话">
<!-- 默认通话图标 -->
<svg id="callIcon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z" fill="white"/>
</svg>
<!-- 通话中图标(初始隐藏) -->
<svg id="callingIcon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="display: none;">
<circle cx="12" cy="12" r="3" fill="white">
<animate attributeName="r" values="3;5;3" dur="1.5s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="1;0.5;1" dur="1.5s" repeatCount="indefinite"/>
</circle>
<circle cx="12" cy="12" r="8" stroke="white" stroke-width="2" fill="none" opacity="0.6">
<animate attributeName="r" values="8;12;8" dur="2s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.6;0.2;0.6" dur="2s" repeatCount="indefinite"/>
</circle>
<path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z" fill="white" opacity="0.8"/>
</svg>
</button>
<button id="stopButton" class="btn btn-danger" disabled title="结束通话">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.23 15.26l-2.54-.29c-.61-.07-1.21.14-1.64.57l-1.84 1.84c-2.83-1.44-5.15-3.75-6.59-6.59l1.85-1.85c.43-.43.64-1.03.57-1.64l-.29-2.52c-.12-1.01-.97-1.77-1.99-1.77H5.03c-1.13 0-2.07.94-2 2.07.53 8.54 7.36 15.36 15.89 15.89 1.13.07 2.07-.87 2.07-2v-1.73c.01-1.01-.75-1.86-1.76-1.98z" fill="white"/>
<line x1="18" y1="6" x2="6" y2="18" stroke="white" stroke-width="2"/>
</svg>
</button>
</div>
<!-- 隐藏的输入区域 -->
<div class="input-section" style="display: none;">
<div class="text-input-group">
<input type="text" id="textInput" placeholder="输入文本内容..." />
<button id="sendTextButton" class="btn btn-primary">发送文本</button>
</div>
</div>
<!-- 隐藏的视频选择 -->
<div class="video-selection" style="display: none;">
<h3>选择要播放的视频</h3>
<div id="videoList" class="video-list">
<!-- 视频列表将在这里动态生成 -->
</div>
</div>
<!-- 隐藏的状态显示 -->
<div class="status-section" style="display: none;">
<div id="connectionStatus" class="status">未连接</div>
<div id="messageLog" class="message-log"></div>
</div>
</div>
</div>
<!-- 隐藏的视频元素用于WebRTC连接 -->
<video id="localVideo" autoplay muted playsinline style="display: none;"></video>
<video id="remoteVideo" autoplay playsinline style="display: none;"></video>
<script src="/socket.io/socket.io.js"></script>
<script type="module" src="./index.js"></script>
</body>
</html>

View File

@ -86,6 +86,10 @@ class WebRTCChat {
this.recordedVideoBuffer = document.getElementById('recordedVideoBuffer'); // 新增缓冲视频元素
this.videoLoading = document.getElementById('videoLoading'); // 加载指示器
// 头像和视频容器元素
this.avatarContainer = document.getElementById('avatarContainer');
this.videoContainer = document.getElementById('videoContainer');
// 当前活跃的视频元素标识
this.activeVideoElement = 'main'; // 'main' 或 'buffer'
@ -122,7 +126,7 @@ class WebRTCChat {
});
this.socket.on('disconnect', () => {
this.updateStatus('与服务器断开连接', 'disconnected');
this.connectionStatus.style.display = 'none';
this.logMessage('与服务器断开连接', 'error');
});
@ -153,9 +157,21 @@ class WebRTCChat {
// 场景切换处理
this.socket.on('scene-switched', (data) => {
this.logMessage(`场景已切换到: ${data.currentScene}`, 'info');
// 移除自动清除缓存和播放视频的逻辑
// 现在依赖页面刷新来处理缓存清除
this.logMessage(`场景已切换到: ${data.scene.name}`, 'info');
// 更新视频映射
this.videoMapping = data.mapping;
this.interactionVideo = data.mapping.interactionVideo;
this.defaultVideo = data.mapping.defaultVideo;
// 立即切换到新场景的默认视频
this.switchVideoStream(this.defaultVideo, 'scene-change');
console.log('场景切换完成,新的视频配置:', {
defaultVideo: this.defaultVideo,
interactionVideo: this.interactionVideo,
tag: data.mapping.tag
});
});
}
@ -832,7 +848,12 @@ class WebRTCChat {
this.stopButton.disabled = false;
// 显示结束通话按钮
this.stopButton.classList.add('show');
this.stopButton.style.display = 'block';
// 隐藏头像,显示视频
if (this.videoContainer) {
this.videoContainer.classList.add('calling');
}
this.updateAudioStatus('已连接', 'connected');
this.logMessage('音频通话已开始', 'success');
@ -883,6 +904,15 @@ class WebRTCChat {
this.peerConnection.close();
this.peerConnection = null;
}
// 隐藏结束通话按钮
this.stopButton.style.display = 'none';
this.stopButton.disabled = true;
// 显示头像,隐藏视频
if (this.videoContainer) {
this.videoContainer.classList.remove('calling');
}
// 直接刷新页面清除所有缓存
console.log('通话已结束,正在刷新页面清除缓存...');
@ -1185,6 +1215,10 @@ class WebRTCChat {
updateStatus(message, type) {
this.connectionStatus.textContent = message;
this.connectionStatus.className = `status ${type}`;
// 显示状态元素(仅在连接时显示)
if (type === 'connected') {
this.connectionStatus.style.display = 'block';
}
}
updateAudioStatus(message, type) {
@ -1219,12 +1253,12 @@ class WebRTCChat {
// 添加图标切换方法
switchToCallingIcon() {
const callIcon = document.getElementById('callIcon');
const callingIcon = document.getElementById('callingIcon');
const callingText = document.getElementById('callingText');
const startButton = this.startButton;
if (callIcon && callingIcon && startButton) {
if (callIcon && callingText && startButton) {
callIcon.style.display = 'none';
callingIcon.style.display = 'block';
callingText.style.display = 'block';
startButton.classList.add('calling');
startButton.title = '通话中...';
}
@ -1232,12 +1266,12 @@ class WebRTCChat {
switchToDefaultIcon() {
const callIcon = document.getElementById('callIcon');
const callingIcon = document.getElementById('callingIcon');
const callingText = document.getElementById('callingText');
const startButton = this.startButton;
if (callIcon && callingIcon && startButton) {
if (callIcon && callingText && startButton) {
callIcon.style.display = 'block';
callingIcon.style.display = 'none';
callingText.style.display = 'none';
startButton.classList.remove('calling');
startButton.title = '开始通话';
startButton.disabled = false;

BIN
src/tx.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
videos/dj.mp4 Normal file

Binary file not shown.

BIN
videos/hc-bd-3.mp4 Normal file

Binary file not shown.

BIN
videos/hc-sh-3(1).mp4 Normal file

Binary file not shown.

BIN
videos/hc-sh-3.mp4 Normal file

Binary file not shown.

BIN
videos/kc-bd-3.mp4 Normal file

Binary file not shown.

BIN
videos/kc-sh-3.mp4 Normal file

Binary file not shown.

BIN
videos/qc-bd-4.mp4 Normal file

Binary file not shown.

BIN
videos/qc-sh-4.mp4 Normal file

Binary file not shown.