Files

1400 lines
47 KiB
HTML

<!DOCTYPE html>
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<title>STREAM</title>
<style>
#divBody {
flex-direction: column !important;
flex-wrap: nowrap !important;
align-items: center !important;
justify-content: flex-start !important;
align-content: flex-start !important;
gap: 10px;
overflow-y: auto;
padding-bottom: 80px;
}
#divStreamPlayer,
#divStreamInfo,
#divChatComposer,
#divChatFeed {
width: 80%;
min-width: 300px;
max-width: 700px;
}
#divStreamPlayer {
margin-top: 8px;
}
#videoStream {
width: 100%;
max-height: min(60vh, 700px);
background: #000;
border-radius: 8px;
}
#divStreamInfo .divPostContent {
margin-top: 6px;
margin-bottom: 8px;
}
#divStreamControls {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 8px;
margin-bottom: 8px;
}
.streamInput {
width: 100%;
box-sizing: border-box;
margin-top: 6px;
margin-bottom: 6px;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-color);
color: var(--text-color);
font-family: inherit;
font-size: 14px;
}
#divChatFeed {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 20px;
}
#divChatMeta {
opacity: 0.8;
font-size: 12px;
margin-top: 4px;
}
#divDiscoverStreams {
margin-top: 12px;
margin-bottom: 8px;
padding: 10px;
border-top: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
display: flex;
flex-direction: column;
flex: 1;
min-height: 260px;
}
#divDiscoverStreamsHeader {
font-size: 12px;
opacity: 0.85;
font-weight: 700;
margin-bottom: 8px;
letter-spacing: 0.02em;
text-transform: uppercase;
}
#divDiscoverStreamsList {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-height: 220px;
overflow-y: auto;
padding-right: 2px;
}
.discoverStreamCard {
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
padding: 8px;
border-radius: 8px;
}
.discoverStreamCard:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.discoverStreamCard.isActive {
border: 1px solid var(--accent-color);
}
.discoverStreamThumb {
width: 100%;
max-height: 100px;
object-fit: cover;
border-radius: 6px;
margin-bottom: 6px;
background: #000;
}
.discoverStreamMeta {
display: flex;
gap: 6px;
flex-wrap: wrap;
font-size: 11px;
opacity: 0.85;
margin-top: 4px;
word-break: break-word;
}
.discoverStreamStatus.live {
color: #ff4d4d;
font-weight: 700;
}
.discoverStreamStatus.planned {
color: var(--accent-color);
font-weight: 600;
}
</style>
<link rel="stylesheet" href="./css/client.css" />
<!-- Initialize theme BEFORE any components load -->
<script>
(function () {
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
document.documentElement.classList.add('dark-mode');
if (document.body) {
document.body.classList.add('dark-mode');
}
}
})();
</script>
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="../js/vendor/svg.min.js"></script>
<script src="../js/vendor/hls.min.js"></script>
</head>
<body>
<!-- ================================================================
HAMBURGER BUTTON (Fixed, separate from header)
================================================================
The hamburger button is a fixed element outside the header
to ensure it stays visible above the sidenav (z-index: 10 > 3).
================================================================ -->
<div id="divSvgHam" class="divHeaderButtons">
<!-- HamburgerMorphing will be injected here -->
</div>
<!-- ================================================================
HEADER
================================================================
Standard header with title (center).
================================================================ -->
<div id="divHeader">
<div id="divHeaderFlexLeft">
<!-- Hamburger is now separate fixed element -->
</div>
<div id="divHeaderFlexCenter">
<div class="divHeaderText"></div>
</div>
<div id="divHeaderFlexRight">
<!-- No button in header right - logout is in sidenav footer -->
</div>
</div>
<!-- ================================================================
BODY
================================================================
Main content area. Add your page-specific content here.
================================================================ -->
<div id="divBody">
<div id="divStreamPlayer" class="divPostItem">
<video id="videoStream" controls playsinline></video>
<div id="divStreamPlayerHint" class="divPostTime">No stream URL yet.</div>
</div>
<div id="divStreamInfo" class="divPostItem">
<div class="divPostHeader">
<div class="divPostAuthorName" id="divStreamTitle">Untitled stream</div>
<div class="divPostTime" id="divStreamStatus">planned</div>
</div>
<div class="divPostContent" id="divStreamSummary">No summary yet.</div>
<div id="divChatMeta">0 comments · 0 participants</div>
<input id="inputStreamingUrl" class="streamInput" placeholder="Streaming URL (https://... or .m3u8)" />
<input id="inputStreamTitle" class="streamInput" placeholder="Stream title" />
<textarea id="inputStreamSummary" class="streamInput" rows="3" placeholder="Stream summary"></textarea>
<input id="inputStreamImage" class="streamInput" placeholder="Image URL (optional)" />
<div id="divStreamControls">
<button id="btnSaveStream" class="btn">Save Planned</button>
<button id="btnGoLive" class="btn">Go Live</button>
<button id="btnEndStream" class="btn">End Stream</button>
</div>
</div>
<div id="divChatComposer" class="divPostItem"></div>
<div id="divChatFeed"></div>
</div>
<!-- ================================================================
FOOTER
================================================================
Three-section footer layout:
- Left: Relay status animations (HamburgerMorphing instances)
- Center: General status information
- Right: Additional information
================================================================ -->
<div id="divFooter">
<div id="divFooterLeft" class="divFooterBox"></div>
<div id="divFooterCenter" class="divFooterBox"></div>
<div id="divFooterRight" class="divFooterBox"></div>
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
</div>
<!-- ================================================================
SIDENAV
================================================================
Slide-out navigation panel. Opens from left when hamburger clicked.
Uses flexbox layout to pin version bar to bottom.
Includes a version bar footer with theme toggle and logout buttons.
================================================================ -->
<div id="divSideNav">
<div id="divSideNavHeader">
<!-- No close button - use main hamburger to close -->
</div>
<div id="divSideNavBody">
<div id="divFiles"></div>
<div id="divDiscoverStreams">
<div id="divDiscoverStreamsHeader">Discover live streams</div>
<div id="divDiscoverStreamsList"></div>
</div>
</div>
<div id="divAiSection" class="sidenavSection">
<div id="divAiSectionTitle" class="sidenavSectionTitle">AI</div>
<div id="divAiList" class="sidenavSectionList">
<div id="divAiProvidersList">No saved providers yet.</div>
</div>
</div>
<div id="divRelaySection">
<div id="divRelaySectionTitle">
リレー
</div>
<div id="divRelayList">
Loading relays...
</div>
</div>
<div id="divBlossomSection">
<div id="divBlossomSectionTitle">ブロッサム</div>
<div id="divBlossomList">Loading blossom servers...</div>
</div>
<div id="divVersionBar">
<span id="versionDisplay">v0.0.1</span>
<div id="divVersionBarButtons">
<button id="themeToggleButton" title="Toggle Dark/Light Mode">
<div id="themeToggleHamburgerContainer"></div>
</button>
<button id="logoutButton" title="Logout">
<div id="logoutHamburgerContainer"></div>
</button>
</div>
</div>
</div>
<!-- ================================================================
REQUIRED SCRIPTS
================================================================
These scripts must be loaded in this order:
1. nostr.bundle.js - Nostr tools library
2. nostr-lite.js - Authentication modal (nostr-login-lite)
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================
IMPORTS
================================================================
Import shared NDK functionality from init-ndk.mjs:
- initNDKPage() - Initialize authentication and worker
- getPubkey() - Get current user's pubkey
- subscribe() - Create NDK subscriptions
- publishEvent() - Publish events via NDK
- disconnect() - Disconnect from worker
- getRelayData() - Get relay connection data
- getRelayStats() - Get relay activity statistics
Import HamburgerMorphing for animated icons
================================================================ */
import {
initNDKPage,
getPubkey, injectHeaderAvatar,
subscribe,
publishEvent,
disconnect,
getVersion,
updateVersionDisplay,
getUserSettings,
patchUserSettings,
onUserSettings,
ndkFetchEvents,
queryCache,
fetchCachedProfile,
storeProfile
} from './js/init-ndk.mjs';
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
import { mountComposer } from './js/post-composer.mjs';
import { initInteractions, renderPostItem } from './js/post-interactions.mjs';
// Version will be loaded asynchronously
const versionInfo = await getVersion();
const VERSION = versionInfo.VERSION;
console.log(`[stream.html ${VERSION}] Loading...`);
/* ================================================================
GLOBAL VARIABLES
================================================================
Track state for hamburger menu, relay status, and theme.
================================================================ */
let updateIntervalId = null;
let currentPubkey = null;
/*
AUTH STATE MODEL (Template reference)
------------------------------------------------------------------
This template now demonstrates three auth modes for standalone pages:
- required (default):
Behaves like existing pages: login is required immediately.
- optional:
Page can render public/read-only data without login, but can still
prompt login later for user actions (publish, settings, etc).
- none:
Never auto-login on load (pure public page).
URL behavior in this template:
- If ?auth=required|optional|none is present, it wins.
- Otherwise, if URL includes ?npub=... or ?pubkey=..., mode defaults
to optional because pages with explicit profile targets are commonly
public-readable.
- Otherwise, mode defaults to required.
*/
let isAuthenticated = false;
let authMode = 'required';
let authedPageInitialized = false;
let relayActivityListenersBound = false;
// Hamburger menu
let hamburgerInstance = null;
let isNavOpen = false;
// Version bar buttons
let logoutHamburger = null;
let themeToggleHamburger = null;
let isDarkMode = false;
// App-wide user settings (NIP-78 kind 30078, d:user-settings)
let pageSettings = {};
let unsubscribeUserSettings = null;
/* ================================================================
DOM VARIABLES
================================================================
Cache DOM element references for better performance.
================================================================ */
const divBody = document.getElementById("divBody");
const divSideNav = document.getElementById("divSideNav");
const divSideNavBody = document.getElementById("divSideNavBody");
const divFooterCenter = document.getElementById("divFooterCenter");
const divFooterRight = document.getElementById("divFooterRight");
const divHeaderText = document.querySelector('.divHeaderText');
const videoStream = document.getElementById('videoStream');
const divStreamPlayerHint = document.getElementById('divStreamPlayerHint');
const divStreamTitle = document.getElementById('divStreamTitle');
const divStreamStatus = document.getElementById('divStreamStatus');
const divStreamSummary = document.getElementById('divStreamSummary');
const divChatMeta = document.getElementById('divChatMeta');
const divChatComposer = document.getElementById('divChatComposer');
const divChatFeed = document.getElementById('divChatFeed');
const divDiscoverStreams = document.getElementById('divDiscoverStreams');
const divDiscoverStreamsList = document.getElementById('divDiscoverStreamsList');
const inputStreamingUrl = document.getElementById('inputStreamingUrl');
const inputStreamTitle = document.getElementById('inputStreamTitle');
const inputStreamSummary = document.getElementById('inputStreamSummary');
const inputStreamImage = document.getElementById('inputStreamImage');
const btnSaveStream = document.getElementById('btnSaveStream');
const btnGoLive = document.getElementById('btnGoLive');
const btnEndStream = document.getElementById('btnEndStream');
const STREAM_KIND = 30311;
const CHAT_KIND = 1311;
const DEFAULT_STREAM_D_TAG = 'stream-main';
let streamAuthorPubkey = '';
let streamDTag = DEFAULT_STREAM_D_TAG;
let streamCoordinate = '';
let latestStreamEvent = null;
let streamSubId = null;
let chatSubId = null;
let chatComposer = null;
let hlsInstance = null;
let streamListenersBound = false;
const renderedChatIds = new Set();
const chatParticipantPubkeys = new Set();
let discoverSubId = null;
const discoveredStreams = new Map();
// Ensure post-interactions profile hydration is wired (avatars/names)
let interactionsInitialized = false;
/* ================================================================
HAMBURGER MENU
================================================================
Initialize and control the animated hamburger menu.
================================================================ */
function initHamburgerMenu() {
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
hamburgerInstance.animateTo('burger');
}
/* ================================================================
SIDENAV FUNCTIONS
================================================================
Open/close sidenav with hamburger morphing animation.
================================================================ */
function openNav() {
divSideNav.style.zIndex = 3;
divSideNav.style.width = "clamp(400px, 50vw, 600px)";
isNavOpen = true;
if (hamburgerInstance) {
hamburgerInstance.animateTo('arrow_left');
}
// Initialize version bar buttons when sidenav opens (lazy load)
if (!logoutHamburger) {
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
logoutHamburger.animateTo('x');
}
if (!themeToggleHamburger) {
themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
// Determine current theme
const savedTheme = localStorage.getItem('theme');
isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode');
const initialShape = isDarkMode ? 'moon' : 'circle';
themeToggleHamburger.animateTo(initialShape);
}
}
function closeNav() {
divSideNav.style.width = "0vw";
divSideNav.style.zIndex = -1;
isNavOpen = false;
if (hamburgerInstance) {
hamburgerInstance.animateTo('burger');
}
}
function toggleNav() {
if (isNavOpen) {
closeNav();
} else {
openNav();
}
}
/* ================================================================
AUTH MODE HELPERS
================================================================
These functions are meant as reusable guidance for future pages.
================================================================ */
function hasTargetPubkeyInUrl() {
const params = new URLSearchParams(window.location.search || '');
const npub = String(params.get('npub') || '').trim();
const pubkey = String(params.get('pubkey') || '').trim();
return Boolean(npub || pubkey);
}
function hasTargetStreamInUrl() {
const params = new URLSearchParams(window.location.search || '');
const a = String(params.get('a') || '').trim();
const naddr = String(params.get('naddr') || '').trim();
return Boolean(a || naddr);
}
function resolveAuthModeFromUrl() {
const params = new URLSearchParams(window.location.search || '');
const explicitAuth = String(params.get('auth') || '').trim().toLowerCase();
if (explicitAuth === 'required' || explicitAuth === 'optional' || explicitAuth === 'none') {
return explicitAuth;
}
// Convention: explicit target profiles/streams are public-readable by default.
if (hasTargetPubkeyInUrl() || hasTargetStreamInUrl()) {
return 'optional';
}
return 'required';
}
function isAuthRequiredError(error) {
const message = String(error?.message || error || '').toLowerCase();
return message.includes('authentication required');
}
async function initializeAuthentication(mode) {
// required: existing behavior, throw if auth fails.
if (mode === 'required') {
await initNDKPage();
currentPubkey = await getPubkey();
isAuthenticated = true;
return;
}
// none: public page, no login attempt on load.
if (mode === 'none') {
isAuthenticated = false;
currentPubkey = null;
return;
}
// optional: try silent/normal init; if auth required, continue public.
try {
await initNDKPage();
currentPubkey = await getPubkey();
isAuthenticated = true;
} catch (error) {
if (isAuthRequiredError(error)) {
console.log('[stream.html] Optional auth mode: continuing unauthenticated');
isAuthenticated = false;
currentPubkey = null;
return;
}
throw error;
}
}
async function initializeAuthenticatedPageFeatures() {
if (!isAuthenticated || authedPageInitialized) return;
if (!interactionsInitialized) {
initInteractions({
subscribe,
publishEvent,
getPubkey,
ndkFetchEvents,
queryCache,
fetchCachedProfile,
storeProfile
});
interactionsInitialized = true;
}
await injectHeaderAvatar(currentPubkey);
console.log('[stream.html] Authenticated as:', currentPubkey);
// Hydrate app-wide user settings for this page
try {
pageSettings = await getUserSettings();
} catch (error) {
console.warn('[stream.html] getUserSettings failed:', error);
pageSettings = {};
}
// Subscribe to live user settings updates (cross-tab + publish echoes)
if (!unsubscribeUserSettings) {
unsubscribeUserSettings = onUserSettings((settings) => {
pageSettings = settings || {};
// TODO: Re-render page-specific UI from pageSettings here.
});
}
// Initialize relay-dependent UI only once authenticated.
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
initAiSectionWithLocalConfig();
await UpdateFooter();
if (!updateIntervalId) {
updateIntervalId = setInterval(UpdateFooter, 1000);
}
// Relay activity listeners only matter after worker init/auth.
if (!relayActivityListenersBound) {
window.addEventListener('ndkRelayActivity', (event) => {
const { relayUrl, activity, stats } = event.detail;
console.log(`[stream.html] Relay activity: ${relayUrl} - ${activity}`, stats);
setRelayActivityState(relayUrl, activity);
});
window.addEventListener('message', (event) => {
if (event.data && event.data.type === 'relayActivity') {
const { relayUrl, activity } = event.data;
console.log(`[stream.html] Relay activity: ${relayUrl} - ${activity}`);
setRelayActivityState(relayUrl, activity);
}
});
relayActivityListenersBound = true;
}
authedPageInitialized = true;
}
async function promptLoginIfNeeded() {
if (isAuthenticated) return true;
await initNDKPage();
currentPubkey = await getPubkey();
isAuthenticated = true;
await initializeAuthenticatedPageFeatures();
mountChatComposerIfAllowed();
applyOwnershipUiRules();
return true;
}
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 = String(data.identifier || '').trim();
if (kind === STREAM_KIND && pubkey && d) {
return { kind, pubkey, d, coord: `${kind}:${pubkey}:${d}` };
}
}
} catch (error) {
console.warn('[stream.html] Failed to decode naddr:', error);
}
}
return null;
}
function buildStreamTags({ dTag, title, summary, image, streamingUrl, 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();
if (safeSummary) tags.push(['summary', safeSummary]);
if (safeImage) tags.push(['image', safeImage]);
if (safeStreaming) tags.push(['streaming', safeStreaming]);
return tags;
}
function setStreamPlayerSource(url) {
const streamUrl = String(url || '').trim();
if (!videoStream) return;
if (hlsInstance) {
hlsInstance.destroy();
hlsInstance = null;
}
if (!streamUrl) {
videoStream.removeAttribute('src');
videoStream.load();
divStreamPlayerHint.textContent = 'No stream URL yet.';
return;
}
const isHls = /\.m3u8(\?|$)/i.test(streamUrl);
if (isHls && window.Hls?.isSupported?.()) {
hlsInstance = new window.Hls();
hlsInstance.loadSource(streamUrl);
hlsInstance.attachMedia(videoStream);
} else {
videoStream.src = streamUrl;
}
divStreamPlayerHint.textContent = streamUrl;
}
function updateStreamUi(evt) {
if (!evt) return;
latestStreamEvent = evt;
const title = getTagValue(evt, 'title') || 'Untitled stream';
const status = getTagValue(evt, 'status') || 'planned';
const summary = getTagValue(evt, 'summary') || 'No summary yet.';
const image = getTagValue(evt, 'image') || '';
const streaming = getTagValue(evt, 'streaming') || '';
const dTag = getTagValue(evt, 'd') || streamDTag;
streamDTag = dTag;
streamCoordinate = `${STREAM_KIND}:${streamAuthorPubkey}:${streamDTag}`;
divStreamTitle.textContent = title;
divStreamStatus.textContent = status;
divStreamSummary.textContent = summary;
if (inputStreamTitle) inputStreamTitle.value = title;
if (inputStreamSummary) inputStreamSummary.value = summary;
if (inputStreamImage) inputStreamImage.value = image;
if (inputStreamingUrl) inputStreamingUrl.value = streaming;
setStreamPlayerSource(streaming);
if (divChatMeta) {
divChatMeta.textContent = `${renderedChatIds.size} comments · ${chatParticipantPubkeys.size} participants`;
}
}
function appendChatEvent(evt) {
if (!evt?.id || renderedChatIds.has(evt.id)) return;
renderedChatIds.add(evt.id);
if (evt?.pubkey) chatParticipantPubkeys.add(evt.pubkey);
const chatItem = renderPostItem(evt, {
currentPubkey,
showHeader: true,
isCompact: true,
autoplayVideo: false
});
divChatFeed.prepend(chatItem);
if (divChatMeta) {
divChatMeta.textContent = `${renderedChatIds.size} comments · ${chatParticipantPubkeys.size} participants`;
}
}
function setStreamControlsEnabled(enabled) {
const isEnabled = Boolean(enabled);
[btnSaveStream, btnGoLive, btnEndStream, inputStreamingUrl, inputStreamTitle, inputStreamSummary, inputStreamImage]
.forEach((el) => {
if (!el) return;
el.disabled = !isEnabled;
});
}
async function publishStreamStatus(nextStatus) {
await promptLoginIfNeeded();
const dTag = streamDTag || DEFAULT_STREAM_D_TAG;
const title = String(inputStreamTitle?.value || '').trim();
const summary = String(inputStreamSummary?.value || '').trim();
const image = String(inputStreamImage?.value || '').trim();
const streamingUrl = String(inputStreamingUrl?.value || '').trim();
const now = Math.floor(Date.now() / 1000);
const tags = buildStreamTags({ dTag, title, summary, image, streamingUrl, status: nextStatus });
await publishEvent({
kind: STREAM_KIND,
created_at: now,
content: summary,
tags
});
if (nextStatus === 'live') {
streamCoordinate = `${STREAM_KIND}:${currentPubkey}:${dTag}`;
subscribeToChat();
}
}
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 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' }
);
}
function getStreamStatusRank(statusRaw) {
const status = String(statusRaw || '').toLowerCase();
if (status === 'live') return 0;
if (status === 'planned') return 1;
if (status === 'ended') return 9;
return 2;
}
function renderDiscoveredStreams() {
if (!divDiscoverStreamsList) return;
const items = Array.from(discoveredStreams.values())
.filter((evt) => String(getTagValue(evt, 'status') || '').toLowerCase() === 'live')
.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0));
if (items.length === 0) {
divDiscoverStreamsList.innerHTML = '<div class="divPostTime">No live streams discovered yet.</div>';
return;
}
divDiscoverStreamsList.innerHTML = '';
items.forEach((evt) => {
const dTag = getTagValue(evt, 'd');
if (!dTag) return;
const coord = `${STREAM_KIND}:${evt.pubkey}:${dTag}`;
const title = getTagValue(evt, 'title') || 'Untitled stream';
const summary = getTagValue(evt, 'summary') || '';
const status = String(getTagValue(evt, 'status') || 'planned').toLowerCase();
const image = getTagValue(evt, 'image') || '';
const authorShort = `${String(evt.pubkey || '').slice(0, 8)}${String(evt.pubkey || '').slice(-6)}`;
const card = document.createElement('div');
card.className = 'divPostItem discoverStreamCard';
if (coord === streamCoordinate) card.classList.add('isActive');
card.dataset.streamCoordinate = coord;
if (image) {
const img = document.createElement('img');
img.className = 'discoverStreamThumb';
img.src = image;
img.alt = title;
img.loading = 'lazy';
card.appendChild(img);
}
const header = document.createElement('div');
header.className = 'divPostHeader';
const titleEl = document.createElement('div');
titleEl.className = 'divPostAuthorName';
titleEl.textContent = title;
const statusEl = document.createElement('div');
statusEl.className = `divPostTime discoverStreamStatus ${status}`;
statusEl.textContent = status;
header.appendChild(titleEl);
header.appendChild(statusEl);
card.appendChild(header);
if (summary) {
const summaryEl = document.createElement('div');
summaryEl.className = 'divPostContent';
summaryEl.textContent = summary;
card.appendChild(summaryEl);
}
const meta = document.createElement('div');
meta.className = 'discoverStreamMeta';
meta.innerHTML = `<span>${authorShort}</span><span>${coord}</span>`;
card.appendChild(meta);
card.addEventListener('click', () => {
const nextUrl = `./stream.html?a=${encodeURIComponent(coord)}`;
window.location.href = nextUrl;
});
divDiscoverStreamsList.appendChild(card);
});
}
function upsertDiscoveredStream(evt) {
if (evt?.kind !== STREAM_KIND) return;
const dTag = getTagValue(evt, 'd');
if (!dTag || !evt?.pubkey) return;
const coord = `${STREAM_KIND}:${evt.pubkey}:${dTag}`;
const existing = discoveredStreams.get(coord);
if (existing && (existing?.created_at || 0) > (evt?.created_at || 0)) return;
discoveredStreams.set(coord, evt);
renderDiscoveredStreams();
}
function subscribeToDiscoveredStreams() {
discoverSubId?.close?.();
const since = Math.floor(Date.now() / 1000) - (7 * 24 * 3600);
discoverSubId = subscribe(
{
kinds: [STREAM_KIND],
since,
limit: 500
},
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
}
function handleIncomingEvent(evt) {
if (!evt?.kind) return;
if (evt.kind === STREAM_KIND) {
upsertDiscoveredStream(evt);
const dTag = getTagValue(evt, 'd');
if (!dTag || dTag !== streamDTag) return;
if (evt.pubkey !== streamAuthorPubkey) return;
updateStreamUi(evt);
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 (!divChatComposer) return;
divChatComposer.innerHTML = '';
if (!isAuthenticated) {
divChatComposer.textContent = 'Sign in to chat.';
return;
}
const hostEl = document.createElement('div');
divChatComposer.appendChild(hostEl);
chatComposer = mountComposer(hostEl, {
currentPubkey,
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;
}
});
}
function applyOwnershipUiRules() {
const isOwner = isAuthenticated && currentPubkey === streamAuthorPubkey;
setStreamControlsEnabled(isOwner);
}
async function initializeStreamPage() {
const target = parseStreamTargetFromUrl();
if (target) {
streamAuthorPubkey = target.pubkey;
streamDTag = target.d;
streamCoordinate = target.coord;
} else if (isAuthenticated && currentPubkey) {
streamAuthorPubkey = currentPubkey;
streamDTag = DEFAULT_STREAM_D_TAG;
streamCoordinate = `${STREAM_KIND}:${streamAuthorPubkey}:${streamDTag}`;
} else {
divStreamPlayerHint.textContent = 'No target stream in URL. Use ?a=30311:<pubkey>:<d-tag> or sign in.';
}
bindStreamEventListeners();
mountChatComposerIfAllowed();
applyOwnershipUiRules();
subscribeToDiscoveredStreams();
renderDiscoveredStreams();
if (streamAuthorPubkey && streamDTag) {
subscribeToStream();
}
if (streamCoordinate) {
subscribeToChat();
}
if (btnSaveStream) {
btnSaveStream.addEventListener('click', async () => {
try {
await publishStreamStatus('planned');
} catch (error) {
console.error('[stream.html] Failed to save stream:', error);
}
});
}
if (btnGoLive) {
btnGoLive.addEventListener('click', async () => {
try {
await publishStreamStatus('live');
} catch (error) {
console.error('[stream.html] Failed to go live:', error);
}
});
}
if (btnEndStream) {
btnEndStream.addEventListener('click', async () => {
try {
await publishStreamStatus('ended');
} catch (error) {
console.error('[stream.html] Failed to end stream:', error);
}
});
}
if (!latestStreamEvent && streamCoordinate) {
divStreamPlayerHint.textContent = `Watching ${streamCoordinate}`;
}
}
/* ================================================================
UPDATE FOOTER
================================================================
Update footer sections with relay status, pubkey, and other info.
Called periodically by update loop.
================================================================ */
const UpdateFooter = async () => {
try {
// Update relay status visuals in footer and sidenav
await updateFooterRelayStatus();
await updateSidenavRelaySection();
await updateBlossomSection();
// Clear center and right sections
divFooterCenter.innerHTML = '';
divFooterRight.innerHTML = '';
} catch (error) {
console.error('[stream.html] Error updating footer:', error);
}
};
/* ================================================================
LOGOUT
================================================================
Complete logout process:
1. Stop update loop
2. Disconnect from NDK worker
3. Logout from nostr-login-lite
4. Clear all storage (localStorage, sessionStorage, IndexedDB)
5. Reload page
================================================================ */
const Logout = async () => {
console.log("[stream.html] Starting logout process...");
// Stop the update loop
if (updateIntervalId) {
clearInterval(updateIntervalId);
updateIntervalId = null;
}
// Disconnect from worker
disconnect();
// Logout from nostr-login-lite
if (window.NOSTR_LOGIN_LITE && window.NOSTR_LOGIN_LITE.logout) {
await window.NOSTR_LOGIN_LITE.logout();
}
// Clear all storage
localStorage.clear();
sessionStorage.clear();
// Clear IndexedDB
if (window.indexedDB) {
const databases = await window.indexedDB.databases();
for (const db of databases) {
if (db.name) {
window.indexedDB.deleteDatabase(db.name);
}
}
}
console.log("[stream.html] Logged out, reloading page");
location.reload(true);
};
/* ================================================================
STREAM PAGE EVENT LISTENERS
================================================================ */
/* ================================================================
INITIALIZATION
================================================================
Main initialization sequence:
1. Initialize hamburger menu
2. Set up hamburger click handler
3. Resolve auth mode from URL/query policy
4. Initialize authentication based on mode
5. Initialize authenticated-only features (if signed in)
6. Set up version bar button listeners
7. Restore sidenav state
8. Update version display
Notes:
- required mode = existing behavior (prompt login on load)
- optional mode = allow public load, login later on demand
- none mode = no auto-login on load
================================================================ */
(async function main() {
console.log("[stream.html] Starting initialization...");
try {
// Initialize hamburger menu first
initHamburgerMenu();
// Add click handler to hamburger
const divSvgHam = document.getElementById('divSvgHam');
if (divSvgHam) {
divSvgHam.addEventListener('click', toggleNav);
}
// Initialize version bar buttons
const themeToggleButton = document.getElementById('themeToggleButton');
const logoutButton = document.getElementById('logoutButton');
if (themeToggleButton) {
themeToggleButton.addEventListener('click', () => {
isDarkMode = !isDarkMode;
if (isDarkMode) {
localStorage.setItem('theme', 'dark');
} else {
localStorage.setItem('theme', 'light');
}
// Save sidenav state before reload
localStorage.setItem('sidenavWasOpen', isNavOpen ? 'true' : 'false');
window.location.reload();
});
}
if (logoutButton) {
logoutButton.addEventListener('click', async () => {
try {
// In optional/none modes this doubles as a "Sign in" entry point.
if (!isAuthenticated) {
await promptLoginIfNeeded();
return;
}
await Logout();
} catch (error) {
console.error('Logout/login action failed:', error);
}
});
}
// Resolve and initialize page auth policy.
authMode = resolveAuthModeFromUrl();
console.log('[stream.html] Resolved auth mode:', authMode);
await initializeAuthentication(authMode);
// Initialize authenticated features only when signed in.
await initializeAuthenticatedPageFeatures();
if (divHeaderText) {
divHeaderText.textContent = 'STREAM';
}
await initializeStreamPage();
// Restore sidenav state if it was open before theme toggle
const sidenavWasOpen = localStorage.getItem('sidenavWasOpen');
if (sidenavWasOpen === 'true') {
localStorage.removeItem('sidenavWasOpen');
openNav();
}
// Optional UX note for public mode pages.
if (!isAuthenticated && (authMode === 'optional' || authMode === 'none')) {
divFooterCenter.textContent = 'Public mode';
divFooterRight.textContent = 'Sign in from side menu for private features';
}
// Update version display
await updateVersionDisplay();
console.log('[stream.html] Initialization complete');
} catch (error) {
console.error('[stream.html] Initialization failed:', error);
divBody.innerHTML = `<div style="text-align: center; padding: 50px;">
<div style="font-size: 24px; margin-bottom: 20px; color: red;">❌ Authentication Error</div>
<div style="font-size: 16px; color: #666;">${error.message}</div>
<div style="margin-top: 20px;">
<button onclick="location.reload()" style="padding: 10px 20px; font-size: 16px;">Retry</button>
</div>
</div>`;
}
})();
/* ================================================================
WORKER MESSAGE TYPES
================================================================
The NDK worker can send these message types:
1. 'response' - Response to init/subscribe/publish requests
- data.profile - User profile (from init)
- data.relays - User relays (from init)
- data.success - Publish success status
- data.relayResults - Relay publish results
2. 'event' - Nostr event from subscription
- Dispatched as 'ndkEvent' window event
- event.detail contains the Nostr event
3. 'eose' - End of stored events for subscription
- Dispatched as 'ndkEose' window event
- event.detail.subId contains subscription ID
4. 'signRequest' - Request to sign event/encrypt/decrypt
- Handled automatically by init-ndk.mjs
- Calls window.nostr methods and sends response
5. 'error' - Error from worker
- Logged to console automatically
6. 'relayActivity' - Relay read/write activity notification
- Dispatched as 'ndkRelayActivity' window event
- Used to animate relay status icons in footer
================================================================ */
/* ================================================================
DISTRIBUTED ARCHITECTURE NOTES
================================================================
Each page is independently accessible and self-contained:
1. Authentication persists via nostr-login-lite localStorage
- Login once on any page
- All other pages automatically authenticated
2. NDK SharedWorker is shared across all tabs/pages
- Single NDK instance manages all connections
- Subscriptions from all pages handled by one worker
- Events broadcast to all connected pages
3. Dexie cache is shared across all pages
- IndexedDB persists across sessions
- Cache-first queries are fast
- Reduces relay load
4. User settings are centralized and shared
- Worker hydrates kind 30078 (`d:user-settings`) on init
- Pages read via getUserSettings()
- Pages patch via patchUserSettings({ featureNamespace: ... })
- Pages subscribe via onUserSettings() for live updates
5. Each page can be distributed independently
- Copy template.html and customize
- No dependencies on other pages
- Works standalone or as part of suite
6. Message-based signer bridges worker and page
- Worker's NDK uses MessageBasedSigner
- Signer sends sign requests to page
- Page calls window.nostr.signEvent()
- Response sent back to worker
- NDK completes signing and publishing
7. Relay status visualization
- Footer left section shows connected relays
- Each relay has animated icon (HamburgerMorphing)
- Icons morph based on activity (read/write)
- Temporary animations show real-time activity
================================================================ */
</script>
</body>
</html>