147 lines
6.3 KiB
HTML
147 lines
6.3 KiB
HTML
|
|
<!DOCTYPE html>
|
||
|
|
<html lang="en">
|
||
|
|
<head>
|
||
|
|
<meta charset="utf-8">
|
||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
|
|
<title>{{ domain }}</title>
|
||
|
|
<style>
|
||
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
|
|
body {
|
||
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||
|
|
background: #0d1117; color: #c9d1d9;
|
||
|
|
min-height: 100vh; padding: 2rem;
|
||
|
|
max-width: 700px; margin: 0 auto;
|
||
|
|
}
|
||
|
|
h1 { color: #e6edf3; margin-bottom: .25rem; font-size: 1.6rem; }
|
||
|
|
.subtitle { color: #8b949e; margin-bottom: 2rem; font-size: .9rem; }
|
||
|
|
.live-banner {
|
||
|
|
background: #1a0e0e; border: 1px solid #da3633;
|
||
|
|
border-radius: 8px; padding: 1.25rem; margin-bottom: 2rem;
|
||
|
|
text-align: center;
|
||
|
|
}
|
||
|
|
.live-banner .badge {
|
||
|
|
display: inline-block; background: #da3633; color: #fff;
|
||
|
|
font-weight: 700; font-size: .75rem; padding: .2rem .6rem;
|
||
|
|
border-radius: 4px; margin-bottom: .5rem; letter-spacing: .05em;
|
||
|
|
animation: pulse 2s ease-in-out infinite;
|
||
|
|
}
|
||
|
|
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .6; } }
|
||
|
|
.live-banner a {
|
||
|
|
display: block; color: #f0f6fc; font-size: 1.1rem;
|
||
|
|
font-weight: 600; text-decoration: none; margin-top: .4rem;
|
||
|
|
}
|
||
|
|
.live-banner .note { color: #8b949e; font-size: .85rem; margin-top: .5rem; }
|
||
|
|
.live-banner a:hover { text-decoration: underline; }
|
||
|
|
.offline-banner {
|
||
|
|
background: #161b22; border: 1px solid #30363d;
|
||
|
|
border-radius: 8px; padding: 1.25rem; margin-bottom: 2rem;
|
||
|
|
text-align: center; color: #8b949e;
|
||
|
|
}
|
||
|
|
section { margin-bottom: 2rem; }
|
||
|
|
h2 { color: #e6edf3; font-size: 1.1rem; margin-bottom: .75rem;
|
||
|
|
border-bottom: 1px solid #21262d; padding-bottom: .4rem; }
|
||
|
|
.stream-list { list-style: none; }
|
||
|
|
.stream-list li {
|
||
|
|
background: #161b22; border: 1px solid #21262d;
|
||
|
|
border-radius: 6px; padding: .75rem 1rem; margin-bottom: .5rem;
|
||
|
|
display: flex; justify-content: space-between; align-items: center;
|
||
|
|
}
|
||
|
|
.stream-path { color: #58a6ff; font-weight: 600; font-family: monospace; }
|
||
|
|
.stream-time { color: #8b949e; font-size: .85rem; }
|
||
|
|
.stream-duration { color: #8b949e; font-size: .8rem; font-style: italic; }
|
||
|
|
.empty { color: #484f58; font-style: italic; }
|
||
|
|
code {
|
||
|
|
background: #161b22; border: 1px solid #30363d;
|
||
|
|
border-radius: 4px; padding: .15rem .4rem;
|
||
|
|
font-size: .85rem; color: #79c0ff;
|
||
|
|
}
|
||
|
|
pre {
|
||
|
|
background: #161b22; border: 1px solid #30363d;
|
||
|
|
border-radius: 6px; padding: 1rem; overflow-x: auto;
|
||
|
|
font-size: .85rem; color: #c9d1d9; margin: .5rem 0;
|
||
|
|
}
|
||
|
|
.instructions p { margin-bottom: .6rem; line-height: 1.5; }
|
||
|
|
.instructions ol { padding-left: 1.5rem; margin-bottom: .6rem; }
|
||
|
|
.instructions li { margin-bottom: .4rem; line-height: 1.5; }
|
||
|
|
</style>
|
||
|
|
</head>
|
||
|
|
<body>
|
||
|
|
<h1>{{ domain }}</h1>
|
||
|
|
<p class="subtitle">Live streaming</p>
|
||
|
|
<div id="status"><div class="offline-banner">Checking stream status…</div></div>
|
||
|
|
<section id="history-section">
|
||
|
|
<h2>Recent streams</h2>
|
||
|
|
<div id="history"><p class="empty">Loading…</p></div>
|
||
|
|
</section>
|
||
|
|
<section class="instructions">
|
||
|
|
<h2>How to stream</h2>
|
||
|
|
<p>Streaming credentials are shared via IRC — ask there for the current
|
||
|
|
publish URL/password. This is a single shared stream: whoever is
|
||
|
|
currently publishing is what everyone sees.</p>
|
||
|
|
<ol>
|
||
|
|
<li>Open <strong>OBS Studio</strong> (or use ffmpeg directly).</li>
|
||
|
|
<li>In OBS, go to <em>Settings → Stream</em>, set Service to
|
||
|
|
<strong>Custom...</strong>, and paste the full URL you were given
|
||
|
|
into the <strong>Server</strong> field, leaving Stream Key empty.</li>
|
||
|
|
<li>Or with ffmpeg: <code>ffmpeg -re -i input -c:v libx264 -c:a aac -f flv "<url you were given>"</code>
|
||
|
|
(audio must be AAC and video H.264 — RTMP/FLV doesn't support other codecs)</li>
|
||
|
|
<li>Click <strong>Start Streaming</strong>.</li>
|
||
|
|
</ol>
|
||
|
|
<h2>How to watch</h2>
|
||
|
|
<p>Open this URL in your browser:</p>
|
||
|
|
<pre>https://{{ domain }}/<path></pre>
|
||
|
|
<p>Replace <code><path></code> with the stream name in use (e.g., <code>live</code>).</p>
|
||
|
|
</section>
|
||
|
|
<script>
|
||
|
|
function relTime(iso) {
|
||
|
|
const s = (Date.now() - new Date(iso).getTime()) / 1000;
|
||
|
|
if (s < 60) return Math.floor(s) + 's ago';
|
||
|
|
if (s < 3600) return Math.floor(s / 60) + 'm ago';
|
||
|
|
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
|
||
|
|
return Math.floor(s / 86400) + 'd ago';
|
||
|
|
}
|
||
|
|
function duration(start, end) {
|
||
|
|
const s = (new Date(end) - new Date(start)) / 1000;
|
||
|
|
if (s < 60) return Math.floor(s) + 's';
|
||
|
|
if (s < 3600) return Math.floor(s / 60) + 'm ' + Math.floor(s % 60) + 's';
|
||
|
|
return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm';
|
||
|
|
}
|
||
|
|
async function load() {
|
||
|
|
try {
|
||
|
|
const ar = await fetch('/api/active').then(r => r.json()).catch(() => []);
|
||
|
|
const el = document.getElementById('status');
|
||
|
|
if (ar.length > 0) {
|
||
|
|
el.innerHTML = ar.map(s =>
|
||
|
|
'<div class="live-banner"><span class="badge">\u25CF LIVE</span>' +
|
||
|
|
'<a href="https://{{ domain }}/' + s.path + '">Watch <strong>' + s.path + '</strong></a></div>'
|
||
|
|
).join("");
|
||
|
|
} else {
|
||
|
|
el.innerHTML = '<div class="offline-banner">No active streams</div>';
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
document.getElementById('status').innerHTML =
|
||
|
|
'<div class="offline-banner">No active streams</div>';
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
const hr = await fetch('/api/history').then(r => r.json()).catch(() => []);
|
||
|
|
const hel = document.getElementById('history');
|
||
|
|
if (hr.length === 0) {
|
||
|
|
hel.innerHTML = '<p class="empty">No recent streams.</p>';
|
||
|
|
} else {
|
||
|
|
hel.innerHTML = '<ul class="stream-list">' + hr.map(s =>
|
||
|
|
'<li><div><span class="stream-path">' + s.path + '</span>' +
|
||
|
|
(s.end ? ' <span class="stream-duration">(' + duration(s.start, s.end) + ')</span>' : "") +
|
||
|
|
'</div><span class="stream-time">' + relTime(s.start) + '</span></li>'
|
||
|
|
).join("") + '</ul>';
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
document.getElementById('history').innerHTML =
|
||
|
|
'<p class="empty">No recent streams.</p>';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
load();
|
||
|
|
setInterval(load, 15000);
|
||
|
|
</script>
|
||
|
|
</body>
|
||
|
|
</html>
|