feat: add SFX volume control and improve audio feedback
- Implemented separate sound effects volume control in settings. - Updated backend to handle new `sfx_volume` parameter. - Enhanced UI to include sound effects volume slider in admin panel. - Integrated sound effects for various user interactions (clicks, navigation, alerts). - Added a new global sound engine to manage audio synthesis without external files. - Updated documentation for Docker deployment and Windows packaging. - Refactored audio context management to ensure sound effects are available post user interaction.
This commit is contained in:
@@ -84,3 +84,32 @@ curl -X POST http://localhost:10085/api/display/command \
|
||||
- 大屏数据由后端模拟引擎产生(`sim_engine.py`,与原前端 `parkData.js` 逻辑一致);前端离线时也有本地兜底
|
||||
- 媒体文件存放于 `backend/media/`,`/file/...` 直接返回
|
||||
- 安全:`backend/.env` 与根 `.env.local` 已加入 `.gitignore`,含阿里云密钥与 MQTT 凭据,勿提交
|
||||
|
||||
|
||||
## Docker 部署(推荐)
|
||||
|
||||
一键构建并启动 **后端 + EMQX Broker**:
|
||||
|
||||
```bash
|
||||
# 1) (可选)配置部署参数
|
||||
export MQTT_PUBLIC_HOST=192.168.1.50 # 展播端可访问的服务器局域网 IP(Broker WebSocket)
|
||||
export EMQX_USER=dpmserver
|
||||
export EMQX_PASS=你的密码
|
||||
export DASHSCOPE_API_KEY=sk-xxx # AI/语音密钥(未配置时 AI 走本地规则引擎)
|
||||
export DPM_S2S_ENABLED=1 # 实时语音对话开关
|
||||
|
||||
# 2) 构建并启动
|
||||
docker compose up -d --build
|
||||
|
||||
# 3) 验证
|
||||
curl http://127.0.0.1:10085/api/health # {"ok":true,...}
|
||||
docker compose logs -f backend
|
||||
```
|
||||
|
||||
- 端口:`10085`(API/后台/媒体)、`8765`(s2s 语音)、`1883/8083/18083`(EMQX)
|
||||
- 数据卷:`dpm_media`(媒体)、`dpm_knowledge`(知识库 park.md,可热更新)、`dpm_data`(设置/播放列表)
|
||||
- 展播端(Tauri 大屏)启动时经 `GET /api/config` 自动获取 MQTT WebSocket 地址
|
||||
- 常用命令:`docker compose down` 停止;`docker compose up -d --build backend` 仅重建后端;
|
||||
`docker compose ps` 查看状态
|
||||
|
||||
> 单独构建镜像:`docker build -f backend/Dockerfile -t dpm-backend .`
|
||||
|
||||
@@ -72,6 +72,7 @@ async def admin_page(request: Request):
|
||||
"play_mode": s.get("play_mode", "sequential"),
|
||||
"image_duration": s.get("image_duration", 5),
|
||||
"volume": s.get("volume", 80),
|
||||
"sfx_volume": s.get("sfx_volume", 60),
|
||||
"fullscreen": s.get("fullscreen", True),
|
||||
"autostart": s.get("autostart", False),
|
||||
"from_screen": request.query_params.get("from") == "screen",
|
||||
|
||||
@@ -38,6 +38,7 @@ def _media_type(path):
|
||||
|
||||
class SettingsBody(BaseModel):
|
||||
volume: int | None = None
|
||||
sfx_volume: int | None = None
|
||||
play_mode: str | None = None
|
||||
image_duration: int | None = None
|
||||
fullscreen: bool | None = None
|
||||
@@ -106,7 +107,7 @@ async def login(request: Request):
|
||||
@router.get("/api/settings")
|
||||
async def get_settings():
|
||||
s = storage.get_settings()
|
||||
return {k: s[k] for k in ("volume", "play_mode", "image_duration", "fullscreen", "autostart")}
|
||||
return {k: s[k] for k in ("volume", "sfx_volume", "play_mode", "image_duration", "fullscreen", "autostart")}
|
||||
|
||||
|
||||
@router.get("/api/config")
|
||||
@@ -135,7 +136,9 @@ async def runtime_config():
|
||||
async def update_settings(body: SettingsBody):
|
||||
storage.update_settings(**body.model_dump(exclude_none=True))
|
||||
s = storage.get_settings()
|
||||
hub.publish_command("settings_changed", {"volume": s["volume"], "play_mode": s["play_mode"]})
|
||||
hub.publish_command("settings_changed", {
|
||||
"volume": s["volume"], "sfx_volume": s["sfx_volume"], "play_mode": s["play_mode"],
|
||||
})
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ DEFAULT_SETTINGS = {
|
||||
"username": "admin",
|
||||
"password": "123456",
|
||||
"volume": 80,
|
||||
"sfx_volume": 60,
|
||||
"auto_play": True,
|
||||
"play_mode": "sequential",
|
||||
"image_duration": 5,
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ def main():
|
||||
print(f" 本地资源 : TORCH_HOME={os.environ.get('TORCH_HOME')}")
|
||||
print(f" NLTK_DATA={os.environ.get('NLTK_DATA')}")
|
||||
print("=" * 64)
|
||||
uvicorn.run("app.main:app", host=settings.HOST, port=settings.PORT, reload=True)
|
||||
uvicorn.run("app.main:app", host=settings.HOST, port=settings.PORT, reload=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+14
-1
@@ -222,7 +222,20 @@
|
||||
volumeLabel.textContent = volumeRange.value + '%';
|
||||
if (volTimer) clearTimeout(volTimer);
|
||||
volTimer = setTimeout(function () {
|
||||
post('/api/settings', { volume: Number(volumeRange.value) }, function () { toast('音量已更新'); });
|
||||
post('/api/settings', { volume: Number(volumeRange.value) }, function () { toast('媒体音量已更新'); });
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
var sfxVolumeRange = document.getElementById('sfxVolumeRange');
|
||||
var sfxVolumeLabel = document.getElementById('sfxVolumeLabel');
|
||||
var sfxVolTimer = null;
|
||||
if (sfxVolumeRange && sfxVolumeLabel) {
|
||||
sfxVolumeRange.addEventListener('input', function () {
|
||||
sfxVolumeLabel.textContent = sfxVolumeRange.value + '%';
|
||||
if (sfxVolTimer) clearTimeout(sfxVolTimer);
|
||||
sfxVolTimer = setTimeout(function () {
|
||||
post('/api/settings', { sfx_volume: Number(sfxVolumeRange.value) }, function () { toast('音效音量已更新'); });
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -230,10 +230,15 @@
|
||||
<input type="number" id="imageDuration" value="{{ image_duration }}" min="1" max="300">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>音量</label>
|
||||
<label>音量(媒体)</label>
|
||||
<input type="range" id="volumeRange" min="0" max="100" value="{{ volume }}">
|
||||
<div class="volume-label" id="volumeLabel">{{ volume }}%</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>音量(音效)</label>
|
||||
<input type="range" id="sfxVolumeRange" min="0" max="100" value="{{ sfx_volume }}">
|
||||
<div class="volume-label" id="sfxVolumeLabel">{{ sfx_volume }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Windows 打包部署指南(免重打包改地址)
|
||||
|
||||
## 一、打包后在 exe 同目录放置 config.json(推荐)
|
||||
|
||||
打包安装后,在 exe 所在目录(如 `C:\Program Files\云超服昆创园OPC运营中心\`)新建 **`config.json`**,
|
||||
改地址**不需要重新打包**,重启应用即生效:
|
||||
|
||||
```json
|
||||
{
|
||||
"api_base": "http://192.168.1.9:10085",
|
||||
"mqtt_url": "ws://192.168.1.3:8083/mqtt",
|
||||
"mqtt_username": "dpm",
|
||||
"mqtt_password": "123456"
|
||||
}
|
||||
```
|
||||
|
||||
- `api_base`:FastAPI 后端地址(API / 管理后台 / 媒体)
|
||||
- `mqtt_url`:EMQX Broker 的 **WebSocket** 地址(端口 8083)
|
||||
- `mqtt_username/password`:Broker 鉴权账号(默认 dpm / 123456,与后端 .env 一致)
|
||||
|
||||
地址解析优先级:**config.json > 后端 /api/config > 构建默认(127.0.0.1)**。
|
||||
|
||||
## 二、后端 /api/config 方式(同机部署自动生效)
|
||||
|
||||
后端 `.env`(backend/.env)配置 Broker 后,展播端启动时自动从
|
||||
`GET /api/config` 获取 MQTT 地址,**无需 config.json**:
|
||||
|
||||
```
|
||||
DPM_MQTT_HOST=192.168.1.3
|
||||
DPM_MQTT_PORT=1883
|
||||
DPM_MQTT_WS=ws://192.168.1.3:8083/mqtt # 展播端可访问的 WebSocket 地址
|
||||
MQTT_USERNAME=dpmserver
|
||||
MQTT_PASSWORD=你的密码
|
||||
```
|
||||
|
||||
> 若后端与 Broker 同机:后端默认 `MQTT_WS_URL=ws://localhost:8083/mqtt` 即可,
|
||||
> 展播端连 `127.0.0.1` 也通;跨机必须显式配置上面的 IP。
|
||||
|
||||
## 三、构建期固定地址(不推荐,需重新打包)
|
||||
|
||||
在项目根目录(**构建机器上**)配置 `.env.local`(已被 gitignore):
|
||||
|
||||
```
|
||||
VITE_API_BASE=http://192.168.1.9:10085
|
||||
VITE_MQTT_URL=ws://192.168.1.3:8083/mqtt
|
||||
VITE_MQTT_USERNAME=dpm
|
||||
VITE_MQTT_PASSWORD=123456
|
||||
```
|
||||
|
||||
然后 `yarn build:win`(脚本自动清理旧 exe 进程再打包)。
|
||||
|
||||
## 四、打包前必须确认
|
||||
|
||||
| 检查项 | 说明 |
|
||||
|---|---|
|
||||
| exe 未被运行 | `taskkill /f /im "昆明大学生创业园展播系统.exe"`,或直接用 `yarn build:win` |
|
||||
| 后端已启动 | 浏览器打开 `http://<后端IP>:10085/api/health` 应返回 `{"ok":true,...}` |
|
||||
| Broker WebSocket 已开 | EMQX 需开启 8083 端口 WS 监听,且账号密码与 config.json 一致 |
|
||||
| Windows 防火墙 | 首次运行允许,或放行出站 10085/8083(管理端上传用 10085) |
|
||||
|
||||
## 五、常见排查
|
||||
|
||||
```
|
||||
# 测试后端连通(展播机 PowerShell)
|
||||
Invoke-WebRequest http://192.168.1.9:10085/api/health
|
||||
|
||||
# 测试 Broker WS 端口
|
||||
Test-NetConnection 192.168.1.3 -Port 8083
|
||||
|
||||
# 看前端启动日志(bootstrap 会打印 MQTT 地址来源)
|
||||
打开应用后按 F12(WebView2 开发者工具)→ Console
|
||||
```
|
||||
|
||||
- 应用日志打印 `[bootstrap] config.json MQTT -> ...` = config.json 生效
|
||||
- 打印 `[bootstrap] /api/config MQTT -> ...` = 后端下发
|
||||
- 两者都没有 = 两个来源都不可达,请检查 IP/防火墙
|
||||
+2
-1
@@ -7,7 +7,8 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
"tauri": "tauri",
|
||||
"build:win": "scripts\\build-win.cmd"
|
||||
},
|
||||
"dependencies": {
|
||||
"@appica/icons-react": "^1.0.0",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
@echo off
|
||||
REM ============================================================
|
||||
REM Windows 一键打包脚本(Tauri)
|
||||
REM 解决「error: failed to remove file ... .exe / 拒绝访问(os error 5)」:
|
||||
REM 本应用关闭窗口=最小化(进程常驻),旧 exe 会被运行进程锁定无法覆盖。
|
||||
REM 打包前自动结束占用进程,再执行 tauri build。
|
||||
REM
|
||||
REM 用法:双击运行,或在项目根目录执行 scripts\build-win.cmd
|
||||
REM ============================================================
|
||||
chcp 65001 >nul
|
||||
setlocal
|
||||
|
||||
echo.
|
||||
echo [1/3] 结束占用旧 exe 的进程(无则跳过)...
|
||||
taskkill /f /im "昆明大学生创业园展播系统.exe" >nul 2>&1
|
||||
taskkill /f /im "云超服昆创园OPC运营中心.exe" >nul 2>&1
|
||||
taskkill /f /im "dpm.exe" >nul 2>&1
|
||||
echo 已尝试清理。
|
||||
|
||||
echo.
|
||||
echo [2/3] 清理 Rust 增量产物中的旧 exe 残留(可选,失败不影响)...
|
||||
if exist "src-tauri\target\release\昆明大学生创业园展播系统.exe" (
|
||||
del /f /q "src-tauri\target\release\昆明大学生创业园展播系统.exe" >nul 2>&1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [3/3] 开始 Tauri 打包(yarn tauri build)...
|
||||
call yarn tauri build
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo [错误] 打包失败。若仍报「拒绝访问」,请:
|
||||
echo 1. 确认任务管理器中没有 dpm/昆明大学生创业园 相关进程
|
||||
echo 2. 将 D:\MyCode\DPM\src-tauri\target 加入杀毒软件排除目录
|
||||
echo 3. 关闭杀毒实时防护后重试
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [完成] 打包成功!安装包位于 src-tauri\target\release\bundle\
|
||||
endlocal
|
||||
+40
-1
@@ -1,5 +1,43 @@
|
||||
// 说明:早期 Rust 内嵌后端(server/storage/models/events,axum :10801)已随架构迁移到
|
||||
// Python 后端(FastAPI :10085)而移除;当前 Rust 侧仅负责窗口与生命周期。
|
||||
// Python 后端(FastAPI :10085)而移除;当前 Rust 侧仅负责窗口与生命周期,
|
||||
// 外加一个「运行时配置读取」命令(供前端读取 exe 旁 config.json,部署免重打包改地址)。
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
/// 读取运行时配置(部署用,免重新打包):
|
||||
/// 1) exe 同目录 config.json / dpm.config.json(最高优先)
|
||||
/// 2) 应用配置目录 config.json
|
||||
/// 内容示例:
|
||||
/// { "api_base": "http://192.168.1.9:10085",
|
||||
/// "mqtt_url": "ws://192.168.1.3:8083/mqtt",
|
||||
/// "mqtt_username": "dpm", "mqtt_password": "123456" }
|
||||
/// 返回 JSON 字符串;未配置返回 null。
|
||||
#[tauri::command]
|
||||
fn read_runtime_config(app: AppHandle) -> Option<String> {
|
||||
let mut candidates: Vec<PathBuf> = Vec::new();
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
candidates.push(dir.join("config.json"));
|
||||
candidates.push(dir.join("dpm.config.json"));
|
||||
}
|
||||
}
|
||||
if let Ok(dir) = app.path().app_config_dir() {
|
||||
candidates.push(dir.join("config.json"));
|
||||
}
|
||||
for p in candidates {
|
||||
if p.exists() {
|
||||
if let Ok(s) = fs::read_to_string(&p) {
|
||||
let t = s.trim();
|
||||
if t.starts_with('{') {
|
||||
return Some(t.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
@@ -9,6 +47,7 @@ pub fn run() {
|
||||
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
||||
Some(vec![]),
|
||||
))
|
||||
.invoke_handler(tauri::generate_handler![read_runtime_config])
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import Icon from './Icons';
|
||||
import { getApiBase as API_BASE } from '../config';
|
||||
import { Sfx } from '../utils/sounds';
|
||||
import { getMqttStatus } from '../utils/mqtt';
|
||||
import { renderMarkdown } from '../voice/markdown.jsx';
|
||||
|
||||
@@ -133,6 +134,7 @@ export default function AiChatPanel() {
|
||||
if (!reply) reply = pick(FALLBACK_REPLIES);
|
||||
setTyping(false);
|
||||
setMsgs((prev) => [...prev, { id: nextId(), role: 'ai', text: reply }]);
|
||||
Sfx.notify();
|
||||
executeLocalTools(tools);
|
||||
resetIdleTimer(); // 有提问 → 重置 1 分钟空闲计时
|
||||
}, [typing, resetIdleTimer]);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import Icon from './Icons';
|
||||
import { getApiBase as API_BASE } from '../config';
|
||||
import { Sfx } from '../utils/sounds';
|
||||
|
||||
/* =========================================================
|
||||
DpmOverlays —— 后端/AI 指令驱动的全局覆盖层
|
||||
@@ -110,6 +111,7 @@ export default function DpmOverlays() {
|
||||
const alertTimer = useRef(null);
|
||||
|
||||
const showAlert = useCallback((params) => {
|
||||
Sfx.alert();
|
||||
const item = { title: params.title || '提示', content: params.content || '', id: Date.now() + Math.random() };
|
||||
setAlert(item);
|
||||
clearTimeout(alertTimer.current);
|
||||
@@ -117,6 +119,7 @@ export default function DpmOverlays() {
|
||||
}, []);
|
||||
|
||||
const showCard = useCallback((params) => {
|
||||
Sfx.notify();
|
||||
setCard({ card: params.card || 'custom', title: params.title || '', content: params.content || '', id: Date.now() + Math.random() });
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import useVisionDetection from '../voice/useVisionDetection.js';
|
||||
import { Sfx } from '../utils/sounds';
|
||||
|
||||
/* =========================================================
|
||||
全局手势识别(挂载于 ScreenLayout —— 所有大屏页面共享)
|
||||
@@ -18,6 +19,7 @@ export default function GlobalVision() {
|
||||
const vision = useVisionDetection({
|
||||
onGesture: (g) => {
|
||||
// 全部手势类型(raise/fist/both_up/point/hands_close/wave)统一分发
|
||||
Sfx.success();
|
||||
window.dispatchEvent(new CustomEvent('dpm:gesture', { detail: { type: g } }));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Icon from './Icons';
|
||||
import { onMediaUiState, mediaCommand } from '../utils/mediaControl';
|
||||
import { Sfx } from '../utils/sounds';
|
||||
|
||||
/* =========================================================
|
||||
页眉媒体控制区 —— 仅媒体轮播页显示
|
||||
@@ -26,13 +27,13 @@ export default function MediaHeaderControls() {
|
||||
{/* 播放 / 暂停 */}
|
||||
<button
|
||||
className={`bd-hd-media-btn ${st.paused ? 'primary' : ''}`}
|
||||
onClick={() => mediaCommand(st.paused ? 'play' : 'pause')}
|
||||
onClick={() => { Sfx.click(); mediaCommand(st.paused ? 'play' : 'pause'); }}
|
||||
title={st.paused ? '播放' : '暂停'}
|
||||
>
|
||||
<Icon name={st.paused ? 'play' : 'pause'} size={14} />
|
||||
</button>
|
||||
{/* 上一段 */}
|
||||
<button className="bd-hd-media-btn" onClick={() => mediaCommand('prev')} title="上一段">
|
||||
<button className="bd-hd-media-btn" onClick={() => { Sfx.click(); mediaCommand('prev'); }} title="上一段">
|
||||
<Icon name="chevron-left" size={13} />
|
||||
</button>
|
||||
{/* 序号 */}
|
||||
@@ -40,7 +41,7 @@ export default function MediaHeaderControls() {
|
||||
<b>{st.index + 1}</b>/<em>{st.total}</em>
|
||||
</span>
|
||||
{/* 下一段 */}
|
||||
<button className="bd-hd-media-btn" onClick={() => mediaCommand('next')} title="下一段">
|
||||
<button className="bd-hd-media-btn" onClick={() => { Sfx.click(); mediaCommand('next'); }} title="下一段">
|
||||
<Icon name="chevron-right" size={13} />
|
||||
</button>
|
||||
{/* 声音解锁 */}
|
||||
|
||||
@@ -5,10 +5,13 @@ import MediaHeaderControls from './MediaHeaderControls';
|
||||
import AboutDialog from './AboutDialog';
|
||||
import { PAGE_ORDER, usePageIndex } from '../utils/pageNav';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@appica/ui-react/avatar'
|
||||
import { Sfx } from '../utils/sounds';
|
||||
import { getApiBase as API_BASE } from '../config';
|
||||
|
||||
/* =========================================================
|
||||
共享页眉 —— 所有大屏页面统一(由 ScreenLayout 注入)
|
||||
状态文案按当前页面自动识别;左右键循环 + 可点击按钮切换
|
||||
· 音量面板:媒体音量 + 音效音量(写回后端设置并 MQTT 广播同步)
|
||||
========================================================= */
|
||||
|
||||
const STATUS_BY_PATH = {
|
||||
@@ -25,9 +28,66 @@ export default function PageHeader() {
|
||||
const [aboutOpen, setAboutOpen] = useState(false);
|
||||
const [gestureHit, setGestureHit] = useState(false); // 识别到手势 → 惊喜图标 3s
|
||||
const [vision, setVision] = useState({ status: 'off', cameraOn: false, gesture: null }); // 视觉识别状态
|
||||
const [volOpen, setVolOpen] = useState(false);
|
||||
const [mediaVol, setMediaVol] = useState(80); // 媒体音量 0-100
|
||||
const [sfxVol, setSfxVol] = useState(Sfx.getVolume()); // 音效音量 0-100
|
||||
const pageIdx = usePageIndex();
|
||||
const gestureTimer = useRef(null);
|
||||
|
||||
// 启动时拉取后端设置(媒体/音效音量),并监听 MQTT settings_changed 同步
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE()}/api/settings`, { cache: 'no-store' })
|
||||
.then((r) => r.json())
|
||||
.then((s) => {
|
||||
if (typeof s.volume === 'number') setMediaVol(s.volume);
|
||||
if (typeof s.sfx_volume === 'number') {
|
||||
setSfxVol(s.sfx_volume);
|
||||
Sfx.setVolume(s.sfx_volume);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
const onSettings = (e) => {
|
||||
const p = (e.detail && (e.detail.params || e.detail)) || {};
|
||||
if (typeof p.volume === 'number') setMediaVol(p.volume);
|
||||
if (typeof p.sfx_volume === 'number') {
|
||||
setSfxVol(p.sfx_volume);
|
||||
Sfx.setVolume(p.sfx_volume);
|
||||
}
|
||||
};
|
||||
// 后端/MQTT settings_changed → 同步音量(SSE 走 api/events,MQTT 走 dpm:media-control)
|
||||
window.addEventListener('dpm:mqtt-local', onSettings);
|
||||
window.addEventListener('dpm:media-control', onSettings);
|
||||
return () => {
|
||||
window.removeEventListener('dpm:mqtt-local', onSettings);
|
||||
window.removeEventListener('dpm:media-control', onSettings);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 点击面板外部 → 关闭音量面板
|
||||
useEffect(() => {
|
||||
if (!volOpen) return undefined;
|
||||
const close = () => setVolOpen(false);
|
||||
document.addEventListener('pointerdown', close);
|
||||
return () => document.removeEventListener('pointerdown', close);
|
||||
}, [volOpen]);
|
||||
|
||||
// 音量修改 → 写回后端(MQTT settings_changed 广播给所有大屏)
|
||||
const changeVolume = (key, value) => {
|
||||
const v = Math.max(0, Math.min(100, Number(value) || 0));
|
||||
if (key === 'sfx_volume') {
|
||||
setSfxVol(v);
|
||||
Sfx.setVolume(v);
|
||||
Sfx.notify();
|
||||
} else {
|
||||
setMediaVol(v);
|
||||
}
|
||||
fetch(`${API_BASE()}/api/settings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [key]: v }),
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const iv = setInterval(() => setNow(new Date()), 1000);
|
||||
return () => clearInterval(iv);
|
||||
@@ -93,16 +153,44 @@ export default function PageHeader() {
|
||||
<div className="bd-header-right">
|
||||
{/* 媒体轮播页:页眉内嵌播放控制 */}
|
||||
{location.pathname === '/screen' && <MediaHeaderControls />}
|
||||
{/* 音量控制(媒体 + 音效) */}
|
||||
<div className="bd-vol-wrap">
|
||||
<button
|
||||
className={`bd-vol-btn${mediaVol === 0 ? ' muted' : ''}`}
|
||||
type="button"
|
||||
title="音量控制"
|
||||
aria-label="音量控制"
|
||||
onClick={() => { setVolOpen((v) => !v); Sfx.click(); }}
|
||||
>
|
||||
<Icon name={mediaVol === 0 ? 'volume-off' : 'volume'} size={15} />
|
||||
</button>
|
||||
{volOpen && (
|
||||
<div className="bd-vol-panel" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="bd-vol-title"><Icon name="volume" size={13} /> 媒体音量</div>
|
||||
<input
|
||||
type="range" min="0" max="100" value={mediaVol}
|
||||
onChange={(e) => changeVolume('volume', e.target.value)}
|
||||
/>
|
||||
<span className="bd-vol-val">{mediaVol}%</span>
|
||||
<div className="bd-vol-title"><Icon name="sparkles" size={13} /> 音效音量</div>
|
||||
<input
|
||||
type="range" min="0" max="100" value={sfxVol}
|
||||
onChange={(e) => changeVolume('sfx_volume', e.target.value)}
|
||||
/>
|
||||
<span className="bd-vol-val">{sfxVol}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="bd-nav-keys" title="左右方向键或点击切换页面">
|
||||
{left && (
|
||||
<button className="bd-nav-key" onClick={() => navigate(left.path)}>
|
||||
<button className="bd-nav-key" onClick={() => { Sfx.page(); navigate(left.path); }}>
|
||||
<Icon name="chevron-left" size={13} />
|
||||
{left.label}
|
||||
</button>
|
||||
)}
|
||||
<span className="bd-nav-dot">·</span>
|
||||
{right && (
|
||||
<button className="bd-nav-key" onClick={() => navigate(right.path)}>
|
||||
<button className="bd-nav-key" onClick={() => { Sfx.page(); navigate(right.path); }}>
|
||||
{right.label}
|
||||
<Icon name="chevron-right" size={13} />
|
||||
</button>
|
||||
|
||||
+33
-15
@@ -2,27 +2,45 @@ import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import { ThemeProvider } from "@appica/ui-react/providers/theme-provider";
|
||||
import { API_BASE } from "./config";
|
||||
import { getApiBase } from "./config";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import "./styles/global.css";
|
||||
|
||||
/* =========================================================
|
||||
启动引导(打包部署关键):
|
||||
渲染前先从后端 GET /api/config 拉取运行时配置
|
||||
(MQTT 地址/账号、API 基址),写入 window.__DPM_*__,
|
||||
之后 api.js / mqtt.js 均以惰性读取方式生效。
|
||||
后端不可达时静默使用内置默认(Tauri 打包回退 127.0.0.1)。
|
||||
启动引导(打包部署关键)—— 地址解析优先级:
|
||||
1. exe 旁 config.json(Tauri 命令读取,部署免重打包改 IP)★
|
||||
2. 后端 GET /api/config(MQTT 地址/账号,后端 .env 为准)
|
||||
3. 内置默认(Tauri 打包回退 127.0.0.1 / 浏览器用当前主机)
|
||||
结果写入 window.__DPM_*__,api.js / mqtt.js 惰性读取生效。
|
||||
========================================================= */
|
||||
async function bootstrapRuntimeConfig() {
|
||||
// 1) exe 旁 config.json(最高优先):{api_base, mqtt_url, mqtt_username, mqtt_password}
|
||||
try {
|
||||
const res = await fetch(`${API_BASE()}/api/config`, { cache: 'no-store' });
|
||||
if (!res.ok) return;
|
||||
const cfg = await res.json();
|
||||
// 仅覆盖 MQTT 连接信息(后端 .env 为准);API 基址以前端实际可达地址为准
|
||||
if (cfg.mqtt_url) window.__DPM_MQTT__ = cfg.mqtt_url;
|
||||
if (cfg.mqtt_username) window.__DPM_MQTT_USER__ = cfg.mqtt_username;
|
||||
if (cfg.mqtt_password) window.__DPM_MQTT_PASS__ = cfg.mqtt_password;
|
||||
// eslint-disable-next-line no-console
|
||||
if (cfg.mqtt_url) console.info(`[bootstrap] MQTT -> ${cfg.mqtt_url}`);
|
||||
const raw = await invoke("read_runtime_config");
|
||||
if (raw) {
|
||||
const cfg = JSON.parse(raw);
|
||||
if (cfg.api_base) window.__DPM_API__ = cfg.api_base;
|
||||
if (cfg.mqtt_url) window.__DPM_MQTT__ = cfg.mqtt_url;
|
||||
if (cfg.mqtt_username) window.__DPM_MQTT_USER__ = cfg.mqtt_username;
|
||||
if (cfg.mqtt_password) window.__DPM_MQTT_PASS__ = cfg.mqtt_password;
|
||||
// eslint-disable-next-line no-console
|
||||
if (cfg.mqtt_url) console.info(`[bootstrap] config.json MQTT -> ${cfg.mqtt_url}`);
|
||||
}
|
||||
} catch {
|
||||
/* 浏览器开发环境或未注册命令:跳过 */
|
||||
}
|
||||
|
||||
// 2) 后端 /api/config(MQTT 地址/账号;API 基址以前端实际可达地址为准)
|
||||
try {
|
||||
const res = await fetch(`${getApiBase()}/api/config`, { cache: 'no-store' });
|
||||
if (res.ok) {
|
||||
const cfg = await res.json();
|
||||
if (cfg.mqtt_url) window.__DPM_MQTT__ = cfg.mqtt_url;
|
||||
if (cfg.mqtt_username) window.__DPM_MQTT_USER__ = cfg.mqtt_username;
|
||||
if (cfg.mqtt_password) window.__DPM_MQTT_PASS__ = cfg.mqtt_password;
|
||||
// eslint-disable-next-line no-console
|
||||
if (cfg.mqtt_url) console.info(`[bootstrap] /api/config MQTT -> ${cfg.mqtt_url}`);
|
||||
}
|
||||
} catch {
|
||||
/* 后端不可达:使用内置默认地址 */
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import * as api from '../utils/api';
|
||||
import { getApiBase } from '../config';
|
||||
import { Sfx } from '../utils/sounds';
|
||||
import { API_BASE } from '../utils/api';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { enable, disable } from '@tauri-apps/plugin-autostart';
|
||||
@@ -323,6 +324,9 @@ export default function MediaScreen() {
|
||||
}, [reloadPlaylist]);
|
||||
|
||||
const applySettings = useCallback((msg) => {
|
||||
if (msg.sfx_volume !== undefined) {
|
||||
Sfx.setVolume(msg.sfx_volume);
|
||||
}
|
||||
if (msg.volume !== undefined) {
|
||||
const vol = msg.volume / 100;
|
||||
setVolume(vol);
|
||||
|
||||
@@ -2354,6 +2354,94 @@
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
/* ============ 页眉音量控制(媒体 + 音效) ============ */
|
||||
.bd-vol-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bd-vol-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #4a5b76;
|
||||
background: rgba(255,255,255,0.7);
|
||||
border: 1px solid rgba(47,107,255,0.14);
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.bd-vol-btn:hover {
|
||||
color: var(--bd-blue);
|
||||
border-color: rgba(47,107,255,0.4);
|
||||
background: rgba(255,255,255,0.95);
|
||||
}
|
||||
.bd-vol-btn.muted { color: var(--bd-red); }
|
||||
.bd-vol-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
z-index: 60;
|
||||
width: 220px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(255,255,255,0.96);
|
||||
border: 1px solid rgba(47,107,255,0.16);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 30px rgba(56,100,170,0.16), 0 1px 0 rgba(255,255,255,0.95) inset;
|
||||
backdrop-filter: blur(14px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(140%);
|
||||
animation: bdRotIn 0.2s ease both;
|
||||
}
|
||||
.bd-vol-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--bd-ink);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.bd-vol-title .appica-ico { color: var(--bd-blue); }
|
||||
.bd-vol-panel input[type="range"] {
|
||||
width: 100%;
|
||||
height: 5px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
border-radius: 3px;
|
||||
background: rgba(47,107,255,0.14);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.bd-vol-panel input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(140deg, var(--bd-blue), var(--bd-cyan));
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 2px 6px rgba(47,107,255,0.4);
|
||||
}
|
||||
.bd-vol-panel input[type="range"]::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--bd-blue);
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 2px 6px rgba(47,107,255,0.4);
|
||||
}
|
||||
.bd-vol-val {
|
||||
display: block;
|
||||
text-align: right;
|
||||
font-family: "JetBrains Mono", "Oswald", monospace;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
color: var(--bd-blue);
|
||||
margin: 2px 0 8px;
|
||||
}
|
||||
.bd-vol-val:last-child { margin-bottom: 0; }
|
||||
.bd-nav-key {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Sfx } from './sounds';
|
||||
|
||||
/* =========================================================
|
||||
页面循环导航 —— 左右方向键切换大屏页面
|
||||
@@ -34,8 +35,10 @@ export function usePageNav() {
|
||||
if (idx === -1) return;
|
||||
const n = PAGE_ORDER.length;
|
||||
if (e.key === 'ArrowRight') {
|
||||
Sfx.page();
|
||||
navigate(PAGE_ORDER[(idx + 1) % n].path);
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
Sfx.page();
|
||||
navigate(PAGE_ORDER[(idx - 1 + n) % n].path);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/* =========================================================
|
||||
全局音效引擎 —— Web Audio 合成音(无需音频资源文件)
|
||||
· 独立于媒体播放音量:sfx 音量单独控制(localStorage 持久化)
|
||||
· 首次用户交互后自动激活 AudioContext(浏览器自动播放策略)
|
||||
· 提供:click 点击 / page 切页 / alert 通知 / success 成功 /
|
||||
error 错误 / notify 提示 / tick 轻响
|
||||
========================================================= */
|
||||
|
||||
let ctx = null; // AudioContext
|
||||
let sfxGain = null; // 音效增益(接 master)
|
||||
const SFX_KEY = 'dpm_sfx_volume';
|
||||
let sfxVolume = Number(localStorage.getItem(SFX_KEY));
|
||||
if (!Number.isFinite(sfxVolume)) sfxVolume = 60; // 默认 60%
|
||||
sfxVolume = Math.max(0, Math.min(100, sfxVolume));
|
||||
|
||||
function ensureCtx() {
|
||||
if (!ctx) {
|
||||
const AC = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AC) return null;
|
||||
ctx = new AC();
|
||||
sfxGain = ctx.createGain();
|
||||
sfxGain.gain.value = sfxVolume / 100;
|
||||
sfxGain.connect(ctx.destination);
|
||||
}
|
||||
if (ctx.state === 'suspended') {
|
||||
ctx.resume().catch(() => { /* 等用户交互后再恢复 */ });
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合成一个音
|
||||
* @param {Object} o freq 起始频率 / end 结束频率 / dur 时长s / type 波形 /
|
||||
* vol 音量0-1 / delay 延迟s / attack 起音s
|
||||
*/
|
||||
function tone(o = {}) {
|
||||
const c = ensureCtx();
|
||||
if (!c) return;
|
||||
const {
|
||||
freq = 880, end = freq, dur = 0.12, type = 'sine',
|
||||
vol = 0.4, delay = 0, attack = 0.005,
|
||||
} = o;
|
||||
const t0 = c.currentTime + delay;
|
||||
const osc = c.createOscillator();
|
||||
const g = c.createGain();
|
||||
osc.type = type;
|
||||
osc.frequency.setValueAtTime(freq, t0);
|
||||
if (end !== freq) osc.frequency.exponentialRampToValueAtTime(Math.max(20, end), t0 + dur);
|
||||
g.gain.setValueAtTime(0, t0);
|
||||
g.gain.linearRampToValueAtTime(vol, t0 + attack);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
||||
osc.connect(g);
|
||||
g.connect(sfxGain);
|
||||
osc.start(t0);
|
||||
osc.stop(t0 + dur + 0.03);
|
||||
}
|
||||
|
||||
/** 用户交互时调用:激活/恢复 AudioContext */
|
||||
export function unlockAudio() {
|
||||
ensureCtx();
|
||||
}
|
||||
|
||||
// 全局首次交互(点击/按键/触摸)即解锁 AudioContext,保证后续音效可用
|
||||
if (typeof window !== 'undefined') {
|
||||
const unlockOnce = () => { unlockAudio(); };
|
||||
window.addEventListener('pointerdown', unlockOnce, { once: true });
|
||||
window.addEventListener('keydown', unlockOnce, { once: true });
|
||||
window.addEventListener('touchstart', unlockOnce, { once: true });
|
||||
}
|
||||
|
||||
export const Sfx = {
|
||||
/** 按钮点击 */
|
||||
click() {
|
||||
tone({ freq: 1300, dur: 0.05, type: 'triangle', vol: 0.22 });
|
||||
},
|
||||
/** 页面切换(上滑音) */
|
||||
page() {
|
||||
tone({ freq: 560, end: 920, dur: 0.16, type: 'sine', vol: 0.3 });
|
||||
},
|
||||
/** 通知提醒(双音 ding-ding) */
|
||||
alert() {
|
||||
tone({ freq: 880, dur: 0.12, type: 'sine', vol: 0.38 });
|
||||
tone({ freq: 1174, dur: 0.2, type: 'sine', vol: 0.38, delay: 0.14 });
|
||||
},
|
||||
/** 成功(上行双音) */
|
||||
success() {
|
||||
tone({ freq: 660, dur: 0.1, type: 'sine', vol: 0.3 });
|
||||
tone({ freq: 990, dur: 0.18, type: 'sine', vol: 0.3, delay: 0.09 });
|
||||
},
|
||||
/** 错误(下行低音) */
|
||||
error() {
|
||||
tone({ freq: 320, end: 180, dur: 0.28, type: 'sawtooth', vol: 0.22 });
|
||||
},
|
||||
/** 轻提示(AI 回复等) */
|
||||
notify() {
|
||||
tone({ freq: 1046, dur: 0.07, type: 'triangle', vol: 0.18 });
|
||||
tone({ freq: 1568, dur: 0.09, type: 'triangle', vol: 0.14, delay: 0.06 });
|
||||
},
|
||||
/** 极轻节拍(数据 tick / 播放控制) */
|
||||
tick() {
|
||||
tone({ freq: 1500, dur: 0.03, type: 'square', vol: 0.06 });
|
||||
},
|
||||
/** 设置音效音量 0-100(持久化) */
|
||||
setVolume(v) {
|
||||
sfxVolume = Math.max(0, Math.min(100, Number(v) || 0));
|
||||
localStorage.setItem(SFX_KEY, String(sfxVolume));
|
||||
if (sfxGain) sfxGain.gain.value = sfxVolume / 100;
|
||||
},
|
||||
getVolume() {
|
||||
return sfxVolume;
|
||||
},
|
||||
};
|
||||
|
||||
export default Sfx;
|
||||
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { onMqttMessage, connectMqtt, publishMqtt, getClientId, useMqttStatus } from './mqtt';
|
||||
import { MQTT_TOPIC_HEARTBEAT } from '../config';
|
||||
import { Sfx } from './sounds';
|
||||
import { PAGE_ORDER } from './pageNav';
|
||||
|
||||
/* =========================================================
|
||||
@@ -71,7 +72,7 @@ export function useMqttControl() {
|
||||
switch (cmd.action) {
|
||||
case 'navigate': {
|
||||
const path = resolvePage(cmd.params?.page);
|
||||
if (path) navigate(path);
|
||||
if (path) { Sfx.page(); navigate(path); }
|
||||
break;
|
||||
}
|
||||
case 'navigate_rel': {
|
||||
@@ -93,6 +94,7 @@ export function useMqttControl() {
|
||||
case 'pause':
|
||||
case 'next':
|
||||
case 'prev':
|
||||
Sfx.tick();
|
||||
case 'set_mode':
|
||||
case 'play_target':
|
||||
case 'minimize':
|
||||
|
||||
Reference in New Issue
Block a user