- tauri 设计完成

This commit is contained in:
Pine
2026-05-12 23:19:37 +08:00
parent c8e8dd12d2
commit 4ad266f6c4
25 changed files with 3495 additions and 3039 deletions
+227
View File
@@ -213,6 +213,71 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "axum"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"axum-macros",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"multer",
"percent-encoding",
"pin-project-lite",
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-macros"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "base64"
version = "0.21.7"
@@ -460,8 +525,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link 0.2.1",
]
@@ -797,11 +864,21 @@ dependencies = [
name = "dpm"
version = "0.1.0"
dependencies = [
"axum",
"chrono",
"dirs",
"futures",
"once_cell",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-opener",
"tokio",
"tokio-stream",
"tower",
"tower-http",
"uuid",
]
[[package]]
@@ -866,6 +943,15 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.1"
@@ -1036,6 +1122,21 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "futures"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
@@ -1043,6 +1144,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@@ -1110,6 +1212,7 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
@@ -1501,12 +1604,24 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.9.0"
@@ -1520,6 +1635,7 @@ dependencies = [
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
@@ -1976,6 +2092,12 @@ dependencies = [
"web_atoms",
]
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "memchr"
version = "2.8.0"
@@ -1997,6 +2119,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2039,6 +2171,23 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "multer"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
dependencies = [
"bytes",
"encoding_rs",
"futures-util",
"http",
"httparse",
"memchr",
"mime",
"spin",
"version_check",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -2796,6 +2945,12 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
@@ -2957,6 +3112,17 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_repr"
version = "0.1.20"
@@ -2986,6 +3152,18 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_with"
version = "3.20.0"
@@ -3158,6 +3336,12 @@ dependencies = [
"system-deps",
]
[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -3695,11 +3879,37 @@ dependencies = [
"bytes",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "tokio-stream"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
"tokio-util",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -3846,6 +4056,7 @@ dependencies = [
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -3856,10 +4067,19 @@ checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
dependencies = [
"bitflags 2.11.1",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower",
"tower-layer",
"tower-service",
@@ -3884,6 +4104,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@@ -4001,6 +4222,12 @@ dependencies = [
"unic-common",
]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-ident"
version = "1.0.24"
+12 -8
View File
@@ -1,16 +1,11 @@
[package]
name = "dpm"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
description = "大屏媒体轮播系统 - Tauri App"
authors = ["Pine"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "dpm_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
@@ -22,4 +17,13 @@ tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
axum = { version = "0.8", features = ["macros", "multipart"] }
tower-http = { version = "0.6", features = ["cors", "fs"] }
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }
tower = "0.5"
once_cell = "1"
dirs = "6"
futures = "0.3"
tokio-stream = { version = "0.1", features = ["sync"] }
-1
View File
@@ -1 +0,0 @@
{"settings": {"1": {"username": "admin", "password": "123456", "volume": 60, "auto_play": true, "play_mode": "sequential", "image_duration": 8, "control_action": "play"}}, "playlist": {"1": {"path": "\u540d\u7247.png"}, "2": {"path": "02.mp4"}}, "url_media": {"1": {"url": "https://video-qn.ibaotu.com/20/08/52/29f888piCpMR.mp4", "name": "29f888piCpMR.mp4", "type": "video", "added_at": "2026-05-12T20:36:28.512177"}}}
-352
View File
@@ -1,352 +0,0 @@
from pathlib import Path
import asyncio, json
from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Request
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from tinydb import TinyDB, Query
from datetime import datetime
from typing import Any, Dict, List
app = FastAPI(title="大屏媒体轮播系统")
# ===================== 模板加载 =====================
TEMPLATE_DIR = Path(__file__).parent / "templates"
def _load_template(name: str) -> str:
return (TEMPLATE_DIR / name).read_text(encoding="utf-8")
SCREEN_HTML = _load_template("screen.html")
ADMIN_HTML = _load_template("admin.html")
# ===================== SSE 事件管理器 =====================
class EventManager:
def __init__(self):
self._queues: list[asyncio.Queue] = []
def subscribe(self, q: asyncio.Queue):
self._queues.append(q)
def unsubscribe(self, q: asyncio.Queue):
try:
self._queues.remove(q)
except ValueError:
pass
def broadcast(self, event: dict):
data = json.dumps(event)
dead = []
for q in self._queues:
try:
q.put_nowait(data)
except asyncio.QueueFull:
dead.append(q)
for q in dead:
self.unsubscribe(q)
events = EventManager()
# ===================== 播放状态(内存) =====================
playback_state: dict = {
"status": "idle", # playing | paused | idle
"index": 0,
"name": "",
"type": "",
}
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ===================== 配置 =====================
MEDIA_DIR = Path.home() / "Downloads" / "Media"
MEDIA_DIR.mkdir(exist_ok=True, parents=True)
DB = TinyDB("db.json")
FILES_TABLE = DB.table("files")
PLAYLIST_TABLE = DB.table("playlist")
SETTINGS_TABLE = DB.table("settings")
URL_MEDIA_TABLE = DB.table("url_media")
User = Query()
FileQuery = Query()
UrlMediaQuery = Query()
ALLOWED_EXTENSIONS = {".mp4", ".mkv", ".avi", ".jpg", ".jpeg", ".png"}
# ===================== 初始化 =====================
DEFAULT_SETTINGS = {
"username": "admin",
"password": "123456",
"volume": 80,
"auto_play": True,
"play_mode": "sequential",
"image_duration": 5,
}
if not SETTINGS_TABLE.search(User.username == "admin"):
SETTINGS_TABLE.insert(DEFAULT_SETTINGS)
else:
# 补齐缺失的默认字段
current = SETTINGS_TABLE.get(User.username == "admin")
updated = False
for key, val in DEFAULT_SETTINGS.items():
if key not in current:
current[key] = val
updated = True
if updated:
SETTINGS_TABLE.update(current, User.username == "admin")
# ===================== 前端页面 =====================
@app.get("/", response_class=HTMLResponse)
async def screen_page():
return SCREEN_HTML
@app.get("/admin", response_class=HTMLResponse)
async def admin_page():
return ADMIN_HTML
# ===================== 文件访问 =====================
from fastapi.responses import FileResponse
@app.get("/file/{path:path}")
async def get_file(path: str):
target = MEDIA_DIR / path
if not target.exists() or not target.is_file():
raise HTTPException(404)
return FileResponse(target)
# ===================== API =====================
@app.post("/api/login")
async def login(username: str = Form(...), password: str = Form(...)):
admin = SETTINGS_TABLE.get(User.username == "admin")
if admin and admin["password"] == password:
return {"success": True}
return {"success": False}
@app.get("/api/settings")
async def get_settings():
s = SETTINGS_TABLE.get(User.username == "admin")
return {k: s[k] for k in DEFAULT_SETTINGS if k in s}
@app.post("/api/settings")
async def update_settings(data: dict):
"""统一设置更新,支持 volume/play_mode/image_duration"""
allowed = {"volume", "play_mode", "image_duration"}
update = {k: v for k, v in data.items() if k in allowed}
if update:
SETTINGS_TABLE.update(update, User.username == "admin")
events.broadcast({"action": "settings_changed", **update})
return {"ok": True}
@app.get("/media")
async def get_media_files():
files = []
# 本地文件
for p in MEDIA_DIR.rglob("*"):
if not p.is_file(): continue
ext = p.suffix.lower()
if ext not in ALLOWED_EXTENSIONS: continue
rel = str(p.relative_to(MEDIA_DIR))
t = "video" if ext in [".mp4",".mkv",".avi"] else "image"
files.append({
"name": p.name,
"relative_path": rel,
"type": t,
"url": f"/file/{rel}",
"source": "local",
})
# URL 媒体(持久化在 url_media 表中)
for item in URL_MEDIA_TABLE.all():
files.append({
"name": item["name"],
"relative_path": item["url"],
"type": item["type"],
"url": item["url"],
"source": "url",
})
return {"files": files}
@app.post("/upload")
async def upload(file: UploadFile = File(...)):
ext = Path(file.filename).suffix.lower()
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(400)
ts = datetime.now().strftime("%Y%m%d%H%M%S")
name = f"{Path(file.filename).stem}_{ts}{ext}"
path = MEDIA_DIR / name
with open(path, "wb") as f:
f.write(await file.read())
return {"ok": True}
@app.post("/api/delete")
async def delete_file(data: dict):
path = data["path"]
if path.startswith(("http://", "https://")):
# 删除 URL 媒体及其播放列表引用
URL_MEDIA_TABLE.remove(UrlMediaQuery.url == path)
else:
p = MEDIA_DIR / path
if p.exists() and p.is_file():
p.unlink()
PLAYLIST_TABLE.remove(FileQuery.path == path)
events.broadcast({"action": "playlist_changed"})
return {"ok": True}
@app.get("/api/playlist")
async def get_playlist():
items = PLAYLIST_TABLE.all()
admin = SETTINGS_TABLE.get(User.username == "admin")
files = []
for item in items:
if item.get("source") == "url":
ext = Path(item["path"]).suffix.lower()
files.append({
"name": item.get("name", item["path"]),
"relative_path": item["path"],
"type": "video" if ext in [".mp4",".mkv",".avi"] else "image",
"source": "url",
})
else:
p = MEDIA_DIR / item["path"]
if p.exists():
ext = p.suffix.lower()
files.append({
"name": p.name,
"relative_path": item["path"],
"type": "video" if ext in [".mp4",".mkv",".avi"] else "image",
"source": "local",
})
return {
"files": files,
"volume": admin["volume"],
"play_mode": admin.get("play_mode", "sequential"),
"image_duration": admin.get("image_duration", 5),
}
@app.post("/api/playlist/add")
async def add_playlist(data: dict):
path = data["path"]
if not PLAYLIST_TABLE.contains(FileQuery.path == path):
item: dict = {"path": path}
if path.startswith(("http://", "https://")):
item["source"] = "url"
# 从 URL 媒体库中获取名称
um = URL_MEDIA_TABLE.get(UrlMediaQuery.url == path)
if um:
item["name"] = um["name"]
PLAYLIST_TABLE.insert(item)
events.broadcast({"action": "playlist_changed"})
return {"ok": True}
@app.post("/api/playlist/remove")
async def remove_playlist(data: dict):
PLAYLIST_TABLE.remove(FileQuery.path == data["path"])
events.broadcast({"action": "playlist_changed"})
return {"ok": True}
@app.post("/api/media/add-url")
async def add_url_media(data: dict):
"""添加 URL 到媒体库(持久化),不自动加入播放列表"""
url = data.get("url", "").strip()
if not url:
raise HTTPException(400, "url 不能为空")
if URL_MEDIA_TABLE.contains(UrlMediaQuery.url == url):
return {"ok": True, "duplicate": True}
ext = Path(url.split("?")[0]).suffix.lower()
name = data.get("name", "").strip() or url.rsplit("/", 1)[-1].split("?")[0]
URL_MEDIA_TABLE.insert({
"url": url,
"name": name,
"type": "video" if ext in [".mp4",".mkv",".avi"] else "image",
"added_at": datetime.now().isoformat(),
})
return {"ok": True}
@app.get("/api/files")
async def list_files_with_url():
"""输出媒体文件夹中所有文件的 URL"""
files = []
for p in sorted(MEDIA_DIR.rglob("*")):
if not p.is_file(): continue
ext = p.suffix.lower()
if ext not in ALLOWED_EXTENSIONS: continue
rel = str(p.relative_to(MEDIA_DIR))
t = "video" if ext in [".mp4",".mkv",".avi"] else "image"
files.append({
"name": p.name,
"type": t,
"url": f"/file/{rel}",
})
return {"files": files}
@app.get("/api/events")
async def sse_events(request: Request):
"""SSE 端点,向后端推送实时播放控制指令"""
q: asyncio.Queue = asyncio.Queue(maxsize=32)
events.subscribe(q)
async def generate():
try:
while True:
if await request.is_disconnected():
break
try:
data = await asyncio.wait_for(q.get(), timeout=15)
yield f"data: {data}\n\n"
except asyncio.TimeoutError:
yield ": keepalive\n\n"
finally:
events.unsubscribe(q)
return StreamingResponse(generate(), media_type="text/event-stream")
@app.post("/api/control")
async def playback_control(data: dict):
"""后端控制播放:{action: 'play'|'pause'|'next'|'prev'}"""
action = data.get("action", "")
if action not in ("play", "pause", "next", "prev"):
raise HTTPException(400, "action 必须是 play/pause/next/prev")
SETTINGS_TABLE.update({"control_action": action}, User.username == "admin")
events.broadcast({"action": action})
# 更新本地状态(乐观)
if action == "play":
playback_state["status"] = "playing"
elif action == "pause":
playback_state["status"] = "paused"
events.broadcast({"action": "state_update", "state": playback_state})
return {"ok": True, "action": action}
@app.get("/api/state")
async def get_state():
"""获取当前播放状态"""
return {"state": playback_state}
@app.post("/api/state")
async def update_state(data: dict):
"""前端上报播放状态变更"""
state = data.get("state", {})
if "status" in state:
playback_state["status"] = state["status"]
if "index" in state:
playback_state["index"] = state["index"]
if "name" in state:
playback_state["name"] = state["name"]
if "type" in state:
playback_state["type"] = state["type"]
events.broadcast({"action": "state_update", "state": playback_state})
return {"ok": True}
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
host="0.0.0.0",
port=8000,
reload=True
)
+29
View File
@@ -0,0 +1,29 @@
use crate::models::ServerEvent;
use once_cell::sync::Lazy;
use tokio::sync::broadcast;
/// SSE 事件管理器
pub struct EventManager {
tx: broadcast::Sender<String>,
}
impl EventManager {
pub fn new() -> Self {
let (tx, _) = broadcast::channel(64);
Self { tx }
}
/// 广播事件给所有 SSE 客户端
pub fn broadcast(&self, event: &ServerEvent) {
if let Ok(json) = serde_json::to_string(event) {
let _ = self.tx.send(json);
}
}
/// 获取广播接收器
pub fn subscribe(&self) -> broadcast::Receiver<String> {
self.tx.subscribe()
}
}
pub static EVENTS: Lazy<EventManager> = Lazy::new(EventManager::new);
+12 -6
View File
@@ -1,14 +1,20 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
mod events;
mod models;
mod server;
mod storage;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// 在后台线程启动 HTTP 服务器
std::thread::spawn(|| {
let rt = tokio::runtime::Runtime::new().expect("无法创建 tokio runtime");
rt.block_on(async {
server::start().await;
});
});
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+161
View File
@@ -0,0 +1,161 @@
use serde::{Deserialize, Serialize};
// ============ Settings ============
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
pub username: String,
pub password: String,
pub volume: u32,
pub auto_play: bool,
pub play_mode: String,
pub image_duration: u32,
}
impl Default for Settings {
fn default() -> Self {
Self {
username: "admin".into(),
password: "123456".into(),
volume: 80,
auto_play: true,
play_mode: "sequential".into(),
image_duration: 5,
}
}
}
// ============ Playlist Item ============
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlaylistItem {
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
// ============ URL Media Item ============
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UrlMediaItem {
pub url: String,
pub name: String,
#[serde(rename = "type")]
pub media_type: String,
pub added_at: String,
}
// ============ Media File (response) ============
#[derive(Debug, Clone, Serialize)]
pub struct MediaFile {
pub name: String,
pub relative_path: String,
#[serde(rename = "type")]
pub file_type: String,
pub url: String,
pub source: String,
}
// ============ Playback State ============
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlaybackState {
pub status: String,
pub index: usize,
pub name: String,
#[serde(rename = "type")]
pub media_type: String,
}
impl Default for PlaybackState {
fn default() -> Self {
Self {
status: "idle".into(),
index: 0,
name: String::new(),
media_type: String::new(),
}
}
}
// ============ Events ============
#[derive(Debug, Clone, Serialize)]
pub struct ServerEvent {
pub action: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<PlaybackState>,
#[serde(skip_serializing_if = "Option::is_none")]
pub volume: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub play_mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_duration: Option<u32>,
}
// ============ Playlist Response ============
#[derive(Debug, Clone, Serialize)]
pub struct PlaylistResponse {
pub files: Vec<MediaFile>,
pub volume: u32,
pub play_mode: String,
pub image_duration: u32,
}
// ============ API Request Bodies ============
#[derive(Debug, Deserialize)]
pub struct LoginForm {
pub username: String,
pub password: String,
}
#[derive(Debug, Serialize)]
pub struct LoginResponse {
pub success: bool,
}
#[derive(Debug, Deserialize)]
pub struct SettingsUpdate {
pub volume: Option<u32>,
pub play_mode: Option<String>,
pub image_duration: Option<u32>,
}
#[derive(Debug, Deserialize)]
pub struct PathBody {
pub path: String,
}
#[derive(Debug, Deserialize)]
pub struct UrlBody {
pub url: String,
}
#[derive(Debug, Deserialize)]
pub struct ActionBody {
pub action: String,
}
#[derive(Debug, Deserialize)]
pub struct StateBody {
pub state: PlaybackState,
}
#[derive(Debug, Serialize)]
pub struct OkResponse {
pub ok: bool,
}
#[derive(Debug, Serialize)]
pub struct OkDuplicateResponse {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub duplicate: Option<bool>,
}
#[derive(Debug, Serialize)]
pub struct FilesResponse {
pub files: Vec<MediaFile>,
}
#[derive(Debug, Serialize)]
pub struct StateResponse {
pub state: PlaybackState,
}
+415
View File
@@ -0,0 +1,415 @@
use crate::events::EVENTS;
use crate::models::*;
use crate::storage::{get_media_type, is_allowed_extension, STORAGE};
use axum::{
extract::{Form, Multipart, State},
http::StatusCode,
response::sse::{Event, KeepAlive, Sse},
response::Json,
routing::{get, post},
Router,
};
use chrono::Local;
use futures::stream::Stream;
use std::convert::Infallible;
use std::path::PathBuf;
use std::sync::Arc;
use std::fs;
use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::StreamExt;
use tower_http::cors::CorsLayer;
use tower_http::services::{ServeDir, ServeFile};
// ===================== 媒体目录 =====================
fn media_dir() -> PathBuf {
let mut path = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
path.push("Downloads");
path.push("Media");
fs::create_dir_all(&path).ok();
path
}
fn media_dir_str() -> String {
media_dir().to_string_lossy().to_string()
}
#[derive(Clone)]
pub struct AppState {
pub playback: Arc<tokio::sync::Mutex<PlaybackState>>,
}
impl AppState {
pub fn new() -> Self {
Self {
playback: Arc::new(tokio::sync::Mutex::new(PlaybackState::default())),
}
}
}
// ===================== 路由构建 =====================
pub fn create_router(state: AppState) -> Router {
Router::new()
.route("/api/login", post(login_handler))
.route("/api/settings", get(get_settings).post(update_settings))
.route("/media", get(list_media))
.route("/upload", post(upload_handler))
.route("/api/delete", post(delete_handler))
.route("/api/media/add-url", post(add_url_handler))
.route("/api/playlist", get(get_playlist))
.route("/api/playlist/add", post(add_playlist))
.route("/api/playlist/remove", post(remove_playlist))
.route("/api/control", post(control_handler))
.route("/api/state", get(get_state).post(update_state))
.route("/api/events", get(sse_handler))
.nest_service("/file", ServeDir::new(media_dir_str()))
.fallback_service(
ServeDir::new("../dist")
.append_index_html_on_directories(true)
.fallback(ServeFile::new("../dist/index.html")),
)
.layer(CorsLayer::permissive())
.with_state(state)
}
pub async fn start() {
let state = AppState::new();
let app = create_router(state);
println!("🚀 大屏媒体轮播系统启动于 http://0.0.0.0:8000");
println!(" - 管理后台: http://localhost:8000/admin");
println!(" - 大屏展示: http://localhost:8000/screen");
let listener = tokio::net::TcpListener::bind("0.0.0.0:8000")
.await
.expect("无法绑定 0.0.0.0:8000,请检查端口是否被占用");
axum::serve(listener, app)
.await
.expect("服务器启动失败");
}
// ===================== Handler: 登录 =====================
async fn login_handler(
Form(form): Form<LoginForm>,
) -> Json<LoginResponse> {
let s = STORAGE.get_settings();
Json(LoginResponse {
success: form.username == s.username && form.password == s.password,
})
}
// ===================== Handler: 设置 =====================
async fn get_settings() -> Json<serde_json::Value> {
let s = STORAGE.get_settings();
Json(serde_json::json!({
"volume": s.volume,
"play_mode": s.play_mode,
"image_duration": s.image_duration,
}))
}
async fn update_settings(
Json(body): Json<SettingsUpdate>,
) -> Json<serde_json::Value> {
STORAGE.update_settings(SettingsUpdate {
volume: body.volume,
play_mode: body.play_mode.clone(),
image_duration: body.image_duration,
});
EVENTS.broadcast(&ServerEvent {
action: "settings_changed".into(),
state: None,
volume: body.volume,
play_mode: body.play_mode,
image_duration: body.image_duration,
});
Json(serde_json::json!({"ok": true}))
}
// ===================== Handler: 媒体列表 =====================
async fn list_media() -> Json<FilesResponse> {
let mut files = Vec::new();
let dir = media_dir();
// 本地文件
if let Ok(entries) = fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let name = path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
if !is_allowed_extension(&name) {
continue;
}
let rel = name.clone();
let t = get_media_type(&name).to_string();
files.push(MediaFile {
name,
relative_path: rel.clone(),
file_type: t,
url: format!("/file/{}", rel),
source: "local".into(),
});
}
}
// URL 媒体
for item in STORAGE.get_url_media() {
files.push(MediaFile {
name: item.name,
relative_path: item.url.clone(),
file_type: item.media_type,
url: item.url,
source: "url".into(),
});
}
Json(FilesResponse { files })
}
// ===================== Handler: 上传 =====================
async fn upload_handler(
mut multipart: Multipart,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
while let Ok(Some(field)) = multipart.next_field().await {
let file_name = field.file_name().unwrap_or("file").to_string();
let ext = std::path::Path::new(&file_name)
.extension()
.and_then(|e| e.to_str())
.map(|e| format!(".{}", e.to_lowercase()))
.unwrap_or_default();
if !is_allowed_extension(&ext) {
return Err((StatusCode::BAD_REQUEST, "不支持的文件类型".into()));
}
let ts = Local::now().format("%Y%m%d%H%M%S");
let stem = std::path::Path::new(&file_name)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("file");
let new_name = format!("{}_{}{}", stem, ts, ext);
let save_path = media_dir().join(&new_name);
let data = field.bytes().await.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
})?;
fs::write(&save_path, &data).map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
})?;
}
Ok(Json(serde_json::json!({"ok": true})))
}
// ===================== Handler: 删除 =====================
async fn delete_handler(
Json(body): Json<PathBody>,
) -> Json<OkResponse> {
let path = body.path;
if !path.starts_with("http://") && !path.starts_with("https://") {
let file_path = media_dir().join(&path);
if file_path.exists() && file_path.is_file() {
fs::remove_file(&file_path).ok();
}
}
STORAGE.delete_media(&path);
EVENTS.broadcast(&ServerEvent {
action: "playlist_changed".into(),
state: None,
volume: None,
play_mode: None,
image_duration: None,
});
Json(OkResponse { ok: true })
}
// ===================== Handler: 添加 URL =====================
async fn add_url_handler(
Json(body): Json<UrlBody>,
) -> Json<OkDuplicateResponse> {
let url = body.url.trim().to_string();
if url.is_empty() {
return Json(OkDuplicateResponse { ok: false, duplicate: None });
}
let name = url.rsplit('/').next().unwrap_or(&url)
.split('?').next().unwrap_or(&url)
.to_string();
let t = get_media_type(&url).to_string();
let is_new = STORAGE.add_url_media(&url, name, t);
Json(OkDuplicateResponse { ok: true, duplicate: Some(!is_new) })
}
// ===================== Handler: 播放列表 =====================
async fn get_playlist() -> Json<PlaylistResponse> {
let items = STORAGE.get_playlist();
let settings = STORAGE.get_settings();
let dir = media_dir();
let mut files = Vec::new();
for item in &items {
if item.source.as_deref() == Some("url") {
let ext = std::path::Path::new(&item.path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
let t = if ["mp4", "mkv", "avi"].contains(&ext) { "video" } else { "image" };
files.push(MediaFile {
name: item.name.clone().unwrap_or_else(|| item.path.clone()),
relative_path: item.path.clone(),
file_type: t.to_string(),
url: item.path.clone(),
source: "url".into(),
});
} else {
let file_path = dir.join(&item.path);
if file_path.exists() {
let ext = file_path.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
let t = if ["mp4", "mkv", "avi"].contains(&ext) { "video" } else { "image" };
files.push(MediaFile {
name: file_path.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&item.path)
.to_string(),
relative_path: item.path.clone(),
file_type: t.to_string(),
url: format!("/file/{}", item.path),
source: "local".into(),
});
}
}
}
Json(PlaylistResponse {
files,
volume: settings.volume,
play_mode: settings.play_mode,
image_duration: settings.image_duration,
})
}
async fn add_playlist(
Json(body): Json<PathBody>,
) -> Json<OkResponse> {
STORAGE.add_to_playlist(&body.path);
EVENTS.broadcast(&ServerEvent {
action: "playlist_changed".into(),
state: None,
volume: None,
play_mode: None,
image_duration: None,
});
Json(OkResponse { ok: true })
}
async fn remove_playlist(
Json(body): Json<PathBody>,
) -> Json<OkResponse> {
STORAGE.remove_from_playlist(&body.path);
EVENTS.broadcast(&ServerEvent {
action: "playlist_changed".into(),
state: None,
volume: None,
play_mode: None,
image_duration: None,
});
Json(OkResponse { ok: true })
}
// ===================== Handler: 播放控制 =====================
async fn control_handler(
State(state): State<AppState>,
Json(body): Json<ActionBody>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
if !["play", "pause", "next", "prev"].contains(&body.action.as_str()) {
return Err((StatusCode::BAD_REQUEST, "action 必须是 play/pause/next/prev".into()));
}
EVENTS.broadcast(&ServerEvent {
action: body.action.clone(),
state: None,
volume: None,
play_mode: None,
image_duration: None,
});
let mut playback = state.playback.lock().await;
playback.status = match body.action.as_str() {
"play" => "playing".into(),
"pause" => "paused".into(),
_ => playback.status.clone(),
};
let state_snapshot = playback.clone();
drop(playback);
EVENTS.broadcast(&ServerEvent {
action: "state_update".into(),
state: Some(state_snapshot),
volume: None,
play_mode: None,
image_duration: None,
});
Ok(Json(serde_json::json!({"ok": true, "action": body.action})))
}
// ===================== Handler: 状态 =====================
async fn get_state(
State(state): State<AppState>,
) -> Json<StateResponse> {
let s = state.playback.lock().await;
Json(StateResponse { state: s.clone() })
}
async fn update_state(
State(state): State<AppState>,
Json(body): Json<StateBody>,
) -> Json<OkResponse> {
{
let mut s = state.playback.lock().await;
if !body.state.status.is_empty() {
s.status = body.state.status.clone();
}
s.index = body.state.index;
if !body.state.name.is_empty() {
s.name = body.state.name.clone();
}
if !body.state.media_type.is_empty() {
s.media_type = body.state.media_type.clone();
}
}
let s2 = state.playback.lock().await;
EVENTS.broadcast(&ServerEvent {
action: "state_update".into(),
state: Some(s2.clone()),
volume: None,
play_mode: None,
image_duration: None,
});
Json(OkResponse { ok: true })
}
// ===================== Handler: SSE =====================
async fn sse_handler() -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let rx = EVENTS.subscribe();
let stream = BroadcastStream::new(rx).map(|msg| match msg {
Ok(data) => Ok(Event::default().data(data)),
Err(_) => Ok(Event::default().data("")),
});
Sse::new(stream).keep_alive(
KeepAlive::new()
.interval(std::time::Duration::from_secs(15)),
)
}
+197
View File
@@ -0,0 +1,197 @@
use crate::models::*;
use once_cell::sync::Lazy;
use std::path::PathBuf;
use std::sync::Mutex;
/// 数据文件路径:系统数据目录 /dpm/data.json
fn db_path() -> PathBuf {
let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from("."));
path.push("dpm");
std::fs::create_dir_all(&path).ok();
path.push("data.json");
path
}
/// 持久化数据结构
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct PersistentData {
settings: std::collections::HashMap<String, Settings>,
playlist: Vec<PlaylistItem>,
url_media: Vec<UrlMediaItem>,
}
impl Default for PersistentData {
fn default() -> Self {
let mut s = std::collections::HashMap::new();
s.insert("admin".into(), Settings::default());
Self {
settings: s,
playlist: Vec::new(),
url_media: Vec::new(),
}
}
}
/// 数据存储
pub struct Storage {
inner: Mutex<PersistentData>,
path: PathBuf,
}
impl Storage {
pub fn new() -> Self {
let path = db_path();
let data = if path.exists() {
std::fs::read_to_string(&path)
.ok()
.and_then(|c| serde_json::from_str(&c).ok())
.unwrap_or_default()
} else {
PersistentData::default()
};
let storage = Self {
inner: Mutex::new(data),
path,
};
storage.save();
storage
}
fn save(&self) {
if let Ok(data) = self.inner.lock() {
if let Ok(json) = serde_json::to_string_pretty(&*data) {
std::fs::write(&self.path, json).ok();
}
}
}
// === Settings ===
pub fn get_settings(&self) -> Settings {
self.inner
.lock()
.ok()
.and_then(|d| d.settings.get("admin").cloned())
.unwrap_or_default()
}
pub fn update_settings(&self, update: SettingsUpdate) {
if let Ok(mut data) = self.inner.lock() {
if let Some(s) = data.settings.get_mut("admin") {
if let Some(v) = update.volume {
s.volume = v;
}
if let Some(m) = update.play_mode {
s.play_mode = m;
}
if let Some(d) = update.image_duration {
s.image_duration = d;
}
}
}
self.save();
}
// === Playlist ===
pub fn get_playlist(&self) -> Vec<PlaylistItem> {
self.inner
.lock()
.ok()
.map(|d| d.playlist.clone())
.unwrap_or_default()
}
pub fn add_to_playlist(&self, path: &str) {
if let Ok(mut data) = self.inner.lock() {
if !data.playlist.iter().any(|p| p.path == path) {
let mut item = PlaylistItem {
path: path.into(),
source: None,
name: None,
};
if path.starts_with("http://") || path.starts_with("https://") {
item.source = Some("url".into());
if let Some(um) = data.url_media.iter().find(|u| u.url == path) {
item.name = Some(um.name.clone());
}
}
data.playlist.push(item);
}
}
self.save();
}
pub fn remove_from_playlist(&self, path: &str) {
if let Ok(mut data) = self.inner.lock() {
data.playlist.retain(|p| p.path != path);
}
self.save();
}
// === URL Media ===
pub fn add_url_media(&self, url: &str, name: String, media_type: String) -> bool {
let is_new = if let Ok(mut data) = self.inner.lock() {
if !data.url_media.iter().any(|u| u.url == url) {
data.url_media.push(UrlMediaItem {
url: url.into(),
name,
media_type,
added_at: chrono::Local::now()
.format("%Y-%m-%dT%H:%M:%S%.3f")
.to_string(),
});
true
} else {
false
}
} else {
false
};
if is_new {
self.save();
}
is_new
}
pub fn get_url_media(&self) -> Vec<UrlMediaItem> {
self.inner
.lock()
.ok()
.map(|d| d.url_media.clone())
.unwrap_or_default()
}
pub fn remove_url_media(&self, url: &str) {
if let Ok(mut data) = self.inner.lock() {
data.url_media.retain(|u| u.url != url);
}
self.save();
}
// === Delete ===
pub fn delete_media(&self, path: &str) {
if path.starts_with("http://") || path.starts_with("https://") {
self.remove_url_media(path);
}
self.remove_from_playlist(path);
}
}
// 全局共享实例
pub static STORAGE: Lazy<Storage> = Lazy::new(Storage::new);
/// 判断文件扩展名是否允许
pub fn is_allowed_extension(path: &str) -> bool {
let allowed = [".mp4", ".mkv", ".avi", ".jpg", ".jpeg", ".png"];
let lower = path.to_lowercase();
allowed.iter().any(|ext| lower.ends_with(ext))
}
/// 获取媒体类型
pub fn get_media_type(path: &str) -> &'static str {
let lower = path.to_lowercase();
if lower.ends_with(".mp4") || lower.ends_with(".mkv") || lower.ends_with(".avi") {
"video"
} else {
"image"
}
}
+3 -3
View File
@@ -12,9 +12,9 @@
"app": {
"windows": [
{
"title": "dpm",
"width": 800,
"height": 600
"title": "大屏媒体轮播系统",
"width": 1200,
"height": 800
}
],
"security": {