11 KiB
VJ Page — Streaming Spec Alignment Plan
Aligns vj.html and vj-stream.mjs with the updated self-streaming spec at 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:
- Remove
const DEFAULT_STREAM_D_TAG = 'stream-main'at line 3 - Add a helper to derive all URLs from a slug:
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_TAGwith the selected show slug - Replace the hardcoded
STATS_URLconstant 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, before the video element:
<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:
- Add
selectShow,inputNewShowSlug,btnCreateShowto theelsobject - On initialize, fetch the user's published
kind:30311events and populate the dropdown - On show selection: set
streamDTagto 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, add episode field after the title field (around line 1322):
<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:
- Add
streamEpisodeIdDisplay,streamEpisodeDescriptionDisplay, andinputStreamEpisodeDescriptiontoels - 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
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() 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 |
Use deriveShowUrls(streamDTag).masterPlaylist |
vj-stream.mjs:336 |
Same |
vj-stream.mjs:459 |
Same |
vj-stream.mjs:786 |
Same |
vj.html:1313 |
Remove hardcoded src attribute from <video> |
vj.html:1323 |
Remove hardcoded display text |
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:
- Remove the static
const STATS_URL = 'https://laantungir.net/stream/stats'at line 4 - In
pollViewerCount(), use the slug-derived stats URL:const statsUrl = deriveShowUrls(streamDTag).stats; const res = await fetch(statsUrl); - Update response parsing to handle the new shape:
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, add after the stream controls div (around line 1340):
<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:
- Add
spanObsServerandspanObsKeytoels - Update OBS key display whenever the show slug changes:
if (els.spanObsKey) { els.spanObsKey.textContent = `${streamDTag}/src/{SECRET_KEY}`; }
Step 8 — Update video element in HTML
In vj.html:1313, change:
<video id="videoStream" playsinline src="https://laantungir.net/hls/stream.m3u8" muted></video>
To:
<video id="videoStream" playsinline muted></video>
The source is set dynamically by setStreamPlayerSource() after the show slug is known.
Step 9 — Add kind:30078 episode playlist support (song-triggered updates)
Add to vj-stream.mjs:
New constants/state:
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:30078event only when a new song starts. - On each song start:
- Append next
tracktag - Capture one viewer snapshot (
viewerstag) from latest stats data - Republish full replaceable event for this episode
- Append next
Integration points:
- On "Go Live": generate timestamp episode ID and initialize 30078 state for this episode
- In
announceNowPlaying(): append track + viewer snapshot and republishkind:30078 - On "End Stream": publish final
kind:30078withstatus=ended
This keeps event volume low and naturally aligns viewer snapshots with track changes.
Step 10 — Fetch user's shows on initialization
In initialize(), after auth is resolved:
- Subscribe to the user's
kind:30311events (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:
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 |
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 |
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
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.