Files
RadioPlayer/modules/web.html
T
2026-04-16 16:49:18 +02:00

649 lines
35 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: "Segoe UI", Roboto, "Helvetica Neue", Arial;
font-weight: 400;
}
html,body{height:100%; margin:0; background:linear-gradient(180deg,#071018,#0b1220 60%); color:#eef2f6; overflow: hidden;}
.container{max-width:1600px; margin:16px auto; padding:16px; display:flex; flex-direction:column; gap:10px}
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:8px; 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:6px}
.frame-title-nc{ font-size:16px; color:var(--muted); margin-bottom:6px; display: flex; justify-content: space-between; align-items: center; user-select: none; padding: 4px 0; transition: color 0.2s ease; }
.frame-title{ font-size:16px; color:var(--muted); margin-bottom:6px; 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.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 .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:10px; 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.5s 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.15s ease, transform 0.15s ease; }
button:hover, .btn:hover { background-color: var(--glass); }
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:6px; margin-top:6px}
.box {display:flex; flex-direction:column; gap:6px; flex-grow: 1; overflow: hidden; transition: flex 0.4s cubic-bezier(0.4, 0, 0.2, 1);}
.listbox{
height:100%;
min-height:100px;
overflow:auto;
border-radius:6px;
background:var(--card);
padding:8px;
font-size:14px;
scrollbar-width: none;
}
.listbox::-webkit-scrollbar { display: none; }
.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)}
#section-playlist { flex: 3 1 0; }
#section-queue { flex: 1 1 0; display: flex; flex-direction: column; }
#section-queue .collapsible-content { flex-grow: 1; }
#section-queue.collapsed .collapsible-content { display: none; }
#section-playlist.collapsed + #section-queue { flex: 1 1 auto !important; }
.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: flex;
gap: 12px;
flex-grow: 1;
overflow: hidden;
}
.directory-grid > .box {
flex: 1 1 0;
min-width: 0; /* important for overflow handling */
}
#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; }
/* ── WHEP Player ── */
#whep-section { flex-shrink: 0 }
.whep-row { display: flex; gap: 8px; align-items: center; }
#whep-url-input {
flex: 1; font-size: 12px; font-family: "Consolas", monospace;
background: var(--card); border: 1px solid rgba(255,255,255,0.08);
border-radius: 6px; color: #eef2f6; padding: 5px 8px;
outline: none;
}
#whep-url-input:focus { border-color: rgba(62,166,255,0.4); }
.whep-status-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--muted); flex-shrink: 0; transition: background 0.3s;
}
.whep-status-dot.connecting { background: #f0c040; }
.whep-status-dot.connected { background: var(--success); box-shadow: 0 0 6px rgba(71,209,139,0.5); }
.whep-status-dot.error { background: var(--danger); }
.whep-vol-row { display: flex; gap: 8px; align-items: center; }
#whep-vol {
flex: 1; -webkit-appearance: none; appearance: none;
height: 4px; background: rgba(255,255,255,0.12); border-radius: 4px; outline: none;
}
#whep-vol::-webkit-slider-thumb {
-webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%;
background: var(--accent); cursor: pointer;
}
#whep-vol::-moz-range-thumb {
width: 14px; height: 14px; border-radius: 50%;
background: var(--accent); border: none; cursor: pointer;
}
#whep-log {
font-family: "Consolas", monospace; font-size: 11px;
background: var(--card); border-radius: 6px;
padding: 6px 8px; max-height: 120px; overflow-y: auto;
scrollbar-width: none; line-height: 1.6;
}
#whep-log::-webkit-scrollbar { display: none; }
.wlog-ok { color: var(--success); }
.wlog-err { color: var(--danger); }
.wlog-info { color: var(--muted); }
@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; }
#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-nc" 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="small" id="rds-text" style="margin-top:6px; color:var(--muted); font-family:'Consolas',monospace;"></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-nc">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;">
<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>
<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">
<!-- WHEP Player Section -->
<div class="box" id="whep-section">
<div class="frame-title" onclick="toggleSection('whep-section')">
<span style="display:flex;align-items:center;gap:8px;">
<span id="whep-dot" class="whep-status-dot"></span>
Live Player
</span>
</div>
<div class="collapsible-content">
<div class="whep-row">
<input id="whep-url-input" type="text" value="https://batorego.flerken.pl.eu.org/webrtc/radio/whep" spellcheck="false" />
<button id="whep-btn" class="btn" onclick="whepToggle()" style="white-space:nowrap;padding:6px 12px;">▶ Connect</button>
</div>
<div class="whep-vol-row">
<span class="small">Vol</span>
<input type="range" id="whep-vol" min="0" max="1" step="0.01" value="0.8" oninput="whepSetVol(this.value)" />
<span class="small" id="whep-vol-out" style="min-width:30px;text-align:right">80%</span>
</div>
<div id="whep-log"></div>
</div>
</div>
<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;
function toggleSection(id) {
document.getElementById(id).classList.toggle('collapsed');
}
function initLayout() {
if (window.innerWidth <= 800) {
document.getElementById('section-playlist').classList.add('collapsed');
document.getElementById('section-dirs').classList.add('collapsed');
document.getElementById('section-subdir').classList.add('collapsed');
}
}
function connectWs(){
const statusText = document.getElementById("server-status");
statusText.textContent = "connecting...";
ws = new WebSocket("/ws");
ws.addEventListener("open", () => {
statusText.textContent = "connected";
reconnectDelay = 1500;
ws.send(JSON.stringify({action:"get_toplay"}));
ws.send(JSON.stringify({action:"skipc"}));
});
ws.addEventListener("close", () => {
statusText.textContent = "disconnected — reconnecting...";
setTimeout(connectWs, reconnectDelay);
reconnectDelay = Math.min(10000, reconnectDelay + 1500);
});
ws.addEventListener("error", (e) => {
console.error("WS error", e);
statusText.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 || {};
if(d.track) applyProgressState(d.track);
if(d.dirs) updateDirs(d.dirs);
} else if (msg.event === "rds") {
const rt = (msg.data?.rt) ?? "";
document.getElementById("rds-text").textContent = rt ?? "";
} 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"}));
ws.send(JSON.stringify({action:"skipc"}));
} 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 => {
if(!f.startsWith(".")) {
const node = document.createElement("div");
node.className = "item";
node.textContent = f;
node.addEventListener("click", () => onDirClicked(f, node));
dirsBox.appendChild(node);
}
});
files.sort().forEach(f => {
if(!f.startsWith(".")) {
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}));
});
/* ── WHEP Player logic ── */
let whepPc = null;
let whepAudio = null;
let whepConnected = false;
function whepLog(msg, type = 'info') {
const el = document.getElementById('whep-log');
const line = document.createElement('div');
line.className = 'wlog-' + type;
const ts = new Date().toLocaleTimeString("pl-PL");
line.textContent = `[${ts}] ${msg}`;
el.appendChild(line);
el.scrollTop = el.scrollHeight;
// Keep log short
while (el.children.length > 30) el.removeChild(el.firstChild);
}
function whepSetDot(state) {
const dot = document.getElementById('whep-dot');
dot.className = 'whep-status-dot' + (state !== 'idle' ? ' ' + state : '');
const btn = document.getElementById('whep-btn');
if (state === 'connected' || state === 'connecting') {
btn.textContent = '⏹ Disconnect';
btn.classList.add('activated');
} else {
btn.textContent = '▶ Connect';
btn.classList.remove('activated');
}
}
function whepSetVol(v) {
document.getElementById('whep-vol-out').textContent = Math.round(v * 100) + '%';
if (whepAudio) whepAudio.volume = parseFloat(v);
}
function whepToggle() {
if (whepConnected || whepPc) { whepDisconnect(); return; }
whepConnect();
}
function whepDisconnect() {
whepConnected = false;
if (whepPc) { try { whepPc.close(); } catch(e){} whepPc = null; }
if (whepAudio) { whepAudio.pause(); whepAudio.srcObject = null; whepAudio = null; }
whepSetDot('idle');
whepLog('Disconnected');
}
async function whepConnect() {
const url = document.getElementById('whep-url-input').value.trim();
if (!url) return;
whepSetDot('connecting');
whepLog('Creating peer connection…');
try {
whepPc = new RTCPeerConnection();
whepPc.ontrack = (e) => {
whepLog('Track received, starting playback', 'ok');
whepAudio = new Audio();
whepAudio.srcObject = e.streams[0];
whepAudio.volume = parseFloat(document.getElementById('whep-vol').value);
whepAudio.play().then(() => {
whepLog('Audio playing', 'ok');
whepConnected = true;
whepSetDot('connected');
}).catch(() => {
whepLog('Autoplay blocked — click anywhere to resume', 'err');
document.addEventListener('click', () => whepAudio && whepAudio.play(), { once: true });
});
};
whepPc.onconnectionstatechange = () => {
whepLog('State: ' + whepPc.connectionState);
if (whepPc.connectionState === 'failed' || whepPc.connectionState === 'disconnected') {
whepLog('Connection lost', 'err');
whepDisconnect();
whepSetDot('error');
}
};
whepPc.oniceconnectionstatechange = () => whepLog('ICE: ' + whepPc.iceConnectionState);
whepPc.addTransceiver('audio', { direction: 'recvonly' });
const offer = await whepPc.createOffer();
offer.sdp = offer.sdp
.replace(/useinbandfec=1/g, 'useinbandfec=1;stereo=1;sprop-stereo=1')
.replace(/minptime=10/g, 'minptime=10;ptime=10;maxptime=10');
await whepPc.setLocalDescription(offer);
whepLog('Sending offer to ' + url);
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/sdp', 'Accept': 'application/sdp' },
body: whepPc.localDescription.sdp
});
if (!resp.ok) throw new Error(`Server returned ${resp.status} ${resp.statusText}`);
const answerSdp = await resp.text();
whepLog(`Got SDP answer (${answerSdp.length} bytes)`, 'ok');
await whepPc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
whepLog('Waiting for ICE + track…');
} catch(err) {
whepLog('Error: ' + err.message, 'err');
whepDisconnect();
whepSetDot('error');
}
}
// Start
initLayout();
setTimeout(connectWs, 100);
</script>
</body>
</html>