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:
Pine
2026-08-18 07:14:13 +08:00
parent 18d28be943
commit d70d9d5fbd
22 changed files with 561 additions and 28 deletions
+29
View File
@@ -84,3 +84,32 @@ curl -X POST http://localhost:10085/api/display/command \
- 大屏数据由后端模拟引擎产生(`sim_engine.py`,与原前端 `parkData.js` 逻辑一致);前端离线时也有本地兜底 - 大屏数据由后端模拟引擎产生(`sim_engine.py`,与原前端 `parkData.js` 逻辑一致);前端离线时也有本地兜底
- 媒体文件存放于 `backend/media/``/file/...` 直接返回 - 媒体文件存放于 `backend/media/``/file/...` 直接返回
- 安全:`backend/.env` 与根 `.env.local` 已加入 `.gitignore`,含阿里云密钥与 MQTT 凭据,勿提交 - 安全:`backend/.env` 与根 `.env.local` 已加入 `.gitignore`,含阿里云密钥与 MQTT 凭据,勿提交
## Docker 部署(推荐)
一键构建并启动 **后端 + EMQX Broker**
```bash
# 1) (可选)配置部署参数
export MQTT_PUBLIC_HOST=192.168.1.50 # 展播端可访问的服务器局域网 IPBroker 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 .`
+1
View File
@@ -72,6 +72,7 @@ async def admin_page(request: Request):
"play_mode": s.get("play_mode", "sequential"), "play_mode": s.get("play_mode", "sequential"),
"image_duration": s.get("image_duration", 5), "image_duration": s.get("image_duration", 5),
"volume": s.get("volume", 80), "volume": s.get("volume", 80),
"sfx_volume": s.get("sfx_volume", 60),
"fullscreen": s.get("fullscreen", True), "fullscreen": s.get("fullscreen", True),
"autostart": s.get("autostart", False), "autostart": s.get("autostart", False),
"from_screen": request.query_params.get("from") == "screen", "from_screen": request.query_params.get("from") == "screen",
+5 -2
View File
@@ -38,6 +38,7 @@ def _media_type(path):
class SettingsBody(BaseModel): class SettingsBody(BaseModel):
volume: int | None = None volume: int | None = None
sfx_volume: int | None = None
play_mode: str | None = None play_mode: str | None = None
image_duration: int | None = None image_duration: int | None = None
fullscreen: bool | None = None fullscreen: bool | None = None
@@ -106,7 +107,7 @@ async def login(request: Request):
@router.get("/api/settings") @router.get("/api/settings")
async def get_settings(): async def get_settings():
s = storage.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") @router.get("/api/config")
@@ -135,7 +136,9 @@ async def runtime_config():
async def update_settings(body: SettingsBody): async def update_settings(body: SettingsBody):
storage.update_settings(**body.model_dump(exclude_none=True)) storage.update_settings(**body.model_dump(exclude_none=True))
s = storage.get_settings() 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} return {"ok": True}
+1
View File
@@ -12,6 +12,7 @@ DEFAULT_SETTINGS = {
"username": "admin", "username": "admin",
"password": "123456", "password": "123456",
"volume": 80, "volume": 80,
"sfx_volume": 60,
"auto_play": True, "auto_play": True,
"play_mode": "sequential", "play_mode": "sequential",
"image_duration": 5, "image_duration": 5,
+1 -1
View File
@@ -67,7 +67,7 @@ def main():
print(f" 本地资源 : TORCH_HOME={os.environ.get('TORCH_HOME')}") print(f" 本地资源 : TORCH_HOME={os.environ.get('TORCH_HOME')}")
print(f" NLTK_DATA={os.environ.get('NLTK_DATA')}") print(f" NLTK_DATA={os.environ.get('NLTK_DATA')}")
print("=" * 64) 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__": if __name__ == "__main__":
+14 -1
View File
@@ -222,7 +222,20 @@
volumeLabel.textContent = volumeRange.value + '%'; volumeLabel.textContent = volumeRange.value + '%';
if (volTimer) clearTimeout(volTimer); if (volTimer) clearTimeout(volTimer);
volTimer = setTimeout(function () { 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); }, 300);
}); });
} }
+6 -1
View File
@@ -230,10 +230,15 @@
<input type="number" id="imageDuration" value="{{ image_duration }}" min="1" max="300"> <input type="number" id="imageDuration" value="{{ image_duration }}" min="1" max="300">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>音量</label> <label>音量(媒体)</label>
<input type="range" id="volumeRange" min="0" max="100" value="{{ volume }}"> <input type="range" id="volumeRange" min="0" max="100" value="{{ volume }}">
<div class="volume-label" id="volumeLabel">{{ volume }}%</div> <div class="volume-label" id="volumeLabel">{{ volume }}%</div>
</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>
</div> </div>
+76
View File
@@ -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
View File
@@ -7,7 +7,8 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri" "tauri": "tauri",
"build:win": "scripts\\build-win.cmd"
}, },
"dependencies": { "dependencies": {
"@appica/icons-react": "^1.0.0", "@appica/icons-react": "^1.0.0",
+40
View File
@@ -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
View File
@@ -1,5 +1,43 @@
// 说明:早期 Rust 内嵌后端(server/storage/models/eventsaxum :10801)已随架构迁移到 // 说明:早期 Rust 内嵌后端(server/storage/models/eventsaxum :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)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
@@ -9,6 +47,7 @@ pub fn run() {
tauri_plugin_autostart::MacosLauncher::LaunchAgent, tauri_plugin_autostart::MacosLauncher::LaunchAgent,
Some(vec![]), Some(vec![]),
)) ))
.invoke_handler(tauri::generate_handler![read_runtime_config])
.on_window_event(|window, event| { .on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event { if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close(); api.prevent_close();
+2
View File
@@ -1,6 +1,7 @@
import { useState, useRef, useEffect, useCallback } from 'react'; import { useState, useRef, useEffect, useCallback } from 'react';
import Icon from './Icons'; import Icon from './Icons';
import { getApiBase as API_BASE } from '../config'; import { getApiBase as API_BASE } from '../config';
import { Sfx } from '../utils/sounds';
import { getMqttStatus } from '../utils/mqtt'; import { getMqttStatus } from '../utils/mqtt';
import { renderMarkdown } from '../voice/markdown.jsx'; import { renderMarkdown } from '../voice/markdown.jsx';
@@ -133,6 +134,7 @@ export default function AiChatPanel() {
if (!reply) reply = pick(FALLBACK_REPLIES); if (!reply) reply = pick(FALLBACK_REPLIES);
setTyping(false); setTyping(false);
setMsgs((prev) => [...prev, { id: nextId(), role: 'ai', text: reply }]); setMsgs((prev) => [...prev, { id: nextId(), role: 'ai', text: reply }]);
Sfx.notify();
executeLocalTools(tools); executeLocalTools(tools);
resetIdleTimer(); // 有提问 → 重置 1 分钟空闲计时 resetIdleTimer(); // 有提问 → 重置 1 分钟空闲计时
}, [typing, resetIdleTimer]); }, [typing, resetIdleTimer]);
+3
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import Icon from './Icons'; import Icon from './Icons';
import { getApiBase as API_BASE } from '../config'; import { getApiBase as API_BASE } from '../config';
import { Sfx } from '../utils/sounds';
/* ========================================================= /* =========================================================
DpmOverlays —— 后端/AI 指令驱动的全局覆盖层 DpmOverlays —— 后端/AI 指令驱动的全局覆盖层
@@ -110,6 +111,7 @@ export default function DpmOverlays() {
const alertTimer = useRef(null); const alertTimer = useRef(null);
const showAlert = useCallback((params) => { const showAlert = useCallback((params) => {
Sfx.alert();
const item = { title: params.title || '提示', content: params.content || '', id: Date.now() + Math.random() }; const item = { title: params.title || '提示', content: params.content || '', id: Date.now() + Math.random() };
setAlert(item); setAlert(item);
clearTimeout(alertTimer.current); clearTimeout(alertTimer.current);
@@ -117,6 +119,7 @@ export default function DpmOverlays() {
}, []); }, []);
const showCard = useCallback((params) => { const showCard = useCallback((params) => {
Sfx.notify();
setCard({ card: params.card || 'custom', title: params.title || '', content: params.content || '', id: Date.now() + Math.random() }); setCard({ card: params.card || 'custom', title: params.title || '', content: params.content || '', id: Date.now() + Math.random() });
}, []); }, []);
+2
View File
@@ -1,6 +1,7 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import useVisionDetection from '../voice/useVisionDetection.js'; import useVisionDetection from '../voice/useVisionDetection.js';
import { Sfx } from '../utils/sounds';
/* ========================================================= /* =========================================================
全局手势识别(挂载于 ScreenLayout —— 所有大屏页面共享) 全局手势识别(挂载于 ScreenLayout —— 所有大屏页面共享)
@@ -18,6 +19,7 @@ export default function GlobalVision() {
const vision = useVisionDetection({ const vision = useVisionDetection({
onGesture: (g) => { onGesture: (g) => {
// 全部手势类型(raise/fist/both_up/point/hands_close/wave)统一分发 // 全部手势类型(raise/fist/both_up/point/hands_close/wave)统一分发
Sfx.success();
window.dispatchEvent(new CustomEvent('dpm:gesture', { detail: { type: g } })); window.dispatchEvent(new CustomEvent('dpm:gesture', { detail: { type: g } }));
}, },
}); });
+4 -3
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Icon from './Icons'; import Icon from './Icons';
import { onMediaUiState, mediaCommand } from '../utils/mediaControl'; import { onMediaUiState, mediaCommand } from '../utils/mediaControl';
import { Sfx } from '../utils/sounds';
/* ========================================================= /* =========================================================
页眉媒体控制区 —— 仅媒体轮播页显示 页眉媒体控制区 —— 仅媒体轮播页显示
@@ -26,13 +27,13 @@ export default function MediaHeaderControls() {
{/* 播放 / 暂停 */} {/* 播放 / 暂停 */}
<button <button
className={`bd-hd-media-btn ${st.paused ? 'primary' : ''}`} 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 ? '播放' : '暂停'} title={st.paused ? '播放' : '暂停'}
> >
<Icon name={st.paused ? 'play' : 'pause'} size={14} /> <Icon name={st.paused ? 'play' : 'pause'} size={14} />
</button> </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} /> <Icon name="chevron-left" size={13} />
</button> </button>
{/* 序号 */} {/* 序号 */}
@@ -40,7 +41,7 @@ export default function MediaHeaderControls() {
<b>{st.index + 1}</b>/<em>{st.total}</em> <b>{st.index + 1}</b>/<em>{st.total}</em>
</span> </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} /> <Icon name="chevron-right" size={13} />
</button> </button>
{/* 声音解锁 */} {/* 声音解锁 */}
+90 -2
View File
@@ -5,10 +5,13 @@ import MediaHeaderControls from './MediaHeaderControls';
import AboutDialog from './AboutDialog'; import AboutDialog from './AboutDialog';
import { PAGE_ORDER, usePageIndex } from '../utils/pageNav'; import { PAGE_ORDER, usePageIndex } from '../utils/pageNav';
import { Avatar, AvatarImage, AvatarFallback } from '@appica/ui-react/avatar' import { Avatar, AvatarImage, AvatarFallback } from '@appica/ui-react/avatar'
import { Sfx } from '../utils/sounds';
import { getApiBase as API_BASE } from '../config';
/* ========================================================= /* =========================================================
共享页眉 —— 所有大屏页面统一(由 ScreenLayout 注入) 共享页眉 —— 所有大屏页面统一(由 ScreenLayout 注入)
状态文案按当前页面自动识别;左右键循环 + 可点击按钮切换 状态文案按当前页面自动识别;左右键循环 + 可点击按钮切换
· 音量面板:媒体音量 + 音效音量(写回后端设置并 MQTT 广播同步)
========================================================= */ ========================================================= */
const STATUS_BY_PATH = { const STATUS_BY_PATH = {
@@ -25,9 +28,66 @@ export default function PageHeader() {
const [aboutOpen, setAboutOpen] = useState(false); const [aboutOpen, setAboutOpen] = useState(false);
const [gestureHit, setGestureHit] = useState(false); // 识别到手势 → 惊喜图标 3s const [gestureHit, setGestureHit] = useState(false); // 识别到手势 → 惊喜图标 3s
const [vision, setVision] = useState({ status: 'off', cameraOn: false, gesture: null }); // 视觉识别状态 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 pageIdx = usePageIndex();
const gestureTimer = useRef(null); 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/eventsMQTT 走 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(() => { useEffect(() => {
const iv = setInterval(() => setNow(new Date()), 1000); const iv = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(iv); return () => clearInterval(iv);
@@ -93,16 +153,44 @@ export default function PageHeader() {
<div className="bd-header-right"> <div className="bd-header-right">
{/* 媒体轮播页:页眉内嵌播放控制 */} {/* 媒体轮播页:页眉内嵌播放控制 */}
{location.pathname === '/screen' && <MediaHeaderControls />} {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="左右方向键或点击切换页面"> <div className="bd-nav-keys" title="左右方向键或点击切换页面">
{left && ( {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} /> <Icon name="chevron-left" size={13} />
{left.label} {left.label}
</button> </button>
)} )}
<span className="bd-nav-dot">·</span> <span className="bd-nav-dot">·</span>
{right && ( {right && (
<button className="bd-nav-key" onClick={() => navigate(right.path)}> <button className="bd-nav-key" onClick={() => { Sfx.page(); navigate(right.path); }}>
{right.label} {right.label}
<Icon name="chevron-right" size={13} /> <Icon name="chevron-right" size={13} />
</button> </button>
+33 -15
View File
@@ -2,27 +2,45 @@ import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App"; import App from "./App";
import { ThemeProvider } from "@appica/ui-react/providers/theme-provider"; 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"; import "./styles/global.css";
/* ========================================================= /* =========================================================
启动引导(打包部署关键): 启动引导(打包部署关键)—— 地址解析优先级
渲染前先从后端 GET /api/config 拉取运行时配置 1. exe 旁 config.jsonTauri 命令读取,部署免重打包改 IP)★
(MQTT 地址/账号、API 基址),写入 window.__DPM_*__ 2. 后端 GET /api/configMQTT 地址/账号,后端 .env 为准)
之后 api.js / mqtt.js 均以惰性读取方式生效。 3. 内置默认(Tauri 打包回退 127.0.0.1 / 浏览器用当前主机)
后端不可达时静默使用内置默认(Tauri 打包回退 127.0.0.1 结果写入 window.__DPM_*__api.js / mqtt.js 惰性读取生效
========================================================= */ ========================================================= */
async function bootstrapRuntimeConfig() { async function bootstrapRuntimeConfig() {
// 1) exe 旁 config.json(最高优先):{api_base, mqtt_url, mqtt_username, mqtt_password}
try { try {
const res = await fetch(`${API_BASE()}/api/config`, { cache: 'no-store' }); const raw = await invoke("read_runtime_config");
if (!res.ok) return; if (raw) {
const cfg = await res.json(); const cfg = JSON.parse(raw);
// 仅覆盖 MQTT 连接信息(后端 .env 为准);API 基址以前端实际可达地址为准 if (cfg.api_base) window.__DPM_API__ = cfg.api_base;
if (cfg.mqtt_url) window.__DPM_MQTT__ = cfg.mqtt_url; if (cfg.mqtt_url) window.__DPM_MQTT__ = cfg.mqtt_url;
if (cfg.mqtt_username) window.__DPM_MQTT_USER__ = cfg.mqtt_username; if (cfg.mqtt_username) window.__DPM_MQTT_USER__ = cfg.mqtt_username;
if (cfg.mqtt_password) window.__DPM_MQTT_PASS__ = cfg.mqtt_password; if (cfg.mqtt_password) window.__DPM_MQTT_PASS__ = cfg.mqtt_password;
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
if (cfg.mqtt_url) console.info(`[bootstrap] MQTT -> ${cfg.mqtt_url}`); if (cfg.mqtt_url) console.info(`[bootstrap] config.json MQTT -> ${cfg.mqtt_url}`);
}
} catch {
/* 浏览器开发环境或未注册命令:跳过 */
}
// 2) 后端 /api/configMQTT 地址/账号;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 { } catch {
/* 后端不可达:使用内置默认地址 */ /* 后端不可达:使用内置默认地址 */
} }
+4
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import * as api from '../utils/api'; import * as api from '../utils/api';
import { getApiBase } from '../config'; import { getApiBase } from '../config';
import { Sfx } from '../utils/sounds';
import { API_BASE } from '../utils/api'; import { API_BASE } from '../utils/api';
import { getCurrentWindow } from '@tauri-apps/api/window'; import { getCurrentWindow } from '@tauri-apps/api/window';
import { enable, disable } from '@tauri-apps/plugin-autostart'; import { enable, disable } from '@tauri-apps/plugin-autostart';
@@ -323,6 +324,9 @@ export default function MediaScreen() {
}, [reloadPlaylist]); }, [reloadPlaylist]);
const applySettings = useCallback((msg) => { const applySettings = useCallback((msg) => {
if (msg.sfx_volume !== undefined) {
Sfx.setVolume(msg.sfx_volume);
}
if (msg.volume !== undefined) { if (msg.volume !== undefined) {
const vol = msg.volume / 100; const vol = msg.volume / 100;
setVolume(vol); setVolume(vol);
+88
View File
@@ -2354,6 +2354,94 @@
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
-webkit-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 { .bd-nav-key {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+3
View File
@@ -1,5 +1,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { Sfx } from './sounds';
/* ========================================================= /* =========================================================
页面循环导航 —— 左右方向键切换大屏页面 页面循环导航 —— 左右方向键切换大屏页面
@@ -34,8 +35,10 @@ export function usePageNav() {
if (idx === -1) return; if (idx === -1) return;
const n = PAGE_ORDER.length; const n = PAGE_ORDER.length;
if (e.key === 'ArrowRight') { if (e.key === 'ArrowRight') {
Sfx.page();
navigate(PAGE_ORDER[(idx + 1) % n].path); navigate(PAGE_ORDER[(idx + 1) % n].path);
} else if (e.key === 'ArrowLeft') { } else if (e.key === 'ArrowLeft') {
Sfx.page();
navigate(PAGE_ORDER[(idx - 1 + n) % n].path); navigate(PAGE_ORDER[(idx - 1 + n) % n].path);
} }
}; };
+114
View File
@@ -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;
+3 -1
View File
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { onMqttMessage, connectMqtt, publishMqtt, getClientId, useMqttStatus } from './mqtt'; import { onMqttMessage, connectMqtt, publishMqtt, getClientId, useMqttStatus } from './mqtt';
import { MQTT_TOPIC_HEARTBEAT } from '../config'; import { MQTT_TOPIC_HEARTBEAT } from '../config';
import { Sfx } from './sounds';
import { PAGE_ORDER } from './pageNav'; import { PAGE_ORDER } from './pageNav';
/* ========================================================= /* =========================================================
@@ -71,7 +72,7 @@ export function useMqttControl() {
switch (cmd.action) { switch (cmd.action) {
case 'navigate': { case 'navigate': {
const path = resolvePage(cmd.params?.page); const path = resolvePage(cmd.params?.page);
if (path) navigate(path); if (path) { Sfx.page(); navigate(path); }
break; break;
} }
case 'navigate_rel': { case 'navigate_rel': {
@@ -93,6 +94,7 @@ export function useMqttControl() {
case 'pause': case 'pause':
case 'next': case 'next':
case 'prev': case 'prev':
Sfx.tick();
case 'set_mode': case 'set_mode':
case 'play_target': case 'play_target':
case 'minimize': case 'minimize':