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

248 lines
8.5 KiB
Markdown

# 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 `<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:
```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 |