feat: 初始化屏幕播放控制系统
搭建基于 Flask 的多屏幕定时播放控制后端,提供设备管理控制台、播放端页面、媒体上传与配置文件持久化,并补充项目文档、示例配置及 MIT 许可证。
这个提交包含在:
+337
@@ -0,0 +1,337 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>屏幕播放控制系统 - 控制台</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
|
||||
background: #f5f6fa; color: #2f3640; line-height: 1.5;
|
||||
}
|
||||
header {
|
||||
background: #273c75; color: #fff;
|
||||
padding: 16px 24px; display: flex; align-items: center; gap: 16px;
|
||||
}
|
||||
header h1 { font-size: 20px; }
|
||||
.layout { display: flex; height: calc(100vh - 64px); }
|
||||
.sidebar {
|
||||
width: 240px; background: #fff; border-right: 1px solid #e1e2e6;
|
||||
padding: 16px; overflow-y: auto;
|
||||
}
|
||||
.main { flex: 1; padding: 24px; overflow-y: auto; }
|
||||
.device-item {
|
||||
padding: 10px 12px; border-radius: 8px; cursor: pointer;
|
||||
margin-bottom: 8px; border: 1px solid transparent;
|
||||
}
|
||||
.device-item:hover { background: #f0f1f5; }
|
||||
.device-item.active { background: #e8f0fe; border-color: #273c75; }
|
||||
.btn {
|
||||
border: none; border-radius: 6px; padding: 8px 14px;
|
||||
cursor: pointer; font-size: 14px; background: #273c75; color: #fff;
|
||||
}
|
||||
.btn:hover { opacity: 0.9; }
|
||||
.btn.secondary { background: #7f8fa6; }
|
||||
.btn.danger { background: #c23616; }
|
||||
.btn.small { padding: 4px 10px; font-size: 12px; }
|
||||
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.card { background: #fff; border-radius: 10px; padding: 18px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 8px; border-bottom: 1px solid #eee; font-size: 14px; }
|
||||
input, select { padding: 6px 8px; border: 1px solid #ccc; border-radius: 6px; font-size: 14px; }
|
||||
.empty { color: #999; padding: 24px; text-align: center; }
|
||||
.player-link { font-size: 12px; color: #273c75; text-decoration: none; }
|
||||
#status { margin-top: 12px; font-size: 14px; color: #273c75; }
|
||||
.topbar { display:flex; justify-content: space-between; align-items: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>屏幕播放控制系统</h1>
|
||||
<span style="font-size:13px;opacity:.8;">展厅多屏定时播放控制台</span>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<!-- 左侧设备列表 -->
|
||||
<div class="sidebar">
|
||||
<div class="topbar" style="margin-bottom:12px;">
|
||||
<strong>设备</strong>
|
||||
<button class="btn small" onclick="addDevice()">+ 新增</button>
|
||||
</div>
|
||||
<div id="device-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧主区域 -->
|
||||
<div class="main">
|
||||
<div id="detail" class="empty">请选择左侧设备,或点击「新增」创建设备。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status"></div>
|
||||
|
||||
<script>
|
||||
let config = { devices: [] };
|
||||
let activeId = null;
|
||||
|
||||
// ---------- 数据加载 ----------
|
||||
async function fetchConfig() {
|
||||
const resp = await fetch("/get_config");
|
||||
config = await resp.json();
|
||||
if (!config.devices) config.devices = [];
|
||||
renderDeviceList();
|
||||
if (activeId && !findDevice(activeId)) activeId = null;
|
||||
if (!activeId && config.devices.length) activeId = config.devices[0].id;
|
||||
renderDetail();
|
||||
}
|
||||
|
||||
function findDevice(id) {
|
||||
return config.devices.find(d => d.id === id);
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
const resp = await fetch("/save_config", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
const r = await resp.json();
|
||||
setStatus(r.status === "success" ? "配置已保存" : ("保存失败:" + r.message));
|
||||
if (r.status === "success") fetchConfig();
|
||||
}
|
||||
|
||||
function setStatus(msg) {
|
||||
const el = document.getElementById("status");
|
||||
el.innerText = msg + " @" + new Date().toLocaleTimeString();
|
||||
}
|
||||
|
||||
// ---------- 设备列表 ----------
|
||||
function renderDeviceList() {
|
||||
const box = document.getElementById("device-list");
|
||||
box.innerHTML = "";
|
||||
if (!config.devices.length) {
|
||||
box.innerHTML = '<div class="empty">暂无设备</div>';
|
||||
return;
|
||||
}
|
||||
config.devices.forEach(d => {
|
||||
const div = document.createElement("div");
|
||||
div.className = "device-item" + (d.id === activeId ? " active" : "");
|
||||
div.onclick = () => { activeId = d.id; renderDeviceList(); renderDetail(); };
|
||||
div.innerHTML = `<div><strong>${escapeHtml(d.name || d.id)}</strong></div>
|
||||
<div style="font-size:12px;color:#888;">${d.id} · ${(d.playlist||[]).length} 条</div>`;
|
||||
box.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function addDevice() {
|
||||
const id = "d" + (config.devices.length + 1);
|
||||
config.devices.push({ id, name: "新设备", playlist: [] });
|
||||
activeId = id;
|
||||
renderDeviceList();
|
||||
renderDetail();
|
||||
saveConfig();
|
||||
}
|
||||
|
||||
function removeDevice() {
|
||||
if (!activeId) return;
|
||||
if (!confirm("确认删除该设备及其播放列表?")) return;
|
||||
config.devices = config.devices.filter(d => d.id !== activeId);
|
||||
activeId = config.devices.length ? config.devices[0].id : null;
|
||||
renderDeviceList();
|
||||
renderDetail();
|
||||
saveConfig();
|
||||
}
|
||||
|
||||
// ---------- 详情 / 播放列表 ----------
|
||||
async function renderDetail() {
|
||||
const box = document.getElementById("detail");
|
||||
const device = findDevice(activeId);
|
||||
if (!device) { box.className = "empty"; box.innerText = "请选择设备"; return; }
|
||||
box.className = "";
|
||||
|
||||
const playerUrl = `${location.origin}/player?device_id=${device.id}`;
|
||||
let html = `
|
||||
<div class="card">
|
||||
<div class="row" style="justify-content:space-between;margin-bottom:12px;">
|
||||
<div class="row">
|
||||
<label>设备名称: <input id="dev-name" value="${escapeHtml(device.name||'')}" style="width:160px;"></label>
|
||||
<label>设备ID: <input id="dev-id" value="${escapeHtml(device.id)}" style="width:120px;"></label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<a class="player-link" href="/player?device_id=${device.id}" target="_blank">打开播放端 ↗</a>
|
||||
<button class="btn small secondary" onclick="copyPlayerUrl('${playerUrl}')">复制播放链接</button>
|
||||
<button class="btn small danger" onclick="removeDevice()">删除设备</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="btn small" onclick="saveDeviceMeta()">保存设备信息</button>
|
||||
<span style="font-size:12px;color:#888;">播放端地址:${playerUrl}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="topbar" style="margin-bottom:12px;">
|
||||
<strong>播放列表(按开始时间升序)</strong>
|
||||
<button class="btn small" onclick="addItem()">+ 添加条目</button>
|
||||
</div>
|
||||
<div id="playlist-box"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="topbar" style="margin-bottom:12px;">
|
||||
<strong>媒体库</strong>
|
||||
<label class="btn small">
|
||||
上传媒体
|
||||
<input id="file-input" type="file" accept="image/*,video/*" style="display:none;" onchange="uploadMedia()">
|
||||
</label>
|
||||
</div>
|
||||
<div id="file-gallery"></div>
|
||||
</div>
|
||||
`;
|
||||
box.innerHTML = html;
|
||||
renderPlaylist(device);
|
||||
fetchFiles();
|
||||
}
|
||||
|
||||
function renderPlaylist(device) {
|
||||
const box = document.getElementById("playlist-box");
|
||||
if (!device.playlist || !device.playlist.length) {
|
||||
box.innerHTML = '<div class="empty">暂无播放条目</div>';
|
||||
return;
|
||||
}
|
||||
let rows = device.playlist.map((it, i) => `
|
||||
<tr>
|
||||
<td>${i + 1}</td>
|
||||
<td>
|
||||
<select onchange="updateItem(${i},'type',this.value)">
|
||||
<option value="image" ${it.type==='image'?'selected':''}>图片</option>
|
||||
<option value="video" ${it.type==='video'?'selected':''}>视频</option>
|
||||
</select>
|
||||
</td>
|
||||
<td><input style="width:260px;" value="${escapeHtml(it.path||'')}" onchange="updateItem(${i},'path',this.value)"></td>
|
||||
<td><input type="datetime-local" value="${toLocalInput(it.start_time)}" onchange="updateItem(${i},'start_time',this.value)"></td>
|
||||
<td><input style="width:70px;" type="number" min="1" value="${it.duration||10}" onchange="updateItem(${i},'duration',this.value)" title="图片展示秒数"></td>
|
||||
<td>
|
||||
<button class="btn small danger" onclick="removeItem(${i})">删除</button>
|
||||
</td>
|
||||
</tr>`).join("");
|
||||
box.innerHTML = `
|
||||
<table>
|
||||
<thead><tr><th>#</th><th>类型</th><th>路径</th><th>开始时间</th><th>时长(秒)</th><th>操作</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
<div class="row" style="margin-top:12px;">
|
||||
<button class="btn small" onclick="saveDeviceMeta()">保存播放列表</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function saveDeviceMeta() {
|
||||
const device = findDevice(activeId);
|
||||
if (!device) return;
|
||||
device.name = document.getElementById("dev-name").value;
|
||||
const newId = document.getElementById("dev-id").value.trim();
|
||||
if (newId && newId !== device.id) {
|
||||
if (config.devices.some(d => d.id === newId)) {
|
||||
alert("该设备ID已存在"); return;
|
||||
}
|
||||
device.id = newId; activeId = newId;
|
||||
}
|
||||
saveConfig();
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
const device = findDevice(activeId);
|
||||
if (!device) return;
|
||||
device.playlist = device.playlist || [];
|
||||
device.playlist.push({ type: "image", path: "/static/", start_time: toLocalInput(new Date().toISOString()), duration: 10 });
|
||||
renderDetail();
|
||||
}
|
||||
|
||||
function removeItem(i) {
|
||||
const device = findDevice(activeId);
|
||||
device.playlist.splice(i, 1);
|
||||
renderDetail();
|
||||
}
|
||||
|
||||
function updateItem(i, key, value) {
|
||||
const device = findDevice(activeId);
|
||||
if (key === "duration") value = parseInt(value, 10) || 10;
|
||||
device.playlist[i][key] = value;
|
||||
}
|
||||
|
||||
// ---------- 工具 ----------
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? "" : s).replace(/[&<>"']/g, c => (
|
||||
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]
|
||||
));
|
||||
}
|
||||
|
||||
function toLocalInput(iso) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d)) return "";
|
||||
const pad = n => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function copyPlayerUrl(url) {
|
||||
navigator.clipboard.writeText(url).then(() => setStatus("播放链接已复制"));
|
||||
}
|
||||
|
||||
// ---------- 媒体文件上传 ----------
|
||||
async function uploadMedia() {
|
||||
const input = document.getElementById("file-input");
|
||||
if (!input.files.length) return;
|
||||
const fd = new FormData();
|
||||
fd.append("file", input.files[0]);
|
||||
const resp = await fetch("/upload", { method: "POST", body: fd });
|
||||
const r = await resp.json();
|
||||
if (r.status === "success") {
|
||||
setStatus("上传成功:" + r.filename);
|
||||
// 若播放列表正在编辑,自动填入路径
|
||||
const device = findDevice(activeId);
|
||||
if (device) {
|
||||
device.playlist = device.playlist || [];
|
||||
device.playlist.push({ type: r.type, path: r.path, start_time: toLocalInput(new Date().toISOString()), duration: 10 });
|
||||
}
|
||||
renderDetail();
|
||||
fetchFiles();
|
||||
} else {
|
||||
alert("上传失败:" + r.message);
|
||||
}
|
||||
input.value = "";
|
||||
}
|
||||
|
||||
async function fetchFiles() {
|
||||
const resp = await fetch("/files");
|
||||
const data = await resp.json();
|
||||
const box = document.getElementById("file-gallery");
|
||||
if (!box) return;
|
||||
if (!data.files.length) { box.innerHTML = '<div class="empty">暂无媒体文件,请上传</div>'; return; }
|
||||
box.innerHTML = data.files.map(f => `
|
||||
<div style="display:inline-block;margin:6px;text-align:center;">
|
||||
<div style="width:120px;height:80px;background:#000;display:flex;align-items:center;justify-content:center;overflow:hidden;border-radius:6px;">
|
||||
${f.type==='image'
|
||||
? `<img src="${f.path}" style="max-width:100%;max-height:100%;">`
|
||||
: `<span style="color:#fff;font-size:12px;">🎬 视频</span>`}
|
||||
</div>
|
||||
<div style="font-size:11px;width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${f.name}</div>
|
||||
<button class="btn small" onclick="useFile('${f.path}','${f.type}')">使用</button>
|
||||
</div>`).join("");
|
||||
}
|
||||
|
||||
function useFile(path, type) {
|
||||
const device = findDevice(activeId);
|
||||
if (!device) { alert("请先选择设备"); return; }
|
||||
device.playlist = device.playlist || [];
|
||||
device.playlist.push({ type, path, start_time: toLocalInput(new Date().toISOString()), duration: 10 });
|
||||
renderDetail();
|
||||
setStatus("已添加:" + path);
|
||||
}
|
||||
|
||||
// 初始化
|
||||
fetchConfig();
|
||||
setInterval(fetchFiles, 10000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,171 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>定时播放器</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
width: 100%; height: 100%;
|
||||
background: #000; color: #fff;
|
||||
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
#player-container {
|
||||
width: 100vw; height: 100vh;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
#content {
|
||||
width: 100%; height: 100%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
#content img, #content video {
|
||||
max-width: 100%; max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
#current-status {
|
||||
position: absolute; bottom: 16px; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 14px; color: rgba(255, 255, 255, 0.7);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
padding: 6px 14px; border-radius: 20px;
|
||||
white-space: nowrap; pointer-events: none;
|
||||
}
|
||||
#device-id {
|
||||
position: absolute; top: 16px; left: 16px;
|
||||
font-size: 14px; color: rgba(255, 255, 255, 0.5);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
padding: 4px 10px; border-radius: 6px;
|
||||
}
|
||||
/* 时钟(用于校准本地时间) */
|
||||
#clock {
|
||||
position: absolute; top: 16px; right: 16px;
|
||||
font-size: 14px; color: rgba(255, 255, 255, 0.5);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
padding: 4px 10px; border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="device-id"></div>
|
||||
<div id="clock"></div>
|
||||
<div id="player-container">
|
||||
<div id="content"></div>
|
||||
<p id="current-status">正在初始化...</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const url = new URL(window.location.href);
|
||||
const params = new URLSearchParams(url.search);
|
||||
// device_id 支持 ?device_id=xxx 或 /player/xxx
|
||||
const deviceId = params.get("device_id") || (url.pathname.split("/").pop() !== "player" ? url.pathname.split("/").pop() : null);
|
||||
|
||||
let playlist = [];
|
||||
let timer = null; // 待播放的 setTimeout 句柄
|
||||
let pollTimer = null; // 轮询句柄
|
||||
let currentItem = null;
|
||||
|
||||
if (deviceId) {
|
||||
document.getElementById("device-id").innerText = `设备: ${deviceId}`;
|
||||
} else {
|
||||
document.getElementById("device-id").innerText = "设备: 未指定";
|
||||
document.getElementById("current-status").innerText = "请在 URL 中指定 device_id,例如 /player?device_id=d1";
|
||||
}
|
||||
|
||||
// 实时时钟
|
||||
function tickClock() {
|
||||
const now = new Date();
|
||||
document.getElementById("clock").innerText = now.toLocaleString();
|
||||
}
|
||||
setInterval(tickClock, 1000);
|
||||
tickClock();
|
||||
|
||||
// 从服务端获取播放列表
|
||||
async function fetchPlaylist() {
|
||||
if (!deviceId) return;
|
||||
try {
|
||||
const resp = await fetch(`/get_playlist/${deviceId}`);
|
||||
if (!resp.ok) {
|
||||
document.getElementById("current-status").innerText = "获取配置失败:设备不存在";
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const newPlaylist = (data.playlist || []).map(item => ({
|
||||
...item,
|
||||
start_time: new Date(item.start_time),
|
||||
}));
|
||||
// 仅在内容变化时重新调度,避免打断正在播放的视频
|
||||
if (JSON.stringify(newPlaylist) !== JSON.stringify(playlist)) {
|
||||
playlist = newPlaylist;
|
||||
if (!currentItem) scheduleNext();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("获取播放列表失败:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// 调度下一条应播放的内容
|
||||
function scheduleNext() {
|
||||
const now = new Date();
|
||||
const upcoming = playlist.find(item => item.start_time > now);
|
||||
if (upcoming) {
|
||||
const wait = Math.max(0, upcoming.start_time - now);
|
||||
document.getElementById("current-status").innerText =
|
||||
`即将播放: ${upcoming.path} @ ${upcoming.start_time.toLocaleString()}`;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => playContent(upcoming), wait);
|
||||
} else {
|
||||
document.getElementById("current-status").innerText = "当前没有待播放内容";
|
||||
}
|
||||
}
|
||||
|
||||
// 播放指定内容
|
||||
function playContent(item) {
|
||||
currentItem = item;
|
||||
const box = document.getElementById("content");
|
||||
box.innerHTML = "";
|
||||
|
||||
if (item.type === "video") {
|
||||
const video = document.createElement("video");
|
||||
video.src = item.path;
|
||||
video.controls = false; // 展厅全屏场景无需控件
|
||||
video.autoplay = true;
|
||||
video.muted = false;
|
||||
video.playsInline = true;
|
||||
video.addEventListener("ended", onMediaEnded);
|
||||
box.appendChild(video);
|
||||
video.play().catch(e => console.warn("自动播放被阻止:", e));
|
||||
} else if (item.type === "image") {
|
||||
const img = document.createElement("img");
|
||||
img.src = item.path;
|
||||
img.addEventListener("load", () => {
|
||||
// 图片默认展示 10 秒后切换(可通过 duration 字段自定义秒数)
|
||||
const dur = (item.duration || 10) * 1000;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(onMediaEnded, dur);
|
||||
});
|
||||
box.appendChild(img);
|
||||
} else {
|
||||
// 未知类型直接结束
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(onMediaEnded, 2000);
|
||||
}
|
||||
|
||||
document.getElementById("current-status").innerText = `正在播放: ${item.path}`;
|
||||
}
|
||||
|
||||
// 单条内容播放结束后,调度下一条
|
||||
function onMediaEnded() {
|
||||
currentItem = null;
|
||||
scheduleNext();
|
||||
}
|
||||
|
||||
// 启动:每 30 秒轮询一次服务端(注释修正)
|
||||
fetchPlaylist();
|
||||
pollTimer = setInterval(fetchPlaylist, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
在新工单中引用
屏蔽一个用户