feat: 初始化屏幕播放控制系统
搭建基于 Flask 的多屏幕定时播放控制后端,提供设备管理控制台、播放端页面、媒体上传与配置文件持久化,并补充项目文档、示例配置及 MIT 许可证。
这个提交包含在:
+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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
Flask>=3.0.0
|
||||||
|
Werkzeug>=3.0.0
|
||||||
@@ -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 => (
|
||||||
|
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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>
|
||||||
@@ -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) 的「常见问题」章节。*
|
||||||
在新工单中引用
屏蔽一个用户