Files
CC-StationBroadcast/CloudServer/public/index.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

1542 lines
58 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Station Broadcast Console</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 {
/* Ultra-Dark / Minimalist Theme */
--bg-body: #09090b;
/* Zinc-950 */
--bg-panel: #18181b;
/* Zinc-900 */
--bg-hover: #27272a;
/* Zinc-800 */
--bg-border: #27272a;
/* Zinc-800 */
--text-primary: #e4e4e7;
/* Zinc-200 */
--text-muted: #71717a;
/* Zinc-500 */
--accent: #fafafa;
/* Zinc-50 */
--danger: #ef4444;
/* Red-500 */
--success: #10b981;
/* Emerald-500 */
--warning: #f59e0b;
/* Amber-500 */
--radius: 6px;
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--font-mono: 'JetBrains Mono', 'Menlo', monospace;
}
* {
box-sizing: border-box;
}
body {
font-family: var(--font-ui);
background-color: var(--bg-body);
color: var(--text-primary);
margin: 0;
padding: 0;
height: 100vh;
display: grid;
grid-template-rows: 1fr 4px 300px;
overflow: hidden;
}
/* --- Splitter / Resizer --- */
.resizer-horizontal {
height: 4px;
background: var(--bg-border);
cursor: row-resize;
transition: background 0.2s;
z-index: 50;
}
.resizer-horizontal:hover {
background: var(--text-muted);
}
.resizer-vertical {
width: 4px;
background: var(--bg-border);
cursor: col-resize;
transition: background 0.2s;
z-index: 50;
}
.resizer-vertical:hover {
background: var(--text-muted);
}
/* --- Main Area (Top): Status & File Manager --- */
.main-area {
display: grid;
grid-template-columns: 400px 4px 1fr;
/* Add col for resizer */
background: var(--bg-body);
overflow: hidden;
}
/* Left Sidebar: Status */
.sidebar {
background: var(--bg-body);
display: flex;
flex-direction: column;
overflow: hidden;
}
.version-panel {
padding: 16px 20px;
background: var(--bg-panel);
border-bottom: 1px solid var(--bg-border);
display: flex;
flex-direction: column;
gap: 8px;
}
.version-panel-header {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
display: flex;
justify-content: space-between;
align-items: center;
}
.version-display {
font-family: var(--font-mono);
font-size: 1.1rem;
color: var(--success);
font-weight: 500;
background: #000;
padding: 8px 12px;
border-radius: 4px;
border: 1px solid var(--bg-border);
text-align: center;
}
.section-header {
padding: 16px 20px;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--bg-border);
background: var(--bg-body);
display: flex;
justify-content: space-between;
align-items: center;
}
.status-list {
flex: 1;
overflow-y: auto;
padding: 0;
}
.status-item {
border-bottom: 1px solid var(--bg-border);
transition: background 0.1s;
}
.status-main {
padding: 12px 20px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
}
.status-main:hover {
background: var(--bg-hover);
}
.item-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.item-title {
font-weight: 500;
font-size: 0.95rem;
}
.item-sub {
font-size: 0.75rem;
color: var(--text-muted);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--bg-border);
}
.dot-online {
background: var(--success);
box-shadow: 0 0 8px rgba(16, 185, 129, 0.4);
}
.dot-offline {
background: var(--danger);
}
.dot-warn {
background: var(--warning);
}
.dot-broadcasting {
background: #3b82f6; /* Blue for broadcasting */
box-shadow: 0 0 8px rgba(59, 130, 246, 0.6);
animation: pulse 1s infinite;
}
@keyframes pulse {
0% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.5); }
100% { opacity: 1; transform: scale(1); }
}
/* Expandable Details */
.status-details {
display: none;
background: var(--bg-panel);
padding: 10px 20px;
font-size: 0.8rem;
color: var(--text-muted);
border-top: 1px solid var(--bg-border);
}
.status-details.open {
display: block;
}
.trip-log {
display: grid;
grid-template-columns: 80px 1fr auto;
gap: 10px;
padding: 4px 0;
border-bottom: 1px dashed var(--bg-border);
}
.trip-log:last-child {
border-bottom: none;
}
/* Right Content: File Manager */
.content-area {
background: var(--bg-body);
display: flex;
flex-direction: column;
overflow: hidden;
}
.fm-toolbar {
padding: 12px 24px;
border-bottom: 1px solid var(--bg-border);
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-body);
}
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
color: var(--text-muted);
}
.breadcrumb-item {
cursor: pointer;
transition: color 0.2s;
}
.breadcrumb-item:hover {
color: var(--text-primary);
}
.breadcrumb-item.active {
color: var(--text-primary);
font-weight: 600;
cursor: default;
}
.breadcrumb-sep {
opacity: 0.3;
}
.actions {
display: flex;
gap: 12px;
}
.btn {
background: transparent;
border: 1px solid var(--bg-border);
color: var(--text-primary);
padding: 6px 12px;
border-radius: var(--radius);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 6px;
}
.btn:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--text-muted);
}
.btn-primary {
background: var(--text-primary);
color: var(--bg-body);
border-color: var(--text-primary);
font-weight: 600;
}
.btn-primary:hover:not(:disabled) {
background: #fff;
border-color: #fff;
}
.btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.btn-icon {
padding: 8px;
border: none;
background: transparent;
color: var(--text-muted);
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.btn-icon:hover {
background: rgba(239, 68, 68, 0.15);
color: var(--danger);
}
.file-list {
flex: 1;
overflow-y: auto;
}
.file-header {
display: grid;
grid-template-columns: 40px 1fr 120px 100px;
padding: 10px 24px;
border-bottom: 1px solid var(--bg-border);
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
position: sticky;
top: 0;
background: var(--bg-body);
z-index: 10;
}
.file-row {
display: grid;
grid-template-columns: 40px 1fr 120px 100px;
padding: 12px 24px;
border-bottom: 1px solid var(--bg-border);
align-items: center;
cursor: pointer;
transition: background 0.1s;
}
.file-row:hover {
background: var(--bg-hover);
}
.file-icon {
width: 20px;
height: 20px;
color: var(--text-muted);
}
.file-name {
font-weight: 500;
margin-left: 12px;
}
.file-meta {
font-size: 0.8rem;
color: var(--text-muted);
}
/* --- Console (Bottom) --- */
.console-area {
background: #000;
/* Pure black for console */
border-top: 1px solid var(--bg-border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.console-header {
padding: 8px 20px;
background: var(--bg-panel);
border-bottom: 1px solid var(--bg-border);
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
display: flex;
justify-content: space-between;
}
.console-logs {
flex: 1;
padding: 16px 20px;
overflow-y: auto;
font-family: var(--font-mono);
font-size: 0.85rem;
display: flex;
flex-direction: column;
gap: 4px;
}
.log-entry {
display: flex;
gap: 12px;
line-height: 1.5;
}
.log-time {
color: var(--text-muted);
opacity: 0.5;
white-space: nowrap;
}
.log-msg {
color: #d4d4d8;
white-space: pre-wrap;
word-break: break-all;
}
.type-info {
color: #3b82f6;
}
.type-warn {
color: #eab308;
}
.type-err {
color: #ef4444;
}
.type-success {
color: #10b981;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
-webkit-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
.modal-overlay.active {
opacity: 1;
pointer-events: auto;
}
.modal {
background: var(--bg-panel);
border: 1px solid var(--bg-border);
border-radius: var(--radius);
width: 90%;
max-width: 800px;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5);
}
.modal-header {
padding: 16px 24px;
border-bottom: 1px solid var(--bg-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title {
font-weight: 600;
font-size: 1.1rem;
}
.modal-body {
padding: 24px;
overflow-y: auto;
flex: 1;
display: flex;
flex-direction: column;
gap: 16px;
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid var(--bg-border);
display: flex;
justify-content: flex-end;
gap: 12px;
background: var(--bg-body);
}
textarea.editor {
width: 100%;
height: 400px;
background: var(--bg-body);
border: 1px solid var(--bg-border);
border-radius: var(--radius);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 0.9rem;
padding: 16px;
resize: none;
outline: none;
}
textarea.editor:focus {
border-color: var(--text-muted);
}
/* Custom Audio Player */
.audio-player {
background: var(--bg-body);
border: 1px solid var(--bg-border);
border-radius: var(--radius);
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
}
.play-btn {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--text-primary);
color: var(--bg-body);
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 0.1s;
}
.play-btn:active {
transform: scale(0.95);
}
.progress-container {
flex: 1;
height: 6px;
background: var(--bg-hover);
border-radius: 3px;
position: relative;
cursor: pointer;
}
.progress-bar {
height: 100%;
background: var(--text-primary);
border-radius: 3px;
width: 0%;
transition: width 0.1s linear;
}
.time-display {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--text-muted);
min-width: 80px;
text-align: right;
}
/* Toasts */
.toast-container {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 200;
display: flex;
flex-direction: column;
gap: 10px;
}
.toast {
background: var(--bg-panel);
border: 1px solid var(--bg-border);
padding: 12px 20px;
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
gap: 12px;
min-width: 300px;
animation: slideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1);
color: var(--text-primary);
}
.toast-icon {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.toast-content {
flex: 1;
}
.toast-title {
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 2px;
}
.toast-msg {
font-size: 0.8rem;
color: var(--text-muted);
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Utilities */
input[type="checkbox"] {
appearance: none;
width: 16px;
height: 16px;
border: 1px solid var(--text-muted);
border-radius: 3px;
background: transparent;
cursor: pointer;
}
input[type="checkbox"]:checked {
background: var(--text-primary);
border-color: var(--text-primary);
}
input[type="checkbox"]:checked::after {
content: '';
position: absolute;
left: 5px;
top: 2px;
width: 4px;
height: 8px;
border: solid #000;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
input[type="file"] {
display: none;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--bg-hover);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #52525b;
}
</style>
</head>
<body>
<!-- Top Area: Sidebar (Status) + File Manager -->
<div class="main-area" id="main-area">
<!-- Sidebar: Status -->
<div class="sidebar" style="min-width: 200px; display:flex; flex-direction:column; height:100%;">
<!-- Version Control (Hidden since we don't use it anymore) -->
<div class="version-panel" style="display:none;">
<div class="version-panel-header">
<span>Audio Version</span>
<button class="btn-icon" onclick="fetchVersion()" title="Refresh Version">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg>
</button>
</div>
<div class="version-display" id="current-version">Loading...</div>
</div>
<!-- Stations -->
<div class="section-header">Active Stations</div>
<div class="status-list" id="station-list">
</div>
<!-- Trains -->
<div class="section-header" style="border-top:1px solid var(--bg-border)">Active Trains</div>
<div class="status-list" id="train-list" style="flex:1;">
</div>
</div>
<!-- Resizer Vertical -->
<div class="resizer-vertical" id="resizer-v"></div>
<!-- Content: Radio Stream -->
<div class="content-area" id="radio-view" style="display:none; min-width: 300px; padding: 20px; overflow-y: auto;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px; border-bottom:1px solid var(--bg-border); padding-bottom:10px;">
<h2 style="margin:0; font-weight:500; font-size:1.2rem; color:var(--text-primary);">Global Radio Stream</h2>
<button class="btn" onclick="showFileManager()">Back to Files</button>
</div>
<div id="radio-streams-container" style="display:grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap:16px;">
<!-- Stream cards will be generated here -->
<div style="grid-column: 1 / -1; text-align:center; padding:40px; color:var(--text-muted); font-style:italic;">
Waiting for broadcast streams...
</div>
</div>
</div>
<!-- Content: File Manager -->
<div class="content-area" id="file-manager-view" style="min-width: 300px;">
<div class="fm-toolbar">
<div class="breadcrumb" id="breadcrumb"></div>
<div class="actions">
<span id="selection-count"
style="font-size:0.8rem; color:var(--text-muted); display:flex; align-items:center;"></span>
<button class="btn btn-primary" onclick="showRadioStream()" title="View Radio Streams">Radio Stream</button>
<a href="/infoboard.html" target="_blank" class="btn btn-primary" style="text-decoration:none; background: var(--warning); color: black; border-color: var(--warning);">Full Info Board</a>
<a href="/generator.html" class="btn btn-primary" style="text-decoration:none">Broadcast Generator</a>
<a href="/tempbroadcast.html" class="btn btn-primary" style="text-decoration:none">Temp Broadcast</a>
<a href="/music.html" class="btn btn-primary" style="text-decoration:none">Music</a>
<a href="/listmanager.html" class="btn btn-primary" style="text-decoration:none">list.txt Manager</a>
<button class="btn" onclick="refreshFiles()">Refresh</button>
<button class="btn" id="btn-delete" disabled onclick="deleteSelected()">Delete</button>
<button class="btn btn-primary" id="btn-upload" onclick="triggerUpload()">Upload</button>
</div>
</div>
<div class="file-header">
<div style="display:flex;justify-content:center">
<input type="checkbox" id="select-all" title="Select All/None" aria-label="Select All/None" onclick="toggleSelectAll()">
</div>
<div>Name</div>
<div>Type</div>
<div style="text-align:right">Action</div>
</div>
<div class="file-list" id="file-list-body">
</div>
</div>
</div>
<!-- Resizer Horizontal -->
<div class="resizer-horizontal" id="resizer-h"></div>
<!-- Bottom Area: Console -->
<div class="console-area" style="min-height: 100px;">
<div class="console-header">
<div style="display:flex;align-items:center;gap:10px">
<span>System Output</span>
<span id="conn-status" style="color:var(--danger)">Disconnected</span>
</div>
<button class="btn" style="padding:2px 8px;font-size:0.7rem" onclick="forceSave()">Force Save Data</button>
</div>
<div class="console-logs" id="console-logs">
</div>
</div>
<!-- Hidden Inputs -->
<input type="file" id="upload-config" accept=".txt" onchange="performUpload('listFile', '/api/upload/list', this)">
<input type="file" id="upload-music" multiple accept=".wav,.mp3,.ogg,.flac,.m4a,.aac,.opus,.dfpwm"
onchange="performUpload('musicAudioFiles', '/api/upload/music-audio', this)">
<input type="file" id="upload-voice" multiple accept=".wav,.mp3,.ogg,.flac,.m4a,.aac,.opus,.dfpwm"
onchange="performUpload('voiceAudioFiles', '/api/upload/voice-audio', this)">
<input type="file" id="upload-general" multiple accept=".wav,.mp3,.ogg,.flac,.m4a,.aac,.opus,.dfpwm"
onchange="performUpload('generalAudioFiles', '/api/upload/general-audio', this)">
<input type="file" id="upload-station" multiple accept=".wav,.mp3,.ogg,.flac,.m4a,.aac,.opus,.dfpwm"
onchange="performUpload('stationAudioFiles', '/api/upload/station-audio', this)">
<!-- Preview Modal -->
<div class="modal-overlay" id="preview-modal">
<div class="modal">
<div class="modal-header">
<div class="modal-title" id="modal-title">File Preview</div>
<button class="btn-icon" onclick="closeModal()" title="Close Modal">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="modal-body" id="modal-content">
</div>
<div class="modal-footer" id="modal-footer">
<button class="btn" onclick="closeModal()">Close</button>
<button class="btn btn-primary" id="btn-save" onclick="saveFile()">Save</button>
</div>
</div>
</div>
<!-- Toast Container -->
<div class="toast-container" id="toast-container"></div>
<script>
// --- Icons (SVG) ---
const ICONS = {
folder: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#fbbf24"><path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 2H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z"/></svg>`,
audio: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#38bdf8"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>`,
file: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#94a3b8"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>`,
trash: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>`,
play: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M5 3l14 9-14 9V3z"/></svg>`,
pause: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>`,
chevronDown: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>`,
chevronUp: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>`
};
// --- State ---
let allFiles = [];
let currentPath = 'root';
let stationCodeMap = {};
let broadcastingStations = new Set();
// --- WebSocket ---
const connStatus = document.getElementById('conn-status');
let ws;
function connect() {
const wsProto = window.location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${wsProto}://${window.location.host}`);
ws.onopen = () => {
connStatus.textContent = 'Connected';
connStatus.style.color = 'var(--success)';
ws.send(JSON.stringify({ type: 'console_connect' }));
};
ws.onclose = () => {
connStatus.textContent = 'Disconnected (Reconnecting...)';
connStatus.style.color = 'var(--danger)';
setTimeout(connect, 3000);
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'init_state' || data.type === 'state_update') {
updateState(data);
if (data.logs && data.type === 'init_state') data.logs.forEach(l => addLog(l));
} else if (data.type === 'log') {
addLog(data.data);
} else if (data.type === 'event') {
showToast(data.title, data.message);
} else if (data.type === 'stream_start') {
broadcastingStations.add(data.station);
updateStationDots();
updateRadioStreams(data.station, data.seq, true, data.hlsUrl);
} else if (data.type === 'stream_end') {
broadcastingStations.delete(data.station);
updateStationDots();
updateRadioStreams(data.station, null, false);
}
};
}
connect();
// --- Renderers ---
function updateState(state) {
if (state.stations) {
const list = document.getElementById('station-list');
list.innerHTML = '';
const stList = Array.isArray(state.stations) ? state.stations : Object.values(state.stations);
stList.forEach(st => {
stationCodeMap[st.name] = st.code;
});
if (stList.length === 0) list.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text-muted);font-size:0.8rem">No active stations</div>';
stList.forEach(st => {
const isOnline = (Date.now() - st.lastSeen) < 60000;
const isBroadcasting = broadcastingStations.has(st.name);
const dotClass = isBroadcasting ? 'dot-broadcasting' : (isOnline ? 'dot-online' : 'dot-offline');
const hasCC = Array.isArray(st.tags) && st.tags.includes('CC');
const stationTarget = st.target || st.name;
const memberCount = Number(st.memberCount || 1);
const div = document.createElement('div');
div.className = 'status-item';
div.id = `station-item-${st.name.replace(/[^a-zA-Z0-9]/g, '_')}`;
div.innerHTML = `
<div class="status-main">
<div class="item-info">
<div class="item-title">
${st.name}
${hasCC ? '<span style="font-size:0.65rem; background:rgba(16, 185, 129, 0.1); color:#34d399; padding:1px 5px; border-radius:4px; margin-left:6px; font-weight:normal; border:1px solid rgba(16, 185, 129, 0.2)">CC</span>' : ''}
</div>
<div class="item-sub">${st.code}${st.platform ? ` - Platform ${st.platform}` : ''}${memberCount > 1 ? ` · Nodes ${memberCount}` : ''}</div>
</div>
<div style="display:flex;align-items:center;gap:10px">
<div class="status-dot ${dotClass}" id="station-dot-${st.name.replace(/[^a-zA-Z0-9]/g, '_')}" data-online="${isOnline}" data-name="${st.name}"></div>
<button class="btn-icon" onclick="deleteStation('${stationTarget}')">${ICONS.trash}</button>
</div>
</div>
`;
list.appendChild(div);
});
}
if (state.activeTrains) {
const list = document.getElementById('train-list');
list.innerHTML = '';
if (!state.activeTrains || state.activeTrains.length === 0) list.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text-muted);font-size:0.8rem">No active trains</div>';
state.activeTrains.forEach(tr => {
const div = document.createElement('div');
div.className = 'status-item';
const currentStCode = stationCodeMap[tr.currentStation] || tr.currentStation || '-';
div.innerHTML = `
<div class="status-main">
<div class="item-info">
<div class="item-title">${tr.trainId}</div>
<div class="item-sub">${currentStCode} <span style="margin-left:8px;color:var(--text-muted)">${tr.status || '-'}</span></div>
</div>
<div style="display:flex;align-items:center;gap:10px">
<div class="status-dot ${tr.status === 'At Station' ? 'dot-online' : 'dot-warn'}"></div>
<button class="btn-icon" onclick="deleteTrain('${tr.trainId}')" style="display:inline-flex">${ICONS.trash}</button>
</div>
</div>
`;
list.appendChild(div);
});
}
}
function showRadioStream() {
document.getElementById('file-manager-view').style.display = 'none';
document.getElementById('radio-view').style.display = 'flex';
document.getElementById('radio-view').style.flexDirection = 'column';
}
function showFileManager() {
document.getElementById('radio-view').style.display = 'none';
document.getElementById('file-manager-view').style.display = 'flex';
document.getElementById('file-manager-view').style.flexDirection = 'column';
}
function updateRadioStreams(station, seq, isStarting, hlsUrl) {
const container = document.getElementById('radio-streams-container');
const cardId = `stream-card-${station.replace(/[^a-zA-Z0-9]/g, '_')}`;
if (isStarting) {
// Clear empty state
if (container.innerHTML.includes('Waiting for broadcast streams...')) {
container.innerHTML = '';
}
let card = document.getElementById(cardId);
if (!card) {
card = document.createElement('div');
card.id = cardId;
card.style.cssText = 'background:var(--bg-panel); border:1px solid #3b82f6; border-radius:var(--radius); padding:16px; box-shadow:0 4px 12px rgba(59, 130, 246, 0.15); animation:slideIn 0.3s ease-out; position:relative; overflow:hidden; display:flex; flex-direction:column; gap:12px;';
container.appendChild(card);
}
const seqHtml = seq ? seq.map(s => `<span style="background:var(--bg-hover); padding:2px 6px; border-radius:4px; font-size:0.75rem; font-family:var(--font-mono); margin:2px; display:inline-block;">${s}</span>`).join('') : 'Unknown sequence';
card.innerHTML = `
<div style="position:absolute; top:0; left:0; right:0; height:3px; background:#3b82f6; animation:pulse 1s infinite;"></div>
<div style="display:flex; justify-content:space-between; align-items:center;">
<div style="font-weight:600; font-size:1.1rem;">${station}</div>
<div class="status-dot dot-broadcasting"></div>
</div>
${hlsUrl ? `<div style="font-size:0.8rem; color:var(--text-muted);">HLS: <a href="${hlsUrl}" target="_blank" rel="noreferrer" style="color:#60a5fa; text-decoration:none; font-family:var(--font-mono);">${hlsUrl}</a></div>` : ''}
<div>
<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:8px;">PLAYING SEQUENCE:</div>
<div style="display:flex; flex-wrap:wrap; gap:4px; max-height:100px; overflow-y:auto; padding-right:4px;">
${seqHtml}
</div>
</div>
`;
} else {
const card = document.getElementById(cardId);
if (card) {
card.style.opacity = '0';
card.style.transform = 'scale(0.95)';
card.style.transition = 'all 0.3s';
setTimeout(() => {
card.remove();
if (container.children.length === 0) {
container.innerHTML = '<div style="grid-column: 1 / -1; text-align:center; padding:40px; color:var(--text-muted); font-style:italic;">Waiting for broadcast streams...</div>';
}
}, 300);
}
}
}
function updateStationDots() {
const dots = document.querySelectorAll('[id^="station-dot-"]');
dots.forEach(dot => {
const name = dot.getAttribute('data-name');
const isOnline = dot.getAttribute('data-online') === 'true';
const isBroadcasting = broadcastingStations.has(name);
dot.className = 'status-dot ' + (isBroadcasting ? 'dot-broadcasting' : (isOnline ? 'dot-online' : 'dot-offline'));
});
}
function addLog(logText) {
const container = document.getElementById('console-logs');
const div = document.createElement('div');
div.className = 'log-entry';
const timeMatch = logText.match(/^\[(.*?)\]/);
let time = new Date().toLocaleTimeString();
let content = logText;
let typeClass = '';
if (timeMatch) {
// Try to parse ISO string to local time
const dateStr = timeMatch[1];
const date = new Date(dateStr);
if (!isNaN(date.getTime())) {
time = date.toLocaleTimeString();
} else {
// Fallback if not valid date
time = dateStr.split('T')[1]?.split('.')[0] || dateStr;
}
content = logText.substring(timeMatch[0].length).trim();
}
if (content.includes('[ERROR]')) typeClass = 'type-err';
else if (content.includes('[WARN]')) typeClass = 'type-warn';
else if (content.includes('[SUCCESS]')) typeClass = 'type-success';
else typeClass = 'type-info';
content = content.replace(/^\[.*?\]\s*/, '');
div.innerHTML = `<div class="log-time">${time}</div><div class="log-msg"><span class="${typeClass}">></span> ${content}</div>`;
container.prepend(div);
if (container.children.length > 100) container.lastChild.remove();
}
function showToast(title, message) {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = 'toast';
// Icon based on title/message
let iconColor = 'var(--text-primary)';
if (title.includes('Arrival')) iconColor = 'var(--success)';
if (title.includes('Departure')) iconColor = 'var(--warning)';
toast.innerHTML = `
<div class="toast-icon" style="background:${iconColor}; color:var(--bg-body)">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
</div>
<div class="toast-content">
<div class="toast-title">${title}</div>
<div class="toast-msg">${message}</div>
</div>
`;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateX(100%)';
setTimeout(() => toast.remove(), 300);
}, 5000);
}
async function forceSave() {
try {
await fetch('/api/save-data', { method: 'POST' });
addLog('[INFO] Data save requested');
} catch (e) {
addLog('[ERROR] Save request failed');
}
}
// --- File Manager ---
async function refreshFiles() {
try {
const res = await fetch('/api/files');
const data = await res.json();
allFiles = data.files;
renderFiles();
} catch (e) { console.error(e); }
}
function renderFiles() {
const container = document.getElementById('file-list-body');
const breadcrumb = document.getElementById('breadcrumb');
const btnUpload = document.getElementById('btn-upload');
container.innerHTML = '';
// Breadcrumb
if (currentPath === 'root') {
breadcrumb.innerHTML = '<div class="breadcrumb-item active">Home</div>';
btnUpload.disabled = true;
} else {
const nameMap = {
config: 'Configuration',
music: 'Music',
voice: 'Voice',
beep: 'Beep',
station: 'Station'
};
const name = nameMap[currentPath] || currentPath;
breadcrumb.innerHTML = `
<div class="breadcrumb-item" onclick="navigate('root')">Home</div>
<div class="breadcrumb-sep">/</div>
<div class="breadcrumb-item active">${name}</div>
`;
btnUpload.disabled = false;
}
// Content
if (currentPath === 'root') {
const folders = [
{ id: 'config', name: 'Configuration', count: allFiles.filter(f => f.type === 'config').length },
{ id: 'music', name: 'Music', count: allFiles.filter(f => f.type === 'music').length },
{ id: 'voice', name: 'Voice', count: allFiles.filter(f => f.type === 'voice').length },
{ id: 'beep', name: 'Beep', count: allFiles.filter(f => f.type === 'beep').length },
{ id: 'station', name: 'Station', count: allFiles.filter(f => f.type === 'station').length }
];
folders.forEach(f => {
const row = document.createElement('div');
row.className = 'file-row';
row.onclick = () => navigate(f.id);
row.innerHTML = `
<div></div>
<div style="display:flex;align-items:center;gap:10px">${ICONS.folder}<span class="file-name">${f.name}</span></div>
<div class="file-meta">${f.count} items</div>
<div></div>
`;
container.appendChild(row);
});
document.getElementById('select-all').disabled = true;
} else {
document.getElementById('select-all').disabled = false;
const files = allFiles.filter(f => f.type === currentPath);
if (files.length === 0) container.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text-muted)">Empty folder</div>';
files.forEach(f => {
const typeLabelMap = {
config: 'Configuration',
music: 'Music',
voice: 'Voice',
beep: 'Beep',
station: 'Station'
};
const row = document.createElement('div');
row.className = 'file-row';
// Click to preview
row.onclick = (e) => {
if (e.target.type !== 'checkbox' && e.target.tagName !== 'BUTTON' && e.target.tagName !== 'path' && e.target.tagName !== 'svg') {
openPreview(f);
}
};
row.innerHTML = `
<div style="display:flex;justify-content:center"><input type="checkbox" class="file-check" value="${f.path}" onchange="updateSelection()"></div>
<div style="display:flex;align-items:center;gap:10px">${f.name.endsWith('.txt') ? ICONS.file : ICONS.audio}<span class="file-name">${f.name}</span></div>
<div class="file-meta">${typeLabelMap[f.type] || f.type}</div>
<div style="text-align:right">
<button class="btn-icon" onclick="deleteFile('${f.path}')">${ICONS.trash}</button>
</div>
`;
container.appendChild(row);
});
}
updateSelection();
}
function navigate(path) {
currentPath = path;
document.getElementById('select-all').checked = false;
renderFiles();
}
function toggleSelectAll() {
const main = document.getElementById('select-all');
document.querySelectorAll('.file-check').forEach(c => c.checked = main.checked);
updateSelection();
}
function updateSelection() {
const count = document.querySelectorAll('.file-check:checked').length;
const btn = document.getElementById('btn-delete');
const span = document.getElementById('selection-count');
if (count > 0) {
btn.disabled = false;
span.textContent = `${count} selected`;
} else {
btn.disabled = true;
span.textContent = '';
}
}
// --- Actions ---
async function deleteFile(path) {
await fetch(`/api/files?path=${encodeURIComponent(path)}`, { method: 'DELETE' });
refreshFiles();
}
async function deleteSelected() {
const checks = document.querySelectorAll('.file-check:checked');
const btn = document.getElementById('btn-delete');
btn.textContent = '...';
for (const c of checks) await deleteFile(c.value);
btn.textContent = 'Delete';
refreshFiles();
}
async function deleteStation(name) {
await fetch(`/api/stations/${encodeURIComponent(name)}`, { method: 'DELETE' });
}
async function deleteTrain(id) {
await fetch(`/api/trains/${encodeURIComponent(id)}`, { method: 'DELETE' });
}
function triggerUpload() {
if (currentPath === 'config') document.getElementById('upload-config').click();
else if (currentPath === 'music') document.getElementById('upload-music').click();
else if (currentPath === 'voice') document.getElementById('upload-voice').click();
else if (currentPath === 'beep') document.getElementById('upload-general').click();
else if (currentPath === 'station') document.getElementById('upload-station').click();
}
async function performUpload(field, url, input) {
if (input.files.length === 0) return;
const btn = document.getElementById('btn-upload');
const originalText = btn.textContent;
const total = input.files.length;
btn.disabled = true;
const BATCH_SIZE = 5;
let successCount = 0;
const uploadBatch = async (files) => {
const formData = new FormData();
for (let f of files) formData.append(field, f);
try {
await fetch(url, { method: 'POST', body: formData });
return files.length;
} catch (e) { return 0; }
};
if (field === 'listFile') {
btn.textContent = 'Uploading...';
const formData = new FormData();
formData.append(field, input.files[0]);
try { await fetch(url, { method: 'POST', body: formData }); } catch (e) { }
} else {
const fileArray = Array.from(input.files);
for (let i = 0; i < total; i += BATCH_SIZE) {
const batch = fileArray.slice(i, i + BATCH_SIZE);
const progress = Math.min(Math.round(((i + batch.length) / total) * 100), 100);
btn.textContent = `Uploading ${progress}%`;
await uploadBatch(batch);
}
}
btn.textContent = originalText;
btn.disabled = false;
input.value = '';
refreshFiles();
}
// --- Preview & Edit ---
let currentPreviewFile = null;
let audioInstance = null;
async function openPreview(file) {
currentPreviewFile = file;
const modal = document.getElementById('preview-modal');
const title = document.getElementById('modal-title');
const content = document.getElementById('modal-content');
const saveBtn = document.getElementById('btn-save');
title.textContent = file.name;
content.innerHTML = '<div style="text-align:center; color:var(--text-muted)">Loading...</div>';
modal.classList.add('active');
if (file.name.endsWith('.txt')) {
saveBtn.style.display = 'block';
try {
const res = await fetch(`/api/files/content?path=${encodeURIComponent(file.path)}`);
const data = await res.json();
if (data.success) {
content.innerHTML = `<textarea class="editor" id="file-editor" spellcheck="false">${data.content}</textarea>`;
} else {
content.innerHTML = `<div style="color:var(--danger)">Error: ${data.message}</div>`;
}
} catch (e) {
content.innerHTML = `<div style="color:var(--danger)">Failed to load content</div>`;
}
} else if (/\.(wav|mp3|ogg|flac|m4a|aac|opus)$/i.test(file.name)) {
saveBtn.style.display = 'none';
content.innerHTML = `
<div class="audio-player">
<button class="play-btn" id="audio-play-btn">${ICONS.play}</button>
<div class="progress-container" id="audio-progress">
<div class="progress-bar" id="audio-bar"></div>
</div>
<span class="time-display" id="audio-time">0:00 / 0:00</span>
</div>
`;
initAudioPlayer(file.path);
} else {
saveBtn.style.display = 'none';
content.innerHTML = `
<div style="text-align:center; padding: 40px; color:var(--text-muted)">
<div>Preview not available for this file type</div>
${file.name.endsWith('.dfpwm') ? '<div style="margin-top:8px; font-size:0.8rem">DFPWM requires in-game player</div>' : ''}
</div>
`;
}
}
function initAudioPlayer(path) {
if (audioInstance) { audioInstance.pause(); audioInstance = null; }
let source = '/' + path;
if (path.startsWith('Audio/music/')) {
source = `/media/audio/music/${encodeURIComponent(path.slice('Audio/music/'.length))}`;
} else if (path.startsWith('Audio/voice/')) {
source = `/media/audio/voice/${encodeURIComponent(path.slice('Audio/voice/'.length))}`;
} else if (path.startsWith('Audio/general/')) {
source = `/media/audio/general/${encodeURIComponent(path.slice('Audio/general/'.length))}`;
} else if (path.startsWith('Audio/station/')) {
source = `/media/audio/station/${encodeURIComponent(path.slice('Audio/station/'.length))}`;
}
const audio = new Audio(source);
audioInstance = audio;
const btn = document.getElementById('audio-play-btn');
const progress = document.getElementById('audio-progress');
const bar = document.getElementById('audio-bar');
const timeDisplay = document.getElementById('audio-time');
const formatTime = (s) => {
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, '0')}`;
};
audio.addEventListener('loadedmetadata', () => {
timeDisplay.textContent = `0:00 / ${formatTime(audio.duration)}`;
});
audio.addEventListener('timeupdate', () => {
if (!audio.duration) return;
const pct = (audio.currentTime / audio.duration) * 100;
bar.style.width = `${pct}%`;
timeDisplay.textContent = `${formatTime(audio.currentTime)} / ${formatTime(audio.duration)}`;
});
audio.addEventListener('ended', () => {
btn.innerHTML = ICONS.play;
bar.style.width = '0%';
});
btn.addEventListener('click', () => {
if (audio.paused) {
audio.play();
btn.innerHTML = ICONS.pause;
} else {
audio.pause();
btn.innerHTML = ICONS.play;
}
});
progress.addEventListener('click', (e) => {
const rect = progress.getBoundingClientRect();
const x = e.clientX - rect.left;
const pct = x / rect.width;
audio.currentTime = pct * audio.duration;
});
}
async function saveFile() {
if (!currentPreviewFile || !currentPreviewFile.name.endsWith('.txt')) return;
const editor = document.getElementById('file-editor');
if (!editor) return;
const btn = document.getElementById('btn-save');
const originalText = btn.textContent;
btn.textContent = 'Saving...';
btn.disabled = true;
try {
const res = await fetch('/api/files/content', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
path: currentPreviewFile.path,
content: editor.value
})
});
const data = await res.json();
if (data.success) {
btn.textContent = 'Saved';
setTimeout(() => { btn.textContent = originalText; btn.disabled = false; }, 1000);
} else {
alert('Save failed: ' + data.message);
btn.textContent = originalText;
btn.disabled = false;
}
} catch (e) {
alert('Save error');
btn.textContent = originalText;
btn.disabled = false;
}
}
function closeModal() {
document.getElementById('preview-modal').classList.remove('active');
if (audioInstance) {
audioInstance.pause();
audioInstance = null;
}
currentPreviewFile = null;
}
// Init
refreshFiles();
fetchVersion();
async function fetchVersion() {
try {
const res = await fetch('/api/version');
const data = await res.json();
if (data.version) {
document.getElementById('current-version').textContent = data.version;
}
} catch (e) {
document.getElementById('current-version').textContent = 'Error';
}
}
// --- Resizers ---
const resizerV = document.getElementById('resizer-v');
const resizerH = document.getElementById('resizer-h');
const mainArea = document.getElementById('main-area');
const body = document.body;
// Vertical Resizer (Sidebar width)
let isResizingV = false;
resizerV.addEventListener('mousedown', (e) => {
isResizingV = true;
document.body.style.cursor = 'col-resize';
e.preventDefault();
});
// Horizontal Resizer (Console height)
let isResizingH = false;
resizerH.addEventListener('mousedown', (e) => {
isResizingH = true;
document.body.style.cursor = 'row-resize';
e.preventDefault();
});
window.addEventListener('mousemove', (e) => {
if (isResizingV) {
// Constrain width (min 200px, max 600px)
const newWidth = Math.max(200, Math.min(e.clientX, 600));
mainArea.style.gridTemplateColumns = `${newWidth}px 4px 1fr`;
}
if (isResizingH) {
// Constrain console height (min 100px, max 80vh)
const containerHeight = window.innerHeight;
const newConsoleHeight = containerHeight - e.clientY;
const clampedHeight = Math.max(100, Math.min(newConsoleHeight, containerHeight - 200));
body.style.gridTemplateRows = `1fr 4px ${clampedHeight}px`; // Add 4px for splitter
}
});
window.addEventListener('mouseup', () => {
if (isResizingV || isResizingH) {
isResizingV = false;
isResizingH = false;
document.body.style.cursor = 'default';
}
});
</script>
</body>
</html>