feat: 初始化屏幕播放控制系统
搭建基于 Flask 的多屏幕定时播放控制后端,提供设备管理控制台、播放端页面、媒体上传与配置文件持久化,并补充项目文档、示例配置及 MIT 许可证。
这个提交包含在:
@@ -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>
|
||||
在新工单中引用
屏蔽一个用户