Files
CC-StationBroadcast/CloudServer/public/music.html
T
Henry_Du 304d413482 feat(station): group slave nodes by station target
Merge stations with the same station code and platform into one logical target, and expand music and temp broadcast playback to all online slave nodes while the console and picker UIs show grouped station entries with node counts.
2026-08-01 07:44:38 +08:00

438 lines
17 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>音乐播放</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg-body: #09090b;
--bg-panel: #18181b;
--bg-hover: #27272a;
--bg-border: #27272a;
--text-primary: #e4e4e7;
--text-muted: #71717a;
--accent: #fafafa;
--danger: #ef4444;
--radius: 6px;
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--font-mono: 'JetBrains Mono', 'Menlo', monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 24px;
background: var(--bg-body);
color: var(--text-primary);
font-family: var(--font-ui);
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.title {
font-size: 1.1rem;
font-weight: 600;
}
.btn {
border: 1px solid var(--bg-border);
background: transparent;
color: var(--text-primary);
padding: 8px 10px;
border-radius: var(--radius);
cursor: pointer;
font-size: 0.85rem;
}
.btn:hover { background: var(--bg-hover); }
.btn-primary {
background: var(--accent);
color: #09090b;
border-color: var(--accent);
}
.btn-primary:hover { filter: brightness(0.92); background: var(--accent); }
.btn-danger {
background: var(--danger);
border-color: var(--danger);
color: white;
}
.btn-danger:hover { filter: brightness(0.92); background: var(--danger); }
.grid {
display: grid;
grid-template-columns: 1fr 380px;
gap: 16px;
}
.panel {
background: var(--bg-panel);
border: 1px solid var(--bg-border);
border-radius: var(--radius);
padding: 14px;
overflow: hidden;
}
.card-title {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.panel h3 {
margin: 0;
font-size: 0.9rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 999px;
border: 1px solid var(--bg-border);
background: #0f0f12;
color: var(--text-muted);
font-size: 0.78rem;
font-family: var(--font-mono);
}
label {
display: block;
margin: 10px 0 6px 0;
font-size: 0.85rem;
color: var(--text-muted);
}
select, input[type="text"] {
width: 100%;
padding: 10px;
border-radius: var(--radius);
border: 1px solid var(--bg-border);
background: #0f0f12;
color: var(--text-primary);
font-family: var(--font-ui);
font-size: 0.9rem;
outline: none;
}
.row {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
margin-top: 10px;
}
.status {
margin-top: 10px;
padding: 10px;
border-radius: var(--radius);
border: 1px solid var(--bg-border);
background: #0f0f12;
font-family: var(--font-mono);
font-size: 0.8rem;
white-space: pre-wrap;
}
.stations {
max-height: 420px;
overflow: auto;
border: 1px solid var(--bg-border);
border-radius: var(--radius);
background: #0f0f12;
}
.station-toolbar {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
align-items: center;
margin-top: 10px;
}
.station-item {
display: flex;
gap: 10px;
padding: 8px 10px;
border-bottom: 1px solid var(--bg-border);
align-items: center;
}
.station-item:last-child { border-bottom: none; }
.station-name { flex: 1; }
.station-meta {
font-family: var(--font-mono);
color: var(--text-muted);
font-size: 0.75rem;
}
@media (max-width: 980px) {
.grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="topbar">
<div class="title">音乐播放</div>
<div class="row" style="margin-top:0">
<a class="btn" href="/index.html" style="text-decoration:none; display:inline-flex; align-items:center;">返回控制台</a>
</div>
</div>
<div class="grid">
<div class="panel">
<div class="card-title">
<h3>控制</h3>
<span class="badge" id="track_badge">未选择</span>
</div>
<label for="track_select">选择音乐(来自 /opt/CC-StationBroadcast/Audio/music)</label>
<div class="row" style="margin-top:0">
<select id="track_select" onchange="onTrackChange()" style="flex:1; min-width: 200px;"></select>
<button class="btn" onclick="loadTracks()">刷新</button>
<button class="btn btn-primary" onclick="document.getElementById('local_file_input').click()">从本地选择</button>
<input type="file" id="local_file_input" accept=".wav,.mp3,.ogg,.flac,.m4a,.aac,.opus,.dfpwm" style="display:none" onchange="uploadLocalFile(this)" />
</div>
<div class="row">
<label style="margin:0; display:flex; align-items:center; gap:8px; color:var(--text-muted); font-size:0.85rem;">
<input type="checkbox" id="loop_chk" />
循环播放
</label>
</div>
<div class="row">
<button class="btn btn-primary" onclick="startMusic()">开始播放</button>
<button class="btn btn-danger" onclick="stopMusic()">停止播放</button>
</div>
<div class="status" id="status_box">就绪</div>
</div>
<div class="panel">
<div class="card-title">
<h3>目标站台</h3>
<span class="badge" id="sel_badge">已选 0</span>
</div>
<div class="station-toolbar">
<input type="text" id="station_filter" placeholder="搜索站台…" oninput="applyStationFilter()" />
<button class="btn" onclick="reloadStations()">刷新</button>
</div>
<div class="row">
<button class="btn" onclick="selectAll(true)">全选</button>
<button class="btn" onclick="selectAll(false)">全不选</button>
</div>
<div class="stations" id="stations_box" style="margin-top:10px;"></div>
</div>
</div>
<script>
function setStatus(msg) {
document.getElementById("status_box").textContent = String(msg || "");
}
function escapeHtml(str) {
return String(str || "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function getSelectedStations() {
const nodes = document.querySelectorAll('input[data-station-name="1"]:checked');
const names = [];
for (const n of nodes) names.push(n.getAttribute("data-name"));
return names;
}
function updateSelectedBadge() {
const n = getSelectedStations().length;
document.getElementById("sel_badge").textContent = `已选 ${n}`;
}
function applyStationFilter() {
const keyword = String(document.getElementById("station_filter").value || "").trim().toLowerCase();
const rows = document.querySelectorAll("#stations_box .station-item");
for (const row of rows) {
const nameEl = row.querySelector(".station-name");
const metaEl = row.querySelector(".station-meta");
const text = `${nameEl ? nameEl.textContent : ""} ${metaEl ? metaEl.textContent : ""}`.toLowerCase();
row.style.display = keyword === "" || text.includes(keyword) ? "" : "none";
}
}
function selectAll(on) {
const nodes = document.querySelectorAll('input[data-station-name="1"]');
for (const n of nodes) n.checked = !!on;
updateSelectedBadge();
}
async function reloadStations() {
const box = document.getElementById("stations_box");
box.innerHTML = "";
setStatus("加载站台列表中…");
try {
const resp = await fetch("/api/stations");
const data = await resp.json();
if (!data || !data.success) throw new Error(data?.message || "加载失败");
const stations = Array.isArray(data.stations) ? data.stations : [];
if (stations.length === 0) {
box.innerHTML = '<div class="station-item" style="color:var(--text-muted); font-style:italic;">暂无在线/已注册站台</div>';
setStatus("站台列表为空");
return;
}
for (const st of stations) {
const name = st.name || "";
const target = st.target || name;
const code = st.code || "";
const memberCount = Number(st.memberCount || 1);
const lastSeen = st.lastSeen ? new Date(st.lastSeen).toLocaleString() : "";
const row = document.createElement("div");
row.className = "station-item";
row.innerHTML = `
<input type="checkbox" data-station-name="1" data-name="${escapeHtml(target)}" onchange="updateSelectedBadge()" />
<div class="station-name">${escapeHtml(name)}</div>
<div class="station-meta">${escapeHtml(code)}${memberCount > 1 ? " · 从机 " + memberCount : ""}${lastSeen ? " · " + escapeHtml(lastSeen) : ""}</div>
`;
box.appendChild(row);
}
setStatus("站台列表已加载");
updateSelectedBadge();
applyStationFilter();
} catch (e) {
setStatus("加载站台失败: " + (e?.message || String(e)));
}
}
function onTrackChange() {
const sel = document.getElementById("track_select");
const v = String(sel.value || "").trim();
document.getElementById("track_badge").textContent = v ? v : "未选择";
}
async function loadTracks() {
const sel = document.getElementById("track_select");
sel.innerHTML = "";
setStatus("加载音乐列表中…");
try {
const resp = await fetch("/api/music/tracks");
const data = await resp.json();
if (!data || !data.success) throw new Error(data?.message || "加载失败");
const tracks = Array.isArray(data.tracks) ? data.tracks : [];
if (tracks.length === 0) {
sel.innerHTML = '<option value="">(无可用音频)</option>';
onTrackChange();
setStatus("没有可用音频(请上传到 /opt/CC-StationBroadcast/Audio/music)");
return;
}
sel.appendChild(new Option("请选择…", ""));
for (const t of tracks) sel.appendChild(new Option(t, t));
onTrackChange();
setStatus(`音乐列表已加载:${tracks.length} 个`);
} catch (e) {
sel.innerHTML = '<option value="">(加载失败)</option>';
onTrackChange();
setStatus("加载音乐列表失败: " + (e?.message || String(e)));
}
}
async function uploadLocalFile(input) {
const file = input.files[0];
if (!file) return;
const formData = new FormData();
formData.append("generalAudioFiles", file);
setStatus(`正在上传 ${file.name}…`);
try {
const resp = await fetch("/api/upload/music-audio", {
method: "POST",
body: formData
});
if (!resp.ok) {
const text = await resp.text();
let msg = `服务器返回错误 (${resp.status})`;
try {
const errData = JSON.parse(text);
if (errData.message) msg += `: ${errData.message}`;
} catch {
msg += `: ${text.slice(0, 100)}${text.length > 100 ? "..." : ""}`;
}
throw new Error(msg);
}
const data = await resp.json();
if (!data || !data.success) throw new Error(data?.message || "上传失败");
setStatus(`文件 ${file.name} 上传成功,正在刷新列表…`);
await loadTracks();
// Try to select the uploaded file
const baseName = String(file.name || "").replace(/\.(wav|mp3|ogg|flac|m4a|aac|opus|dfpwm)$/i, "").trim();
const sel = document.getElementById("track_select");
for (let i = 0; i < sel.options.length; i++) {
if (sel.options[i].value === baseName) {
sel.selectedIndex = i;
onTrackChange();
break;
}
}
setStatus(`已选择上传的文件: ${baseName}`);
} catch (e) {
setStatus("上传失败: " + (e?.message || String(e)));
} finally {
input.value = ""; // Clear for next selection
}
}
async function startMusic() {
const stations = getSelectedStations();
const track = String(document.getElementById("track_select").value || "").trim();
const loop = Boolean(document.getElementById("loop_chk").checked);
if (!track) {
setStatus("请选择音乐");
return;
}
if (stations.length === 0) {
setStatus("请至少勾选一个站台");
return;
}
setStatus("开始播放中…");
try {
const resp = await fetch("/api/music/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ stations, track, loop })
});
const data = await resp.json();
if (!data || !data.success) throw new Error(data?.message || "启动失败");
setStatus(`已下发播放:成功=${data.sent || 0} 失败=${data.failed || 0}`);
} catch (e) {
setStatus("启动失败: " + (e?.message || String(e)));
}
}
async function stopMusic() {
const stations = getSelectedStations();
if (stations.length === 0) {
setStatus("请至少勾选一个站台");
return;
}
setStatus("停止中…");
try {
const resp = await fetch("/api/music/stop", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ stations })
});
const data = await resp.json();
if (!data || !data.success) throw new Error(data?.message || "停止失败");
setStatus(`已停止:${data.stopped || 0} 个站台`);
} catch (e) {
setStatus("停止失败: " + (e?.message || String(e)));
}
}
reloadStations();
loadTracks();
</script>
</body>
</html>