8.5 KiB
Stream Control Page (stream-ctrl.html)
Purpose
A personal live-streaming control dashboard for the streamer. Unlike stream.html (which is viewer-facing), stream-ctrl.html is the streamer's cockpit — managing NIP-53 stream events, posting kind 1 announcements, monitoring viewer count, and participating in stream chat.
Architecture Overview
flowchart TD
subgraph StreamCtrl[stream-ctrl.html]
A[Kind 1 Announcement Composer]
B[Stream Controls - NIP-53 kind:30311]
C[Video Player - self-monitor]
D[Viewer Count Display]
E[Chat Composer - kind:1311]
F[Chat Feed]
end
subgraph Server[laantungir.net]
G[nginx-rtmp stats endpoint]
H[Public JSON stats proxy]
end
subgraph Nostr[Nostr Relays]
I[kind:1 announcements]
J[kind:30311 stream events]
K[kind:1311 chat messages]
end
A -->|publishEvent| I
B -->|publishEvent| J
E -->|publishEvent| K
D -->|poll /stream/stats| H
H -->|parse XML| G
D -->|update current_participants| J
Changes from stream.html
What stays
- Video player (for self-monitoring your own stream)
- Stream info section with controls (Save Planned / Go Live / End Stream)
- Stream control inputs (URL, title, summary, image)
- Chat composer (kind:1311) and chat feed
- Sidenav with relay/blossom/AI sections
- All existing auth, hamburger, footer infrastructure
What changes
| Area | Change |
|---|---|
| Page title | STREAM → STREAM CTRL |
| Header text | STREAM → STREAM CTRL |
| Default streaming URL | Pre-fill rtmp://laantungir.net:1935/live/stream |
| Discover streams sidebar | Remove — not needed for control page |
| Kind 1 composer | Add — new announcement composer section above stream info |
| Viewer count | Add — live viewer count from server stats endpoint |
| CSS imports | Add post-composer.css for the announcement composer styling |
What gets removed
#divDiscoverStreamsHTML section from sidenav#divDiscoverStreamsList,.discoverStreamCard,.discoverStreamThumb,.discoverStreamMeta,.discoverStreamStatusCSSsubscribeToDiscoveredStreams(),renderDiscoveredStreams(),upsertDiscoveredStream()JS functionsdiscoverSubId,discoveredStreamsvariables
Detailed Implementation
1. Page Title and Header
Update <title> to STREAM CTRL and the divHeaderText.textContent assignment in main() from 'STREAM' to 'STREAM CTRL'.
2. Pre-fill Default Streaming URL
In initializeStreamPage(), after setting up streamAuthorPubkey and before subscribeToStream(), set:
if (!inputStreamingUrl.value) {
inputStreamingUrl.value = 'rtmp://laantungir.net:1935/live/stream';
}
3. Kind 1 Announcement Composer
Add a new HTML section between the video player and stream info:
<div id="divAnnouncementComposer" class="divPostItem">
<div class="divPostHeader">
<div class="divPostAuthorName">Announcement</div>
<div class="divPostTime">kind:1 post</div>
</div>
<!-- composer mounts here dynamically -->
</div>
Mount using the same pattern as mountTopComposer():
async function mountAnnouncementComposer() {
const container = document.getElementById('divAnnouncementComposer');
if (!container) return;
const hostEl = document.createElement('div');
hostEl.className = 'topPostComposerInput';
container.appendChild(hostEl);
let composer = null;
composer = mountComposer(hostEl, {
currentPubkey,
followedProfiles: [],
showUploadIcon: true,
showPreview: true,
autoHideOnSubmit: false,
onSubmit: async (content) => {
const text = String(content || '').trim();
if (!text) return;
await publishEvent({
kind: 1,
content: text,
tags: [],
created_at: Math.floor(Date.now() / 1000)
});
composer?.clear();
}
});
}
Call mountAnnouncementComposer() from initializeStreamPage() after auth check.
4. Remove Discover Streams
- Delete the
#divDiscoverStreamsdiv from the sidenav HTML - Remove all discover-related CSS rules
- Remove
subscribeToDiscoveredStreams()call frominitializeStreamPage() - Remove
renderDiscoveredStreams()call frominitializeStreamPage() - Remove the JS functions:
renderDiscoveredStreams,upsertDiscoveredStream,subscribeToDiscoveredStreams - Remove the
discoverSubIdanddiscoveredStreamsvariables - Remove the discover stream handling from
handleIncomingEvent()
5. CSS Import
Add <link rel="stylesheet" href="./css/post-composer.css" /> in the <head> section, and add width rules for #divAnnouncementComposer to match the existing content width pattern.
6. Server: Public Stats Endpoint
On laantungir.net, add an nginx location block that proxies to the internal RTMP stats XML and converts it to a simple JSON response. Two approaches:
Option A — nginx + XSLT module (preferred, no extra service):
location /stream/stats {
add_header Access-Control-Allow-Origin *;
add_header Content-Type application/json;
# Use a small proxy_pass + sub_filter or a tiny script
proxy_pass http://127.0.0.1:8080/stat;
# Parse with a small server-side script
}
Option B — Small Python/bash CGI (simpler to implement):
Add a small Python HTTP handler (similar to rtmp-auth.py) that:
- Fetches
http://127.0.0.1:8080/stat(nginx-rtmp XML stats) - Parses the XML to extract
nclientsfor thelive/streamapplication - Returns JSON:
{"viewers": N, "live": true/false}
Managed as a systemd service (stream-stats.service) on a local port (e.g., 8092), proxied through nginx at https://laantungir.net/stream/stats.
7. Viewer Count Display
Add a viewer count element in the stream info section:
<div id="divViewerCount" class="divPostTime">👁 — viewers</div>
Poll the stats endpoint every 15 seconds:
const STATS_URL = 'https://laantungir.net/stream/stats';
const STATS_POLL_INTERVAL = 15000;
let viewerCount = 0;
let statsIntervalId = null;
async function pollViewerCount() {
try {
const res = await fetch(STATS_URL);
const data = await res.json();
viewerCount = data.viewers || 0;
const el = document.getElementById('divViewerCount');
if (el) el.textContent = `👁 ${viewerCount} viewer${viewerCount !== 1 ? 's' : ''}`;
} catch (e) {
console.warn('[stream-ctrl] Stats poll failed:', e);
}
}
function startViewerCountPolling() {
pollViewerCount();
statsIntervalId = setInterval(pollViewerCount, STATS_POLL_INTERVAL);
}
8. Update NIP-53 Event with Viewer Count
In publishStreamStatus(), include the current_participants tag from the polled viewer count:
// Inside buildStreamTags or publishStreamStatus:
if (viewerCount > 0) {
tags.push(['current_participants', String(viewerCount)]);
}
Page Layout (top to bottom)
┌─────────────────────────────────┐
│ Header: STREAM CTRL │
├─────────────────────────────────┤
│ Video Player (self-monitor) │
├─────────────────────────────────┤
│ Announcement Composer (kind:1) │
│ [rich text input + send button] │
├─────────────────────────────────┤
│ Stream Info + Controls │
│ Title / Status / Summary │
│ 👁 N viewers │
│ [URL] [Title] [Summary] [Image] │
│ [Save Planned] [Go Live] [End] │
├─────────────────────────────────┤
│ Chat Composer (kind:1311) │
├─────────────────────────────────┤
│ Chat Feed │
├─────────────────────────────────┤
│ Footer: relay status │
└─────────────────────────────────┘
Files Modified
| File | Type | Description |
|---|---|---|
www/stream-ctrl.html |
Modified | All client-side changes (HTML, CSS, JS) |
| Server: nginx config | New location block | /stream/stats proxy |
| Server: stats service | New Python script + systemd unit | Parse RTMP XML → JSON |
self-streaming/README.md |
Updated | Document the new stats endpoint |