Files
client/www/js/vj-stream.mjs
T

1986 lines
67 KiB
JavaScript

import { uploadToAllServers, getBlobUrl } from './blossom-api.mjs';
const STREAM_KIND = 30311;
const CHAT_KIND = 1311;
const MUSIC_PLAYLIST_KIND = 30004;
const LEGACY_PLAYLIST_KIND = 30078;
const CREATE_SHOW_OPTION_VALUE = '__create_new__';
const STATS_POLL_INTERVAL = 10000;
const DEFAULT_STREAMING_SITE = Object.freeze({
name: 'laantungir.net',
streamBaseUrl: 'https://laantungir.net',
streamUrlTemplate: '{base}/stream/{slug}/stream.m3u8',
rtmpServer: 'rtmp://laantungir.net:1935/publish',
obsKeyTemplate: '{slug}/src/{SECRET_KEY}',
});
const DEFAULT_MONOCHROME_BASE_URL = 'https://monochrome.laantungir.net';
function normalizeStreamingSiteConfig(site) {
const input = site && typeof site === 'object' ? site : {};
const name = String(input.name || '').trim() || DEFAULT_STREAMING_SITE.name;
const streamBaseUrl = String(input.streamBaseUrl || '').trim() || DEFAULT_STREAMING_SITE.streamBaseUrl;
const streamUrlTemplate = String(input.streamUrlTemplate || '').trim() || DEFAULT_STREAMING_SITE.streamUrlTemplate;
const rtmpServer = String(input.rtmpServer || '').trim() || DEFAULT_STREAMING_SITE.rtmpServer;
const obsKeyTemplate = String(input.obsKeyTemplate || '').trim() || DEFAULT_STREAMING_SITE.obsKeyTemplate;
return {
name,
streamBaseUrl: streamBaseUrl.replace(/\/+$/, ''),
streamUrlTemplate,
rtmpServer,
obsKeyTemplate,
};
}
function normalizeStreamingSitesList(list) {
if (!Array.isArray(list)) return [normalizeStreamingSiteConfig(DEFAULT_STREAMING_SITE)];
const deduped = new Map();
list.forEach((site) => {
const normalized = normalizeStreamingSiteConfig(site);
deduped.set(normalized.name, normalized);
});
if (!deduped.size) {
deduped.set(DEFAULT_STREAMING_SITE.name, normalizeStreamingSiteConfig(DEFAULT_STREAMING_SITE));
}
return Array.from(deduped.values());
}
function normalizeBaseUrl(value) {
const raw = String(value || '').trim();
const safe = raw || DEFAULT_MONOCHROME_BASE_URL;
return safe.replace(/\/+$/, '');
}
function normalizeShowSlug(value) {
return String(value || '').trim().toLowerCase();
}
function deriveSlugFromTitle(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-{2,}/g, '-');
}
function isValidShowSlug(value) {
return /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(normalizeShowSlug(value));
}
function deriveShowUrls(slug, siteConfig = DEFAULT_STREAMING_SITE) {
const site = normalizeStreamingSiteConfig(siteConfig);
const safeSlug = normalizeShowSlug(slug);
const obsKeyTemplate = safeSlug
? site.obsKeyTemplate.replace(/\{slug\}/g, safeSlug)
: site.obsKeyTemplate;
if (!safeSlug) {
return {
masterPlaylist: '',
viewerPage: '',
stats: '',
obsServer: site.rtmpServer,
obsKeyTemplate,
};
}
const base = site.streamBaseUrl;
const masterPlaylist = String(site.streamUrlTemplate || '').trim()
.replace(/\{base\}/gi, base)
.replace(/\{slug\}/gi, safeSlug)
|| `${base}/stream/${safeSlug}/stream.m3u8`;
return {
masterPlaylist,
viewerPage: `${base}/stream/${safeSlug}`,
stats: `${base}/api/stream/stats?show=${encodeURIComponent(safeSlug)}`,
obsServer: site.rtmpServer,
obsKeyTemplate,
};
}
function getIsoNow() {
return new Date().toISOString();
}
function buildEpisodeIdTimestamp() {
return String(Math.floor(Date.now() / 1000));
}
function formatDuration(seconds) {
const n = Number(seconds);
if (!Number.isFinite(n) || n <= 0) return '';
const total = Math.floor(n);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
if (h > 0) {
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
return `${m}:${String(s).padStart(2, '0')}`;
}
function escapeHtmlText(text) {
const source = String(text ?? '');
const amp = '&' + 'amp;';
const lt = '&' + 'lt;';
const gt = '&' + 'gt;';
const quot = '&' + 'quot;';
const apos = '&#' + '39;';
return source
.replace(/&/g, amp)
.replace(/</g, lt)
.replace(/>/g, gt)
.replace(/"/g, quot)
.replace(/'/g, apos);
}
function isLikelyImageUrl(value) {
const url = String(value || '').trim().toLowerCase();
if (!url) return false;
return /\.(png|jpe?g|gif|webp|avif|svg)(\?|#|$)/i.test(url) || url.includes('/images/');
}
function renderTemplateWithSongData(template, tokenMap) {
let output = String(template || '');
output = output.replace(/\{url-([a-z0-9-]+)\{([^{}]+)\}\}/gi, (_full, urlKeyRaw, innerRaw) => {
const urlKey = String(urlKeyRaw || '').trim().toLowerCase();
const innerExpr = String(innerRaw || '').trim();
const href = String(tokenMap[`url-${urlKey}`] || '').trim();
if (!href) return '';
const isSimpleToken = /^[a-z0-9-]+$/i.test(innerExpr);
const resolvedInner = (isSimpleToken
? String(tokenMap[innerExpr.toLowerCase()] ?? '')
: innerExpr.replace(/\{([^{}]+)\}/g, (_m, key) => {
const token = String(key || '').trim().toLowerCase();
return String(tokenMap[token] ?? '');
})
).trim();
if (!resolvedInner) return '';
if (isLikelyImageUrl(resolvedInner)) {
const safeHref = escapeHtmlText(href);
const safeSrc = escapeHtmlText(resolvedInner);
return `<a href="${safeHref}" target="_blank" rel="noopener noreferrer"><img src="${safeSrc}" alt="cover"></a>`;
}
const safeHref = escapeHtmlText(href);
const safeText = escapeHtmlText(resolvedInner);
return `<a href="${safeHref}" target="_blank" rel="noopener noreferrer">${safeText}</a>`;
});
output = output.replace(/\{([^{}]+)\}/g, (_full, keyRaw) => {
const key = String(keyRaw || '').trim().toLowerCase();
return String(tokenMap[key] ?? '');
});
return output;
}
function getTagValue(evt, key) {
const tag = (evt?.tags || []).find((t) => t?.[0] === key && typeof t?.[1] === 'string');
return tag?.[1] || '';
}
function parseCoordinate(aValue) {
const raw = String(aValue || '').trim();
const [kindStr, pubkey, ...rest] = raw.split(':');
const d = rest.join(':');
const kind = Number(kindStr);
if (!Number.isFinite(kind) || !pubkey || !d) return null;
return { kind, pubkey, d, coord: `${kind}:${pubkey}:${d}` };
}
function parseStreamTargetFromUrl() {
const params = new URLSearchParams(window.location.search || '');
const a = String(params.get('a') || '').trim();
const naddr = String(params.get('naddr') || '').trim();
if (a) {
const parsed = parseCoordinate(a);
if (parsed && parsed.kind === STREAM_KIND) return parsed;
}
if (naddr && window?.NostrTools?.nip19?.decode) {
try {
const decoded = window.NostrTools.nip19.decode(naddr);
if (decoded?.type === 'naddr') {
const data = decoded?.data || {};
const kind = Number(data.kind);
const pubkey = String(data.pubkey || '').trim();
const d = normalizeShowSlug(data.identifier || '');
if (kind === STREAM_KIND && pubkey && d) {
return { kind, pubkey, d, coord: `${kind}:${pubkey}:${d}` };
}
}
} catch (error) {
console.warn('[vj.html] Failed to decode naddr:', error);
}
}
return null;
}
function buildStreamTags({ dTag, title, summary, image, streamingUrl, status, episodeId, episodeDescription, webUrl }) {
const safeStatus = String(status || 'planned').trim() || 'planned';
const tags = [
['d', dTag],
['title', String(title || '').trim() || 'Untitled stream'],
['status', safeStatus]
];
const safeSummary = String(summary || '').trim();
const safeImage = String(image || '').trim();
const safeStreaming = String(streamingUrl || '').trim();
const safeEpisodeId = String(episodeId || '').trim();
const safeEpisodeDescription = String(episodeDescription || '').trim();
const safeWeb = String(webUrl || '').trim();
const nowUnix = String(Math.floor(Date.now() / 1000));
if (safeSummary) tags.push(['summary', safeSummary]);
if (safeImage) tags.push(['image', safeImage]);
if (safeStreaming) tags.push(['streaming', safeStreaming]);
if (safeEpisodeId) tags.push(['episode', safeEpisodeId]);
if (safeEpisodeDescription) tags.push(['episode_description', safeEpisodeDescription]);
if (safeWeb) tags.push(['web', safeWeb]);
// NIP-53 compatibility: include timestamps so clients don't default to Unix epoch.
// Prefer episodeId timestamp when available because it is set when the episode starts.
const startsAt = /^\d+$/.test(safeEpisodeId) ? safeEpisodeId : nowUnix;
tags.push(['starts', startsAt]);
if (safeStatus === 'ended') {
tags.push(['ends', nowUnix]);
}
return tags;
}
function buildNaddr(pubkey, dTag) {
if (!pubkey || !dTag) return null;
try {
if (window?.NostrTools?.nip19?.naddrEncode) {
return 'nostr:' + window.NostrTools.nip19.naddrEncode({
kind: STREAM_KIND,
pubkey,
identifier: dTag
});
}
} catch (error) {
console.warn('[vj.html] Failed to encode naddr:', error);
}
return null;
}
export function initVjStreamPanel({
subscribe,
publishEvent,
mountComposer,
renderPostItem,
getCurrentPubkey,
getIsAuthenticated,
promptLoginIfNeeded,
getStreamingSiteSettings,
onStreamingSiteSettingsChange,
onPlaylistChange,
}) {
const els = {
selectStreamingSite: document.getElementById('selectStreamingSite'),
btnAddStreamingSite: document.getElementById('btnAddStreamingSite'),
btnEditStreamingSite: document.getElementById('btnEditStreamingSite'),
btnDeleteStreamingSite: document.getElementById('btnDeleteStreamingSite'),
streamingSiteFormPanel: document.getElementById('streamingSiteFormPanel'),
inputSiteName: document.getElementById('inputSiteName'),
inputSiteStreamBaseUrl: document.getElementById('inputSiteStreamBaseUrl'),
inputSiteStreamUrlTemplate: document.getElementById('inputSiteStreamUrlTemplate'),
inputSiteRtmpServer: document.getElementById('inputSiteRtmpServer'),
inputSiteObsKeyTemplate: document.getElementById('inputSiteObsKeyTemplate'),
btnSaveStreamingSite: document.getElementById('btnSaveStreamingSite'),
btnCancelStreamingSite: document.getElementById('btnCancelStreamingSite'),
videoStream: document.getElementById('videoStream'),
divViewerCount: document.getElementById('divViewerCount'),
spanStreamHealth: document.getElementById('spanStreamHealth'),
btnCopyNaddr: document.getElementById('btnCopyNaddr'),
divChatComposer: document.getElementById('divChatComposer'),
divChatFeed: document.getElementById('divChatFeed'),
inputStreamingUrl: document.getElementById('inputStreamingUrl'),
inputStreamTitle: document.getElementById('inputStreamTitle'),
inputStreamSummary: document.getElementById('inputStreamSummary'),
inputStreamImage: document.getElementById('inputStreamImage'),
btnStreamImageScreenshot: document.getElementById('btnStreamImageScreenshot'),
streamImageEditorRow: document.getElementById('streamImageEditorRow'),
inputStreamEpisodeDescription: document.getElementById('inputStreamEpisodeDescription'),
imgStreamImagePreview: document.getElementById('imgStreamImagePreview'),
streamTitleDisplay: document.getElementById('streamTitleDisplay'),
spanStreamUrl: document.getElementById('spanStreamUrl'),
spanStreamNaddr: document.getElementById('spanStreamNaddr'),
btnCopyStreamUrl: document.getElementById('btnCopyStreamUrl'),
streamSummaryDisplay: document.getElementById('streamSummaryDisplay'),
streamEpisodeIdDisplay: document.getElementById('streamEpisodeIdDisplay'),
streamEpisodeDescriptionDisplay: document.getElementById('streamEpisodeDescriptionDisplay'),
selectStreamShow: document.getElementById('selectStreamShow'),
inputNewShowTitle: document.getElementById('inputNewShowTitle'),
inputNewShowSummary: document.getElementById('inputNewShowSummary'),
newShowSlugPreview: document.getElementById('newShowSlugPreview'),
btnCreateShow: document.getElementById('btnCreateShow'),
createShowPanel: document.getElementById('createShowPanel'),
divHeaderTitleText: document.getElementById('divHeaderTitleText'),
spanObsServer: document.getElementById('spanObsServer'),
spanObsKey: document.getElementById('spanObsKey'),
btnCopyObsServer: document.getElementById('btnCopyObsServer'),
btnCopyObsKey: document.getElementById('btnCopyObsKey'),
btnSaveStream: document.getElementById('btnSaveStream'),
btnGoLive: document.getElementById('btnGoLive'),
btnEndStream: document.getElementById('btnEndStream'),
btnVideoPlay: document.getElementById('btnVideoPlay'),
btnVideoPause: document.getElementById('btnVideoPause'),
btnVideoMute: document.getElementById('btnVideoMute'),
btnStreamRefresh: document.getElementById('btnStreamRefresh'),
};
let streamAuthorPubkey = '';
let streamDTag = '';
let streamCoordinate = '';
let streamSubId = null;
let chatSubId = null;
let showDiscoverySubId = null;
let playlistSubId = null;
let chatComposer = null;
let hlsInstance = null;
let currentPlayerUrl = '';
let currentNaddr = '';
let currentStreamStatus = 'none';
let viewerCount = 0;
let statsIntervalId = null;
let wasStreamLive = false;
let streamListenersBound = false;
let episodeId = '';
let playlistDTag = '';
let playlistTracks = [];
let playlistViewerSnapshots = [];
let lastVariants = {};
let lastAnnouncedSongSig = '';
let latestPlaylistEventCreatedAt = 0;
let latestPlaylistEventId = '';
const renderedChatIds = new Set();
const knownShowsBySlug = new Map();
let autoRepublishTimerId = null;
let autoRepublishInFlight = false;
let streamingSites = normalizeStreamingSitesList([DEFAULT_STREAMING_SITE]);
let selectedStreamingSiteName = DEFAULT_STREAMING_SITE.name;
let currentStreamingSite = normalizeStreamingSiteConfig(DEFAULT_STREAMING_SITE);
let streamingSiteFormMode = 'add';
let editingStreamingSiteName = '';
function isOwner() {
const pubkey = String(getCurrentPubkey?.() || '');
return Boolean(getIsAuthenticated?.() && pubkey && streamAuthorPubkey && pubkey === streamAuthorPubkey);
}
function setEpisodeId(nextEpisodeId) {
episodeId = String(nextEpisodeId || '').trim();
if (els.streamEpisodeIdDisplay) {
els.streamEpisodeIdDisplay.textContent = `Episode ID: ${episodeId || '—'}`;
}
}
function ensureEpisodeId() {
if (!episodeId) setEpisodeId(buildEpisodeIdTimestamp());
return episodeId;
}
function updateObsHints() {
const urls = deriveShowUrls(streamDTag, currentStreamingSite);
if (els.spanObsServer) els.spanObsServer.textContent = urls.obsServer;
if (els.spanObsKey) els.spanObsKey.textContent = urls.obsKeyTemplate;
}
function updateHeaderTitle() {
if (!els.divHeaderTitleText) return;
const title = String(els.inputStreamTitle?.value || '').trim();
els.divHeaderTitleText.textContent = title || streamDTag || 'STREAM';
}
function setNewShowSlugPreview(slug) {
if (!els.newShowSlugPreview) return;
const safeSlug = normalizeShowSlug(slug);
els.newShowSlugPreview.textContent = `Slug: ${safeSlug || '—'}`;
}
function persistStreamingSitesSettings() {
if (typeof onStreamingSiteSettingsChange !== 'function') return;
onStreamingSiteSettingsChange({
sites: streamingSites.map((site) => ({ ...site })),
selectedName: selectedStreamingSiteName,
});
}
function getStreamingSiteByName(name) {
const safeName = String(name || '').trim();
return streamingSites.find((site) => site.name === safeName) || null;
}
function renderStreamingSiteOptions() {
if (!els.selectStreamingSite) return;
els.selectStreamingSite.innerHTML = '';
streamingSites.forEach((site) => {
const option = document.createElement('option');
option.value = site.name;
option.textContent = site.name;
els.selectStreamingSite.appendChild(option);
});
}
function hideStreamingSiteForm() {
if (els.streamingSiteFormPanel) {
els.streamingSiteFormPanel.classList.add('hidden');
}
streamingSiteFormMode = 'add';
editingStreamingSiteName = '';
}
function showStreamingSiteForm(mode = 'add', seedSite = null) {
streamingSiteFormMode = mode === 'edit' ? 'edit' : 'add';
editingStreamingSiteName = streamingSiteFormMode === 'edit' ? String(seedSite?.name || '').trim() : '';
if (els.inputSiteName) {
els.inputSiteName.value = String(seedSite?.name || '').trim();
}
if (els.inputSiteStreamBaseUrl) {
els.inputSiteStreamBaseUrl.value = String(seedSite?.streamBaseUrl || '').trim();
}
if (els.inputSiteStreamUrlTemplate) {
els.inputSiteStreamUrlTemplate.value = String(seedSite?.streamUrlTemplate || '').trim();
}
if (els.inputSiteRtmpServer) {
els.inputSiteRtmpServer.value = String(seedSite?.rtmpServer || '').trim();
}
if (els.inputSiteObsKeyTemplate) {
els.inputSiteObsKeyTemplate.value = String(seedSite?.obsKeyTemplate || '').trim();
}
if (els.streamingSiteFormPanel) {
els.streamingSiteFormPanel.classList.remove('hidden');
}
els.inputSiteName?.focus();
}
function upsertStreamingSite(draft, { mode = 'add', editingName = '' } = {}) {
const normalizedDraft = normalizeStreamingSiteConfig(draft);
if (!normalizedDraft.name || !normalizedDraft.streamBaseUrl || !normalizedDraft.streamUrlTemplate || !normalizedDraft.rtmpServer || !normalizedDraft.obsKeyTemplate) {
return false;
}
let nextSites = streamingSites.filter((site) => site.name !== normalizedDraft.name);
if (mode === 'edit' && editingName && editingName !== normalizedDraft.name) {
nextSites = nextSites.filter((site) => site.name !== editingName);
}
nextSites.push(normalizedDraft);
setStreamingSites(nextSites, normalizedDraft.name, { persist: true, refresh: true });
hideStreamingSiteForm();
return true;
}
function setCurrentStreamingSiteByName(name, { persist = true, refresh = true } = {}) {
const fallbackSite = streamingSites[0] || normalizeStreamingSiteConfig(DEFAULT_STREAMING_SITE);
const selected = getStreamingSiteByName(name) || fallbackSite;
if (!selected) return;
selectedStreamingSiteName = selected.name;
currentStreamingSite = normalizeStreamingSiteConfig(selected);
if (els.selectStreamingSite) {
els.selectStreamingSite.value = selectedStreamingSiteName;
}
if (refresh) {
if (streamDTag && els.inputStreamingUrl) {
const urls = deriveShowUrls(streamDTag, currentStreamingSite);
els.inputStreamingUrl.value = urls.masterPlaylist;
setStreamPlayerSource(urls.masterPlaylist);
}
updateObsHints();
syncStreamDisplayFields();
pollViewerCount();
}
if (persist) {
persistStreamingSitesSettings();
}
}
function setStreamingSites(nextSites, selectedName, { persist = false, refresh = true } = {}) {
streamingSites = normalizeStreamingSitesList(nextSites);
renderStreamingSiteOptions();
const preferred = String(selectedName || '').trim() || selectedStreamingSiteName;
setCurrentStreamingSiteByName(preferred, { persist, refresh });
}
function setStreamPlayerSource(url) {
const streamUrl = String(url || '').trim();
if (!els.videoStream) return;
if (streamUrl && streamUrl === currentPlayerUrl) return;
currentPlayerUrl = streamUrl;
if (hlsInstance) {
hlsInstance.destroy();
hlsInstance = null;
}
if (!streamUrl) {
els.videoStream.removeAttribute('src');
els.videoStream.load();
return;
}
const isHls = /\.m3u8(\?|$)/i.test(streamUrl);
if (isHls && window.Hls?.isSupported?.()) {
hlsInstance = new window.Hls();
hlsInstance.loadSource(streamUrl);
hlsInstance.attachMedia(els.videoStream);
hlsInstance.on(window.Hls.Events.MANIFEST_PARSED, () => {
els.videoStream.play().catch(() => {});
});
} else {
els.videoStream.src = streamUrl;
els.videoStream.play().catch(() => {});
}
}
function updateNaddrDisplay(pubkey, dTag) {
currentNaddr = buildNaddr(pubkey, dTag) || '';
if (els.spanStreamNaddr) {
els.spanStreamNaddr.textContent = currentNaddr || '—';
els.spanStreamNaddr.title = currentNaddr || '';
}
if (!els.btnCopyNaddr) return;
els.btnCopyNaddr.disabled = !currentNaddr;
els.btnCopyNaddr.style.opacity = currentNaddr ? '' : '0.4';
}
function updateStreamButtonStates() {
const owner = isOwner();
const s = currentStreamStatus;
const hasShow = Boolean(streamDTag);
if (els.btnSaveStream) {
els.btnSaveStream.disabled = !owner || !hasShow || s === 'live';
els.btnSaveStream.style.opacity = (!owner || !hasShow || s === 'live') ? '0.4' : '';
}
if (els.btnGoLive) {
els.btnGoLive.disabled = !owner || !hasShow;
els.btnGoLive.style.opacity = (!owner || !hasShow) ? '0.4' : '';
els.btnGoLive.textContent = s === 'live' ? 'Update Live' : 'Go Live';
}
if (els.btnEndStream) {
els.btnEndStream.disabled = !owner || !hasShow || s !== 'live';
els.btnEndStream.style.opacity = (!owner || !hasShow || s !== 'live') ? '0.4' : '';
}
}
function getStreamImagePlaceholderDataUrl() {
return "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='640' height='360'%3E%3Crect width='100%25' height='100%25' fill='%23222'/%3E%3Ctext x='50%25' y='50%25' fill='%23bbb' font-size='24' text-anchor='middle' dominant-baseline='middle'%3EClick to set stream image%3C/text%3E%3C/svg%3E";
}
function renderStreamImagePreview(url) {
if (!els.imgStreamImagePreview) return;
const imageUrl = String(url || '').trim();
if (!imageUrl) {
els.imgStreamImagePreview.src = getStreamImagePlaceholderDataUrl();
els.imgStreamImagePreview.classList.add('empty');
return;
}
els.imgStreamImagePreview.src = imageUrl;
els.imgStreamImagePreview.classList.remove('empty');
}
function normalizeStreamFieldText(value, fallback = '') {
const text = String(value || '').trim();
return text || String(fallback || '').trim();
}
function syncStreamDisplayFields() {
if (els.streamTitleDisplay) {
els.streamTitleDisplay.textContent = normalizeStreamFieldText(els.inputStreamTitle?.value, 'Stream title');
}
if (els.spanStreamUrl) {
const streamUrlValue = normalizeStreamFieldText(
els.inputStreamingUrl?.value,
'—'
);
els.spanStreamUrl.textContent = streamUrlValue;
els.spanStreamUrl.title = streamUrlValue === '—' ? '' : streamUrlValue;
}
if (els.streamSummaryDisplay) {
els.streamSummaryDisplay.textContent = normalizeStreamFieldText(els.inputStreamSummary?.value, 'Stream summary');
}
if (els.streamEpisodeDescriptionDisplay) {
els.streamEpisodeDescriptionDisplay.textContent = normalizeStreamFieldText(
els.inputStreamEpisodeDescription?.value,
'Episode name (optional)'
);
}
updateObsHints();
updateHeaderTitle();
}
function enterInlineEdit(inputEl, displayEl) {
if (!inputEl || !displayEl) return;
displayEl.classList.add('hidden');
inputEl.classList.remove('hidden');
inputEl.focus();
if (typeof inputEl.select === 'function') inputEl.select();
}
function leaveInlineEdit(inputEl, displayEl) {
if (!inputEl || !displayEl) return;
inputEl.classList.add('hidden');
displayEl.classList.remove('hidden');
syncStreamDisplayFields();
}
function clearChatFeed() {
renderedChatIds.clear();
if (els.divChatFeed) els.divChatFeed.innerHTML = '';
}
function upsertShowOption(slug) {
if (!els.selectStreamShow) return;
const safeSlug = normalizeShowSlug(slug);
if (!safeSlug) return;
const existing = Array.from(els.selectStreamShow.options).find((o) => o.value === safeSlug);
if (!existing) {
const option = document.createElement('option');
option.value = safeSlug;
option.textContent = safeSlug;
const createOption = Array.from(els.selectStreamShow.options).find((o) => o.value === CREATE_SHOW_OPTION_VALUE);
if (createOption) {
els.selectStreamShow.insertBefore(option, createOption);
} else {
els.selectStreamShow.appendChild(option);
}
}
}
function getLatestKnownShowSlug() {
let latest = null;
knownShowsBySlug.forEach((entry, slug) => {
if (!latest || Number(entry?.created_at || 0) > Number(latest.created_at || 0)) {
latest = { slug, created_at: Number(entry?.created_at || 0) };
}
});
return latest?.slug || '';
}
function upsertKnownShowFromEvent(evt) {
if (!evt || evt.kind !== STREAM_KIND) return;
if (!streamAuthorPubkey || evt.pubkey !== streamAuthorPubkey) return;
const slug = normalizeShowSlug(getTagValue(evt, 'd'));
if (!slug) return;
const prev = knownShowsBySlug.get(slug);
const createdAt = Number(evt.created_at) || 0;
if (!prev || createdAt >= Number(prev.created_at || 0)) {
knownShowsBySlug.set(slug, { evt, created_at: createdAt });
}
upsertShowOption(slug);
}
function applyShowDefaults(slug, { fromEvent = null, seedTitle = '', seedSummary = '' } = {}) {
const safeSlug = normalizeShowSlug(slug);
if (!safeSlug) return;
if (els.createShowPanel) {
els.createShowPanel.classList.add('hidden');
}
streamDTag = safeSlug;
streamCoordinate = streamAuthorPubkey ? `${STREAM_KIND}:${streamAuthorPubkey}:${streamDTag}` : '';
const urls = deriveShowUrls(streamDTag, currentStreamingSite);
if (els.inputStreamingUrl) {
els.inputStreamingUrl.value = urls.masterPlaylist;
setStreamPlayerSource(urls.masterPlaylist);
}
if (els.selectStreamShow) {
upsertShowOption(safeSlug);
const prevShowValue = String(els.selectStreamShow.value || '').trim();
els.selectStreamShow.value = safeSlug;
if (prevShowValue !== safeSlug) {
els.selectStreamShow.dispatchEvent(new Event('change', { bubbles: true }));
}
}
const evt = fromEvent || knownShowsBySlug.get(safeSlug)?.evt || null;
if (evt) {
if (els.inputStreamTitle) {
els.inputStreamTitle.value = getTagValue(evt, 'title') || seedTitle || safeSlug;
}
if (els.inputStreamSummary) {
els.inputStreamSummary.value = getTagValue(evt, 'summary') || seedSummary || '';
}
if (els.inputStreamImage) els.inputStreamImage.value = getTagValue(evt, 'image') || '';
if (els.inputStreamEpisodeDescription) {
els.inputStreamEpisodeDescription.value = getTagValue(evt, 'episode_description') || '';
}
const existingEpisode = getTagValue(evt, 'episode');
setEpisodeId(existingEpisode || buildEpisodeIdTimestamp());
} else {
setEpisodeId(buildEpisodeIdTimestamp());
if (els.inputStreamTitle) {
els.inputStreamTitle.value = String(seedTitle || '').trim() || safeSlug;
}
if (els.inputStreamSummary) els.inputStreamSummary.value = String(seedSummary || '').trim();
if (els.inputStreamImage) els.inputStreamImage.value = '';
if (els.inputStreamEpisodeDescription) els.inputStreamEpisodeDescription.value = '';
}
playlistDTag = '';
playlistTracks = [];
playlistViewerSnapshots = [];
latestPlaylistEventCreatedAt = 0;
latestPlaylistEventId = '';
emitPlaylistChange();
renderStreamImagePreview(els.inputStreamImage?.value || '');
setNewShowSlugPreview(safeSlug);
syncStreamDisplayFields();
updateNaddrDisplay(streamAuthorPubkey, streamDTag);
updateObsHints();
updateStreamButtonStates();
subscribeToStream();
subscribeToPlaylist();
clearChatFeed();
subscribeToChat();
pollViewerCount();
}
function updateStreamUi(evt) {
if (!evt) return;
const title = getTagValue(evt, 'title') || '';
const status = getTagValue(evt, 'status') || 'planned';
const summary = getTagValue(evt, 'summary') || '';
const image = getTagValue(evt, 'image') || '';
const dTag = normalizeShowSlug(getTagValue(evt, 'd') || streamDTag);
const episode = getTagValue(evt, 'episode') || '';
const episodeDescription = getTagValue(evt, 'episode_description') || '';
if (!dTag) return;
streamDTag = dTag;
streamCoordinate = `${STREAM_KIND}:${streamAuthorPubkey}:${streamDTag}`;
currentStreamStatus = status;
if (els.inputStreamTitle) els.inputStreamTitle.value = title;
if (els.inputStreamSummary) els.inputStreamSummary.value = summary;
if (els.inputStreamImage) els.inputStreamImage.value = image;
if (els.inputStreamEpisodeDescription) {
const isEditingEpisodeDescription = !els.inputStreamEpisodeDescription.classList.contains('hidden')
|| document.activeElement === els.inputStreamEpisodeDescription;
if (!isEditingEpisodeDescription) {
els.inputStreamEpisodeDescription.value = episodeDescription;
}
}
if (els.inputStreamingUrl) {
els.inputStreamingUrl.value = deriveShowUrls(streamDTag, currentStreamingSite).masterPlaylist;
}
const previousEpisode = episodeId;
if (episode) setEpisodeId(episode);
if (episode && episode !== previousEpisode) {
playlistTracks = [];
playlistViewerSnapshots = [];
latestPlaylistEventCreatedAt = 0;
latestPlaylistEventId = '';
emitPlaylistChange();
subscribeToPlaylist();
}
syncStreamDisplayFields();
renderStreamImagePreview(image);
const resolvedStreaming = String(els.inputStreamingUrl?.value || '').trim();
setStreamPlayerSource(resolvedStreaming);
updateNaddrDisplay(streamAuthorPubkey, streamDTag);
updateStreamButtonStates();
}
function appendChatEvent(evt) {
if (!evt?.id || renderedChatIds.has(evt.id)) return;
renderedChatIds.add(evt.id);
const chatItem = renderPostItem(evt, {
currentPubkey: getCurrentPubkey?.() || '',
showHeader: true,
isCompact: true,
autoplayVideo: false
});
if (!chatItem || !els.divChatFeed) return;
const createdAt = Number(evt?.created_at) || 0;
chatItem.dataset.createdAt = String(createdAt);
chatItem.dataset.eventId = String(evt.id || '');
const existingItems = Array.from(els.divChatFeed.children);
const insertBeforeNode = existingItems.find((node) => {
const nodeCreatedAt = Number(node?.dataset?.createdAt || 0);
if (createdAt > nodeCreatedAt) return true;
if (createdAt < nodeCreatedAt) return false;
const nodeEventId = String(node?.dataset?.eventId || '');
return String(evt.id || '') > nodeEventId;
});
if (insertBeforeNode) {
els.divChatFeed.insertBefore(chatItem, insertBeforeNode);
} else {
els.divChatFeed.appendChild(chatItem);
}
}
function buildMusicPlaylistTrackRefs() {
const refs = [];
const seen = new Set();
playlistTracks.forEach((tag) => {
if (!Array.isArray(tag) || tag[0] !== 'track') return;
const rawRef = String(tag[4] || '').trim();
const numericId = /^\d+$/.test(rawRef) ? rawRef : '';
if (!numericId) return;
const ref = `tidal:track:${numericId}`;
if (seen.has(ref)) return;
seen.add(ref);
refs.push(['i', ref]);
});
return refs;
}
function buildMusicPlaylistContent() {
const tracks = playlistTracks
.filter((tag) => Array.isArray(tag) && tag[0] === 'track')
.map((tag) => {
const id = String(tag[4] || '').trim();
return {
id,
title: String(tag[3] || '').trim(),
artist: String(tag[2] || '').trim(),
duration: 0,
cover: '',
albumTitle: '',
};
});
return JSON.stringify({ tracks });
}
function buildPlaylistTags(status = 'live') {
const safeSlug = normalizeShowSlug(streamDTag);
const safeEpisodeId = String(episodeId || '').trim();
if (!safeSlug || !safeEpisodeId || !streamAuthorPubkey) return null;
playlistDTag = `show-playlist:${safeSlug}:${safeEpisodeId}`;
const streamCoord = `${STREAM_KIND}:${streamAuthorPubkey}:${safeSlug}`;
const title = String(els.inputStreamTitle?.value || '').trim() || safeSlug;
const episodeDescription = String(els.inputStreamEpisodeDescription?.value || '').trim();
const streamingUrl = String(els.inputStreamingUrl?.value || '').trim();
const streamingSiteName = String(currentStreamingSite?.name || '').trim();
const tags = [
['d', playlistDTag],
['title', `${title} Playlist`],
['t', 'music'],
['t', 'playlist'],
['t', 'show-playlist'],
['show', safeSlug],
['episode', safeEpisodeId],
['a', streamCoord],
['status', String(status || 'live').trim() || 'live'],
];
if (episodeDescription) tags.push(['episode_description', episodeDescription]);
if (streamingUrl) tags.push(['streaming_url', streamingUrl]);
if (streamingSiteName) tags.push(['streaming_site', streamingSiteName]);
buildMusicPlaylistTrackRefs().forEach((tag) => tags.push(tag));
playlistTracks.forEach((trackTag) => tags.push(trackTag));
playlistViewerSnapshots.forEach((snapshotTag) => tags.push(snapshotTag));
return tags;
}
async function publishPlaylistEvent(status = 'live') {
const tags = buildPlaylistTags(status);
if (!tags) return false;
await publishEvent({
kind: MUSIC_PLAYLIST_KIND,
created_at: Math.floor(Date.now() / 1000),
content: buildMusicPlaylistContent(),
tags,
});
return true;
}
function buildViewerSnapshotTag() {
const timestamp = getIsoNow();
const parts = ['viewers', timestamp, String(Number(viewerCount) || 0)];
Object.entries(lastVariants || {}).forEach(([variant, info]) => {
const count = Number(info?.viewers) || 0;
parts.push(`${variant}:${count}`);
});
return parts;
}
function buildEpisodePlaylistItems() {
return playlistTracks
.map((tag, index) => {
if (!Array.isArray(tag) || tag[0] !== 'track') return null;
return {
index,
trackNumber: String(tag[1] || '').trim(),
artist: String(tag[2] || '').trim(),
title: String(tag[3] || '').trim(),
nostrRef: String(tag[4] || '').trim(),
isoTime: String(tag[5] || '').trim(),
};
})
.filter(Boolean);
}
function emitPlaylistChange() {
if (typeof onPlaylistChange !== 'function') return;
onPlaylistChange(buildEpisodePlaylistItems());
}
function renumberPlaylistTracks() {
playlistTracks = playlistTracks
.map((tag) => {
if (!Array.isArray(tag) || tag[0] !== 'track') return null;
return [...tag];
})
.filter(Boolean)
.map((tag, index) => {
const next = [...tag];
next[1] = String(index + 1).padStart(3, '0');
return next;
});
}
function getEpisodePlaylistTracks() {
return buildEpisodePlaylistItems();
}
async function removeEpisodePlaylistTrack(index) {
const targetIndex = Number(index);
if (!Number.isInteger(targetIndex) || targetIndex < 0 || targetIndex >= playlistTracks.length) return false;
playlistTracks.splice(targetIndex, 1);
renumberPlaylistTracks();
if (currentStreamStatus === 'live') {
await publishPlaylistEvent('live');
}
emitPlaylistChange();
return true;
}
async function reorderEpisodePlaylistTrack(fromIndex, toIndex) {
const from = Number(fromIndex);
const to = Number(toIndex);
if (!Number.isInteger(from) || !Number.isInteger(to)) return false;
if (from < 0 || to < 0 || from >= playlistTracks.length || to >= playlistTracks.length) return false;
if (from === to) return false;
const [moved] = playlistTracks.splice(from, 1);
if (!moved) return false;
playlistTracks.splice(to, 0, moved);
renumberPlaylistTracks();
if (currentStreamStatus === 'live' && playlistTracks.length > 0) {
await publishPlaylistEvent('live');
}
emitPlaylistChange();
return true;
}
async function appendNowPlayingToPlaylist(track) {
if (!track || typeof track !== 'object') return false;
if (!streamDTag || currentStreamStatus !== 'live') return false;
ensureEpisodeId();
const title = String(track.title || '').trim();
const artist = String(track.artist || '').trim();
const nostrRef = String(track?.eventId || track?.id || '').trim();
if (!title) return false;
const signature = `${nostrRef}|${title}|${artist}`;
if (signature === lastAnnouncedSongSig) return false;
lastAnnouncedSongSig = signature;
const trackNumber = String(playlistTracks.length + 1).padStart(3, '0');
playlistTracks.push([
'track',
trackNumber,
artist,
title,
nostrRef,
getIsoNow(),
]);
playlistViewerSnapshots.push(buildViewerSnapshotTag());
await publishPlaylistEvent('live');
emitPlaylistChange();
return true;
}
async function createShowMetadata() {
await promptLoginIfNeeded?.();
if (!streamAuthorPubkey) {
streamAuthorPubkey = String(getCurrentPubkey?.() || '');
}
if (!streamAuthorPubkey || !streamDTag) {
throw new Error('Unable to create show metadata without author and slug.');
}
const title = String(els.inputStreamTitle?.value || '').trim() || streamDTag;
const summary = String(els.inputStreamSummary?.value || '').trim();
const image = String(els.inputStreamImage?.value || '').trim();
const streamingUrl = deriveShowUrls(streamDTag, currentStreamingSite).masterPlaylist;
const episodeDescription = String(els.inputStreamEpisodeDescription?.value || '').trim();
setEpisodeId(buildEpisodeIdTimestamp());
playlistDTag = `show-playlist:${streamDTag}:${episodeId}`;
playlistTracks = [];
playlistViewerSnapshots = [];
lastAnnouncedSongSig = '';
emitPlaylistChange();
const tags = buildStreamTags({
dTag: streamDTag,
title,
summary,
image,
streamingUrl,
status: 'planned',
episodeId,
episodeDescription,
webUrl: deriveShowUrls(streamDTag, currentStreamingSite).viewerPage,
});
await publishEvent({
kind: STREAM_KIND,
created_at: Math.floor(Date.now() / 1000),
content: summary,
tags,
});
currentStreamStatus = 'planned';
updateNaddrDisplay(streamAuthorPubkey, streamDTag);
updateStreamButtonStates();
}
async function publishStreamStatus(nextStatus) {
await promptLoginIfNeeded?.();
if (!streamAuthorPubkey) {
streamAuthorPubkey = String(getCurrentPubkey?.() || '');
}
const dTag = normalizeShowSlug(streamDTag);
if (!dTag) {
throw new Error('Select or create a show before publishing stream status.');
}
const title = String(els.inputStreamTitle?.value || '').trim();
const summary = String(els.inputStreamSummary?.value || '').trim();
const image = String(els.inputStreamImage?.value || '').trim();
const streamingUrl = deriveShowUrls(dTag, currentStreamingSite).masterPlaylist;
const episodeDescription = String(els.inputStreamEpisodeDescription?.value || '').trim();
if (nextStatus === 'live') {
setEpisodeId(buildEpisodeIdTimestamp());
playlistDTag = `show-playlist:${dTag}:${episodeId}`;
playlistTracks = [];
playlistViewerSnapshots = [];
lastAnnouncedSongSig = '';
emitPlaylistChange();
}
if (nextStatus === 'planned') {
ensureEpisodeId();
await publishPlaylistEvent('planned');
}
const tags = buildStreamTags({
dTag,
title,
summary,
image,
streamingUrl,
status: nextStatus,
episodeId,
episodeDescription,
webUrl: deriveShowUrls(dTag, currentStreamingSite).viewerPage,
});
if (viewerCount > 0) tags.push(['current_participants', String(viewerCount)]);
await publishEvent({
kind: STREAM_KIND,
created_at: Math.floor(Date.now() / 1000),
content: summary,
tags
});
currentStreamStatus = nextStatus;
streamCoordinate = `${STREAM_KIND}:${streamAuthorPubkey}:${dTag}`;
if (nextStatus === 'live') {
subscribeToChat();
startViewerCountPolling();
}
if (nextStatus === 'ended') {
if (playlistTracks.length > 0) {
await publishPlaylistEvent('ended');
}
stopViewerCountPolling();
}
updateNaddrDisplay(streamAuthorPubkey, dTag);
updateStreamButtonStates();
}
async function autoRepublishShowMetadata() {
if (autoRepublishInFlight) return;
if (!streamDTag || !isOwner()) return;
autoRepublishInFlight = true;
try {
const statusForRepublish = (currentStreamStatus && currentStreamStatus !== 'none')
? currentStreamStatus
: 'planned';
await publishStreamStatus(statusForRepublish);
} catch (error) {
console.error('[vj.html] Auto-republish stream metadata failed:', error);
} finally {
autoRepublishInFlight = false;
}
}
function scheduleAutoRepublish(delayMs = 350) {
if (!streamDTag || !isOwner()) return;
if (autoRepublishTimerId) {
clearTimeout(autoRepublishTimerId);
autoRepublishTimerId = null;
}
autoRepublishTimerId = setTimeout(() => {
autoRepublishTimerId = null;
autoRepublishShowMetadata();
}, Math.max(0, Number(delayMs) || 0));
}
function subscribeToStream() {
if (!streamAuthorPubkey || !streamDTag) return;
streamSubId?.close?.();
const since = Math.floor(Date.now() / 1000) - (90 * 24 * 3600);
streamSubId = subscribe({
kinds: [STREAM_KIND],
authors: [streamAuthorPubkey],
'#d': [streamDTag],
since,
limit: 20
}, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });
}
function subscribeToPlaylist() {
if (!streamAuthorPubkey || !streamDTag || !episodeId) return;
playlistSubId?.close?.();
const since = Math.floor(Date.now() / 1000) - (365 * 24 * 3600);
playlistSubId = subscribe({
kinds: [MUSIC_PLAYLIST_KIND, LEGACY_PLAYLIST_KIND],
authors: [streamAuthorPubkey],
'#show': [streamDTag],
'#episode': [episodeId],
since,
limit: 100,
}, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });
}
function discoverShows() {
if (!streamAuthorPubkey) return;
showDiscoverySubId?.close?.();
const since = Math.floor(Date.now() / 1000) - (365 * 24 * 3600);
showDiscoverySubId = subscribe({
kinds: [STREAM_KIND],
authors: [streamAuthorPubkey],
since,
limit: 200,
}, { closeOnEose: true, cacheUsage: 'CACHE_FIRST' });
}
function subscribeToChat() {
if (!streamCoordinate) return;
chatSubId?.close?.();
const since = Math.floor(Date.now() / 1000) - (3 * 24 * 3600);
chatSubId = subscribe({
kinds: [CHAT_KIND],
'#a': [streamCoordinate],
since,
limit: 500
}, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });
}
async function pollViewerCount() {
try {
const statsUrl = deriveShowUrls(streamDTag, currentStreamingSite).stats;
if (!statsUrl) {
if (els.divViewerCount) els.divViewerCount.textContent = 'TOTAL: —';
return;
}
const res = await fetch(statsUrl);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
viewerCount = Number(data.viewers) || 0;
const isLive = Boolean(data.live);
lastVariants = (data?.variants && typeof data.variants === 'object') ? data.variants : {};
if (els.divViewerCount) {
const variantParts = Object.entries(lastVariants)
.map(([name, v]) => `${name}: ${Number(v?.viewers) || 0}`)
.join(' | ');
els.divViewerCount.textContent = `TOTAL: ${viewerCount}${variantParts ? ` | ${variantParts}` : ''}`;
}
if (els.spanStreamHealth) {
els.spanStreamHealth.textContent = isLive ? 'STATUS: LIVE' : 'STATUS: OFFLINE';
}
if (isLive && !wasStreamLive) {
currentPlayerUrl = '';
const streamUrl = String(els.inputStreamingUrl?.value || '').trim();
setStreamPlayerSource(streamUrl);
} else if (!isLive && wasStreamLive) {
if (hlsInstance) {
hlsInstance.destroy();
hlsInstance = null;
}
}
wasStreamLive = isLive;
} catch {
if (els.divViewerCount) {
els.divViewerCount.textContent = 'TOTAL: —';
}
if (els.spanStreamHealth) {
els.spanStreamHealth.textContent = 'STATUS: ERROR';
}
}
}
function startViewerCountPolling() {
pollViewerCount();
if (!statsIntervalId) {
statsIntervalId = setInterval(pollViewerCount, STATS_POLL_INTERVAL);
}
}
function stopViewerCountPolling() {
if (statsIntervalId) {
clearInterval(statsIntervalId);
statsIntervalId = null;
}
}
function parsePlaylistTracksFromEvent(evt) {
const tagTracks = (Array.isArray(evt?.tags) ? evt.tags : [])
.filter((tag) => Array.isArray(tag) && tag[0] === 'track')
.map((tag) => [
'track',
String(tag[1] || '').trim(),
String(tag[2] || '').trim(),
String(tag[3] || '').trim(),
String(tag[4] || '').trim(),
String(tag[5] || '').trim(),
]);
if (tagTracks.length > 0) return tagTracks;
let parsedContentTracks = [];
try {
const parsed = JSON.parse(String(evt?.content || '{}'));
if (Array.isArray(parsed?.tracks)) {
parsedContentTracks = parsed.tracks;
}
} catch {
parsedContentTracks = [];
}
return parsedContentTracks
.map((track, index) => {
const t = track && typeof track === 'object' ? track : {};
const id = String(t.id || '').trim();
const title = String(t.title || '').trim();
const artist = String(t.artist || '').trim();
if (!title) return null;
return [
'track',
String(index + 1).padStart(3, '0'),
artist,
title,
id,
getIsoNow(),
];
})
.filter(Boolean);
}
function handleIncomingEvent(evt) {
if (!evt?.kind) return;
if (evt.kind === STREAM_KIND) {
if (evt.pubkey === streamAuthorPubkey) upsertKnownShowFromEvent(evt);
const dTag = normalizeShowSlug(getTagValue(evt, 'd'));
if (!dTag || dTag !== streamDTag) return;
if (evt.pubkey !== streamAuthorPubkey) return;
updateStreamUi(evt);
return;
}
if (evt.kind === MUSIC_PLAYLIST_KIND || evt.kind === LEGACY_PLAYLIST_KIND) {
if (evt.pubkey !== streamAuthorPubkey) return;
const showTag = normalizeShowSlug(getTagValue(evt, 'show'));
if (!showTag || showTag !== streamDTag) return;
const evtEpisode = String(getTagValue(evt, 'episode') || '').trim();
if (episodeId && evtEpisode && evtEpisode !== episodeId) return;
const createdAt = Number(evt.created_at) || 0;
const evtId = String(evt.id || '');
const isNewer = createdAt > latestPlaylistEventCreatedAt
|| (createdAt === latestPlaylistEventCreatedAt && evtId > latestPlaylistEventId);
if (!isNewer) return;
const nextTracks = parsePlaylistTracksFromEvent(evt);
const nextViewerSnapshots = (Array.isArray(evt.tags) ? evt.tags : [])
.filter((tag) => Array.isArray(tag) && tag[0] === 'viewers')
.map((tag) => [...tag]);
playlistTracks = nextTracks;
playlistViewerSnapshots = nextViewerSnapshots;
latestPlaylistEventCreatedAt = createdAt;
latestPlaylistEventId = evtId;
emitPlaylistChange();
return;
}
if (evt.kind === CHAT_KIND) {
const aTags = (evt.tags || []).filter((t) => t?.[0] === 'a').map((t) => t?.[1]).filter(Boolean);
if (!aTags.includes(streamCoordinate)) return;
appendChatEvent(evt);
}
}
function bindStreamEventListeners() {
if (streamListenersBound) return;
streamListenersBound = true;
window.addEventListener('ndkEvent', (event) => {
const evt = event.detail;
handleIncomingEvent(evt);
});
}
function mountChatComposerIfAllowed() {
if (!els.divChatComposer) return;
els.divChatComposer.innerHTML = '';
if (!getIsAuthenticated?.()) {
els.divChatComposer.textContent = 'Sign in to chat.';
return;
}
const hostEl = document.createElement('div');
els.divChatComposer.appendChild(hostEl);
chatComposer = mountComposer(hostEl, {
currentPubkey: getCurrentPubkey?.() || '',
followedProfiles: [],
showUploadIcon: false,
showPreview: true,
autoHideOnSubmit: false,
layout: 'inline',
onSubmit: async (content) => {
const text = String(content || '').trim();
if (!text) return false;
if (!streamCoordinate) return false;
await publishEvent({
kind: CHAT_KIND,
created_at: Math.floor(Date.now() / 1000),
content: text,
tags: [
['a', streamCoordinate],
['p', streamAuthorPubkey]
]
});
return true;
}
});
return chatComposer;
}
function hideStreamImageEditor() {
if (!els.streamImageEditorRow) return;
els.streamImageEditorRow.classList.add('hidden');
}
function showStreamImageEditor() {
if (!els.streamImageEditorRow || !els.inputStreamImage) return;
els.streamImageEditorRow.classList.remove('hidden');
els.inputStreamImage.focus();
els.inputStreamImage.select();
}
async function captureAndUploadStreamScreenshot() {
if (!els.videoStream || !els.inputStreamImage || !els.btnStreamImageScreenshot) return;
const button = els.btnStreamImageScreenshot;
const previousLabel = button.textContent || '📸 Screenshot';
button.disabled = true;
button.textContent = 'Uploading…';
try {
await promptLoginIfNeeded?.();
const video = els.videoStream;
const width = Number(video.videoWidth) || 0;
const height = Number(video.videoHeight) || 0;
if (!width || !height) {
throw new Error('No active video frame available for screenshot.');
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Canvas rendering context unavailable.');
}
ctx.drawImage(video, 0, 0, width, height);
const blob = await new Promise((resolve, reject) => {
canvas.toBlob((out) => {
if (out) {
resolve(out);
} else {
reject(new Error('Failed to capture screenshot image data.'));
}
}, 'image/jpeg', 0.92);
});
const timestamp = Date.now();
const screenshotFile = new File([blob], `stream-screenshot-${timestamp}.jpg`, {
type: 'image/jpeg',
});
const { sha256 } = await uploadToAllServers(screenshotFile);
const screenshotUrl = getBlobUrl(sha256, 'jpg');
els.inputStreamImage.value = screenshotUrl;
renderStreamImagePreview(screenshotUrl);
syncStreamDisplayFields();
els.inputStreamImage.dispatchEvent(new Event('input', { bubbles: true }));
button.textContent = '✓ Added';
setTimeout(() => {
button.textContent = previousLabel;
}, 1200);
} catch (error) {
console.error('[vj.html] Stream screenshot upload failed:', error);
button.textContent = '✗ Failed';
setTimeout(() => {
button.textContent = previousLabel;
}, 1500);
} finally {
button.disabled = false;
}
}
async function announceNowPlaying(track, options = {}) {
if (!track || typeof track !== 'object') return false;
const title = String(track.title || '').trim();
const artist = String(track.artist || '').trim();
if (!title) return false;
if (!streamCoordinate || !streamAuthorPubkey) return false;
await promptLoginIfNeeded?.();
const template = String(options?.template || 'Now playing: {title} — {artist}').trim();
const rawTrack = track?.raw && typeof track.raw === 'object' ? track.raw : {};
const coverUrl = String(options?.coverUrl || track?.coverUrl || '').trim();
const coverReferencedInTemplate = template.includes('{cover}') || /\{url-[^{}]+\{cover\}\}/i.test(template);
const includeCover = Boolean(options?.includeCover) || coverReferencedInTemplate;
const albumTitle = String(track?.albumTitle || rawTrack?.album?.title || '').trim();
const releaseDate = String(rawTrack?.album?.releaseDate || rawTrack?.streamStartDate || '').trim();
const releaseYear = releaseDate ? String(new Date(releaseDate).getFullYear() || '') : '';
const trackId = String(track?.id || rawTrack?.id || '').trim();
const artistId = String(track?.artistId || rawTrack?.artist?.id || rawTrack?.artists?.[0]?.id || '').trim();
const albumId = String(track?.albumId || rawTrack?.album?.id || '').trim();
const durationSeconds = Number(track?.duration || rawTrack?.duration || 0);
const durationText = formatDuration(durationSeconds);
const version = String(rawTrack?.version || '').trim();
const quality = String(rawTrack?.audioQuality || rawTrack?.quality || '').trim();
const explicit = Boolean(rawTrack?.explicit || rawTrack?.explicitLyrics);
const trackNumber = String(rawTrack?.trackNumber || '').trim();
const discNumber = String(rawTrack?.volumeNumber || rawTrack?.discNumber || '').trim();
const isrc = String(rawTrack?.isrc || '').trim();
const copyright = String(rawTrack?.copyright || '').trim();
const popularity = String(rawTrack?.popularity ?? '').trim();
const monochromeBaseUrl = normalizeBaseUrl(options?.urlBase);
const monochromeTrackUrl = trackId ? `${monochromeBaseUrl}/track/${encodeURIComponent(trackId)}` : '';
const monochromeArtistUrl = artistId ? `${monochromeBaseUrl}/artist/${encodeURIComponent(artistId)}` : '';
const monochromeAlbumUrl = albumId ? `${monochromeBaseUrl}/album/${encodeURIComponent(albumId)}` : '';
const tokenMap = {
title,
artist: artist || 'Unknown artist',
album: albumTitle,
cover: (includeCover && coverUrl) ? coverUrl : '',
year: releaseYear && releaseYear !== 'NaN' ? releaseYear : '',
releasedate: releaseDate,
duration: durationText,
durationseconds: Number.isFinite(durationSeconds) && durationSeconds > 0 ? String(Math.floor(durationSeconds)) : '',
tracknumber: trackNumber,
discnumber: discNumber,
version,
quality,
explicit: explicit ? 'yes' : 'no',
isrc,
copyright,
popularity,
'track-id': trackId,
'artist-id': artistId,
'album-id': albumId,
'monochrome-track': monochromeTrackUrl,
'monochrome-artist': monochromeArtistUrl,
'monochrome-album': monochromeAlbumUrl,
'url-monochrome-track': monochromeTrackUrl,
'url-monochrome-artist': monochromeArtistUrl,
'url-monochrome-album': monochromeAlbumUrl,
};
let content = renderTemplateWithSongData(template, tokenMap);
if (includeCover && coverUrl && !template.includes('{cover}') && !/\{url-[^{}]+\{cover\}\}/i.test(template)) {
content = `${content}\n${coverUrl}`;
}
await publishEvent({
kind: CHAT_KIND,
created_at: Math.floor(Date.now() / 1000),
content,
tags: [
['a', streamCoordinate],
['p', streamAuthorPubkey],
],
});
await appendNowPlayingToPlaylist(track);
return true;
}
function updatePlayPauseVisualState() {
if (!els.btnVideoPlay || !els.btnVideoPause || !els.videoStream) return;
const isPlaying = !els.videoStream.paused && !els.videoStream.ended;
els.btnVideoPlay.style.opacity = isPlaying ? '0.45' : '';
els.btnVideoPause.style.opacity = isPlaying ? '' : '0.45';
}
function wireButtons() {
els.btnVideoPlay?.addEventListener('click', () => {
els.videoStream?.play().catch(() => {});
});
els.btnVideoPause?.addEventListener('click', () => {
els.videoStream?.pause();
});
els.videoStream?.addEventListener('play', updatePlayPauseVisualState);
els.videoStream?.addEventListener('pause', updatePlayPauseVisualState);
els.videoStream?.addEventListener('ended', updatePlayPauseVisualState);
els.btnVideoMute?.addEventListener('click', () => {
if (!els.videoStream) return;
els.videoStream.muted = !els.videoStream.muted;
els.btnVideoMute.textContent = els.videoStream.muted ? '🔇' : '🔊';
els.btnVideoMute.classList.toggle('active', els.videoStream.muted);
});
els.btnStreamRefresh?.addEventListener('click', () => {
const streamUrl = String(els.inputStreamingUrl?.value || '').trim();
if (streamUrl) {
currentPlayerUrl = '';
setStreamPlayerSource(streamUrl);
}
pollViewerCount();
});
const copyButtonFeedback = (btn) => {
if (!btn) return;
const prev = btn.textContent;
btn.textContent = '✓ Copied!';
setTimeout(() => { btn.textContent = prev; }, 1200);
};
els.btnCopyStreamUrl?.addEventListener('click', () => {
const value = String(els.inputStreamingUrl?.value || '').trim();
if (!value) return;
navigator.clipboard?.writeText(value).then(() => {
copyButtonFeedback(els.btnCopyStreamUrl);
}).catch(() => {});
});
els.btnCopyNaddr?.addEventListener('click', () => {
if (!currentNaddr) return;
navigator.clipboard?.writeText(currentNaddr).then(() => {
copyButtonFeedback(els.btnCopyNaddr);
}).catch(() => {});
});
els.btnCopyObsServer?.addEventListener('click', () => {
const value = String(els.spanObsServer?.textContent || '').trim();
if (!value) return;
navigator.clipboard?.writeText(value).then(() => {
copyButtonFeedback(els.btnCopyObsServer);
}).catch(() => {});
});
els.btnCopyObsKey?.addEventListener('click', () => {
const value = String(els.spanObsKey?.textContent || '').trim();
if (!value) return;
navigator.clipboard?.writeText(value).then(() => {
copyButtonFeedback(els.btnCopyObsKey);
}).catch(() => {});
});
els.imgStreamImagePreview?.addEventListener('click', () => {
showStreamImageEditor();
});
els.inputStreamImage?.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
hideStreamImageEditor();
return;
}
if (event.key === 'Enter') {
event.preventDefault();
hideStreamImageEditor();
}
});
els.streamImageEditorRow?.addEventListener('focusout', (event) => {
const nextTarget = event.relatedTarget;
if (nextTarget && els.streamImageEditorRow.contains(nextTarget)) return;
hideStreamImageEditor();
});
els.inputStreamImage?.addEventListener('input', () => {
renderStreamImagePreview(els.inputStreamImage?.value || '');
});
els.btnStreamImageScreenshot?.addEventListener('click', () => {
captureAndUploadStreamScreenshot();
});
els.imgStreamImagePreview?.addEventListener('error', () => {
renderStreamImagePreview('');
});
els.streamTitleDisplay?.addEventListener('click', () => {
enterInlineEdit(els.inputStreamTitle, els.streamTitleDisplay);
});
els.streamSummaryDisplay?.addEventListener('click', () => {
enterInlineEdit(els.inputStreamSummary, els.streamSummaryDisplay);
});
els.streamEpisodeDescriptionDisplay?.addEventListener('click', () => {
enterInlineEdit(els.inputStreamEpisodeDescription, els.streamEpisodeDescriptionDisplay);
});
const autoSizeTextarea = (inputEl) => {
if (!inputEl || inputEl.tagName !== 'TEXTAREA') return;
inputEl.style.height = 'auto';
inputEl.style.height = `${Math.max(inputEl.scrollHeight, 44)}px`;
inputEl.style.overflowY = 'hidden';
};
const bindInlineEditor = (inputEl, displayEl) => {
if (!inputEl || !displayEl) return;
if (inputEl.tagName === 'TEXTAREA') {
autoSizeTextarea(inputEl);
}
inputEl.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
leaveInlineEdit(inputEl, displayEl);
return;
}
if (event.key === 'Enter' && inputEl.tagName !== 'TEXTAREA') {
event.preventDefault();
leaveInlineEdit(inputEl, displayEl);
}
});
inputEl.addEventListener('blur', () => {
leaveInlineEdit(inputEl, displayEl);
});
inputEl.addEventListener('input', () => {
if (inputEl.tagName === 'TEXTAREA') {
autoSizeTextarea(inputEl);
}
syncStreamDisplayFields();
});
};
bindInlineEditor(els.inputStreamTitle, els.streamTitleDisplay);
bindInlineEditor(els.inputStreamSummary, els.streamSummaryDisplay);
bindInlineEditor(els.inputStreamEpisodeDescription, els.streamEpisodeDescriptionDisplay);
const updateNewShowSlugPreview = () => {
const title = String(els.inputNewShowTitle?.value || '').trim();
const slug = deriveSlugFromTitle(title);
setNewShowSlugPreview(slug);
return slug;
};
const autoSizeNewShowSummary = () => {
if (!els.inputNewShowSummary) return;
els.inputNewShowSummary.style.height = 'auto';
els.inputNewShowSummary.style.height = `${Math.max(els.inputNewShowSummary.scrollHeight, 72)}px`;
els.inputNewShowSummary.style.overflowY = 'hidden';
};
els.inputNewShowTitle?.addEventListener('input', updateNewShowSlugPreview);
els.inputNewShowSummary?.addEventListener('input', autoSizeNewShowSummary);
autoSizeNewShowSummary();
els.selectStreamingSite?.addEventListener('change', () => {
const selectedName = String(els.selectStreamingSite?.value || '').trim();
setCurrentStreamingSiteByName(selectedName, { persist: true, refresh: true });
});
els.btnAddStreamingSite?.addEventListener('click', () => {
showStreamingSiteForm('add', {
name: '',
streamBaseUrl: 'https://',
streamUrlTemplate: '{base}/stream/{slug}/stream.m3u8',
rtmpServer: 'rtmp://',
obsKeyTemplate: '{slug}/src/{SECRET_KEY}',
});
els.streamingSiteFormPanel?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
els.btnEditStreamingSite?.addEventListener('click', () => {
const selected = getStreamingSiteByName(selectedStreamingSiteName) || currentStreamingSite;
showStreamingSiteForm('edit', selected);
els.streamingSiteFormPanel?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
els.btnDeleteStreamingSite?.addEventListener('click', () => {
if (!Array.isArray(streamingSites) || streamingSites.length <= 1) return;
const nextSites = streamingSites.filter((site) => site.name !== selectedStreamingSiteName);
setStreamingSites(nextSites, nextSites[0]?.name || DEFAULT_STREAMING_SITE.name, { persist: true, refresh: true });
hideStreamingSiteForm();
});
els.btnCancelStreamingSite?.addEventListener('click', () => {
hideStreamingSiteForm();
});
els.btnSaveStreamingSite?.addEventListener('click', () => {
const draft = normalizeStreamingSiteConfig({
name: String(els.inputSiteName?.value || '').trim(),
streamBaseUrl: String(els.inputSiteStreamBaseUrl?.value || '').trim(),
streamUrlTemplate: String(els.inputSiteStreamUrlTemplate?.value || '').trim(),
rtmpServer: String(els.inputSiteRtmpServer?.value || '').trim(),
obsKeyTemplate: String(els.inputSiteObsKeyTemplate?.value || '').trim(),
});
upsertStreamingSite(draft, {
mode: streamingSiteFormMode,
editingName: editingStreamingSiteName,
});
});
hideStreamingSiteForm();
els.selectStreamShow?.addEventListener('change', () => {
const rawValue = String(els.selectStreamShow?.value || '').trim();
if (rawValue === CREATE_SHOW_OPTION_VALUE) {
if (els.createShowPanel) els.createShowPanel.classList.remove('hidden');
setNewShowSlugPreview('');
return;
}
if (els.createShowPanel) els.createShowPanel.classList.add('hidden');
const selected = normalizeShowSlug(rawValue);
if (!selected) return;
applyShowDefaults(selected);
});
els.btnCreateShow?.addEventListener('click', async () => {
const titleForNewShow = String(els.inputNewShowTitle?.value || '').trim();
const summaryForNewShow = String(els.inputNewShowSummary?.value || '').trim();
const inputSlug = updateNewShowSlugPreview();
if (!isValidShowSlug(inputSlug)) {
console.warn('[vj.html] Invalid show slug derived from title:', inputSlug);
return;
}
upsertShowOption(inputSlug);
applyShowDefaults(inputSlug, {
seedTitle: titleForNewShow,
seedSummary: summaryForNewShow,
});
try {
await createShowMetadata();
} catch (error) {
console.error('[vj.html] Create show failed:', error);
}
if (els.inputNewShowTitle) els.inputNewShowTitle.value = '';
if (els.inputNewShowSummary) {
els.inputNewShowSummary.value = '';
autoSizeNewShowSummary();
}
setNewShowSlugPreview(inputSlug);
});
if (els.createShowPanel) {
els.createShowPanel.classList.add('hidden');
}
updateNewShowSlugPreview();
els.btnSaveStream?.addEventListener('click', async () => {
try { await publishStreamStatus('planned'); } catch (error) { console.error('[vj.html] Save stream failed:', error); }
});
els.btnGoLive?.addEventListener('click', async () => {
try { await publishStreamStatus('live'); } catch (error) { console.error('[vj.html] Go live failed:', error); }
});
els.btnEndStream?.addEventListener('click', async () => {
try { await publishStreamStatus('ended'); } catch (error) { console.error('[vj.html] End stream failed:', error); }
});
}
async function initialize() {
const savedSiteSettings = (typeof getStreamingSiteSettings === 'function')
? (getStreamingSiteSettings() || {})
: {};
setStreamingSites(
savedSiteSettings.sites,
savedSiteSettings.selectedName || savedSiteSettings.selectedStreamingSiteName,
{ persist: false, refresh: false }
);
const target = parseStreamTargetFromUrl();
if (target) {
streamAuthorPubkey = target.pubkey;
streamDTag = normalizeShowSlug(target.d);
streamCoordinate = target.coord;
} else {
streamAuthorPubkey = String(getCurrentPubkey?.() || '');
}
setEpisodeId('');
latestPlaylistEventCreatedAt = 0;
latestPlaylistEventId = '';
emitPlaylistChange();
bindStreamEventListeners();
wireButtons();
updatePlayPauseVisualState();
mountChatComposerIfAllowed();
updateObsHints();
renderStreamImagePreview(els.inputStreamImage?.value || '');
hideStreamImageEditor();
syncStreamDisplayFields();
discoverShows();
if (streamDTag) {
applyShowDefaults(streamDTag);
} else {
setTimeout(() => {
if (streamDTag) return;
const latestSlug = getLatestKnownShowSlug();
if (latestSlug) applyShowDefaults(latestSlug);
}, 1200);
}
if (streamCoordinate) subscribeToChat();
startViewerCountPolling();
updateStreamButtonStates();
}
function onAuthChanged() {
if (!streamAuthorPubkey) {
streamAuthorPubkey = String(getCurrentPubkey?.() || '');
discoverShows();
}
if (streamAuthorPubkey && streamDTag) {
streamCoordinate = `${STREAM_KIND}:${streamAuthorPubkey}:${streamDTag}`;
updateNaddrDisplay(streamAuthorPubkey, streamDTag);
subscribeToStream();
subscribeToPlaylist();
subscribeToChat();
}
mountChatComposerIfAllowed();
updateStreamButtonStates();
}
return {
initialize,
onAuthChanged,
announceNowPlaying,
setStreamingSites,
getEpisodePlaylistTracks,
removeEpisodePlaylistTrack,
reorderEpisodePlaylistTrack,
};
}