1580 lines
54 KiB
HTML
1580 lines
54 KiB
HTML
<!DOCTYPE html>
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<html lang="en" dir="ltr">
|
|
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>STREAM CTRL</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;
|
|
}
|
|
|
|
/* Keep inline chat composer visually centered in the card */
|
|
#divChatComposer.divPostItem {
|
|
padding-top: 12px;
|
|
padding-bottom: 12px;
|
|
}
|
|
|
|
#divStreamPlayer {
|
|
margin-top: 8px;
|
|
}
|
|
|
|
#videoStream {
|
|
width: 100%;
|
|
aspect-ratio: 16 / 9;
|
|
max-height: min(60vh, 700px);
|
|
background: #000;
|
|
border-radius: 8px;
|
|
display: block;
|
|
}
|
|
|
|
#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: 4px;
|
|
margin-bottom: 4px;
|
|
padding: 6px 8px;
|
|
border: 1px solid transparent;
|
|
border-radius: 6px;
|
|
background: transparent;
|
|
color: var(--text-color);
|
|
font-family: inherit;
|
|
font-size: 14px;
|
|
cursor: pointer;
|
|
transition: border-color 0.15s ease, background 0.15s ease;
|
|
resize: vertical;
|
|
}
|
|
|
|
.streamInput:hover {
|
|
border-color: var(--border-color);
|
|
background: var(--secondary-color);
|
|
}
|
|
|
|
.streamInput:focus {
|
|
border-color: var(--accent-color);
|
|
background: var(--bg-color);
|
|
cursor: text;
|
|
outline: none;
|
|
}
|
|
|
|
#inputStreamTitle {
|
|
font-size: 16px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
#divChatFeed {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
#divChatMeta {
|
|
opacity: 0.8;
|
|
font-size: 12px;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
#divAnnouncementComposer {
|
|
width: 80%;
|
|
min-width: 300px;
|
|
max-width: 700px;
|
|
}
|
|
|
|
#divAnnouncementComposer .divPostHeader {
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
#divViewerCount {
|
|
font-size: 13px;
|
|
opacity: 0.85;
|
|
margin-top: 4px;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
#divVideoControls {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-top: 8px;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.btnVideoCtrl {
|
|
font-size: 22px;
|
|
line-height: 1;
|
|
padding: 8px 14px;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--border-color);
|
|
background: var(--bg-color);
|
|
color: var(--text-color);
|
|
cursor: pointer;
|
|
transition: background 0.12s ease;
|
|
user-select: none;
|
|
}
|
|
|
|
.btnVideoCtrl:hover {
|
|
background: var(--secondary-color);
|
|
}
|
|
|
|
.btnVideoCtrl.active {
|
|
background: var(--accent-color);
|
|
color: #fff;
|
|
border-color: var(--accent-color);
|
|
}
|
|
</style>
|
|
|
|
<link rel="stylesheet" href="./css/client.css" />
|
|
<link rel="stylesheet" href="./css/post-composer.css" />
|
|
<link rel="stylesheet" href="./css/dot-menu.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">
|
|
<input id="inputStreamTitle" class="divHeaderText" style="background:transparent;border:none;text-align:center;width:100%;cursor:pointer;font-size:max(var(--header-height),var(--header-min-height));font-weight:bold;font-family:inherit;color:var(--primary-color);padding:0;outline:none;line-height:1;" placeholder="Stream title" title="Click to edit stream title" />
|
|
</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="divAnnouncementComposer" class="divPostItem" style="display:none">
|
|
<div class="divPostHeader">
|
|
<div class="divPostAuthorName">Announcement</div>
|
|
<div class="divPostTime">kind:1 post</div>
|
|
</div>
|
|
<!-- announcement composer mounts here dynamically -->
|
|
</div>
|
|
|
|
<div id="divStreamInfo" class="divPostItem">
|
|
<video id="videoStream" playsinline src="https://laantungir.net/hls/stream.m3u8" muted></video>
|
|
<div id="divVideoControls">
|
|
<button id="btnVideoPlay" class="btnVideoCtrl" title="Play">▶</button>
|
|
<button id="btnVideoPause" class="btnVideoCtrl" title="Pause">⏸</button>
|
|
<button id="btnVideoMute" class="btnVideoCtrl active" title="Toggle sound">🔇</button>
|
|
<span id="spanStreamHealth" style="font-size:13px;opacity:0.8;align-self:center" title="Stream health">STATUS: OFFLINE</span>
|
|
<span id="divViewerCount" style="margin-left:auto;font-size:13px;opacity:0.8;align-self:center">TOTAL: — | HLS: — | RTMP: —</span>
|
|
</div>
|
|
|
|
<textarea id="inputStreamSummary" class="streamInput" rows="2" placeholder="Stream summary (click to edit)"></textarea>
|
|
<input id="inputStreamingUrl" class="streamInput" value="https://laantungir.net/hls/stream.m3u8" placeholder="Streaming URL" />
|
|
<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>
|
|
<button id="btnCopyNaddr" class="btn" title="Copy nostr:naddr to clipboard">Copy naddr</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>
|
|
|
|
<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-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-ctrl.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 videoStream = document.getElementById('videoStream');
|
|
const divViewerCount = document.getElementById('divViewerCount');
|
|
const btnCopyNaddr = document.getElementById('btnCopyNaddr');
|
|
const divChatComposer = document.getElementById('divChatComposer');
|
|
// Store current naddr for copy button
|
|
let currentNaddr = '';
|
|
const divChatFeed = document.getElementById('divChatFeed');
|
|
|
|
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 btnVideoPlay = document.getElementById('btnVideoPlay');
|
|
const btnVideoPause = document.getElementById('btnVideoPause');
|
|
const btnVideoMute = document.getElementById('btnVideoMute');
|
|
|
|
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 chatComposerHostEl = null;
|
|
let hlsInstance = null;
|
|
let streamListenersBound = false;
|
|
const renderedChatIds = new Set();
|
|
const chatParticipantPubkeys = new Set();
|
|
|
|
// Ensure post-interactions profile hydration is wired (avatars/names)
|
|
let interactionsInitialized = false;
|
|
|
|
// Viewer count state
|
|
const STATS_URL = 'https://laantungir.net/stream/stats';
|
|
const STATS_POLL_INTERVAL = 10000;
|
|
let viewerCount = 0;
|
|
let statsIntervalId = null;
|
|
let wasStreamLive = false; // track live state transitions
|
|
const spanStreamHealth = document.getElementById('spanStreamHealth');
|
|
|
|
/* ================================================================
|
|
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-ctrl.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,
|
|
onCommentIntent: ({ postId }) => {
|
|
const focusWithReply = () => {
|
|
const host = chatComposerHostEl || divChatComposer?.querySelector("[contenteditable='true']");
|
|
if (!host) return false;
|
|
|
|
let replyPrefix = '';
|
|
try {
|
|
if (postId && window?.NostrTools?.nip19?.noteEncode) {
|
|
replyPrefix = `nostr:${window.NostrTools.nip19.noteEncode(postId)} `;
|
|
}
|
|
} catch (_) {}
|
|
|
|
const current = String(host.innerText || '');
|
|
const ref = String(replyPrefix || '').trim();
|
|
|
|
if (ref && !current.includes(ref)) {
|
|
const trimmedCurrent = current.replace(/\s+$/g, '');
|
|
host.innerText = `${trimmedCurrent}\n${ref}`;
|
|
host.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
|
|
host.focus();
|
|
const selection = window.getSelection();
|
|
const range = document.createRange();
|
|
range.selectNodeContents(host);
|
|
range.collapse(true); // place caret at start, ref stays on trailing line
|
|
selection?.removeAllRanges();
|
|
selection?.addRange(range);
|
|
host.dispatchEvent(new Event('input', { bubbles: true }));
|
|
return true;
|
|
};
|
|
|
|
if (!isAuthenticated) {
|
|
void promptLoginIfNeeded().then(() => focusWithReply());
|
|
return true;
|
|
}
|
|
return focusWithReply();
|
|
}
|
|
});
|
|
interactionsInitialized = true;
|
|
}
|
|
|
|
await injectHeaderAvatar(currentPubkey);
|
|
console.log('[stream-ctrl.html] Authenticated as:', currentPubkey);
|
|
|
|
// Hydrate app-wide user settings for this page
|
|
try {
|
|
pageSettings = await getUserSettings();
|
|
} catch (error) {
|
|
console.warn('[stream-ctrl.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-ctrl.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-ctrl.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();
|
|
await mountAnnouncementComposer();
|
|
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-ctrl.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;
|
|
}
|
|
|
|
let currentPlayerUrl = '';
|
|
|
|
function setStreamPlayerSource(url) {
|
|
const streamUrl = String(url || '').trim();
|
|
if (!videoStream) return;
|
|
|
|
// Skip reinitializing if the URL hasn't changed — prevents video resize flicker
|
|
// when publishing stream status events that don't change the streaming URL.
|
|
if (streamUrl && streamUrl === currentPlayerUrl) {
|
|
|
|
return;
|
|
}
|
|
currentPlayerUrl = streamUrl;
|
|
|
|
if (hlsInstance) {
|
|
hlsInstance.destroy();
|
|
hlsInstance = null;
|
|
}
|
|
|
|
if (!streamUrl) {
|
|
videoStream.removeAttribute('src');
|
|
videoStream.load();
|
|
|
|
return;
|
|
}
|
|
|
|
const isHls = /\.m3u8(\?|$)/i.test(streamUrl);
|
|
if (isHls && window.Hls?.isSupported?.()) {
|
|
hlsInstance = new window.Hls();
|
|
hlsInstance.loadSource(streamUrl);
|
|
hlsInstance.attachMedia(videoStream);
|
|
// Auto-play muted once HLS has enough data
|
|
hlsInstance.on(window.Hls.Events.MANIFEST_PARSED, () => {
|
|
videoStream.play().catch(() => {});
|
|
});
|
|
} else {
|
|
videoStream.src = streamUrl;
|
|
videoStream.play().catch(() => {});
|
|
}
|
|
}
|
|
|
|
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 (e) {
|
|
console.warn('[stream-ctrl] Failed to encode naddr:', e);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function updateNaddrDisplay(pubkey, dTag) {
|
|
currentNaddr = buildNaddr(pubkey, dTag) || '';
|
|
if (btnCopyNaddr) {
|
|
btnCopyNaddr.disabled = !currentNaddr;
|
|
btnCopyNaddr.style.opacity = currentNaddr ? '' : '0.4';
|
|
}
|
|
}
|
|
|
|
// Track current stream status for smart button logic
|
|
let currentStreamStatus = 'none'; // 'none' | 'planned' | 'live' | 'ended'
|
|
|
|
function updateStreamButtonStates() {
|
|
const isOwner = isAuthenticated && currentPubkey === streamAuthorPubkey;
|
|
const s = currentStreamStatus;
|
|
|
|
// Save Planned: only when not live
|
|
if (btnSaveStream) {
|
|
btnSaveStream.disabled = !isOwner || s === 'live';
|
|
btnSaveStream.style.opacity = (!isOwner || s === 'live') ? '0.4' : '';
|
|
}
|
|
// Go Live: always available when owner (acts as update when already live)
|
|
if (btnGoLive) {
|
|
btnGoLive.disabled = !isOwner;
|
|
btnGoLive.style.opacity = !isOwner ? '0.4' : '';
|
|
btnGoLive.textContent = s === 'live' ? 'Update Live' : 'Go Live';
|
|
}
|
|
// End Stream: only when live
|
|
if (btnEndStream) {
|
|
btnEndStream.disabled = !isOwner || s !== 'live';
|
|
btnEndStream.style.opacity = (!isOwner || s !== 'live') ? '0.4' : '';
|
|
}
|
|
}
|
|
|
|
function updateStreamUi(evt) {
|
|
if (!evt) return;
|
|
latestStreamEvent = evt;
|
|
|
|
const title = getTagValue(evt, 'title') || '';
|
|
const status = getTagValue(evt, 'status') || 'planned';
|
|
const summary = getTagValue(evt, 'summary') || '';
|
|
const image = getTagValue(evt, 'image') || '';
|
|
const streaming = getTagValue(evt, 'streaming') || '';
|
|
const dTag = getTagValue(evt, 'd') || streamDTag;
|
|
|
|
streamDTag = dTag;
|
|
streamCoordinate = `${STREAM_KIND}:${streamAuthorPubkey}:${streamDTag}`;
|
|
currentStreamStatus = status;
|
|
|
|
if (inputStreamTitle) inputStreamTitle.value = title;
|
|
if (inputStreamSummary) inputStreamSummary.value = summary;
|
|
if (inputStreamImage) inputStreamImage.value = image;
|
|
|
|
// Do not wipe a locally pre-filled URL when the 30311 event has no streaming tag.
|
|
if (inputStreamingUrl && streaming) {
|
|
inputStreamingUrl.value = streaming;
|
|
}
|
|
|
|
const fallbackStreaming = String(inputStreamingUrl?.value || '').trim() || 'https://laantungir.net/hls/stream.m3u8';
|
|
const resolvedStreaming = String(streaming || fallbackStreaming).trim();
|
|
setStreamPlayerSource(resolvedStreaming);
|
|
updateNaddrDisplay(streamAuthorPubkey, streamDTag);
|
|
updateStreamButtonStates();
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
function setStreamControlsEnabled(enabled) {
|
|
// No-op: button states are now managed by updateStreamButtonStates()
|
|
// Kept for compatibility with applyOwnershipUiRules()
|
|
updateStreamButtonStates();
|
|
}
|
|
|
|
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 });
|
|
|
|
// Include current viewer count in the NIP-53 event
|
|
if (viewerCount > 0) {
|
|
tags.push(['current_participants', String(viewerCount)]);
|
|
}
|
|
|
|
await publishEvent({
|
|
kind: STREAM_KIND,
|
|
created_at: now,
|
|
content: summary,
|
|
tags
|
|
});
|
|
|
|
// Update local status tracking immediately (don't wait for relay echo)
|
|
currentStreamStatus = nextStatus;
|
|
|
|
if (nextStatus === 'live') {
|
|
streamCoordinate = `${STREAM_KIND}:${currentPubkey}:${dTag}`;
|
|
subscribeToChat();
|
|
startViewerCountPolling();
|
|
}
|
|
|
|
if (nextStatus === 'ended') {
|
|
stopViewerCountPolling();
|
|
}
|
|
|
|
// Update naddr display and button states after any publish
|
|
updateNaddrDisplay(currentPubkey, dTag);
|
|
updateStreamButtonStates();
|
|
}
|
|
|
|
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' }
|
|
);
|
|
}
|
|
|
|
/* ================================================================
|
|
VIEWER COUNT
|
|
================================================================
|
|
Poll the server stats endpoint every 15 seconds to get the
|
|
current number of RTMP viewers on the live stream.
|
|
================================================================ */
|
|
async function pollViewerCount() {
|
|
const pollTime = new Date().toISOString();
|
|
try {
|
|
console.log(`[stream-ctrl] Health poll @ ${pollTime} → fetching ${STATS_URL}`);
|
|
const res = await fetch(STATS_URL);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const data = await res.json();
|
|
viewerCount = Number(data.viewers) || 0;
|
|
const hlsViewers = Number(data.hls_viewers) || 0;
|
|
const rtmpViewers = Number(data.rtmp_viewers) || 0;
|
|
const isLive = Boolean(data.live);
|
|
console.log(`[stream-ctrl] Health poll result: live=${isLive} viewers=${viewerCount} hls=${hlsViewers} rtmp=${rtmpViewers} wasLive=${wasStreamLive}`, data);
|
|
|
|
if (divViewerCount) {
|
|
divViewerCount.textContent = `TOTAL: ${viewerCount} | HLS: ${hlsViewers} | RTMP: ${rtmpViewers}`;
|
|
}
|
|
if (spanStreamHealth) {
|
|
if (isLive) {
|
|
spanStreamHealth.textContent = 'STATUS: LIVE';
|
|
} else {
|
|
spanStreamHealth.textContent = 'STATUS: OFFLINE';
|
|
}
|
|
spanStreamHealth.title = isLive ? 'Stream is live' : 'Stream is offline';
|
|
}
|
|
|
|
if (isLive && !wasStreamLive) {
|
|
// Stream just came (back) live — reinitialize the player
|
|
console.log('[stream-ctrl] Transition: offline → live. Reinitializing HLS player.');
|
|
currentPlayerUrl = ''; // force setStreamPlayerSource to reinit
|
|
const streamUrl = String(inputStreamingUrl?.value || '').trim() || 'https://laantungir.net/hls/stream.m3u8';
|
|
setStreamPlayerSource(streamUrl);
|
|
} else if (!isLive && wasStreamLive) {
|
|
// Stream just went offline
|
|
console.log('[stream-ctrl] Transition: live → offline. Destroying HLS instance.');
|
|
if (hlsInstance) {
|
|
hlsInstance.destroy();
|
|
hlsInstance = null;
|
|
}
|
|
}
|
|
|
|
wasStreamLive = isLive;
|
|
} catch (e) {
|
|
console.warn(`[stream-ctrl] Health poll FAILED @ ${pollTime}:`, e);
|
|
if (divViewerCount) {
|
|
divViewerCount.textContent = 'TOTAL: — | HLS: — | RTMP: —';
|
|
}
|
|
if (spanStreamHealth) {
|
|
spanStreamHealth.textContent = 'STATUS: ERROR';
|
|
spanStreamHealth.title = 'Stats endpoint unreachable';
|
|
}
|
|
}
|
|
}
|
|
|
|
function startViewerCountPolling() {
|
|
// Poll immediately on start
|
|
pollViewerCount();
|
|
if (!statsIntervalId) {
|
|
statsIntervalId = setInterval(pollViewerCount, STATS_POLL_INTERVAL);
|
|
}
|
|
}
|
|
|
|
function stopViewerCountPolling() {
|
|
if (statsIntervalId) {
|
|
clearInterval(statsIntervalId);
|
|
statsIntervalId = null;
|
|
}
|
|
}
|
|
|
|
function handleIncomingEvent(evt) {
|
|
if (!evt?.kind) return;
|
|
|
|
if (evt.kind === STREAM_KIND) {
|
|
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);
|
|
});
|
|
}
|
|
|
|
/* ================================================================
|
|
ANNOUNCEMENT COMPOSER (kind:1)
|
|
================================================================
|
|
A standard kind 1 post composer for broadcasting announcements
|
|
about the stream (e.g. "Going live now!").
|
|
================================================================ */
|
|
async function mountAnnouncementComposer() {
|
|
const container = document.getElementById('divAnnouncementComposer');
|
|
if (!container) return;
|
|
container.querySelectorAll('.topPostComposerInput, .divPostTime').forEach((el) => el.remove());
|
|
|
|
if (!isAuthenticated) {
|
|
const hint = document.createElement('div');
|
|
hint.className = 'divPostTime';
|
|
hint.textContent = 'Sign in to post announcements.';
|
|
container.appendChild(hint);
|
|
return;
|
|
}
|
|
|
|
const hostEl = document.createElement('div');
|
|
hostEl.className = 'topPostComposerInput';
|
|
container.appendChild(hostEl);
|
|
|
|
let announcementComposer = null;
|
|
announcementComposer = mountComposer(hostEl, {
|
|
currentPubkey,
|
|
followedProfiles: [],
|
|
showUploadIcon: true,
|
|
showPreview: true,
|
|
autoHideOnSubmit: false,
|
|
onSubmit: async (content) => {
|
|
const text = String(content || '').trim();
|
|
if (!text) return;
|
|
|
|
await publishEvent({
|
|
kind: 1,
|
|
content: text,
|
|
tags: [],
|
|
created_at: Math.floor(Date.now() / 1000)
|
|
});
|
|
|
|
announcementComposer?.clear();
|
|
}
|
|
});
|
|
}
|
|
|
|
function mountChatComposerIfAllowed() {
|
|
if (!divChatComposer) return;
|
|
divChatComposer.innerHTML = '';
|
|
|
|
if (!isAuthenticated) {
|
|
divChatComposer.textContent = 'Sign in to chat.';
|
|
return;
|
|
}
|
|
|
|
const hostEl = document.createElement('div');
|
|
divChatComposer.appendChild(hostEl);
|
|
chatComposerHostEl = 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() {
|
|
updateStreamButtonStates();
|
|
}
|
|
|
|
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 {
|
|
|
|
}
|
|
|
|
// Pre-fill default stream URL for browser playback (HLS)
|
|
if (inputStreamingUrl && !inputStreamingUrl.value) {
|
|
inputStreamingUrl.value = 'https://laantungir.net/hls/stream.m3u8';
|
|
}
|
|
|
|
// Ensure the player/hint reflects the current URL immediately,
|
|
// even before a 30311 event is loaded from relays.
|
|
if (inputStreamingUrl?.value) {
|
|
setStreamPlayerSource(inputStreamingUrl.value);
|
|
}
|
|
|
|
// Show naddr immediately if we have a coordinate
|
|
if (streamAuthorPubkey && streamDTag) {
|
|
updateNaddrDisplay(streamAuthorPubkey, streamDTag);
|
|
}
|
|
|
|
bindStreamEventListeners();
|
|
mountChatComposerIfAllowed();
|
|
await mountAnnouncementComposer();
|
|
applyOwnershipUiRules();
|
|
|
|
if (streamAuthorPubkey && streamDTag) {
|
|
subscribeToStream();
|
|
}
|
|
|
|
if (streamCoordinate) {
|
|
subscribeToChat();
|
|
}
|
|
|
|
// Wire up video control buttons
|
|
if (btnVideoPlay) {
|
|
btnVideoPlay.addEventListener('click', () => {
|
|
videoStream?.play().catch(() => {});
|
|
});
|
|
}
|
|
if (btnVideoPause) {
|
|
btnVideoPause.addEventListener('click', () => {
|
|
videoStream?.pause();
|
|
});
|
|
}
|
|
if (btnVideoMute) {
|
|
btnVideoMute.addEventListener('click', () => {
|
|
if (!videoStream) return;
|
|
videoStream.muted = !videoStream.muted;
|
|
btnVideoMute.textContent = videoStream.muted ? '🔇' : '🔊';
|
|
btnVideoMute.classList.toggle('active', videoStream.muted);
|
|
btnVideoMute.title = videoStream.muted ? 'Unmute' : 'Mute';
|
|
});
|
|
}
|
|
|
|
// Wire up Copy naddr button
|
|
if (btnCopyNaddr) {
|
|
btnCopyNaddr.addEventListener('click', () => {
|
|
if (!currentNaddr) return;
|
|
navigator.clipboard?.writeText(currentNaddr).then(() => {
|
|
const prev = btnCopyNaddr.textContent;
|
|
btnCopyNaddr.textContent = '✓ Copied!';
|
|
setTimeout(() => { btnCopyNaddr.textContent = prev; }, 1500);
|
|
}).catch(() => {});
|
|
});
|
|
}
|
|
|
|
// Start polling viewer count
|
|
startViewerCountPolling();
|
|
|
|
if (btnSaveStream) {
|
|
btnSaveStream.addEventListener('click', async () => {
|
|
try {
|
|
await publishStreamStatus('planned');
|
|
} catch (error) {
|
|
console.error('[stream-ctrl.html] Failed to save stream:', error);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (btnGoLive) {
|
|
btnGoLive.addEventListener('click', async () => {
|
|
try {
|
|
await publishStreamStatus('live');
|
|
} catch (error) {
|
|
console.error('[stream-ctrl.html] Failed to go live:', error);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (btnEndStream) {
|
|
btnEndStream.addEventListener('click', async () => {
|
|
try {
|
|
await publishStreamStatus('ended');
|
|
} catch (error) {
|
|
console.error('[stream-ctrl.html] Failed to end stream:', error);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (!latestStreamEvent && streamCoordinate && !inputStreamingUrl?.value) {
|
|
|
|
}
|
|
}
|
|
|
|
/* ================================================================
|
|
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-ctrl.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-ctrl.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-ctrl.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-ctrl.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-ctrl.html] Resolved auth mode:', authMode);
|
|
await initializeAuthentication(authMode);
|
|
|
|
// Initialize authenticated features only when signed in.
|
|
await initializeAuthenticatedPageFeatures();
|
|
|
|
// Header title is now the stream title input — no static text needed
|
|
|
|
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-ctrl.html] Initialization complete');
|
|
} catch (error) {
|
|
console.error('[stream-ctrl.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>
|