Files
client/plans/stream-ctrl-page.md
T
2026-04-17 16:52:51 -04:00

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 STREAMSTREAM CTRL
Header text STREAMSTREAM 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 <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 #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):

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:

<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