Files
RadioPlayer/modules/web.html
T
2026-04-11 14:36:31 +02:00

549 lines
28 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="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Radio95 Track Monitor</title>
<style>
:root{
--panel:#11161c; --muted:#98a0a8; --accent:#3ea6ff; --card:#0b1014;
--success:#47d18b; --danger:#ff6b6b; --glass: rgba(255,255,255,0.03);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
}
html,body{height:100%; margin:0; background:linear-gradient(180deg,#071018,#0b1220 60%); color:#eef2f6; overflow: hidden;}
.container{max-width:1400px; margin:18px auto; padding:18px; display:flex; flex-direction:column; gap:12px}
h1{margin:0; font-size:22px; font-weight:700}
.layout{display:flex; gap:14px; height:calc(100vh - 140px);}
.panel{background:var(--panel); border-radius:10px; padding:12px; box-shadow: 0 6px 18px rgba(2,8,16,0.6); display:flex; flex-direction:column; overflow:hidden}
.left{flex:1.2; min-width:420px}
.right{flex:1; display:flex; flex-direction:column; gap:12px}
/* Collapsible Header Styling */
.frame-title{ font-size:16px; color:var(--muted); margin-bottom:8px; display: flex; justify-content: space-between; align-items: center; cursor: pointer; user-select: none; padding: 4px 0; transition: color 0.2s ease; }
.frame-title:hover { color: #fff; }
.frame-title::after { content: '▼'; font-size: 10px; transition: transform 0.3s ease; opacity: 0.5; }
.box { transition: flex 0.4s cubic-bezier(0.4, 0, 0.2, 1); }
.box.collapsed { flex-grow: 0 !important; flex-shrink: 0; flex-basis: auto !important; }
.box.collapsed .collapsible-content { display: none; }
.box.collapsed .frame-title::after { transform: rotate(90deg); }
.box.collapsed .frame-title { margin-bottom: 0; }
.now, .next {margin-bottom:12px}
.now .track, .next .track {font-weight:600; font-size:14px; word-break:break-all}
.progress-wrap{display:flex; gap:10px; align-items:center}
.progress-bar{flex:1; height:9px; background:var(--glass); border-radius:8px; overflow:hidden; position:relative}
.progress-fill{height:100%; width:0%; background:linear-gradient(90deg,var(--accent), #6cc7ff); transition:width 0.2s linear}
button, .btn{ background:transparent; border:1px solid rgba(255,255,255,0.06); padding:8px 12px; border-radius:8px; color:inherit; cursor:pointer; transition: background-color 0.2s ease, transform 0.15s ease; }
button:hover, .btn:hover { background-color: var(--glass); transform: translateY(-1px); }
button:active, .btn:active { transform: translateY(0); }
.btn.activated { background:linear-gradient(90deg, #9ed3ff46, #5bbbff2a); border-color: #5bbbff55; }
.btn.primary{background:linear-gradient(90deg,var(--accent), #6cc7ff); color:#001; border:none; font-weight:600}
.btn.primary:hover { background:linear-gradient(90deg, #3399ee, #5bbaff); }
ul.playlist{list-style:none; padding:6px; margin:0; font-family: "Consolas",monospace; font-size:13px;}
ul.playlist li{ padding:6px 8px; border-radius:6px; display:flex; justify-content:space-between; gap:8px; transition: background-color 0.2s ease, transform 0.15s ease; }
ul.playlist li:hover{ background:rgba(255,255,255,0.04); transform: translateX(2px); }
ul.playlist li.current{background:rgba(62,166,255,0.08)}
ul.playlist li.selected{background:rgba(62, 165, 255, 0.305)}
ul.playlist li.pointer{background:rgba(255,255,255,0.04)}
.controls{display:flex; gap:8px; margin-top:8px}
.box{display:flex; flex-direction:column; gap:8px; flex-grow: 1; overflow: hidden;}
.listbox{
height:100%;
min-height:100px;
overflow:auto;
border-radius:6px;
background:var(--card);
padding:8px;
font-size:14px;
scrollbar-width: none; /* For Firefox */
}
.listbox::-webkit-scrollbar {
display: none; /* For Chrome, Safari, and other WebKit browsers */
}
.listbox div.item{ padding:6px; border-radius:6px; transition: background-color 0.2s ease; }
.listbox div.item:hover{background:rgba(255,255,255,0.04); cursor:pointer}
.listbox div.item.selected{background:rgba(62, 165, 255, 0.305)}
/* --- STICKY QUEUE IMPLEMENTATION --- */
#section-playlist {
flex: 3; /* Takes more space by default */
min-height: 100px;
}
#section-queue {
/* flex: 1 allows it to grow into any space left by the playlist */
flex: 1;
min-height: 160px; /* It will be at least this tall */
display: flex;
flex-direction: column;
}
/* Ensure the inner listbox fills the new height */
#section-queue .collapsible-content {
flex-grow: 1;
}
/* When playlist is collapsed, let the queue expand */
#section-playlist.collapsed + #section-queue {
flex: 1 1 auto !important;
}
/* Default behavior: playlist dominates */
#section-playlist {
flex: 3 1 0;
}
#section-queue {
flex: 1 1 0;
}
#section-playlist,
#section-queue {
transition: flex 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.small{font-size:12px; color:var(--muted)}
.muted{color:var(--muted); font-size:13px}
.footer{display:flex; justify-content:space-between; gap:8px; align-items:center; margin-top:8px}
.directory-grid{display:grid; grid-template-columns:1fr 1fr; gap:12px; flex-grow:1; overflow:hidden}
#keybinds {display: flex; gap: 10px; align-items: center; white-space: nowrap; }
#keybinds kbd {background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.12); border-radius: 4px; padding: 2px 6px; font-family: "Consolas", monospace; font-size: 12px; }
@media (max-width:800px){
html, body{overflow: auto; height: auto}
.container{padding: 10px; margin: 0}
.layout{flex-direction: column; height: auto}
.left{min-width:auto}
.directory-grid {grid-template-columns: 1fr; height: auto; overflow: visible}
.listbox {min-height: 200px}
button, .btn {padding: 10px 14px}
.panel.right { height: auto; }
/* On mobile, revert to a stacking layout instead of sticky */
#section-playlist, #section-queue { flex: 1 1 auto; }
}
</style>
</head>
<body>
<div class="container">
<div style="display:flex;align-items:center;justify-content:space-between">
<h1>RadioPlayer status</h1>
</div>
<div class="layout">
<!-- Left Panel -->
<div class="panel left">
<div class="now">
<div class="frame-title" style="cursor: default; pointer-events: none;">Now Playing</div>
<div class="track" id="now-track">Loading...</div>
<div class="progress-wrap" style="margin-top:8px">
<div class="progress-bar" title="progress"><div class="progress-fill" id="prog-fill"></div></div>
<div class="small" id="time-label">00:00 / 00:00 (00:00)</div>
</div>
<div class="controls" style="margin-top:10px">
<button id="skip-btn" class="btn">⏭ Skip</button>
<button id="clear-btn" class="btn">✖ Clear Queue</button>
<div style="display:flex;align-items:center;gap:4px;border:1px solid rgba(255,255,255,0.06);border-radius:8px;padding:2px 4px">
<button id="skpn-dec" class="btn" style="padding:4px 10px;border:none"></button>
<span style="font-size:13px;min-width:60px;text-align:center">Skip next: <b id="skpn-count">0</b></span>
<button id="skpn-inc" class="btn" style="padding:4px 10px;border:none">+</button>
</div>
<button id="jingle-btn" class="btn">🕭 Jingle</button>
</div>
</div>
<div class="next" style="margin-top:12px">
<div class="frame-title" style="cursor: default; pointer-events: none;">Next Track</div>
<div class="track" id="next-track">Loading...</div>
</div>
<div style="margin-top:12px; display:flex; flex-direction: column; flex-grow: 1; overflow: hidden; gap: 12px;">
<!-- Playlist Viewer -->
<div class="box" id="section-playlist">
<div class="frame-title" onclick="toggleSection('section-playlist')">Playlist Viewer</div>
<div class="collapsible-content listbox" style="flex-grow:1">
<div class="listbox" id="playlist-box" style="height: 100%;">
<ul class="playlist" id="playlist-ul"></ul>
</div>
</div>
</div>
<!-- Queue -->
<div class="box" id="section-queue">
<div class="frame-title" onclick="toggleSection('section-queue')">Queue</div>
<div class="collapsible-content listbox" id="queue-box" style="flex-grow: 1;">
<ul class="playlist" id="queue-ul"></ul>
</div>
</div>
</div>
</div>
<!-- Right Panel -->
<div class="panel right">
<div class="directory-grid">
<div class="box" id="section-dirs">
<div class="frame-title" onclick="toggleSection('section-dirs')">Directory Browser</div>
<div class="collapsible-content listbox" id="dirs-box"></div>
</div>
<div class="box" id="section-subdir">
<div class="frame-title" onclick="toggleSection('section-subdir')">Subdirectory Files</div>
<div class="collapsible-content listbox" id="subdir-box"></div>
</div>
</div>
<div style="margin-top:8px; display: flex; gap: 8px;">
<button id="add-to-queue-btn" class="btn primary" style="flex: 1;">Add to Bottom</button>
<button id="add-to-queue2-btn" class="btn primary" style="flex: 1;">Add to Top</button>
</div>
</div>
</div>
<div class="footer">
<div class="muted">
Server: <span id="server-status">connecting...</span>
</div>
<div class="muted small" id="keybinds">
<b>Keys:</b>
<span><kbd>S</kbd> skip</span>
<span><kbd>N</kbd> +skip</span>
<span><kbd>M</kbd> -skip</span>
<span><kbd>J</kbd> jingle</span>
<span><kbd>ENTER</kbd> add file</span>
</div>
</div>
<script>
let ws = null;
let reconnectDelay = 1000;
let playlist = [];
let Queue = [];
let currentTrackPath = "";
let currentTrackIndex = 0;
let selectedPlaylistIndex = null;
let selectedDir = null;
let selectedSubFile = null;
let basePath = "";
let subbasePath = "";
let skipCount = 0;
// UI Toggle Function
function toggleSection(id) {
document.getElementById(id).classList.toggle('collapsed');
}
// Mobile Default Setup
function initLayout() {
if (window.innerWidth <= 800) {
// Collapse major sections by default on mobile
document.getElementById('section-playlist').classList.add('collapsed');
document.getElementById('section-dirs').classList.add('collapsed');
document.getElementById('section-subdir').classList.add('collapsed');
}
}
function connectWs(){
document.getElementById("server-status").textContent = "connecting...";
ws = new WebSocket("/ws");
ws.addEventListener("open", () => {
document.getElementById("server-status").textContent = "connected";
reconnectDelay = 1000;
ws.send(JSON.stringify({action:"get_toplay"}));
});
ws.addEventListener("close", () => {
document.getElementById("server-status").textContent = "disconnected — reconnecting...";
setTimeout(connectWs, reconnectDelay);
reconnectDelay = Math.min(10000, reconnectDelay + 1000);
});
ws.addEventListener("error", (e) => {
console.warn("WS error", e);
document.getElementById("server-status").textContent = "error";
});
ws.addEventListener("message", (evt) => {
try {
handleMessage(JSON.parse(evt.data));
} catch (e) {
console.warn("Bad msg", evt.data, e);
}
});
}
function handleMessage(msg){
if(msg.event === "state"){
const d = msg.data || {};
applyProgressState(d.track);
updateDirs(d.dirs);
if(d.playlist) { playlist = d.playlist; renderPlaylist(); }
} else if(msg.event === "playlist"){
playlist = msg.data || [];
renderPlaylist();
} else if(msg.event === "new_track"){
applyTrackState(msg.data);
ws.send(JSON.stringify({action:"get_toplay"}));
} else if(msg.event === "progress") applyProgressState(msg.data);
else if(msg.event === "toplay") {
Queue = msg.data.data || [];
renderQueue();
} else if(msg.event === "request_dir") applySubdir(msg.data || {})
else if(msg.event === "skipc") {
skipCount = msg.data?.data ?? 0;
document.getElementById("skpn-count").textContent = skipCount;
renderPlaylist();
renderQueue();
}
}
function applyTrackState(payload){
const track = payload.track || {};
const next = payload.next_track || {};
currentTrackPath = (track.official ? "" : "!") + (track.path||"<none>");
currentTrackIndex = payload.index;
document.getElementById("now-track").textContent = `${track.official ? "(official) " : "(unofficial)"}${track.path||"<none>"}`;
document.getElementById("next-track").textContent = `${next.official ? "(official) " : "(unofficial)"}${next.path||"<none>"}`;
renderPlaylist();
}
function applyProgressState(payload){
const track = payload.track || {};
const next_track = payload.next_track || {};
const elapsed = Number(payload.elapsed || 0);
const total = Number(payload.total || payload.real_total || 1) || 1;
const realtotal = Number(payload.real_total || payload.total || 1) || 1;
const percent = Math.max(0, Math.min(100, (elapsed/realtotal)*100));
document.getElementById("prog-fill").style.width = percent + "%";
document.getElementById("time-label").textContent = formatTime(elapsed) + " / " + formatTime(total) + ` (${formatTime(total-elapsed)})`;
currentTrackIndex = payload.index;
if(track.path){
currentTrackPath = (track.official ? "" : "!") + track.path;
document.getElementById("now-track").textContent = `${track.official ? "(official) " : ""}${track.path}`;
} if(next_track.path) document.getElementById("next-track").textContent = `${next_track.official ? "(official) " : ""}${next_track.path}`;
}
function applySubdir(payload) {
if(payload.dir !== selectedDir) return;
const dirsBox = document.getElementById("subdir-box");
dirsBox.innerHTML = "Loading...";
try {
subbasePath = payload.base || "";
const files = payload.files || [];
dirsBox.innerHTML = "";
files.sort().forEach(f => {
const node = document.createElement("div");
node.className = "item";
node.textContent = f;
node.addEventListener("click", () => {
if(node.classList.contains("selected")) {
node.classList.remove("selected");
selectedSubFile = null;
return;
}
Array.from(dirsBox.children).forEach(c=>c.classList.remove("selected"));
Array.from(document.getElementById("playlist-ul").children).forEach(c => c.classList.remove("selected"));
node.classList.add("selected"); selectedSubFile = f;
});
dirsBox.appendChild(node);
});
} catch(e) {
dirsBox.innerHTML = "Error fetching dirs: "+e.message;
}
}
function formatTime(s){
s = Number(s||0);
const h = Math.floor(s/3600), m = Math.floor((s%3600)/60), sec = Math.floor(s%60);
if(h != 0) return [h,m,sec].map(x => String(x).padStart(2,'0')).join(":");
else return [m,sec].map(x => String(x).padStart(2,'0')).join(":");
}
function renderPlaylist(){
const ul = document.getElementById("playlist-ul");
ul.innerHTML = "";
let currentIndex = null;
playlist.forEach((t, i) => {
const li = document.createElement("li");
const idx = i+1;
const path = t.path || "<no path>";
const official = t.official || false;
const displayPath = (official ? "" : "!") + path;
li.dataset.path = path;
li.dataset.idx = i;
li.addEventListener("click", () => { selectPlaylistItem(i, li); });
const p = li.dataset.path;
const currentPath = currentTrackPath.replace(/^!/, "");
if (p === currentPath && i === currentTrackIndex) {
li.classList.add("current");
currentIndex = i;
}
else if (i === currentTrackIndex) {
li.classList.add("pointer");
currentIndex = i - 1;
}
if(currentIndex !== null && Queue.length === 0 && i > currentIndex && i <= currentIndex + skipCount)
li.style.textDecoration = "line-through";
li.textContent = ` ${String(idx).padStart(2,'0')}: `;
li.textContent = li.textContent + (i === currentTrackIndex ? "▶ " : " ") + displayPath;
ul.appendChild(li);
});
if(currentIndex !== null){
const el = ul.children[currentIndex];
if(el) el.scrollIntoView({block:'center', behavior: 'smooth'});
}
updateControls();
}
function renderQueue() {
const ul = document.getElementById("queue-ul");
ul.innerHTML = "";
Queue.forEach((element, i) => {
const li = document.createElement("li");
li.textContent = element;
if(i < skipCount) li.style.textDecoration = "line-through";
ul.appendChild(li);
});
updateControls()
}
function selectPlaylistItem(i, el){
if(el.classList.contains("selected")) {
el.classList.remove("selected");
selectedPlaylistIndex = null;
return;
}
const ul = document.getElementById("playlist-ul");
Array.from(ul.children).forEach(c => c.classList.remove("selected"));
Array.from(document.getElementById("dirs-box").children).forEach(c => c.classList.remove("selected"));
Array.from(document.getElementById("subdir-box").children).forEach(c => c.classList.remove("selected"));
el.classList.add("selected");
selectedPlaylistIndex = i;
updateControls()
}
async function updateDirs(payload){
const dirsBox = document.getElementById("dirs-box");
dirsBox.innerHTML = "Loading...";
try {
basePath = payload.base || "";
const files = payload.files || [];
const dirs = payload.dirs || [];
dirsBox.innerHTML = "";
dirs.sort().forEach(f => {
const node = document.createElement("div");
node.className = "item";
node.textContent = f;
node.addEventListener("click", () => onDirClicked(f, node));
dirsBox.appendChild(node);
});
files.sort().forEach(f => {
const node = document.createElement("div");
node.className = "item";
node.textContent = f;
node.addEventListener("click", () => {
if(node.classList.contains("selected")) {
node.classList.remove("selected");
selectedDir = null;
selectedSubFile = null;
return;
}
Array.from(dirsBox.children).forEach(c=>c.classList.remove("selected"));
Array.from(document.getElementById("playlist-ul").children).forEach(c => c.classList.remove("selected"));
Array.from(document.getElementById("subdir-box").children).forEach(c => c.classList.remove("selected"));
node.classList.add("selected");
selectedDir = null;
selectedSubFile = null;
document.getElementById("subdir-box").innerHTML = "";
});
node.dataset.type = "file";
dirsBox.appendChild(node);
});
} catch(e) {
dirsBox.innerHTML = "Error fetching dirs: "+e.message;
}
}
function onDirClicked(name, node){
if(node.classList.contains("selected")) {
node.classList.remove("selected");
selectedDir = null;
selectedSubFile = null;
document.getElementById("subdir-box").innerHTML = "";
return;
}
Array.from(document.getElementById("dirs-box").children).forEach(c => c.classList.remove("selected"));
Array.from(document.getElementById("playlist-ul").children).forEach(c => c.classList.remove("selected"));
node.classList.add("selected");
selectedDir = name;
selectedSubFile = null;
ws.send(JSON.stringify({action:"request_dir", what: selectedDir}))
}
document.getElementById("skip-btn").addEventListener("click", () => ws.send(JSON.stringify({action:"skip"})));
document.getElementById("skpn-inc").addEventListener("click", () => ws.send(JSON.stringify({action:"skipc", add: 1})));
document.getElementById("skpn-dec").addEventListener("click", () => ws.send(JSON.stringify({action:"skipc", remove: -1})));
document.getElementById("jingle-btn").addEventListener("click", () => ws.send(JSON.stringify({action:"jingle"})));
document.getElementById("clear-btn").addEventListener("click", () => ws.send(JSON.stringify({action:"clear_toplay"})));
function addSelectedFileToQueue(top) {
let fullPath = null;
let success = false;
if (selectedPlaylistIndex != null) {
const selected = playlist[selectedPlaylistIndex];
const path = (selected.official ? "" : "!") + selected.path;
ws.send(JSON.stringify({ action: "add_to_toplay", songs: [path], top: top }));
success = true;
} else if (selectedSubFile && selectedDir) fullPath = subbasePath.replace(/\/$/, '') + '/' + selectedSubFile;
else {
const dirEls = document.getElementById("dirs-box").children;
const selectedItem = Array.from(dirEls).find(el => el.classList.contains("selected"));
if (selectedItem && selectedItem.dataset.type === "file") fullPath = basePath.replace(/\/$/, '') + '/' + selectedItem.textContent;
}
if (fullPath) {
ws.send(JSON.stringify({ action: "add_to_toplay", songs: [fullPath], top: top }));
success = true;
}
Array.from(document.getElementById("playlist-ul").children).forEach(c => c.classList.remove("selected"));
Array.from(document.getElementById("dirs-box").children).forEach(c => c.classList.remove("selected"));
Array.from(document.getElementById("subdir-box").children).forEach(c => c.classList.remove("selected"));
selectedPlaylistIndex = null;
selectedSubFile = null;
return success;
}
document.getElementById("add-to-queue-btn").addEventListener("click", () => addSelectedFileToQueue(false));
document.getElementById("add-to-queue2-btn").addEventListener("click", () => addSelectedFileToQueue(true));
function updateControls() {
document.getElementById("clear-btn").disabled = Queue.length === 0;
}
document.addEventListener("keydown", e => {
if (e.target.tagName === "INPUT") return;
if (e.key === "Enter" && addSelectedFileToQueue(e.shiftKey)) e.preventDefault();
else if (e.key === "s") ws.send(JSON.stringify({action:"skip"}));
else if (e.key === "n") ws.send(JSON.stringify({action:"skipc", add: 1}));
else if (e.key === "m") ws.send(JSON.stringify({action:"skipc", remove: -1}));
else if (e.key.toLowerCase() === "j") ws.send(JSON.stringify({action:"jingle", top: e.shiftKey}));
});
// Start
initLayout();
setTimeout(connectWs, 100);
</script>
</body>
</html>