Files
client/plans/vj-streaming-spec-alignment.md
2026-04-17 16:52:51 -04:00

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_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, 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, 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, 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, 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():

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 spanObsServer and spanObsKey to els
  • 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: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(): 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(), 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:

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.