Files
DPM/backend/static/admin.js
T
Pine d70d9d5fbd 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.
2026-08-18 07:14:13 +08:00

361 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =========================================================
Admin 管理后台前端逻辑(Jinja 页面配套)
事件采用 document 委托绑定,保证按钮点击稳定响应;
连接状态每 3s 轮询 /api/display/state 刷新
========================================================= */
(function () {
'use strict';
var init = window.__DPM_INIT || { fullscreen: true, autostart: false };
var fullscreen = !!init.fullscreen;
var autostart = !!init.autostart;
/* ---------- Toast ---------- */
var toastContainer = document.getElementById('toastContainer');
function toast(msg, type) {
if (!toastContainer) return;
var el = document.createElement('div');
el.className = 'toast ' + (type || 'success');
el.textContent = msg;
toastContainer.appendChild(el);
setTimeout(function () { el.remove(); }, 2800);
}
/* ---------- 通用请求 ---------- */
function post(url, body, cb) {
fetch(url, {
method: 'POST',
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
})
.then(function (r) { return r.json().catch(function () { return {}; }); })
.then(function (d) {
if (d && d.ok === false) { toast(d.error || '操作失败', 'error'); return; }
if (cb) cb(d);
})
.catch(function () { toast('网络错误', 'error'); });
}
function reload(msg, delay) {
if (msg) toast(msg);
setTimeout(function () { location.reload(); }, delay || 600);
}
/* ---------- 播放状态(SSE,仅更新状态文字,不自动重载页面) ---------- */
var statusText = document.getElementById('statusText');
try {
var es = new EventSource('/api/events');
es.onmessage = function (e) {
var msg; try { msg = JSON.parse(e.data); } catch (err) { return; }
if (msg.action === 'state_update' && msg.state && statusText) {
var s = msg.state;
if (!s.status || s.status === 'idle' || !s.name) statusText.textContent = '等待大屏连接...';
else if (s.status === 'playing') statusText.textContent = '正在播放 — ' + s.name;
else if (s.status === 'paused') statusText.textContent = '已暂停 — ' + s.name;
}
};
} catch (err) { /* ignore */ }
/* =========================================================
连接状态轮询(/api/display/state
========================================================= */
var connEls = {
api: document.getElementById('connApi'),
mqtt: document.getElementById('connMqtt'),
screens: document.getElementById('connScreens'),
lastCmd: document.getElementById('connLastCmd'),
};
function setConn(id, ok, value) {
var el = connEls[id];
if (!el) return;
var dot = el.querySelector('.conn-dot');
var val = el.querySelector('.conn-value');
if (dot) dot.className = 'conn-dot ' + (ok ? 'on' : 'off');
if (val) val.textContent = value;
}
function pollStatus() {
fetch('/api/display/state', { cache: 'no-store' })
.then(function (r) { return r.json(); })
.then(function (d) {
setConn('api', true, '在线');
setConn('mqtt', !!d.mqtt_connected, d.mqtt_connected ? '已连接(' + (d.mqtt_host || '') + '' : '未连接');
setConn('screens', (d.screens_online || 0) > 0, (d.screens_online || 0) + ' 台');
var lc = d.last_command;
if (lc) {
var names = { navigate: '页面切换', play: '播放', pause: '暂停', next: '下一项', prev: '上一项',
'play_target': '指定播放', alert: '通知', show_card: '卡片', set_mode: '播放模式' };
var when = new Date(lc.ts).toLocaleTimeString('zh-CN', { hour12: false });
setConn('lastCmd', !!lc.published,
(names[lc.action] || lc.action) + ' · ' + (lc.published ? '已广播' : '未广播') + ' · ' + when);
}
})
.catch(function () {
setConn('api', false, '离线');
setConn('mqtt', false, '未知');
setConn('screens', false, '未知');
});
}
if (connEls.api) { pollStatus(); setInterval(pollStatus, 3000); }
/* =========================================================
事件委托:页面切换 / 播放控制 / 媒体操作
========================================================= */
// 输入框回车提交
document.addEventListener('keydown', function (e) {
if (e.key !== 'Enter') return;
if (e.target && e.target.id === 'mqttAiInput') { document.getElementById('mqttAiSend').click(); }
else if (e.target && e.target.id === 'mqttAlertText') { document.getElementById('mqttAlertSend').click(); }
});
document.addEventListener('click', function (e) {
var btn = e.target.closest ? e.target.closest('[data-nav], [data-mqtt], [data-control], [data-action], .copy-btn, [data-preview], #mqttAiSend, #mqttAlertSend') : null;
if (!btn) return;
e.stopPropagation();
// 预览
if (btn.hasAttribute('data-preview')) { openPreview(btn.closest('.media-card')); return; }
// 复制 URL
if (btn.classList.contains('copy-btn')) {
var url = btn.closest('.media-url').getAttribute('data-url') || '';
var full = /^https?:\/\//.test(url) ? url : window.location.origin + url;
var done = function () { toast('链接已复制'); };
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(full).then(done).catch(function () { fallbackCopy(full); done(); });
} else { fallbackCopy(full); done(); }
return;
}
// 大屏 MQTT 指令(data-mqtt 携带完整 {action, params} JSON
if (btn.hasAttribute('data-mqtt')) {
var cmd = null;
try { cmd = JSON.parse(btn.getAttribute('data-mqtt')); } catch (err) { toast('指令 JSON 错误', 'error'); return; }
post('/api/display/command', cmd, function (d) {
toast(d && d.ok !== false ? '指令已发送:' + (cmd.action || '') : '发送失败(MQTT 未连接?)',
d && d.ok !== false ? 'ok' : 'error');
});
return;
}
// AI 提问输入框
if (btn.id === 'mqttAiSend') {
var text = (document.getElementById('mqttAiInput').value || '').trim();
if (!text) { toast('请输入问题', 'error'); return; }
post('/api/display/command', { action: 'ai_input', params: { text: text } }, function () {
toast('已发送问题到大屏 AI 页');
});
return;
}
// 通知输入框
if (btn.id === 'mqttAlertSend') {
var alertText = (document.getElementById('mqttAlertText').value || '').trim();
if (!alertText) { toast('请输入通知内容', 'error'); return; }
post('/api/display/command', { action: 'alert', params: { text: alertText } }, function () {
toast('通知已广播');
});
return;
}
// 页面切换
if (btn.hasAttribute('data-nav')) {
var page = btn.getAttribute('data-nav');
post('/api/display/command', { action: 'navigate', params: { page: page } }, function () {
toast('已切换大屏至「' + btn.textContent.trim() + '」');
});
return;
}
// 播放控制
if (btn.hasAttribute('data-control')) {
var action = btn.getAttribute('data-control');
if (action === 'minimize') post('/api/display/command', { action: 'minimize', params: {} });
else post('/api/control', { action: action });
return;
}
// 媒体操作
if (btn.hasAttribute('data-action')) {
var card = btn.closest('.media-card');
var path = card.getAttribute('data-path');
var act = btn.getAttribute('data-action');
if (act === 'play-now') {
post('/api/playlist/play', { path: path }, function () { toast('已发送播放指令,大屏即将播放'); });
} else if (act === 'add-playlist') {
post('/api/playlist/add', { path: path }, function () { reload('已加入播放列表'); });
} else if (act === 'remove-playlist') {
post('/api/playlist/remove', { path: path }, function () { reload('已移出播放列表'); });
} else if (act === 'delete') {
if (!window.confirm('确定删除该文件?')) return;
post('/api/delete', { path: path }, function () { reload('已删除'); });
}
}
});
function fallbackCopy(text) {
var ta = document.createElement('textarea');
ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); } catch (err) { /* ignore */ }
document.body.removeChild(ta);
}
/* =========================================================
播放设置(change/input 事件)
========================================================= */
var playMode = document.getElementById('playMode');
if (playMode) playMode.addEventListener('change', function () {
post('/api/settings', { play_mode: playMode.value }, function () { toast('播放模式已更新'); });
});
var imageDuration = document.getElementById('imageDuration');
if (imageDuration) imageDuration.addEventListener('change', function () {
var v = Number(imageDuration.value);
if (v < 1 || v > 300) { toast('时长需在 1-300 秒', 'error'); imageDuration.value = 5; return; }
post('/api/settings', { image_duration: v }, function () { toast('图片时长已更新'); });
});
var volumeRange = document.getElementById('volumeRange');
var volumeLabel = document.getElementById('volumeLabel');
var volTimer = null;
if (volumeRange && volumeLabel) {
volumeRange.addEventListener('input', function () {
volumeLabel.textContent = volumeRange.value + '%';
if (volTimer) clearTimeout(volTimer);
volTimer = setTimeout(function () {
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);
});
}
/* ---------- 显示控制 ---------- */
var btnFullscreen = document.getElementById('btnFullscreen');
var btnAutostart = document.getElementById('btnAutostart');
function renderDisplay() {
if (btnFullscreen) btnFullscreen.textContent = fullscreen ? '切换窗口模式' : '切换大屏模式';
if (btnAutostart) { btnAutostart.textContent = autostart ? '已开启' : '已关闭'; btnAutostart.classList.toggle('active', autostart); }
}
if (btnFullscreen) btnFullscreen.addEventListener('click', function () {
fullscreen = !fullscreen; renderDisplay();
post('/api/settings', { fullscreen: fullscreen });
});
if (btnAutostart) btnAutostart.addEventListener('click', function () {
autostart = !autostart; renderDisplay();
post('/api/settings', { autostart: autostart });
});
renderDisplay();
/* ---------- 上传(带进度) ---------- */
var btnUpload = document.getElementById('btnUpload');
if (btnUpload) btnUpload.addEventListener('click', function () {
var inp = document.getElementById('fileInput');
var fileList = Array.from(inp.files || []);
if (fileList.length === 0) { toast('请选择文件', 'error'); return; }
var prog = document.getElementById('uploadProgress');
var fill = document.getElementById('progressFill');
var text = document.getElementById('progressText');
prog.style.display = 'block';
var done = 0;
fileList.forEach(function (file) {
var fd = new FormData();
fd.append('file', file);
var xhr = new XMLHttpRequest();
xhr.open('POST', '/upload');
xhr.upload.onprogress = function (e) {
if (e.lengthComputable) {
var pct = Math.round((e.loaded / e.total) * 100);
var overall = Math.round(((done + e.loaded / e.total) / fileList.length) * 100);
fill.style.width = overall + '%';
text.textContent = file.name + ' (' + (done + 1) + '/' + fileList.length + ') — ' + pct + '%';
}
};
xhr.onload = function () {
done += 1;
if (done === fileList.length) reload('上传完成');
};
xhr.onerror = function () { toast(file.name + ' 上传失败', 'error'); done += 1; };
xhr.send(fd);
});
});
/* ---------- 远程 URL ---------- */
var btnAddUrl = document.getElementById('btnAddUrl');
var btnAddUrlPlay = document.getElementById('btnAddUrlPlay');
function addUrlThen(play) {
var inp = document.getElementById('urlInput');
var url = (inp.value || '').trim();
if (!url) { toast('请输入 URL', 'error'); return; }
post('/api/media/add-url', { url: url }, function (d) {
if (play) {
post('/api/playlist/play', { path: url }, function () { toast('已加入并发送播放指令'); });
} else {
reload(d && d.duplicate ? 'URL 已存在于媒体库' : '已添加到媒体库');
}
});
}
if (btnAddUrl) btnAddUrl.addEventListener('click', function () { addUrlThen(false); });
if (btnAddUrlPlay) btnAddUrlPlay.addEventListener('click', function () { addUrlThen(true); });
/* =========================================================
预览弹窗
========================================================= */
var modal = document.getElementById('previewModal');
var modalBody = document.getElementById('previewBody');
var current = null;
function openPreview(card) {
if (!card || !modal) return;
var path = card.getAttribute('data-path');
var type = card.getAttribute('data-type');
var source = card.getAttribute('data-source');
var name = card.getAttribute('data-name');
var url = source === 'url' ? path : '/file/' + encodeURI(path);
current = { path: path, url: url, type: type, name: name };
document.getElementById('previewTitle').textContent = name;
modalBody.innerHTML = type === 'image'
? '<img src="' + url + '" alt="' + name + '">'
: '<video src="' + url + '" controls autoplay playsinline></video>';
document.getElementById('previewMeta').innerHTML =
'<span class="badge ' + (source === 'url' ? 'badge-url' : 'badge-local') + '">' + (source === 'url' ? '远程' : '本地') + '</span>' +
'<span class="badge ' + (type === 'video' ? 'badge-video' : 'badge-image') + '">' + (type === 'video' ? '视频' : '图片') + '</span>';
modal.style.display = 'flex';
}
function closePreview() {
var v = modalBody.querySelector('video');
if (v) { v.pause(); v.removeAttribute('src'); v.load(); }
modalBody.innerHTML = '';
modal.style.display = 'none';
current = null;
}
document.getElementById('previewClose').addEventListener('click', closePreview);
if (modal) modal.addEventListener('click', function (e) {
if (e.target === modal) closePreview();
});
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closePreview(); });
document.getElementById('previewAdd').addEventListener('click', function () {
if (!current) return;
post('/api/playlist/add', { path: current.path }, function () { closePreview(); reload('已加入播放列表'); });
});
document.getElementById('previewPlay').addEventListener('click', function () {
if (!current) return;
post('/api/playlist/play', { path: current.path }, function () { closePreview(); toast('已发送播放指令'); });
});
document.getElementById('previewDelete').addEventListener('click', function () {
if (!current) return;
if (!window.confirm('确定删除该文件?')) return;
post('/api/delete', { path: current.path }, function () { closePreview(); reload('已删除'); });
});
})();