zoitestream/app/templates/index.html

169 lines
7.7 KiB
HTML
Raw Normal View History

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&hellip;</div></div>
<section id="history-section">
<h2>Recent streams</h2>
<div id="history"><p class="empty">Loading&hellip;</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 &rarr; 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 "&lt;url you were given&gt;"</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 "&lt;url you were given&gt;"</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>
<p>Open this URL in your browser:</p>
<pre>https://{{ domain }}/&lt;path&gt;</pre>
<p>Replace <code>&lt;path&gt;</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>