Compare commits

...

1 Commits

Author SHA1 Message Date
Laan Tungir
bd713d01f9 Fix stream site URL template editing and add NIP-53 starts/ends tags 2026-05-03 07:00:46 -04:00
6 changed files with 359 additions and 33 deletions

View File

@@ -1,7 +1,7 @@
.msg-thread-pane {
flex: 1;
min-height: 0;
border: 2px solid var(--primary-color);
/* border: 2px solid var(--primary-color); */
border-radius: 10px;
background: var(--secondary-color);
display: flex;

View File

@@ -372,6 +372,21 @@
min-height: 40px;
}
.docEventPre {
margin: 0;
min-height: 40px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--font-family);
font-size: 84%;
line-height: 1.35;
border: 1px solid var(--muted-color);
border-radius: 8px;
padding: 10px;
background: color-mix(in srgb, var(--secondary-color) 90%, var(--muted-color) 10%);
}
#divDocumentInfo {
font-size: 72%;
color: var(--muted-color);
@@ -1286,17 +1301,46 @@ user:
`;
}
function buildEventViewJson() {
const d = String(CURRENT_NOTE || '').trim();
const note = d ? (OBJ_NOTES[d] || null) : null;
const fallbackKind = Number(note?.kind) === 30023 ? 30023 : 30024;
const tags = collectDocumentTags();
return {
id: String(note?.id || ''),
pubkey: String(note?.pubkey || currentPubkey || ''),
kind: fallbackKind,
created_at: Number(note?.created_at || Math.floor(Date.now() / 1000)),
tags,
content: String(taDocument?.value || '')
};
}
function updateDocumentView() {
if (!taDocument || !divDocumentPreview) return;
if (documentViewMode === 'markdown') {
const metaGridEl = document.querySelector('#divAiDocumentPane .docPaneMetaGrid');
const isMarkdown = documentViewMode === 'markdown';
const isEvent = documentViewMode === 'event';
if (isMarkdown || isEvent) {
taDocument.style.display = 'none';
if (metaGridEl) metaGridEl.style.display = 'none';
divDocumentPreview.style.display = 'block';
const metaHtml = renderDocumentMetaPreview();
const bodyHtml = renderDocumentPreview(taDocument.value || '');
divDocumentPreview.innerHTML = `${metaHtml}<div class="docPreviewContent">${bodyHtml}</div>`;
if (isMarkdown) {
const metaHtml = renderDocumentMetaPreview();
const bodyHtml = renderDocumentPreview(taDocument.value || '');
divDocumentPreview.innerHTML = `${metaHtml}<div class="docPreviewContent">${bodyHtml}</div>`;
return;
}
const eventJson = JSON.stringify(buildEventViewJson(), null, 2);
divDocumentPreview.innerHTML = `<pre class="docEventPre">${escapeHtml(eventJson)}</pre>`;
return;
}
taDocument.style.display = 'block';
if (metaGridEl) metaGridEl.style.display = 'grid';
divDocumentPreview.style.display = 'none';
}
@@ -4279,6 +4323,13 @@ user:
updateDocumentView();
}
},
{
label: 'View Event',
onClick: () => {
documentViewMode = 'event';
updateDocumentView();
}
},
{
label: 'Copy document',
onClick: async () => {

View File

@@ -1,5 +1,5 @@
{
"VERSION": "v0.7.10",
"VERSION_NUMBER": "0.7.10",
"BUILD_DATE": "2026-04-28T16:33:28.943Z"
"VERSION": "v0.7.11",
"VERSION_NUMBER": "0.7.11",
"BUILD_DATE": "2026-05-03T11:00:46.559Z"
}

View File

@@ -9,6 +9,7 @@ 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}',
});
@@ -18,11 +19,13 @@ 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,
};
@@ -85,8 +88,12 @@ function deriveShowUrls(slug, siteConfig = DEFAULT_STREAMING_SITE) {
}
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: `${base}/stream/${safeSlug}/stream.m3u8`,
masterPlaylist,
viewerPage: `${base}/stream/${safeSlug}`,
stats: `${base}/api/stream/stats?show=${encodeURIComponent(safeSlug)}`,
obsServer: site.rtmpServer,
@@ -220,10 +227,11 @@ function parseStreamTargetFromUrl() {
}
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', String(status || 'planned').trim() || 'planned']
['status', safeStatus]
];
const safeSummary = String(summary || '').trim();
@@ -232,6 +240,7 @@ function buildStreamTags({ dTag, title, summary, image, streamingUrl, status, ep
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]);
@@ -240,6 +249,14 @@ function buildStreamTags({ dTag, title, summary, image, streamingUrl, status, ep
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;
}
@@ -279,6 +296,7 @@ export function initVjStreamPanel({
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'),
@@ -435,6 +453,9 @@ export function initVjStreamPanel({
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();
}
@@ -450,7 +471,7 @@ export function initVjStreamPanel({
function upsertStreamingSite(draft, { mode = 'add', editingName = '' } = {}) {
const normalizedDraft = normalizeStreamingSiteConfig(draft);
if (!normalizedDraft.name || !normalizedDraft.streamBaseUrl || !normalizedDraft.rtmpServer || !normalizedDraft.obsKeyTemplate) {
if (!normalizedDraft.name || !normalizedDraft.streamBaseUrl || !normalizedDraft.streamUrlTemplate || !normalizedDraft.rtmpServer || !normalizedDraft.obsKeyTemplate) {
return false;
}
@@ -1780,6 +1801,7 @@ export function initVjStreamPanel({
showStreamingSiteForm('add', {
name: '',
streamBaseUrl: 'https://',
streamUrlTemplate: '{base}/stream/{slug}/stream.m3u8',
rtmpServer: 'rtmp://',
obsKeyTemplate: '{slug}/src/{SECRET_KEY}',
});
@@ -1807,6 +1829,7 @@ export function initVjStreamPanel({
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(),
});

View File

@@ -253,6 +253,24 @@
#divHeaderText {
outline: none;
}
#txtDecryptTrace {
font-family: var(--font-mono);
white-space: pre-wrap;
word-break: break-word;
}
#divDiagnosticBadge {
display: inline-block;
margin-left: 10px;
padding: 4px 8px;
border: 1px solid #b45309;
border-radius: 4px;
color: #b45309;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
}
</style>
<script src="./js/marked.min.js"></script>
</head>
@@ -330,10 +348,15 @@
<td class="tdTitle">Autosave</td>
<td id="txtAutosave" class="tdEditable" contenteditable="true">10</td>
</tr>
<tr>
<td class="tdTitle">Decrypt Trace</td>
<td id="txtDecryptTrace">None</td>
</tr>
<tr>
<td class="tdTitle"></td>
<td>
<button id="btnSaveNote" class="btn">Save Note</button>
<span id="divDiagnosticBadge" class="clsHidden">DIAGNOSTIC VIEW — do not save</span>
</td>
</tr>
</table>
@@ -449,10 +472,13 @@ const versionInfo = await getVersion();
let OBJ_NOTES = {};
let CURRENT_NOTE = "";
let ENCRYPTED_TAG_TYPES = ["title", "image", "summary", "t"];
const MAX_DECRYPT_PASSES = 8;
let EDIT_MODE = false;
let LastText = "";
let NUM_LAST_EDIT_TIME = Math.floor(Date.now() / 1000);
let numAutoSaveSec = 30;
let CURRENT_DECRYPT_TRACE = null;
let DIAGNOSTIC_ACTIVE = false;
/* ================================================================
DOM VARIABLES
@@ -478,6 +504,8 @@ const versionInfo = await getVersion();
const divHeaderText = document.getElementById('divHeaderText');
const tblTags = document.getElementById('tblTags');
const divFiles = document.getElementById('divFiles');
const txtDecryptTrace = document.getElementById('txtDecryptTrace');
const divDiagnosticBadge = document.getElementById('divDiagnosticBadge');
/* ================================================================
HAMBURGER MENU
@@ -549,6 +577,195 @@ const versionInfo = await getVersion();
/* ================================================================
NOTE FUNCTIONS
================================================================ */
async function nip44DecryptFromSelf(ciphertext) {
const selfPubkey = String(currentPubkey || await getPubkey() || '').trim();
if (!selfPubkey) throw new Error('Missing pubkey for NIP-44 decryption');
if (!window.nostr?.nip44?.decrypt) throw new Error('NIP-44 decrypt unavailable');
return await window.nostr.nip44.decrypt(selfPubkey, String(ciphertext || ''));
}
async function nip04DecryptFromSelf(ciphertext) {
const selfPubkey = String(currentPubkey || await getPubkey() || '').trim();
if (!selfPubkey) throw new Error('Missing pubkey for NIP-04 decryption');
if (!window.nostr?.nip04?.decrypt) throw new Error('NIP-04 decrypt unavailable');
return await window.nostr.nip04.decrypt(selfPubkey, String(ciphertext || ''));
}
function looksLikeNip04Ciphertext(input) {
const text = String(input || '').trim();
return /^[A-Za-z0-9+/_-]+={0,2}\?iv=[A-Za-z0-9+/_-]+={0,2}$/.test(text);
}
function looksLikeBase64(input) {
const text = String(input || '').trim();
if (!text || text.length < 24 || text.length % 4 !== 0) return false;
return /^[A-Za-z0-9+/]+={0,2}$/.test(text);
}
function looksLikeNip44Ciphertext(input) {
const text = String(input || '').trim();
if (!looksLikeBase64(text)) return false;
try {
const decoded = atob(text);
if (!decoded || decoded.length < 2) return false;
const versionByte = decoded.charCodeAt(0);
return versionByte === 1 || versionByte === 2;
} catch (_error) {
return false;
}
}
function classifyCiphertext(input) {
const text = String(input || '').trim();
if (!text) return 'plaintext';
if (looksLikeNip04Ciphertext(text)) return 'nip04';
if (looksLikeNip44Ciphertext(text)) return 'nip44';
if (/[\n\r\t]/.test(text) || /[#*_`\[\]{}()]/.test(text)) {
return 'plaintext';
}
if (looksLikeBase64(text)) return 'base64-unknown';
return 'plaintext';
}
async function decryptOnePass(input) {
const text = String(input || '').trim();
const kind = classifyCiphertext(text);
const methods = kind === 'nip04'
? ['nip04', 'nip44']
: ['nip44', 'nip04'];
for (const method of methods) {
try {
const output = method === 'nip44'
? await nip44DecryptFromSelf(text)
: await nip04DecryptFromSelf(text);
return {
ok: true,
method,
text: String(output || '')
};
} catch (_error) {
// try next method
}
}
return {
ok: false,
method: null,
text
};
}
async function peelDecrypt(input, maxPasses = MAX_DECRYPT_PASSES) {
let current = String(input || '').trim();
const trace = [];
let stoppedReason = 'max-passes';
for (let pass = 1; pass <= maxPasses; pass++) {
const beforeClass = classifyCiphertext(current);
if (beforeClass === 'plaintext') {
stoppedReason = 'plaintext';
break;
}
const one = await decryptOnePass(current);
if (!one.ok) {
stoppedReason = 'decrypt-failed';
break;
}
trace.push({
pass,
method: one.method,
inLength: current.length,
outLength: one.text.length
});
current = String(one.text || '');
}
return {
finalText: current,
trace,
stoppedReason,
finalClass: classifyCiphertext(current)
};
}
function formatDecryptTrace(diag) {
if (!diag) return 'None';
const passes = Number(diag.passes || 0);
const methods = Array.isArray(diag.methods) && diag.methods.length > 0
? diag.methods.join(' → ')
: 'none';
const reason = String(diag.stoppedReason || 'unknown');
const finalClass = String(diag.finalClass || 'unknown');
const finalLength = Number(diag.finalLength || 0);
return `passes=${passes} | methods=${methods} | stop=${reason} | final=${finalClass} | len=${finalLength}`;
}
async function peelTagsMaybeEncrypted(objTags) {
const safe = objTags && typeof objTags === 'object' ? { ...objTags } : {};
for (const key of ['title', 'image', 'summary']) {
const value = String(safe[key] || '').trim();
if (!value) continue;
const result = await peelDecrypt(value, 3);
safe[key] = result.trace.length > 0 ? result.finalText : value;
}
if (Array.isArray(safe.t)) {
const decryptedTags = [];
for (const tagValue of safe.t) {
const value = String(tagValue || '').trim();
if (!value) continue;
const result = await peelDecrypt(value, 3);
decryptedTags.push(result.trace.length > 0 ? result.finalText : value);
}
safe.t = decryptedTags;
}
return safe;
}
async function ensureNoteDiagnostics(note) {
if (!note || Number(note.kind) !== 30024) return note;
if (note.decryptDiagnostics) return note;
const peel = await peelDecrypt(String(note.content || ''));
const decryptedTags = await peelTagsMaybeEncrypted(note.objTags || {});
note.decryptedContent = peel.finalText;
note.decryptedObjTags = decryptedTags;
note.decryptDiagnostics = {
passes: peel.trace.length,
methods: peel.trace.map((step) => String(step.method || '').toUpperCase()),
stoppedReason: peel.stoppedReason,
finalClass: peel.finalClass,
finalLength: String(peel.finalText || '').length
};
return note;
}
function setDiagnosticUI(note) {
CURRENT_DECRYPT_TRACE = note?.decryptDiagnostics || null;
txtDecryptTrace.textContent = formatDecryptTrace(CURRENT_DECRYPT_TRACE);
DIAGNOSTIC_ACTIVE = Boolean(
Number(note?.kind) === 30024 &&
(Number(CURRENT_DECRYPT_TRACE?.passes || 0) > 0 || CURRENT_DECRYPT_TRACE?.stoppedReason === 'decrypt-failed')
);
if (DIAGNOSTIC_ACTIVE) {
divDiagnosticBadge.className = '';
} else {
divDiagnosticBadge.className = 'clsHidden';
}
}
const LoadSidenav = async () => {
// Subscribe to long-form notes (kind 30023 and 30024)
const notesSub = subscribe(
@@ -561,8 +778,8 @@ const versionInfo = await getVersion();
// Get notes from cache via events
let htmlOut = `<table id="tblNotes">`;
htmlOut += `<tr><th colspan="8"> NOTES </th></tr>`;
htmlOut += `<tr><th colspan="8"> &nbsp </th></tr>`;
htmlOut += `<tr><th colspan="10"> NOTES </th></tr>`;
htmlOut += `<tr><th colspan="10"> &nbsp </th></tr>`;
htmlOut += `<tr>
<th>Evt</th>
<th>Del</th>
@@ -573,21 +790,26 @@ const versionInfo = await getVersion();
<th>Edited</th>
<th>Type</th>
<th>Encrypted</th>
<th>Layers</th>
</tr>`;
// Build table from OBJ_NOTES
for (let Each of Object.keys(OBJ_NOTES).sort((a, b) => b - a)) {
const note = OBJ_NOTES[Each];
if (Number(note?.kind) === 30024) {
await ensureNoteDiagnostics(note);
}
let d = new Date(Number(Each) * 1000);
let dCreated = d.toLocaleString();
d = new Date(Number(OBJ_NOTES[Each].created_at) * 1000);
d = new Date(Number(note?.created_at) * 1000);
let dEdited = d.toLocaleString();
const dTagValue = OBJ_NOTES[Each].objTags?.d ?? `${OBJ_NOTES[Each].created_at} (from created_at)`;
const dTagValue = note?.objTags?.d ?? `${note?.created_at} (from created_at)`;
const rowTitle = note?.decryptedObjTags?.title || note?.objTags?.title || 'Untitled';
let Status = "Published";
let svgPub = `<div class="divTableButtons"></div>`;
if (OBJ_NOTES[Each].kind == 30024) {
Status = "Encrypted";
if (note?.kind == 30024) {
svgPub = `<div id="divSVGPublish-${Each}" class="divTableButtons">➜</div>`;
}
@@ -595,12 +817,13 @@ const versionInfo = await getVersion();
<td class="col5"> <div id="divSVG-${Each}" class="divTableButtons">📋</div> </td>
<td class="col5"> <div id="divSVGDelete-${Each}" class="divTableButtons">✕</div> </td>
<td class="col5"> ${svgPub} </td>
<td class="col0" id="divFileID-${Each}">${OBJ_NOTES[Each].objTags?.title || 'Untitled'}</td>
<td class="col0" id="divFileID-${Each}">${rowTitle}</td>
<td class="col1">${dTagValue}</td>
<td class="col1">${dCreated}</td>
<td class="col1">${dEdited}</td>
<td class="col3">${OBJ_NOTES[Each].kind} </td>
<td class="col3">${OBJ_NOTES[Each].encrypted ? 'Yes' : 'No'} </td>
<td class="col3">${note?.kind} </td>
<td class="col3">${note?.encrypted ? 'Yes' : 'No'} </td>
<td class="col3">${note?.kind == 30024 ? Number(note?.decryptDiagnostics?.passes || 0) : '-'}</td>
</tr>`;
}
@@ -656,9 +879,14 @@ const versionInfo = await getVersion();
// Save when switching from edit to view mode if there are unsaved changes
if (taNote.value.trim() !== LastText) {
console.log('[autosave] Switching to view mode with unsaved changes - triggering save');
await Publish30024Note();
LastText = taNote.value.trim();
if (DIAGNOSTIC_ACTIVE) {
console.log('[autosave] Diagnostic view active - skipping implicit save');
LastText = taNote.value.trim();
} else {
console.log('[autosave] Switching to view mode with unsaved changes - triggering save');
await Publish30024Note();
LastText = taNote.value.trim();
}
}
}
};
@@ -672,15 +900,24 @@ const versionInfo = await getVersion();
return;
}
taNote.value = OBJ_NOTES[id].content || '';
LastText = OBJ_NOTES[id].content || '';
txtTitle.textContent = OBJ_NOTES[id].objTags?.title || '';
divTitle.textContent = OBJ_NOTES[id].objTags?.title || '';
txtSummary.textContent = OBJ_NOTES[id].objTags?.summary || '';
txtTags.textContent = OBJ_NOTES[id].objTags?.t?.join(' ') || '';
const note = OBJ_NOTES[id];
await ensureNoteDiagnostics(note);
const displayTags = note.decryptedObjTags || note.objTags || {};
const displayContent = Number(note.kind) === 30024
? String(note.decryptedContent || note.content || '')
: String(note.content || '');
taNote.value = displayContent;
LastText = taNote.value.trim();
txtTitle.textContent = displayTags?.title || '';
divTitle.textContent = displayTags?.title || '';
txtSummary.textContent = displayTags?.summary || '';
txtTags.textContent = displayTags?.t?.join(' ') || '';
CURRENT_NOTE = id;
txtID.textContent = CURRENT_NOTE;
setDiagnosticUI(note);
// Parse markdown for preview
console.log('[note.html] LoadNote - window.marked exists:', !!window.marked);
@@ -699,11 +936,11 @@ const versionInfo = await getVersion();
console.log('[note.html] No content to display - reason:', reason);
}
if (OBJ_NOTES[id].objTags?.image === "" || !OBJ_NOTES[id].objTags?.image) {
if (displayTags?.image === "" || !displayTags?.image) {
txtImg.textContent = "";
imgMain.className = "clsHidden";
} else {
txtImg.textContent = OBJ_NOTES[id].objTags.image;
txtImg.textContent = displayTags.image;
imgMain.className = "clsVisible";
}
@@ -932,7 +1169,7 @@ const versionInfo = await getVersion();
);
// Listen for incoming note events
window.addEventListener('ndkEvent', (event) => {
window.addEventListener('ndkEvent', async (event) => {
const evt = event.detail;
if (evt.kind === 30023 || evt.kind === 30024) {
console.log("[note.html] Received note:", evt);
@@ -957,6 +1194,14 @@ const versionInfo = await getVersion();
objTags: objTags,
encrypted: evt.kind === 30024
};
if (evt.kind === 30024) {
await ensureNoteDiagnostics(OBJ_NOTES[dTag]);
}
if (isNavOpen) {
LoadSidenav();
}
}
});
@@ -982,6 +1227,7 @@ const versionInfo = await getVersion();
txtSummary.textContent = "";
txtTags.textContent = "";
divHeaderText.textContent = "TITLE";
setDiagnosticUI(null);
SetEditMode(true);
closeNav();
});
@@ -1042,6 +1288,11 @@ const versionInfo = await getVersion();
return;
}
if (DIAGNOSTIC_ACTIVE) {
console.log('[autosave] Skipping: diagnostic mode active');
return;
}
// Skip if not enough time passed since last edit
const secondsSinceEdit = Math.floor(Date.now() / 1000) - NUM_LAST_EDIT_TIME;
// console.log('[autosave] Seconds since edit:', secondsSinceEdit, '| required:', autoSaveSetting);

View File

@@ -1535,6 +1535,7 @@
<div id="streamingSiteFormPanel" class="hidden" style="margin-top:6px;">
<input id="inputSiteName" class="streamInput" placeholder="Site name" />
<input id="inputSiteStreamBaseUrl" class="streamInput" placeholder="Stream base URL (https://example.com)" />
<input id="inputSiteStreamUrlTemplate" class="streamInput" placeholder="Stream URL template ({base}/stream/{slug}/stream.m3u8)" />
<input id="inputSiteRtmpServer" class="streamInput" placeholder="RTMP server (rtmp://example.com:1935/publish)" />
<input id="inputSiteObsKeyTemplate" class="streamInput" placeholder="OBS key template ({slug}/src/{SECRET_KEY})" />
<div style="display:flex; gap:4px; flex-wrap:wrap; margin-top:4px;">