2026-09-14 13:45:57 -04:00
|
|
|
<!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>
|
2026-09-16 10:03:58 -04:00
|
|
|
<p><strong>OBS Studio</strong> works great on any OS (Linux/X11,
|
|
|
|
|
Wayland, macOS, Windows) and is the easiest option for most people —
|
|
|
|
|
screen/window capture, audio mixing, and encoder settings are all
|
|
|
|
|
handled in the UI. If you'd rather use a terminal-only workflow,
|
|
|
|
|
ffmpeg one-liners are also available below depending on your setup.</p>
|
|
|
|
|
<h3>Option A: OBS Studio</h3>
|
2026-09-14 13:45:57 -04:00
|
|
|
<ol>
|
2026-09-16 10:03:58 -04:00
|
|
|
<li>Open OBS, go to <em>Settings → Stream</em>, set Service to
|
2026-09-14 13:45:57 -04:00
|
|
|
<strong>Custom...</strong>, and paste the full URL you were given
|
|
|
|
|
into the <strong>Server</strong> field, leaving Stream Key empty.</li>
|
2026-09-16 10:03:58 -04:00
|
|
|
<li>Add your capture sources (screen/window/game capture + audio) as
|
|
|
|
|
normal, then click <strong>Start Streaming</strong>.</li>
|
2026-09-14 13:45:57 -04:00
|
|
|
</ol>
|
2026-09-16 10:03:58 -04:00
|
|
|
<h3>Option B: ffmpeg one-liner (Linux, X11)</h3>
|
|
|
|
|
<p>If you're on X11 (or a Wayland session with Xwayland exposing
|
|
|
|
|
<code>:0.0</code>), this captures your screen + default audio
|
|
|
|
|
directly, no extra tools needed:</p>
|
|
|
|
|
<pre>ffmpeg -f x11grab -framerate 30 -i :0.0 -f pulse -i default -c:v libx264 -preset veryfast -tune zerolatency -pix_fmt yuv420p -c:a aac -b:a 128k -f flv "<url you were given>"</pre>
|
|
|
|
|
<p>Swap <code>-preset veryfast</code> for <code>ultrafast</code> on
|
|
|
|
|
weaker machines, or <code>fast</code>/<code>medium</code> if you
|
|
|
|
|
have CPU to spare. RTMP/FLV requires H.264 video and AAC audio —
|
|
|
|
|
other codecs won't work here.</p>
|
|
|
|
|
<h3>Option C: ffmpeg with a file/other source</h3>
|
|
|
|
|
<p>For rebroadcasting a pre-recorded file or another running stream
|
|
|
|
|
instead of capturing your screen:</p>
|
|
|
|
|
<pre>ffmpeg -re -i input -c:v libx264 -c:a aac -f flv "<url you were given>"</pre>
|
|
|
|
|
<p>On Wayland (non-X11) screen capture generally needs a portal/PipeWire
|
|
|
|
|
flow rather than a single ffmpeg command — OBS handles this
|
|
|
|
|
transparently across compositors, which is why it's the recommended
|
|
|
|
|
option there.</p>
|
|
|
|
|
</section>
|
2026-09-14 13:45:57 -04:00
|
|
|
<h2>How to watch</h2>
|
2026-09-16 10:34:54 -04:00
|
|
|
<p>In your browser:</p>
|
2026-09-14 13:45:57 -04:00
|
|
|
<pre>https://{{ domain }}/<path></pre>
|
2026-09-16 10:34:54 -04:00
|
|
|
<p>Or directly in <strong>mpv</strong> (or any HLS-capable player):</p>
|
|
|
|
|
<pre>mpv https://{{ domain }}/<path>/index.m3u8</pre>
|
2026-09-14 13:45:57 -04:00
|
|
|
<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>
|