194 lines
6.9 KiB
HTML
194 lines
6.9 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>MusicRoom</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #111; color: #eee; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
|
|
#app { width: 100%; max-width: 480px; padding: 2rem; }
|
|
h1 { font-size: 1.2rem; color: #888; margin-bottom: 1.5rem; }
|
|
#stream-select { margin-bottom: 1.5rem; }
|
|
#stream-select select { background: #222; color: #eee; border: 1px solid #333; padding: 0.4rem 0.8rem; border-radius: 4px; font-size: 0.9rem; }
|
|
#now-playing { margin-bottom: 1rem; }
|
|
#track-name { font-size: 1.4rem; font-weight: 600; margin-bottom: 0.3rem; }
|
|
#stream-name { font-size: 0.85rem; color: #888; }
|
|
#progress-container { background: #222; border-radius: 4px; height: 6px; margin: 1rem 0; cursor: pointer; }
|
|
#progress-bar { background: #555; height: 100%; border-radius: 4px; width: 0%; transition: width 0.3s linear; }
|
|
#progress-bar.playing { background: #4e8; }
|
|
#time { display: flex; justify-content: space-between; font-size: 0.8rem; color: #888; margin-bottom: 1rem; }
|
|
#controls { display: flex; gap: 1rem; align-items: center; }
|
|
#volume { width: 100px; accent-color: #4e8; }
|
|
button { background: #222; color: #eee; border: 1px solid #333; padding: 0.5rem 1.2rem; border-radius: 4px; cursor: pointer; font-size: 0.9rem; }
|
|
button:hover { background: #333; }
|
|
#status { margin-top: 1.5rem; font-size: 0.8rem; color: #666; }
|
|
.empty { color: #666; font-style: italic; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="app">
|
|
<h1>MusicRoom</h1>
|
|
<div id="stream-select"></div>
|
|
<div id="now-playing">
|
|
<div id="track-name" class="empty">Loading...</div>
|
|
<div id="stream-name"></div>
|
|
</div>
|
|
<div id="progress-container"><div id="progress-bar"></div></div>
|
|
<div id="time"><span id="time-current">0:00</span><span id="time-total">0:00</span></div>
|
|
<div id="controls">
|
|
<button id="btn-listen">Unlisten</button>
|
|
<button id="btn-play">Play</button>
|
|
<button id="btn-pause" disabled>Pause</button>
|
|
<button id="btn-stop">Stop</button>
|
|
<button id="btn-server-pause">Server Pause</button>
|
|
<input type="range" id="volume" min="0" max="1" step="0.01" value="1">
|
|
</div>
|
|
<div id="status"></div>
|
|
</div>
|
|
<script>
|
|
(function() {
|
|
const audio = new Audio();
|
|
let ws = null;
|
|
let currentStreamId = null;
|
|
let currentFilename = null;
|
|
let serverTimestamp = 0;
|
|
let serverTrackDuration = 0;
|
|
let lastServerUpdate = 0;
|
|
let serverPaused = false;
|
|
let listening = true;
|
|
|
|
const $ = (s) => document.querySelector(s);
|
|
|
|
function fmt(sec) {
|
|
if (!sec || !isFinite(sec)) return "0:00";
|
|
const m = Math.floor(sec / 60);
|
|
const s = Math.floor(sec % 60);
|
|
return m + ":" + String(s).padStart(2, "0");
|
|
}
|
|
|
|
function getServerTime() {
|
|
if (serverPaused) return serverTimestamp;
|
|
return serverTimestamp + (Date.now() - lastServerUpdate) / 1000;
|
|
}
|
|
|
|
// Update progress bar from server time (always), use audio time when playing
|
|
setInterval(() => {
|
|
if (!listening || serverTrackDuration <= 0) return;
|
|
const t = audio.paused ? getServerTime() : audio.currentTime;
|
|
const dur = audio.paused ? serverTrackDuration : (audio.duration || serverTrackDuration);
|
|
const pct = Math.min((t / dur) * 100, 100);
|
|
$("#progress-bar").style.width = pct + "%";
|
|
$("#time-current").textContent = fmt(t);
|
|
$("#time-total").textContent = fmt(dur);
|
|
}, 250);
|
|
|
|
// Load streams
|
|
async function loadStreams() {
|
|
const res = await fetch("/api/streams");
|
|
const streams = await res.json();
|
|
if (streams.length === 0) {
|
|
$("#track-name").textContent = "No streams available";
|
|
return;
|
|
}
|
|
if (streams.length > 1) {
|
|
const sel = document.createElement("select");
|
|
for (const s of streams) {
|
|
const opt = document.createElement("option");
|
|
opt.value = s.id;
|
|
opt.textContent = s.name;
|
|
sel.appendChild(opt);
|
|
}
|
|
sel.onchange = () => connectStream(sel.value);
|
|
$("#stream-select").appendChild(sel);
|
|
}
|
|
connectStream(streams[0].id);
|
|
}
|
|
|
|
function connectStream(id) {
|
|
if (ws) ws.close();
|
|
currentStreamId = id;
|
|
listening = true;
|
|
$("#btn-listen").textContent = "Unlisten";
|
|
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
|
ws = new WebSocket(proto + "//" + location.host + "/api/streams/" + id + "/ws");
|
|
ws.onmessage = (e) => handleUpdate(JSON.parse(e.data));
|
|
ws.onclose = () => { $("#status").textContent = "Disconnected. Reconnecting..."; setTimeout(() => { if (listening) connectStream(id); }, 3000); };
|
|
ws.onopen = () => { $("#status").textContent = "Connected"; };
|
|
}
|
|
|
|
function handleUpdate(data) {
|
|
if (!data.track) {
|
|
$("#track-name").textContent = "No tracks";
|
|
return;
|
|
}
|
|
$("#stream-name").textContent = data.streamName || "";
|
|
serverTimestamp = data.currentTimestamp;
|
|
serverTrackDuration = data.track.duration;
|
|
lastServerUpdate = Date.now();
|
|
serverPaused = data.paused || false;
|
|
|
|
if (data.track.filename !== currentFilename) {
|
|
// New track
|
|
currentFilename = data.track.filename;
|
|
$("#track-name").textContent = data.track.title;
|
|
if (!audio.paused) {
|
|
audio.src = "/api/tracks/" + encodeURIComponent(data.track.filename);
|
|
audio.currentTime = data.currentTimestamp;
|
|
audio.play().catch(() => {});
|
|
}
|
|
} else if (!audio.paused) {
|
|
// Same track - check drift
|
|
const drift = Math.abs(audio.currentTime - data.currentTimestamp);
|
|
if (drift >= 2) {
|
|
audio.currentTime = data.currentTimestamp;
|
|
}
|
|
}
|
|
}
|
|
|
|
$("#btn-listen").onclick = () => {
|
|
if (listening) {
|
|
listening = false;
|
|
if (ws) ws.close();
|
|
$("#btn-listen").textContent = "Listen";
|
|
$("#status").textContent = "Not listening";
|
|
} else {
|
|
connectStream(currentStreamId);
|
|
}
|
|
};
|
|
|
|
$("#btn-play").onclick = () => {
|
|
if (!currentFilename) return;
|
|
if (!audio.src) {
|
|
audio.src = "/api/tracks/" + encodeURIComponent(currentFilename);
|
|
}
|
|
audio.currentTime = getServerTime();
|
|
audio.play();
|
|
};
|
|
|
|
$("#btn-pause").onclick = () => {
|
|
audio.pause();
|
|
};
|
|
|
|
$("#btn-stop").onclick = () => {
|
|
audio.pause();
|
|
audio.src = "";
|
|
};
|
|
|
|
$("#btn-server-pause").onclick = () => {
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ action: serverPaused ? "unpause" : "pause" }));
|
|
}
|
|
};
|
|
|
|
$("#volume").oninput = (e) => { audio.volume = e.target.value; };
|
|
|
|
audio.onplay = () => { $("#btn-play").disabled = true; $("#btn-pause").disabled = false; $("#progress-bar").classList.add("playing"); };
|
|
audio.onpause = () => { $("#btn-play").disabled = false; $("#btn-pause").disabled = true; $("#progress-bar").classList.remove("playing"); };
|
|
|
|
loadStreams();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|