feat: 初始化屏幕播放控制系统

搭建基于 Flask 的多屏幕定时播放控制后端,提供设备管理控制台、播放端页面、媒体上传与配置文件持久化,并补充项目文档、示例配置及 MIT 许可证。
这个提交包含在:
2026-08-24 10:51:18 +08:00
当前提交 efc80ca1e0
共修改 11 个文件,包含 1172 行新增和 0 行删除
+7
查看文件
@@ -0,0 +1,7 @@
# 服务监听配置(复制为 .env 或直接通过环境变量设置)
# 监听地址,0.0.0.0 表示允许局域网内其他设备访问
HOST=0.0.0.0
# 监听端口
PORT=5000
# 是否开启调试模式(1 开启,0 关闭)。生产环境请设为 0
FLASK_DEBUG=0
+31
查看文件
@@ -0,0 +1,31 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
env/
*.log
# 上传的媒体文件(不纳入版本库,请用户自行放置)
static/*.mp4
static/*.webm
static/*.ogg
static/*.mov
static/*.avi
static/*.mkv
static/*.png
static/*.jpg
static/*.jpeg
static/*.gif
static/*.webp
static/*.bmp
!static/.gitkeep
# 本地配置(可选保留示例)
config.local.json
# 编辑器 / 系统
.idea/
.vscode/
.DS_Store
+21
查看文件
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Screen Controller contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+38
查看文件
@@ -0,0 +1,38 @@
{
"devices": [
{
"id": "d1",
"name": "主屏幕",
"playlist": [
{
"type": "image",
"path": "/static/image1.png",
"start_time": "2024-11-21T16:32:10",
"duration": 10
},
{
"type": "video",
"path": "/static/video1.mp4",
"start_time": "2024-11-21T16:55:00"
}
]
},
{
"id": "d2",
"name": "副屏幕",
"playlist": [
{
"type": "image",
"path": "/static/image1.png",
"start_time": "2024-11-21T16:26:10",
"duration": 10
},
{
"type": "video",
"path": "/static/video2.mp4",
"start_time": "2024-11-21T16:40:00"
}
]
}
]
}
+210
查看文件
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
屏幕播放控制系统 - 后端服务
Screen Controller - Backend Server
一个用于展厅多屏幕定时播放控制的轻量级系统:
- 控制端(/)通过 Web 界面管理设备与播放列表
- 播放端(/player?device_id=xxx)按配置的时间定时播放图片/视频
- 支持局域网内多台设备同时访问
"""
import os
import json
from datetime import datetime
from flask import (
Flask,
render_template,
request,
jsonify,
send_from_directory,
)
from werkzeug.utils import secure_filename
app = Flask(__name__)
# ---------------------------------------------------------------------------
# 基础配置
# ---------------------------------------------------------------------------
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
CONFIG_FILE = os.path.join(BASE_DIR, "config.json")
STATIC_DIR = os.path.join(BASE_DIR, "static")
UPLOAD_DIR = STATIC_DIR # 上传的媒体文件存放目录
# 允许上传的媒体类型(扩展名 -> 类型)
ALLOWED_EXTENSIONS = {
"png": "image",
"jpg": "image",
"jpeg": "image",
"gif": "image",
"webp": "image",
"bmp": "image",
"mp4": "video",
"webm": "video",
"ogg": "video",
"mov": "video",
"avi": "video",
"mkv": "video",
}
# 默认监听地址与端口(0.0.0.0 允许局域网访问)
HOST = os.environ.get("HOST", "0.0.0.0")
PORT = int(os.environ.get("PORT", "5000"))
# ---------------------------------------------------------------------------
# 配置读写
# ---------------------------------------------------------------------------
def load_config():
"""加载配置文件;若不存在则返回默认结构。"""
if os.path.exists(CONFIG_FILE):
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
data = {}
if "devices" not in data or not isinstance(data["devices"], list):
data["devices"] = []
return data
except (json.JSONDecodeError, OSError):
return {"devices": []}
return {"devices": []}
def save_config(config):
"""保存配置到文件。"""
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=4)
def find_device(config, device_id):
"""在配置中查找指定 id 的设备。"""
return next((d for d in config.get("devices", []) if d.get("id") == device_id), None)
def allowed_file(filename):
"""判断文件扩展名是否在允许列表中。"""
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
# ---------------------------------------------------------------------------
# 页面路由
# ---------------------------------------------------------------------------
@app.route("/")
def index():
"""控制端:设备管理页面。"""
return render_template("index.html")
@app.route("/player")
@app.route("/player/<device_id>")
def player(device_id=None):
"""播放端页面。device_id 既可通过路径也可通过查询参数传入。"""
return render_template("player.html")
# ---------------------------------------------------------------------------
# API:配置
# ---------------------------------------------------------------------------
@app.route("/get_config")
def get_config():
"""返回完整 JSON 配置。"""
return jsonify(load_config())
@app.route("/save_config", methods=["POST"])
def save_config_endpoint():
"""保存完整配置(来自控制端编辑)。"""
try:
data = request.get_json(force=True, silent=True)
if not isinstance(data, dict) or "devices" not in data:
return jsonify({"status": "error", "message": "配置格式不正确"}), 400
save_config(data)
return jsonify({"status": "success"})
except Exception as e: # noqa: BLE001
return jsonify({"status": "error", "message": str(e)}), 400
@app.route("/get_playlist/<device_id>")
def get_playlist(device_id):
"""播放端请求某个设备的播放列表。"""
config = load_config()
device = find_device(config, device_id)
if not device:
return jsonify({"error": "Device not found"}), 404
# 按 start_time 升序返回,便于前端调度
playlist = sorted(
device.get("playlist", []),
key=lambda x: x.get("start_time", ""),
)
return jsonify(
{
"device_id": device_id,
"name": device.get("name", device_id),
"playlist": playlist,
}
)
# ---------------------------------------------------------------------------
# API:媒体文件
# ---------------------------------------------------------------------------
@app.route("/upload", methods=["POST"])
def upload_file():
"""上传媒体文件到 static 目录。"""
if "file" not in request.files:
return jsonify({"status": "error", "message": "未找到文件字段"}), 400
file = request.files["file"]
if file.filename == "":
return jsonify({"status": "error", "message": "未选择文件"}), 400
if not allowed_file(file.filename):
return jsonify({"status": "error", "message": "不支持的文件类型"}), 400
filename = secure_filename(file.filename)
# 避免重名覆盖
base, ext = os.path.splitext(filename)
counter = 1
target = os.path.join(UPLOAD_DIR, filename)
while os.path.exists(target):
filename = f"{base}_{counter}{ext}"
target = os.path.join(UPLOAD_DIR, filename)
counter += 1
file.save(target)
media_type = ALLOWED_EXTENSIONS[filename.rsplit(".", 1)[1].lower()]
return jsonify(
{
"status": "success",
"filename": filename,
"path": f"/static/{filename}",
"type": media_type,
}
)
@app.route("/files")
def list_files():
"""列出 static 目录下可用的媒体文件。"""
files = []
if os.path.isdir(STATIC_DIR):
for name in sorted(os.listdir(STATIC_DIR)):
ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
if ext in ALLOWED_EXTENSIONS:
files.append({"name": name, "path": f"/static/{name}", "type": ALLOWED_EXTENSIONS[ext]})
return jsonify({"files": files})
@app.route("/static/<path:filename>")
def serve_static(filename):
"""提供静态媒体文件访问。"""
return send_from_directory(STATIC_DIR, filename)
if __name__ == "__main__":
os.makedirs(STATIC_DIR, exist_ok=True)
# 关闭 debug 以避免生产环境安全隐患;可通过环境变量 FLASK_DEBUG=1 开启
debug = os.environ.get("FLASK_DEBUG", "0") == "1"
print(f"屏幕控制系统已启动: http://{HOST}:{PORT}/ (局域网内其他设备可访问)")
app.run(host=HOST, port=PORT, debug=debug)
+212
查看文件
@@ -0,0 +1,212 @@
# 屏幕播放控制系统 / Screen Controller
> 一个轻量级的展厅多屏幕定时播放控制系统,方便展厅介绍员通过 Web 控制台统一管理多台显示屏的定时图片/视频播放。
A lightweight, web-based multi-screen scheduled playback control system for exhibition halls. It lets presenters centrally manage scheduled image/video playback across multiple displays from a single control console.
---
## 功能特性
- **多设备管理**:在一台控制端集中管理多块屏幕(设备),每块屏幕对应一个独立播放列表。
- **定时播放**:为每条内容设置开始时间,播放端到点自动切换;支持图片(可设展示秒数)与视频。
- **可视化控制台**:无需手改 JSON,通过网页即可新增/删除设备、编辑播放列表、上传媒体文件。
- **媒体库**:支持在控制台直接上传图片/视频,并一键插入到播放列表。
- **局域网部署**:默认监听 `0.0.0.0`,展厅内任意设备(电脑、电视盒子、平板)通过浏览器访问播放端即可。
- **实时轮询**:播放端每 30 秒从服务端拉取最新配置,改完即生效,无需刷新。
---
## 系统架构
```
控制端 (浏览器) ──HTTP──> Flask 服务 <──HTTP── 播放端 (各屏幕浏览器)
/ | config.json /player?device_id=xx
管理设备/播放列表/上传 媒体文件 (static/)
```
- **控制端** `/`:展厅介绍员使用的管理页面。
- **播放端** `/player?device_id=xxx`:部署在每块屏幕的浏览器上,按配置定时播放。
- **数据** `config.json`:设备与播放列表的持久化存储。
- **媒体** `static/`:图片与视频文件存放目录。
---
## 快速开始
### 1. 环境要求
- Python 3.8+
- 支持现代浏览器的播放设备(建议 Chrome / Edge)
### 2. 安装
```bash
# 克隆仓库(开源后)
git clone <your-repo-url>
cd screen_controller
# 创建虚拟环境(可选但推荐)
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# 安装依赖
pip install -r requirements.txt
```
### 3. 运行
```bash
python main.py
```
启动后控制台会输出访问地址,默认:
- 控制端: `http://0.0.0.0:5000/`
- 播放端示例: `http://<服务器IP>:5000/player?device_id=d1`
> 局域网内其他设备请使用**服务器的内网 IP**(如 `http://192.168.1.100:5000/`)访问。
### 4. 环境变量(可选)
可通过环境变量自定义监听配置(参见 `.env.example`):
| 变量 | 说明 | 默认值 |
| --- | --- | --- |
| `HOST` | 监听地址,`0.0.0.0` 允许局域网访问 | `0.0.0.0` |
| `PORT` | 监听端口 | `5000` |
| `FLASK_DEBUG` | 调试模式,`1` 开启 | `0` |
---
## 使用说明
### 控制端
1. 打开 `http://<服务器IP>:5000/`,左侧为设备列表。
2. 点击「+ 新增」创建设备,填写设备名称与 ID。
3. 在「播放列表」中添加条目:
- **类型**:图片 / 视频
- **路径**:媒体文件在 `static/` 下的访问路径,如 `/static/video1.mp4`
- **开始时间**:该内容开始播放的时间
- **时长(秒)**:仅对图片有效,默认 10 秒
4. 「媒体库」区域可直接上传本地图片/视频,上传后点击「使用」即可插入到播放列表。
5. 点击「保存」后配置立即生效,播放端会在 30 秒内自动更新。
### 播放端
在每块屏幕上用浏览器打开对应的播放端地址,例如:
```
http://<服务器IP>:5000/player?device_id=d1
```
建议将播放端浏览器设为**全屏**(F11)。播放端会:
- 按各条目 `start_time` 倒计时到点播放;
- 视频播完后自动切换到下一条;
- 图片按 `duration` 秒数展示后切换;
- 每 30 秒拉取最新配置,后台修改无需人工刷新。
---
## 配置文件格式
`config.json` 结构如下(也可通过控制端界面编辑,无需手写):
```json
{
"devices": [
{
"id": "d1",
"name": "主屏幕",
"playlist": [
{
"type": "image",
"path": "/static/image1.png",
"start_time": "2024-11-21T16:32:10",
"duration": 10
},
{
"type": "video",
"path": "/static/video1.mp4",
"start_time": "2024-11-21T16:55:00"
}
]
}
]
}
```
| 字段 | 说明 |
| --- | --- |
| `devices[].id` | 设备唯一标识,播放端 URL 中 `device_id` 对应此值 |
| `devices[].name` | 设备显示名称 |
| `devices[].playlist[].type` | `image` 或 `video` |
| `devices[].playlist[].path` | 媒体文件访问路径(位于 `static/` 下) |
| `devices[].playlist[].start_time` | 开始播放时间(ISO 8601,本地时间) |
| `devices[].playlist[].duration` | 图片展示秒数(视频忽略) |
---
## 目录结构
```
screen_controller/
├── main.py # Flask 后端服务
├── config.json # 设备与播放列表配置
├── requirements.txt # Python 依赖
├── .env.example # 环境变量示例
├── static/ # 媒体文件存放目录(图片/视频)
└── templates/
├── index.html # 控制端页面
└── player.html # 播放端页面
```
---
## 文档导航
- [用户操作指引](./用户操作指引.md):面向展厅介绍员/运维人员的日常使用手册(快速上手、常见场景、状态说明、排错)。
- 本文档(README):系统介绍、部署与技术说明。
---
## 常见问题
**Q: 播放端打不开 / 一直「正在初始化」?**
- 确认设备 ID 与 `config.json` 中一致;
- 确认访问地址使用的是服务器**内网 IP** 而非 `localhost`(localhost 仅本机有效)。
**Q: 视频无法自动播放?**
- 部分浏览器禁止有声自动播放,展厅设备建议在系统设置中允许自动播放,或保持浏览器在前台。
**Q: 修改配置后播放端没变化?**
- 播放端每 30 秒轮询一次,最多等待 30 秒生效;也可手动刷新播放端页面。
**Q: 时间不准确?**
- 播放以**播放设备本地系统时间**为准,请保证各屏幕设备时间同步(建议开启网络时间同步)。
---
## 开源协议
本项目基于 [MIT License](./LICENSE) 开源,可自由用于学习、商用与二次开发,请保留版权声明。
---
## English
**Screen Controller** is a lightweight web system for scheduling image/video playback across multiple screens in an exhibition hall.
- **Console** (`/`): manage devices, playlists, and upload media via a web UI.
- **Player** (`/player?device_id=xxx`): runs on each screen's browser and plays content by schedule.
- **Backend**: Flask serves config from `config.json` and media from `static/`.
Quick start:
```bash
pip install -r requirements.txt
python main.py
```
Then open `http://<server-ip>:5000/` for the console, and `http://<server-ip>:5000/player?device_id=d1` on each display. Licensed under the MIT License.
+2
查看文件
@@ -0,0 +1,2 @@
Flask>=3.0.0
Werkzeug>=3.0.0
+2
查看文件
@@ -0,0 +1,2 @@
# 该目录用于存放上传/播放的媒体文件(图片、视频)。
# 实际媒体文件已被 .gitignore 忽略,请勿提交大体积文件。
+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 => (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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>
+171
查看文件
@@ -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>
+141
查看文件
@@ -0,0 +1,141 @@
# 屏幕播放控制系统 · 用户操作指引
> 本指引面向**展厅介绍员 / 运维人员**,帮助你快速上手系统的日常使用。
> 技术部署相关内容请参见 [README.md](./README.md)。
---
## 一、系统角色说明
本系统有两套界面,分别用于「管理」和「播放」:
| 界面 | 地址 | 使用者 | 用途 |
| --- | --- | --- | --- |
| **控制端(控制台)** | `http://<服务器IP>:5000/` | 展厅介绍员 | 管理设备、编排播放列表、上传媒体 |
| **播放端** | `http://<服务器IP>:5000/player?device_id=xxx` | 每块屏幕 | 按时间自动播放图片/视频 |
> 说明:`<服务器IP>` 指运行 `main.py` 的那台电脑的内网 IP(如 `192.168.1.100`)。
> 控制端和播放端可以运行在**不同设备**上,只要在同一局域网即可。
---
## 二、首次使用流程
```
1. 启动服务 → 2. 打开控制端 → 3. 创建设备 → 4. 上传媒体
→ 5. 编排播放列表 → 6. 各屏幕打开播放端 → 7. 日常维护
```
### 步骤 1:启动服务
在服务器上执行:
```bash
python main.py
```
看到 `屏幕控制系统已启动: http://0.0.0.0:5000/` 即表示成功。
### 步骤 2:打开控制端
在任意同一局域网的电脑/平板浏览器中访问:
```
http://<服务器IP>:5000/
```
### 步骤 3:创建设备
1. 点击左侧「**+ 新增**」按钮,系统自动生成一个设备(如 `d1`)。
2. 在右侧「设备名称」填写便于识别的名字(如「入口主屏」)。
3. 点击「**保存设备信息**」。
> 每块实体屏幕对应一个设备,设备 ID(如 `d1`)将在播放端地址中使用,请勿重复。
### 步骤 4:上传媒体文件
1. 在页面下方「**媒体库**」区域点击「**上传媒体**」。
2. 选择本地图片(png/jpg 等)或视频(mp4/webm 等)。
3. 上传成功后文件出现在媒体库中,可点击「**使用**」直接加入当前设备的播放列表。
> 也可不通过媒体库,手动在播放列表中填写 `static/` 下的文件路径。
### 步骤 5:编排播放列表
在「播放列表」区域:
1. 点击「**+ 添加条目**」新增一条播放内容。
2. 填写每条内容的参数:
| 参数 | 说明 | 示例 |
| --- | --- | --- |
| 类型 | 图片 / 视频 | `image` |
| 路径 | 媒体在 `static/` 下的访问地址 | `/static/video1.mp4` |
| 开始时间 | 该内容**开始播放**的时间(本地时间) | `2026-08-24T16:32` |
| 时长(秒) | 仅图片有效,到时自动切换 | `10` |
3. 多条内容按「开始时间」从早到晚自动排序播放。
4. 编辑完成后点击「**保存播放列表**」。
### 步骤 6:各屏幕打开播放端
在每块展示屏幕的浏览器中访问对应地址(设备 ID 对应步骤 3 的值):
```
http://<服务器IP>:5000/player?device_id=d1
```
建议按 **F11** 进入全屏,并固定该页面不关闭。
### 步骤 7:日常维护
- 修改配置后**无需重启播放端**:播放端每 30 秒自动拉取最新配置。
- 紧急情况可手动刷新播放端页面(F5)立即生效。
---
## 三、常见操作场景
### 场景 A:临时插播一段视频
1. 控制端 → 媒体库「上传媒体」上传视频。
2. 播放列表「+ 添加条目」→ 类型选视频 → 路径选刚上传的文件 → 开始时间设为「现在或稍后」。
3. 保存。对应屏幕会在到点后自动播放。
### 场景 B:更换某块屏幕的全部内容
直接在控制端选中该设备,清空或重新编排播放列表后保存即可。
### 场景 C:新增一块屏幕
1. 控制端「+ 新增」创建设备,记住其 ID。
2. 新屏幕浏览器打开 `/player?device_id=新ID`。
3. 编排该设备播放列表并保存。
### 场景 D:图片轮播展示
将多条图片条目依次排列,每条设置合适的「时长(秒)」,系统会按时间顺序、各自展示设定秒数后切换。
---
## 四、播放端状态说明
播放端页面右下角会显示当前状态,含义如下:
| 状态文字 | 含义 |
| --- | --- |
| `正在初始化...` | 正在从服务端获取配置 |
| `即将播放: xxx @ 时间` | 已排到下一条,正在倒计时 |
| `正在播放: xxx` | 当前正在展示该内容 |
| `当前没有待播放内容` | 配置中所有条目的开始时间都已过去 |
| `获取配置失败:设备不存在` | `device_id` 在配置中找不到,请核对 |
| `请在 URL 中指定 device_id...` | 打开播放端时未带 `?device_id=`,请补全 |
> 播放端右上角有时钟,用于核对**本地系统时间**是否与标准时间一致(播放以屏幕本机时间为准)。
---
## 五、注意事项与排错
1. **时间同步**:各屏幕设备的系统时间应保持一致(建议开启网络时间同步),否则定时播放会偏差。
2. **访问地址**:播放端必须用**服务器内网 IP** 访问,不能用 `localhost`(`localhost` 只指向本机)。
3. **自动播放限制**:部分浏览器禁止带声音的视频自动播放,展厅设备建议在浏览器设置中允许自动播放,或保持页面在前台。
4. **媒体文件**:上传的视频建议控制在合理体积,避免网络传输卡顿;文件存于 `static/` 目录。
5. **配置备份**:`config.json` 保存了全部设备与播放列表,定期备份该文件以防丢失。
---
## 六、快捷键速查
| 操作 | 快捷键 |
| --- | --- |
| 浏览器全屏(推荐播放端使用) | `F11` |
| 刷新播放端立即生效 | `F5` |
| 控制台保存 | 页面内「保存」按钮 |
---
*如遇到本指引未覆盖的问题,请查阅 [README.md](./README.md) 的「常见问题」章节。*