271 lines
11 KiB
Markdown
271 lines
11 KiB
Markdown
# VJ Page — Streaming Spec Alignment Plan
|
|
|
|
Aligns [`vj.html`](www/vj.html) and [`vj-stream.mjs`](www/js/vj-stream.mjs) with the updated self-streaming spec at [`self-streaming/README.md`](../self-streaming/README.md).
|
|
|
|
## Current State vs Spec
|
|
|
|
| Area | Current | Spec requires |
|
|
|------|---------|---------------|
|
|
| Show identity | Hardcoded `DEFAULT_STREAM_D_TAG = 'stream-main'` | User-selectable show slug as d-tag |
|
|
| Show selector UI | None | Dropdown of user's published kind:30311 events + create new |
|
|
| Episode tracking | None | `episode` tag, auto-increment on each Go Live |
|
|
| Default streaming URL | `https://laantungir.net/hls/stream.m3u8` | `https://laantungir.net/stream/{slug}/stream.m3u8` |
|
|
| Video element src | Hardcoded `src="https://laantungir.net/hls/stream.m3u8"` | Dynamic, set after slug is known |
|
|
| Stats URL | `https://laantungir.net/stream/stats` | `https://laantungir.net/api/stream/stats?show={slug}` |
|
|
| Stats response shape | `{ viewers, hls_viewers, rtmp_viewers, live }` | `{ live, viewers, variants: { src: {viewers}, ... } }` |
|
|
| Viewer count display | `TOTAL: N \| HLS: N \| RTMP: N` | `TOTAL: N \| src: N \| 720p: N \| 480p: N` |
|
|
| NIP-53 tags | `d, title, status, summary, image, streaming` | Add `episode`, `web` tags |
|
|
| OBS config display | None | Show OBS server + key derived from slug |
|
|
| Kind:30078 playlist | None | Episode playlist with track + viewers tags |
|
|
| URL derivation | Manual input | All URLs derived from slug automatically |
|
|
| OBS server format | N/A | `rtmp://laantungir.net:1935/publish` with key `{slug}/src/{SECRET_KEY}` |
|
|
|
|
## Implementation Steps
|
|
|
|
### Step 1 — Remove hardcoded d-tag and add show slug state
|
|
|
|
In [`vj-stream.mjs`](www/js/vj-stream.mjs):
|
|
|
|
- Remove `const DEFAULT_STREAM_D_TAG = 'stream-main'` at [line 3](www/js/vj-stream.mjs:3)
|
|
- Add a helper to derive all URLs from a slug:
|
|
```js
|
|
const STREAM_BASE_URL = 'https://laantungir.net';
|
|
|
|
function deriveShowUrls(slug) {
|
|
return {
|
|
masterPlaylist: `${STREAM_BASE_URL}/stream/${slug}/stream.m3u8`,
|
|
viewerPage: `${STREAM_BASE_URL}/stream/${slug}`,
|
|
stats: `${STREAM_BASE_URL}/api/stream/stats?show=${encodeURIComponent(slug)}`,
|
|
obsServer: 'rtmp://laantungir.net:1935/publish',
|
|
obsKeyTemplate: `${slug}/src/{SECRET_KEY}`,
|
|
};
|
|
}
|
|
```
|
|
- Replace every reference to `DEFAULT_STREAM_D_TAG` with the selected show slug
|
|
- Replace the hardcoded `STATS_URL` constant with a dynamic URL derived from the slug
|
|
|
|
### Step 2 — Add show selector/creator UI to vj.html
|
|
|
|
Add new HTML elements at the top of [`#vjStreamCard`](www/vj.html:1311), before the video element:
|
|
|
|
```html
|
|
<div class="musicSidebarTitle">SHOW</div>
|
|
<select id="selectShow" class="streamInput">
|
|
<option value="">— Select a show —</option>
|
|
</select>
|
|
<div style="display:flex; gap:6px; align-items:center;">
|
|
<input id="inputNewShowSlug" class="streamInput" placeholder="new-show-slug" style="flex:1;" />
|
|
<button id="btnCreateShow" class="btn" type="button">Create</button>
|
|
</div>
|
|
```
|
|
|
|
Wire up in [`vj-stream.mjs`](www/js/vj-stream.mjs):
|
|
|
|
- Add `selectShow`, `inputNewShowSlug`, `btnCreateShow` to the `els` object
|
|
- On initialize, fetch the user's published `kind:30311` events and populate the dropdown
|
|
- On show selection: set `streamDTag` to the slug, derive URLs, pre-fill title/summary/image/episode from the last event
|
|
- On create: validate slug format (lowercase, hyphens, no reserved words), add to dropdown, select it
|
|
- Slug validation regex: `/^[a-z0-9][a-z0-9-]*[a-z0-9]$/` (min 2 chars, no leading/trailing hyphens)
|
|
|
|
### Step 3 — Add episode tracking
|
|
|
|
In [`vj.html`](www/vj.html), add episode field after the title field (around [line 1322](www/vj.html:1322)):
|
|
|
|
```html
|
|
<div id="streamEpisodeIdDisplay" class="streamFieldDisplay" title="Episode ID (timestamp)">Episode ID: —</div>
|
|
<div id="streamEpisodeDescriptionDisplay" class="streamFieldDisplay" title="Click to edit episode description">Episode description (optional)</div>
|
|
<input id="inputStreamEpisodeDescription" class="streamInput hidden" placeholder="Episode description (optional)" />
|
|
```
|
|
|
|
In [`vj-stream.mjs`](www/js/vj-stream.mjs):
|
|
|
|
- Add `streamEpisodeIdDisplay`, `streamEpisodeDescriptionDisplay`, and `inputStreamEpisodeDescription` to `els`
|
|
- Keep **episode ID** as a timestamp (generated on Go Live; reused through the live session)
|
|
- Keep **episode description** as optional editable text (`001`, `Season 2`, freeform)
|
|
- Store/persist only the optional episode description in local draft/settings
|
|
|
|
### Step 4 — Update `buildStreamTags()` to include `episode` and `web` tags
|
|
|
|
In [`buildStreamTags()`](www/js/vj-stream.mjs:116):
|
|
|
|
```js
|
|
function buildStreamTags({ dTag, title, summary, image, streamingUrl, episode, status }) {
|
|
const tags = [
|
|
['d', dTag],
|
|
['title', String(title || '').trim() || 'Untitled stream'],
|
|
['status', String(status || 'planned').trim() || 'planned']
|
|
];
|
|
|
|
const safeSummary = String(summary || '').trim();
|
|
const safeImage = String(image || '').trim();
|
|
const safeStreaming = String(streamingUrl || '').trim();
|
|
const safeEpisode = String(episode || '').trim();
|
|
|
|
if (safeSummary) tags.push(['summary', safeSummary]);
|
|
if (safeImage) tags.push(['image', safeImage]);
|
|
if (safeStreaming) tags.push(['streaming', safeStreaming]);
|
|
if (safeEpisode) tags.push(['episode', safeEpisode]);
|
|
|
|
// web tag: viewer page URL derived from slug
|
|
const webUrl = `${STREAM_BASE_URL}/stream/${dTag}`;
|
|
tags.push(['web', webUrl]);
|
|
|
|
return tags;
|
|
}
|
|
```
|
|
|
|
Update [`publishStreamStatus()`](www/js/vj-stream.mjs:376) to pass `episode` from the input field.
|
|
|
|
### Step 5 — Update all hardcoded URLs to slug-derived URLs
|
|
|
|
Replace every occurrence of `https://laantungir.net/hls/stream.m3u8`:
|
|
|
|
| Location | Change |
|
|
|----------|--------|
|
|
| [`vj-stream.mjs:290`](www/js/vj-stream.mjs:290) | Use `deriveShowUrls(streamDTag).masterPlaylist` |
|
|
| [`vj-stream.mjs:336`](www/js/vj-stream.mjs:336) | Same |
|
|
| [`vj-stream.mjs:459`](www/js/vj-stream.mjs:459) | Same |
|
|
| [`vj-stream.mjs:786`](www/js/vj-stream.mjs:786) | Same |
|
|
| [`vj.html:1313`](www/vj.html:1313) | Remove hardcoded `src` attribute from `<video>` |
|
|
| [`vj.html:1323`](www/vj.html:1323) | Remove hardcoded display text |
|
|
| [`vj.html:1324`](www/vj.html:1324) | Remove hardcoded `value` from input |
|
|
|
|
The streaming URL input should auto-populate when a show is selected, and the video player source should be set dynamically.
|
|
|
|
### Step 6 — Update stats URL and response parsing
|
|
|
|
In [`vj-stream.mjs`](www/js/vj-stream.mjs):
|
|
|
|
- Remove the static `const STATS_URL = 'https://laantungir.net/stream/stats'` at [line 4](www/js/vj-stream.mjs:4)
|
|
- In [`pollViewerCount()`](www/js/vj-stream.mjs:440), use the slug-derived stats URL:
|
|
```js
|
|
const statsUrl = deriveShowUrls(streamDTag).stats;
|
|
const res = await fetch(statsUrl);
|
|
```
|
|
- Update response parsing to handle the new shape:
|
|
```js
|
|
const data = await res.json();
|
|
viewerCount = Number(data.viewers) || 0;
|
|
const isLive = Boolean(data.live);
|
|
const variants = data.variants || {};
|
|
|
|
if (els.divViewerCount) {
|
|
const variantParts = Object.entries(variants)
|
|
.map(([name, v]) => `${name}: ${v.viewers || 0}`)
|
|
.join(' | ');
|
|
els.divViewerCount.textContent = `TOTAL: ${viewerCount}${variantParts ? ' | ' + variantParts : ''}`;
|
|
}
|
|
```
|
|
|
|
### Step 7 — Add OBS configuration display
|
|
|
|
In [`vj.html`](www/vj.html), add after the stream controls div (around [line 1340](www/vj.html:1340)):
|
|
|
|
```html
|
|
<div id="divObsConfig" style="font-family:monospace; font-size:11px; padding:6px 8px; background:rgba(255,255,255,0.02); border-radius:6px; color:var(--text-color); line-height:1.6;">
|
|
<div>OBS Server: <span id="spanObsServer">rtmp://laantungir.net:1935/publish</span></div>
|
|
<div>OBS Key: <span id="spanObsKey">{slug}/src/{SECRET_KEY}</span></div>
|
|
</div>
|
|
```
|
|
|
|
In [`vj-stream.mjs`](www/js/vj-stream.mjs):
|
|
|
|
- Add `spanObsServer` and `spanObsKey` to `els`
|
|
- Update OBS key display whenever the show slug changes:
|
|
```js
|
|
if (els.spanObsKey) {
|
|
els.spanObsKey.textContent = `${streamDTag}/src/{SECRET_KEY}`;
|
|
}
|
|
```
|
|
|
|
### Step 8 — Update video element in HTML
|
|
|
|
In [`vj.html:1313`](www/vj.html:1313), change:
|
|
|
|
```html
|
|
<video id="videoStream" playsinline src="https://laantungir.net/hls/stream.m3u8" muted></video>
|
|
```
|
|
|
|
To:
|
|
|
|
```html
|
|
<video id="videoStream" playsinline muted></video>
|
|
```
|
|
|
|
The source is set dynamically by [`setStreamPlayerSource()`](www/js/vj-stream.mjs:204) after the show slug is known.
|
|
|
|
### Step 9 — Add kind:30078 episode playlist support (song-triggered updates)
|
|
|
|
Add to [`vj-stream.mjs`](www/js/vj-stream.mjs):
|
|
|
|
**New constants/state:**
|
|
```js
|
|
const PLAYLIST_KIND = 30078;
|
|
let playlistDTag = ''; // show-playlist:{show}:{episodeTimestamp}
|
|
let playlistTracks = [];
|
|
let playlistViewerSnapshots = [];
|
|
```
|
|
|
|
**Behavior change (approved):**
|
|
|
|
- No periodic 30s timer.
|
|
- Update/publish the `kind:30078` event **only when a new song starts**.
|
|
- On each song start:
|
|
1. Append next `track` tag
|
|
2. Capture one viewer snapshot (`viewers` tag) from latest stats data
|
|
3. Republish full replaceable event for this episode
|
|
|
|
**Integration points:**
|
|
|
|
- On "Go Live": generate timestamp episode ID and initialize 30078 state for this episode
|
|
- In [`announceNowPlaying()`](www/js/vj-stream.mjs:573): append track + viewer snapshot and republish `kind:30078`
|
|
- On "End Stream": publish final `kind:30078` with `status=ended`
|
|
|
|
This keeps event volume low and naturally aligns viewer snapshots with track changes.
|
|
|
|
### Step 10 — Fetch user's shows on initialization
|
|
|
|
In [`initialize()`](www/js/vj-stream.mjs:771), after auth is resolved:
|
|
|
|
- Subscribe to the user's `kind:30311` events (all d-tags, not just one)
|
|
- Collect unique d-tags as the user's shows
|
|
- Populate the show selector dropdown
|
|
- If a URL parameter specifies a show (via `?a=` or `?naddr=`), auto-select it
|
|
- If no URL parameter, select the most recently updated show (or leave blank for new users)
|
|
|
|
This requires a one-time query:
|
|
```js
|
|
subscribe({
|
|
kinds: [STREAM_KIND],
|
|
authors: [streamAuthorPubkey],
|
|
limit: 50
|
|
}, { closeOnEose: true, cacheUsage: 'CACHE_FIRST' });
|
|
```
|
|
|
|
Collect results, deduplicate by d-tag (keep latest), populate dropdown.
|
|
|
|
## File Change Summary
|
|
|
|
| File | Changes |
|
|
|------|---------|
|
|
| [`www/js/vj-stream.mjs`](www/js/vj-stream.mjs) | Remove `DEFAULT_STREAM_D_TAG` and `STATS_URL` constants; add `deriveShowUrls()` helper; add show selector logic; add episode tracking; update `buildStreamTags()` with `episode` + `web` tags; update `pollViewerCount()` for new stats shape; add kind:30078 playlist support; update `initialize()` to fetch user's shows; replace all hardcoded URLs |
|
|
| [`www/vj.html`](www/vj.html) | Add show selector/creator UI; add episode field; add OBS config display; remove hardcoded `src` from video element; remove hardcoded URL defaults from display/input elements |
|
|
|
|
## Dependency Diagram
|
|
|
|
```mermaid
|
|
flowchart TD
|
|
S1["Step 1: Remove hardcoded d-tag, add deriveShowUrls"] --> S2["Step 2: Show selector UI"]
|
|
S1 --> S5["Step 5: Replace all hardcoded URLs"]
|
|
S1 --> S6["Step 6: Update stats URL + parsing"]
|
|
S2 --> S3["Step 3: Episode tracking"]
|
|
S2 --> S10["Step 10: Fetch user shows on init"]
|
|
S3 --> S4["Step 4: Update buildStreamTags with episode + web"]
|
|
S5 --> S8["Step 8: Update video element"]
|
|
S2 --> S7["Step 7: OBS config display"]
|
|
S4 --> S9["Step 9: Kind:30078 episode playlist"]
|
|
S6 --> S9
|
|
```
|
|
|
|
Steps 1-8 are the core alignment work. Step 9 (kind:30078 playlist) is the largest addition and can be implemented as a follow-up if needed. Step 10 is required for the show selector to be useful.
|