# 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 ```mermaid 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 - `#divDiscoverStreams` HTML section from sidenav - `#divDiscoverStreamsList`, `.discoverStreamCard`, `.discoverStreamThumb`, `.discoverStreamMeta`, `.discoverStreamStatus` CSS - `subscribeToDiscoveredStreams()`, `renderDiscoveredStreams()`, `upsertDiscoveredStream()` JS functions - `discoverSubId`, `discoveredStreams` variables ## Detailed Implementation ### 1. Page Title and Header Update `` 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: ```js 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: ```html <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()`](www/post.html:721): ```js 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 `#divDiscoverStreams` div from the sidenav HTML - Remove all discover-related CSS rules - Remove `subscribeToDiscoveredStreams()` call from `initializeStreamPage()` - Remove `renderDiscoveredStreams()` call from `initializeStreamPage()` - Remove the JS functions: `renderDiscoveredStreams`, `upsertDiscoveredStream`, `subscribeToDiscoveredStreams` - Remove the `discoverSubId` and `discoveredStreams` variables - 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): ```nginx 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: 1. Fetches `http://127.0.0.1:8080/stat` (nginx-rtmp XML stats) 2. Parses the XML to extract `nclients` for the `live/stream` application 3. 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: ```html <div id="divViewerCount" class="divPostTime">👁 — viewers</div> ``` Poll the stats endpoint every 15 seconds: ```js 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: ```js // 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 |