Files
client/www/strudel.html

1970 lines
72 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<title>Strudel</title>
<link rel="stylesheet" href="./css/client.css" />
<link rel="stylesheet" href="./css/strudel.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>
<!-- Strudel REPL Library (locally built from source) -->
<script type="module" src="./strudel-repl/index.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 id="divHeaderText" class="divHeaderText">Strudel</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">
<!-- Strudel Interface -->
<div class="strudel-container">
<!-- Control Panel -->
<div class="strudel-controls">
<button id="strudelToggleButton" class="strudel-button">Play</button>
<button id="strudelReloadButton" class="strudel-button">Reload</button>
</div>
<!-- Strudel Editor Component -->
<div class="strudel-editor-section">
<div class="strudel-editor-wrapper">
<strudel-editor id="strudelEditor">
<!--
// Welcome to Strudel!
// Press Play or Ctrl+Enter to start
note("<c a f e>(3,8)")
.sound("sawtooth")
.room(0.5)
.lpf(2000)
.scope()
.punchcard()
-->
</strudel-editor>
</div>
</div>
<!-- AI Chat Panel -->
<div id="strudelAIPanel" class="strudel-ai-panel">
<div id="strudelAIHistory" class="strudel-ai-history"></div>
<div id="strudelAIInput" class="strudel-ai-input">
<textarea id="strudelAIPrompt" placeholder="Tell the AI what sounds you want... (Enter to send, Ctrl+Enter for new line)" rows="3"></textarea>
</div>
</div>
</div>
<!-- Error Display -->
<div id="strudelError" class="strudel-error">
<div class="strudel-error-header">
<span class="strudel-error-title">⚠️ Error</span>
<button class="strudel-error-close" onclick="document.getElementById('strudelError').classList.remove('show')">×</button>
</div>
<div id="strudelErrorMessage" class="strudel-error-message"></div>
</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>
<!-- ================================================================
SONGS SECTION
================================================================ -->
<div id="divSongsSection">
<div id="divSongsSectionTitle">Songs</div>
<div id="divSongsSaveRow">
<input type="text" id="songsSaveTitle" placeholder="Title..." />
<button id="songsSaveButton" type="button">Save New</button>
<button id="songsUpdateButton" type="button" style="display:none;">Update</button>
</div>
<div id="divSongsCurrent" style="display:none; font-size:11px; margin-bottom:6px; color:var(--accent-color);"></div>
<div id="divSongsList">
<span style="opacity:0.5;font-size:11px;">Login to see your songs</span>
</div>
</div>
<!-- ================================================================
INSTRUMENTS SECTION
================================================================ -->
<div id="divInstrumentsSection">
<div id="divInstrumentsSectionTitle">Instruments</div>
<div id="divInstrumentsSaveRow">
<input type="text" id="instrumentsSaveTitle" placeholder="Title..." />
<button id="instrumentsSaveButton" type="button" disabled>Save New</button>
<button id="instrumentsUpdateButton" type="button" style="display:none;">Update</button>
</div>
<div id="divInstrumentsCurrent" style="display:none; font-size:11px; margin-bottom:6px; color:var(--accent-color);"></div>
<div id="divInstrumentsHint">
<span style="opacity:0.5;font-size:10px;">Select text in editor to save as instrument</span>
</div>
<div id="divInstrumentsList">
<span style="opacity:0.5;font-size:11px;">Login to see your instruments</span>
</div>
</div>
<div id="divMIDISection">
<div id="divMIDISectionTitle">MIDI</div>
<div id="divMIDISettings">
<div id="divMIDIDeviceList">
<span style="opacity:0.5;font-size:11px;">No MIDI devices detected</span>
</div>
<div id="divMIDILearnLog"></div>
<div id="divMIDIControlMap"></div>
<button id="midiScanButton" type="button">Scan Devices</button>
<button id="midiLearnButton" type="button" disabled>Learn Controls</button>
<button id="midiClearMapButton" type="button" disabled>Clear Map</button>
</div>
</div>
<div id="divAISection">
<div id="divAISectionTitle">
AI
</div>
<div id="divAISettings">
<input type="text" id="aiApiUrl" placeholder="API URL (e.g. https://api.ppq.ai)" />
<input type="password" id="aiApiKey" placeholder="API Key" />
<select id="aiModel">
<option value="">Select a model...</option>
</select>
<button id="aiRefreshModels" type="button">Refresh Models</button>
<button id="aiSaveSettings">Save</button>
<button id="aiClearChat">Clear Chat</button>
</div>
</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-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 } 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 { getAISettings, saveAISettings, clearConversationHistory, sendMessage, fetchAvailableModels } from './js/strudel-ai.mjs';
import { initMidi, getConnectedDevices, startLearnMode, stopLearnMode, loadMidiMaps, saveMidiMaps, clearMidiMaps, buildMidiContext, getAllMaps, isMidiAvailable } from './js/strudel-midi.mjs';
import {
KINDS,
saveSong,
saveInstrument,
deleteItem,
subscribeToMyItems,
parseStrudelEvent,
getEditorSelection,
hasEditorSelection,
insertAtCursor,
replaceSelection,
replaceEditorContent,
getEditorContent,
formatTimestamp
} from './js/strudel-nostr.mjs';
// Version will be loaded asynchronously
const versionInfo = await getVersion();
const VERSION = versionInfo.VERSION;
console.log(`[template.html ${VERSION}] Loading...`);
/* ================================================================
GLOBAL VARIABLES
================================================================
Track state for hamburger menu, relay status, and theme.
================================================================ */
let updateIntervalId = null;
let currentPubkey = null;
let lastErrorMessage = null;
// Hamburger menu
let hamburgerInstance = null;
let isNavOpen = false;
// Version bar buttons
let logoutHamburger = null;
let themeToggleHamburger = null;
let isDarkMode = false;
/* ================================================================
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");
/* ================================================================
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');
}
divSideNavBody.innerHTML = "";
// 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();
}
}
/* ================================================================
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();
// Display last error in center section if there is one
if (lastErrorMessage && divFooterCenter) {
divFooterCenter.innerHTML = `<span style="color: #ff4444; font-size: 12px; font-family: monospace;">${escapeHtml(lastErrorMessage)}</span>`;
} else {
divFooterCenter.innerHTML = '';
}
// Clear right section
divFooterRight.innerHTML = '';
} catch (error) {
console.error('[template.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("[template.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("[template.html] Logged out, reloading page");
location.reload(true);
};
/* ================================================================
EVENT LISTENERS
================================================================
Wire up UI interactions.
Main hamburger button click handler is set up in main() after initialization.
================================================================ */
/* ================================================================
SUBSCRIPTION EXAMPLE
================================================================
Example of how to subscribe to Nostr events:
const sub = subscribe(
{ kinds: [1], authors: [pubkey], limit: 10 },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
Cache usage options:
- 'CACHE_FIRST' - Check cache first, then relays
- 'ONLY_RELAY' - Only query relays
- 'ONLY_CACHE' - Only query cache
- 'PARALLEL' - Query cache and relays simultaneously
Listen for events via window events:
window.addEventListener('ndkEvent', (event) => {
const evt = event.detail;
console.log('Received event:', evt);
});
================================================================ */
/* ================================================================
PUBLISH EXAMPLE
================================================================
Example of how to publish a Nostr event:
const event = {
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: "Hello, Nostr!"
};
try {
const result = await publishEvent(event);
console.log("✅ Published to:", result.relayResults.successful);
console.log("❌ Failed:", result.relayResults.failed);
console.log("Total relays:", result.totalRelays);
} catch (error) {
console.error("Publish error:", error);
}
Note: Events are automatically signed by the NDK worker using
the message-based signer (which calls window.nostr.signEvent).
================================================================ */
/* ================================================================
STRUDEL WITH <strudel-editor> COMPONENT
================================================================
Using the @strudel/repl package's web component for full
visualization support (scope, punchcard, pianoroll, etc.)
================================================================ */
// Strudel state
let strudelEditorElement = null;
let isPlaying = false;
/* ================================================================
STRUDEL FUNCTIONS
================================================================ */
function showError(message) {
const errorDiv = document.getElementById('strudelError');
const errorMessage = document.getElementById('strudelErrorMessage');
errorMessage.textContent = message;
errorDiv.classList.add('show');
// Auto-hide after 10 seconds
setTimeout(() => {
errorDiv.classList.remove('show');
}, 10000);
}
// Track error line decorations to clear them later
let errorLineDecorations = [];
function highlightErrorLine(lineNumber) {
console.log('[Strudel] Attempting to highlight error on line:', lineNumber);
if (!strudelEditorElement?.editor?.editor) {
console.log('[Strudel] No editor available');
return;
}
const cmView = strudelEditorElement.editor.editor;
if (!cmView?.dispatch) {
console.log('[Strudel] No dispatch available');
return;
}
// Clear any existing error highlights first
clearErrorHighlights();
// Find the line element - CodeMirror uses .cm-line but nth-child doesn't map to line numbers
// Instead, we need to find all cm-line elements and get the one at the index
const allLines = cmView.dom?.querySelectorAll?.('.cm-line');
console.log('[Strudel] Total lines found:', allLines?.length);
if (allLines && allLines.length >= lineNumber && lineNumber > 0) {
const lineEl = allLines[lineNumber - 1]; // 0-indexed
console.log('[Strudel] Highlighting line element:', lineEl);
// Add class and inline style for maximum visibility
lineEl.classList.add('strudel-error-line');
lineEl.style.backgroundColor = 'var(--accent-color)';
lineEl.style.opacity = '0.3';
errorLineDecorations.push(lineEl);
} else {
console.log('[Strudel] Line not found or out of range');
}
}
function clearErrorHighlights() {
// Remove error class and inline styles from all previously highlighted lines
errorLineDecorations.forEach(el => {
el.classList.remove('strudel-error-line');
el.style.backgroundColor = '';
el.style.opacity = '';
});
errorLineDecorations = [];
}
function updateStatus(status) {
const statusEl = document.getElementById('strudelStatus');
if (statusEl) {
statusEl.className = 'strudel-status-inline ' + status;
statusEl.textContent = status.charAt(0).toUpperCase() + status.slice(1);
}
}
function updateButtonStates(playing) {
const toggleBtn = document.getElementById('strudelToggleButton');
const reloadBtn = document.getElementById('strudelReloadButton');
if (toggleBtn) {
toggleBtn.textContent = playing ? 'Stop' : 'Play';
}
reloadBtn.disabled = false;
}
function handleToggle() {
if (isPlaying) {
handleStop();
} else {
handlePlay();
}
}
/* ================================================================
STRUDEL THEME SYNC
================================================================
Syncs Strudel's theme system with app's day/night mode.
This updates both CSS variables (editor colors) and the JS
theme object (visualization colors like .scope() waveforms).
================================================================ */
function syncStrudelTheme(isDark) {
const editor = strudelEditorElement?.editor;
if (!editor) {
console.log('[Strudel] Editor not ready, skipping theme sync');
return;
}
// Always use clientNdk theme which uses our CSS variables
const themeName = 'clientNdk';
console.log(`[Strudel] Syncing theme to: ${themeName} (isDark: ${isDark})`);
try {
// Use changeSetting() instead of updateSettings() to avoid resetting other settings
// changeSetting() only changes the theme and calls activateTheme() which sets
// both CSS variables and the JS theme object for visualization colors
editor.changeSetting('theme', themeName);
console.log('[Strudel] Theme synced successfully');
} catch (error) {
console.error('[Strudel] Error syncing theme:', error);
}
}
/* ================================================================
AI ASSISTANT FUNCTIONS
================================================================ */
// AI DOM elements
let aiPromptInput = null;
let aiHistoryDiv = null;
let aiApiUrlInput = null;
let aiApiKeyInput = null;
let aiModelInput = null;
let aiRefreshModelsButton = null;
let aiSaveButton = null;
let aiClearChatButton = null;
function initializeAISettings() {
// Get DOM elements
aiPromptInput = document.getElementById('strudelAIPrompt');
aiHistoryDiv = document.getElementById('strudelAIHistory');
aiApiUrlInput = document.getElementById('aiApiUrl');
aiApiKeyInput = document.getElementById('aiApiKey');
aiModelInput = document.getElementById('aiModel');
aiRefreshModelsButton = document.getElementById('aiRefreshModels');
aiSaveButton = document.getElementById('aiSaveSettings');
aiClearChatButton = document.getElementById('aiClearChat');
// Load saved settings
const settings = getAISettings();
if (aiApiUrlInput) aiApiUrlInput.value = settings.url;
if (aiApiKeyInput) aiApiKeyInput.value = settings.key;
if (aiModelInput) aiModelInput.value = settings.model;
// Wire up event listeners - Enter sends, Ctrl+Enter adds newline
if (aiPromptInput) {
aiPromptInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.ctrlKey && !e.shiftKey) {
e.preventDefault();
handleAISend();
}
// Ctrl+Enter or Shift+Enter = newline (default textarea behavior)
});
}
if (aiRefreshModelsButton) {
aiRefreshModelsButton.addEventListener('click', handleAIRefreshModels);
}
if (aiSaveButton) {
aiSaveButton.addEventListener('click', handleAISaveSettings);
}
if (aiClearChatButton) {
aiClearChatButton.addEventListener('click', handleAIClearChat);
}
// Auto-fetch models on startup (only needs URL, no API key required)
handleAIRefreshModels();
// Fetch models when URL changes
if (aiApiUrlInput) {
aiApiUrlInput.addEventListener('change', handleAIRefreshModels);
}
console.log('[Strudel] AI settings initialized');
}
async function handleAIRefreshModels() {
if (!aiApiUrlInput || !aiModelInput) return;
const url = aiApiUrlInput.value;
if (!url) {
console.log('[Strudel] Cannot fetch models: missing URL');
return;
}
if (aiRefreshModelsButton) {
aiRefreshModelsButton.disabled = true;
aiRefreshModelsButton.textContent = 'Loading...';
}
try {
const models = await fetchAvailableModels(url);
// Clear current options except the first placeholder
while (aiModelInput.options.length > 1) {
aiModelInput.remove(1);
}
// Add models
models.forEach(model => {
const option = document.createElement('option');
option.value = model.id;
option.textContent = model.id;
aiModelInput.appendChild(option);
});
// Restore selected value if it exists in the list
const settings = getAISettings();
if (settings.model && aiModelInput.querySelector(`option[value="${settings.model}"]`)) {
aiModelInput.value = settings.model;
}
console.log(`[Strudel] Loaded ${models.length} models`);
} catch (error) {
console.error('[Strudel] Error loading models:', error);
} finally {
aiRefreshModelsButton.disabled = false;
aiRefreshModelsButton.textContent = 'Refresh Models';
}
}
function handleAISaveSettings() {
const url = aiApiUrlInput?.value || '';
const key = aiApiKeyInput?.value || '';
const model = aiModelInput?.value || '';
saveAISettings(url, key, model);
console.log('[Strudel] AI settings saved');
alert('AI settings saved!');
}
function handleAIClearChat() {
clearConversationHistory();
if (aiHistoryDiv) {
aiHistoryDiv.innerHTML = '';
}
console.log('[Strudel] Chat history cleared');
}
/* ================================================================
MIDI SECTION
================================================================ */
let midiScanButton = null;
let midiLearnButton = null;
let midiClearMapButton = null;
let midiDeviceListDiv = null;
let midiControlMapDiv = null;
let midiLearnLogDiv = null;
let midiLearnActive = false;
/* ================================================================
SONGS/INSTRUMENTS SECTION VARIABLES
================================================================ */
let songsSaveTitle = null;
let songsSaveButton = null;
let songsListDiv = null;
let instrumentsSaveTitle = null;
let instrumentsSaveButton = null;
let instrumentsHintDiv = null;
let instrumentsListDiv = null;
let songsUpdateButton = null;
let instrumentsUpdateButton = null;
let editorInstance = null;
let mySongs = new Map();
let myInstruments = new Map();
let songsSubscription = null;
let instrumentsSubscription = null;
let currentSongDTag = null;
let currentInstrumentDTag = null;
let currentSongOriginalContent = null;
let currentInstrumentOriginalContent = null;
function initializeMIDISection() {
midiScanButton = document.getElementById('midiScanButton');
midiLearnButton = document.getElementById('midiLearnButton');
midiClearMapButton = document.getElementById('midiClearMapButton');
midiDeviceListDiv = document.getElementById('divMIDIDeviceList');
midiControlMapDiv = document.getElementById('divMIDIControlMap');
midiLearnLogDiv = document.getElementById('divMIDILearnLog');
loadMidiMaps();
if (midiScanButton) midiScanButton.addEventListener('click', handleMidiScan);
if (midiLearnButton) midiLearnButton.addEventListener('click', handleMidiLearnToggle);
if (midiClearMapButton) midiClearMapButton.addEventListener('click', handleMidiClearMap);
// Auto-scan on load
handleMidiScan();
}
async function handleMidiScan() {
if (midiScanButton) midiScanButton.disabled = true;
if (midiDeviceListDiv) midiDeviceListDiv.innerHTML = '<span style="opacity:0.5;font-size:11px;">Scanning...</span>';
try {
const devices = await initMidi();
renderMidiDevices(devices);
renderMidiControlMap();
if (midiLearnButton) midiLearnButton.disabled = devices.inputs.length === 0;
if (midiClearMapButton) midiClearMapButton.disabled = false;
} catch (err) {
if (midiDeviceListDiv) {
midiDeviceListDiv.innerHTML = `<span style="color:var(--accent-color);font-size:11px;">⚠ ${err.message}</span>`;
}
console.warn('[Strudel MIDI]', err.message);
} finally {
if (midiScanButton) midiScanButton.disabled = false;
}
}
function renderMidiDevices(devices) {
if (!midiDeviceListDiv) return;
if (devices.inputs.length === 0 && devices.outputs.length === 0) {
midiDeviceListDiv.innerHTML = '<span style="opacity:0.5;font-size:11px;">No MIDI devices found</span>';
return;
}
let html = '';
if (devices.inputs.length > 0) {
html += '<div class="midi-device-group"><div class="midi-device-label">Inputs:</div>';
for (const d of devices.inputs) {
html += `<div class="midi-device-name"><span class="midi-device-dot"></span>${d.name}</div>`;
}
html += '</div>';
}
if (devices.outputs.length > 0) {
html += '<div class="midi-device-group"><div class="midi-device-label">Outputs:</div>';
for (const d of devices.outputs) {
html += `<div class="midi-device-name"><span class="midi-device-dot" style="background:var(--muted-color)"></span>${d.name}</div>`;
}
html += '</div>';
}
midiDeviceListDiv.innerHTML = html;
}
function renderMidiControlMap() {
if (!midiControlMapDiv) return;
const maps = getAllMaps();
const entries = Object.entries(maps);
if (entries.length === 0) {
midiControlMapDiv.classList.remove('has-controls');
midiControlMapDiv.innerHTML = '';
return;
}
let html = '';
for (const [deviceName, controls] of entries) {
if (controls.length === 0) continue;
html += `<div style="font-weight:bold;font-size:10px;margin-bottom:2px;">${deviceName}</div>`;
for (const ctrl of controls) {
const val = ctrl.type === 'cc'
? `CC#${ctrl.cc} ch${ctrl.channel}`
: `Note ${ctrl.note} ch${ctrl.channel}`;
html += `<div class="midi-control-entry">
<span class="midi-control-label">${ctrl.label}</span>
<span class="midi-control-value">${val}</span>
</div>`;
}
}
if (html) {
midiControlMapDiv.innerHTML = html;
midiControlMapDiv.classList.add('has-controls');
} else {
midiControlMapDiv.classList.remove('has-controls');
midiControlMapDiv.innerHTML = '';
}
}
function handleMidiLearnToggle() {
if (!midiLearnActive) {
// Start learn mode
midiLearnActive = true;
if (midiLearnButton) {
midiLearnButton.textContent = 'Stop Learning';
midiLearnButton.classList.add('midi-learn-active');
}
if (midiLearnLogDiv) midiLearnLogDiv.textContent = 'Move a control...';
startLearnMode((ctrl) => {
if (midiLearnLogDiv) {
const val = ctrl.type === 'cc' ? `CC#${ctrl.cc}` : `Note ${ctrl.note}`;
midiLearnLogDiv.textContent = `Learned: ${val} ch${ctrl.channel} (${ctrl.deviceName})`;
}
renderMidiControlMap();
});
} else {
// Stop learn mode
midiLearnActive = false;
stopLearnMode();
if (midiLearnButton) {
midiLearnButton.textContent = 'Learn Controls';
midiLearnButton.classList.remove('midi-learn-active');
}
if (midiLearnLogDiv) midiLearnLogDiv.textContent = '';
renderMidiControlMap();
}
}
function handleMidiClearMap() {
clearMidiMaps();
renderMidiControlMap();
if (midiLearnLogDiv) midiLearnLogDiv.textContent = 'Map cleared.';
setTimeout(() => { if (midiLearnLogDiv) midiLearnLogDiv.textContent = ''; }, 2000);
}
/* ================================================================
SONGS/INSTRUMENTS SECTION FUNCTIONS
================================================================ */
function initializeSongsInstrumentsSection() {
// Get DOM elements
songsSaveTitle = document.getElementById('songsSaveTitle');
songsSaveButton = document.getElementById('songsSaveButton');
songsUpdateButton = document.getElementById('songsUpdateButton');
songsListDiv = document.getElementById('divSongsList');
instrumentsSaveTitle = document.getElementById('instrumentsSaveTitle');
instrumentsSaveButton = document.getElementById('instrumentsSaveButton');
instrumentsUpdateButton = document.getElementById('instrumentsUpdateButton');
instrumentsHintDiv = document.getElementById('divInstrumentsHint');
instrumentsListDiv = document.getElementById('divInstrumentsList');
// Add event listeners
if (songsSaveButton) {
songsSaveButton.addEventListener('click', handleSaveSong);
}
if (songsUpdateButton) {
songsUpdateButton.addEventListener('click', handleUpdateSong);
}
if (instrumentsSaveButton) {
instrumentsSaveButton.addEventListener('click', handleSaveInstrument);
}
if (instrumentsUpdateButton) {
instrumentsUpdateButton.addEventListener('click', handleUpdateInstrument);
}
if (songsSaveTitle) {
songsSaveTitle.addEventListener('keypress', (e) => {
if (e.key === 'Enter') handleSaveSong();
});
}
if (instrumentsSaveTitle) {
instrumentsSaveTitle.addEventListener('keypress', (e) => {
if (e.key === 'Enter') handleSaveInstrument();
});
}
// Set up editor selection tracking
setupEditorSelectionTracking();
// Load library if authenticated
if (currentPubkey) {
loadMyLibrary();
}
}
function setupEditorSelectionTracking() {
// The strudel-editor uses CodeMirror 6
// We need to poll for selection changes since CodeMirror doesn't have
// a simple 'selectionchange' event at the DOM level
setInterval(() => {
updateInstrumentSaveButtonState();
updateSaveButtonVisibility();
}, 200);
}
function updateSaveButtonVisibility() {
// Check if we have a current song loaded and if content has changed
if (currentSongDTag && currentSongOriginalContent && strudelEditorElement?.editor) {
const currentContent = strudelEditorElement.editor.code || '';
const hasChanged = currentContent !== currentSongOriginalContent;
if (hasChanged && songsSaveButton && songsUpdateButton) {
// Content changed - show Update, hide Save New
songsSaveButton.style.display = 'none';
songsUpdateButton.style.display = 'inline-block';
}
}
// Check if we have a current instrument loaded and if content has changed
if (currentInstrumentDTag && currentInstrumentOriginalContent && strudelEditorElement?.editor) {
const currentContent = strudelEditorElement.editor.code || '';
const hasChanged = currentContent !== currentInstrumentOriginalContent;
if (hasChanged && instrumentsSaveButton && instrumentsUpdateButton) {
// Content changed - show Update, hide Save New
instrumentsSaveButton.style.display = 'none';
instrumentsUpdateButton.style.display = 'inline-block';
}
}
}
function updateInstrumentSaveButtonState() {
if (!strudelEditorElement || !instrumentsSaveButton) return;
// Try to access the editor's CodeMirror view
const editor = strudelEditorElement.editor;
if (!editor) {
console.log('[Strudel] updateInstrumentSaveButtonState: no editor');
return;
}
// The CodeMirror view is at editor.editor (StrudelMirror.editor = CodeMirror EditorView)
const cmView = editor.editor;
if (!cmView) {
console.log('[Strudel] updateInstrumentSaveButtonState: no cmView');
return;
}
const hasSelection = hasEditorSelection(cmView);
console.log('[Strudel] updateInstrumentSaveButtonState: hasSelection =', hasSelection);
instrumentsSaveButton.disabled = !hasSelection;
if (instrumentsHintDiv) {
instrumentsHintDiv.innerHTML = hasSelection
? '<span style="opacity:0.7;font-size:10px;color:var(--accent-color);">Ready to save selection</span>'
: '<span style="opacity:0.5;font-size:10px;">Select text in editor to save as instrument</span>';
}
}
async function handleSaveSong() {
if (!songsSaveTitle || !currentPubkey) return;
const title = songsSaveTitle.value.trim();
if (!title) {
alert('Please enter a title for your song');
return;
}
// Get current code from editor
const code = strudelEditorElement?.editor?.code || '';
if (!code.trim()) {
alert('Editor is empty');
return;
}
try {
songsSaveButton.disabled = true;
songsSaveButton.textContent = 'Saving...';
await saveSong(title, code);
songsSaveTitle.value = '';
console.log('[Strudel] Song saved:', title);
} catch (err) {
console.error('[Strudel] Failed to save song:', err);
alert('Failed to save song: ' + err.message);
} finally {
songsSaveButton.disabled = false;
songsSaveButton.textContent = 'Save New';
}
}
async function handleUpdateSong() {
if (!songsSaveTitle || !currentPubkey || !currentSongDTag) return;
const title = songsSaveTitle.value.trim();
if (!title) {
alert('Please enter a title for your song');
return;
}
// Get current code from editor
const code = strudelEditorElement?.editor?.code || '';
if (!code.trim()) {
alert('Editor is empty');
return;
}
try {
songsUpdateButton.disabled = true;
songsUpdateButton.textContent = 'Updating...';
// Pass the dTag to update the existing song
await saveSong(title, code, [], '', currentSongDTag);
console.log('[Strudel] Song updated:', title);
} catch (err) {
console.error('[Strudel] Failed to update song:', err);
alert('Failed to update song: ' + err.message);
} finally {
songsUpdateButton.disabled = false;
songsUpdateButton.textContent = 'Update';
}
}
async function handleSaveInstrument() {
if (!instrumentsSaveTitle || !currentPubkey) return;
const title = instrumentsSaveTitle.value.trim();
if (!title) {
alert('Please enter a title for your instrument');
return;
}
// Get selected text from editor
const editor = strudelEditorElement?.editor;
if (!editor || !editor.editor) {
alert('Editor not available');
return;
}
const selection = getEditorSelection(editor.editor);
if (!selection) {
alert('Please select some code to save as an instrument');
return;
}
try {
instrumentsSaveButton.disabled = true;
instrumentsSaveButton.textContent = 'Saving...';
await saveInstrument(title, selection);
instrumentsSaveTitle.value = '';
console.log('[Strudel] Instrument saved:', title);
} catch (err) {
console.error('[Strudel] Failed to save instrument:', err);
alert('Failed to save instrument: ' + err.message);
} finally {
instrumentsSaveButton.disabled = true;
instrumentsSaveButton.textContent = 'Save New';
updateInstrumentSaveButtonState();
}
}
async function handleUpdateInstrument() {
if (!instrumentsSaveTitle || !currentPubkey || !currentInstrumentDTag) return;
const title = instrumentsSaveTitle.value.trim();
if (!title) {
alert('Please enter a title for your instrument');
return;
}
// Get selected text from editor
const editor = strudelEditorElement?.editor;
if (!editor || !editor.editor) {
alert('Editor not available');
return;
}
const selection = getEditorSelection(editor.editor);
if (!selection) {
alert('Please select some code to save as an instrument');
return;
}
try {
instrumentsUpdateButton.disabled = true;
instrumentsUpdateButton.textContent = 'Updating...';
// Pass the dTag to update the existing instrument
await saveInstrument(title, selection, [], '', currentInstrumentDTag);
console.log('[Strudel] Instrument updated:', title);
} catch (err) {
console.error('[Strudel] Failed to update instrument:', err);
alert('Failed to update instrument: ' + err.message);
} finally {
instrumentsUpdateButton.disabled = false;
instrumentsUpdateButton.textContent = 'Update';
}
}
function loadMyLibrary() {
if (!currentPubkey) return;
// Close any existing subscriptions
if (songsSubscription) {
songsSubscription.close();
}
if (instrumentsSubscription) {
instrumentsSubscription.close();
}
// Subscribe to user's songs
songsSubscription = subscribeToMyItems(currentPubkey, KINDS.SONG, (event) => {
const item = parseStrudelEvent(event);
mySongs.set(item.dTag, item);
renderSongsList();
});
// Subscribe to user's instruments
instrumentsSubscription = subscribeToMyItems(currentPubkey, KINDS.INSTRUMENT, (event) => {
const item = parseStrudelEvent(event);
myInstruments.set(item.dTag, item);
renderInstrumentsList();
});
}
function renderSongsList() {
if (!songsListDiv) return;
if (mySongs.size === 0) {
songsListDiv.innerHTML = '<span style="opacity:0.5;font-size:11px;">No songs saved yet</span>';
return;
}
// Sort by creation date (newest first)
const sortedSongs = Array.from(mySongs.values()).sort((a, b) => b.createdAt - a.createdAt);
let html = '';
for (const song of sortedSongs) {
html += `
<div class="library-item" data-dtag="${song.dTag}" data-kind="${KINDS.SONG}">
<div class="library-item-content" onclick="loadSong('${song.dTag}')">
<span class="library-item-icon">🎵</span>
<span class="library-item-title">${escapeHtml(song.title)}</span>
<span class="library-item-date">${formatTimestamp(song.createdAt)}</span>
</div>
<button class="library-item-delete" onclick="deleteLibraryItem(${KINDS.SONG}, '${song.dTag}')" title="Delete">×</button>
</div>
`;
}
songsListDiv.innerHTML = html;
}
function renderInstrumentsList() {
if (!instrumentsListDiv) return;
if (myInstruments.size === 0) {
instrumentsListDiv.innerHTML = '<span style="opacity:0.5;font-size:11px;">No instruments saved yet</span>';
return;
}
// Sort by creation date (newest first)
const sortedInstruments = Array.from(myInstruments.values()).sort((a, b) => b.createdAt - a.createdAt);
let html = '';
for (const inst of sortedInstruments) {
html += `
<div class="library-item" data-dtag="${inst.dTag}" data-kind="${KINDS.INSTRUMENT}">
<div class="library-item-content" onclick="loadInstrument('${inst.dTag}')">
<span class="library-item-icon">🎹</span>
<span class="library-item-title">${escapeHtml(inst.title)}</span>
<span class="library-item-date">${formatTimestamp(inst.createdAt)}</span>
</div>
<button class="library-item-delete" onclick="deleteLibraryItem(${KINDS.INSTRUMENT}, '${inst.dTag}')" title="Delete">×</button>
</div>
`;
}
instrumentsListDiv.innerHTML = html;
}
function loadSong(dTag) {
console.log('[Strudel] loadSong called with dTag:', dTag);
const song = mySongs.get(dTag);
if (!song) {
console.warn('[Strudel] Song not found for dTag:', dTag);
return;
}
// Replace entire editor content
if (strudelEditorElement?.editor) {
const cmView = strudelEditorElement.editor.editor;
if (cmView && cmView.dispatch) {
cmView.dispatch({
changes: {
from: 0,
to: cmView.state.doc.length,
insert: song.content
}
});
} else {
strudelEditorElement.editor.code = song.content;
}
// Track this as the current song and update UI
setCurrentSong(song);
// Close sidebar after loading
closeNav();
}
}
function setCurrentSong(song) {
currentSongDTag = song?.dTag || null;
// Update header with song title or reset to "Strudel"
const headerText = document.getElementById('divHeaderText');
if (headerText) {
headerText.textContent = song?.title || 'Strudel';
}
if (song && songsSaveTitle && songsSaveButton && songsUpdateButton) {
// Set the title input
songsSaveTitle.value = song.title;
// Initially show Save New, hide Update until content changes
songsSaveButton.style.display = 'inline-block';
songsUpdateButton.style.display = 'none';
// Store original content for comparison
currentSongOriginalContent = song.content;
// Show current song indicator
const currentDiv = document.getElementById('divSongsCurrent');
if (currentDiv) {
currentDiv.textContent = `Editing: ${song.title}`;
currentDiv.style.display = 'block';
}
} else {
// No song loaded - reset to Save New mode
if (songsSaveButton) songsSaveButton.style.display = 'inline-block';
if (songsUpdateButton) songsUpdateButton.style.display = 'none';
const currentDiv = document.getElementById('divSongsCurrent');
if (currentDiv) currentDiv.style.display = 'none';
currentSongOriginalContent = null;
}
}
function clearCurrentSong() {
setCurrentSong(null);
if (songsSaveTitle) songsSaveTitle.value = '';
}
function loadInstrument(dTag) {
const inst = myInstruments.get(dTag);
if (!inst) return;
const editor = strudelEditorElement?.editor;
if (!editor || !editor.editor) return;
// Check if there's a selection
if (hasEditorSelection(editor.editor)) {
// Replace selection
replaceSelection(editor.editor, inst.content);
} else {
// Insert at cursor
insertAtCursor(editor.editor, inst.content);
}
console.log('[Strudel] Loaded instrument:', inst.title);
// Track this as the current instrument and update UI
setCurrentInstrument(inst);
// Close sidebar after loading
closeNav();
}
function setCurrentInstrument(inst) {
currentInstrumentDTag = inst?.dTag || null;
if (inst && instrumentsSaveTitle && instrumentsSaveButton && instrumentsUpdateButton) {
// Set the title input
instrumentsSaveTitle.value = inst.title;
// Initially show Save New, hide Update until content changes
instrumentsSaveButton.style.display = 'inline-block';
instrumentsUpdateButton.style.display = 'none';
// Store original content for comparison
currentInstrumentOriginalContent = inst.content;
// Show current instrument indicator
const currentDiv = document.getElementById('divInstrumentsCurrent');
if (currentDiv) {
currentDiv.textContent = `Editing: ${inst.title}`;
currentDiv.style.display = 'block';
}
} else {
// No instrument loaded - reset to Save New mode
if (instrumentsSaveButton) instrumentsSaveButton.style.display = 'inline-block';
if (instrumentsUpdateButton) instrumentsUpdateButton.style.display = 'none';
const currentDiv = document.getElementById('divInstrumentsCurrent');
if (currentDiv) currentDiv.style.display = 'none';
currentInstrumentOriginalContent = null;
}
}
function clearCurrentInstrument() {
setCurrentInstrument(null);
if (instrumentsSaveTitle) instrumentsSaveTitle.value = '';
}
async function deleteLibraryItem(kind, dTag) {
if (!confirm('Delete this item?')) return;
try {
await deleteItem(kind, dTag);
// Remove from local cache
if (kind === KINDS.SONG) {
mySongs.delete(dTag);
renderSongsList();
} else if (kind === KINDS.INSTRUMENT) {
myInstruments.delete(dTag);
renderInstrumentsList();
}
console.log('[Strudel] Deleted item:', dTag);
} catch (err) {
console.error('[Strudel] Failed to delete item:', err);
alert('Failed to delete: ' + err.message);
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Make functions available globally for onclick handlers
window.loadSong = loadSong;
window.loadInstrument = loadInstrument;
window.deleteLibraryItem = deleteLibraryItem;
async function handleAISend() {
if (!aiPromptInput || !aiHistoryDiv) return;
const prompt = aiPromptInput.value.trim();
if (!prompt) return;
// Add user message to chat
addMessageToChat('user', prompt);
aiPromptInput.value = '';
// Disable input during request
aiPromptInput.disabled = true;
// Get current code from editor
let currentCode = '';
if (strudelEditorElement?.editor) {
currentCode = strudelEditorElement.editor.code || '';
}
// Send to AI (include MIDI context)
const result = await sendMessage(
prompt,
currentCode,
strudelEditorElement?.editor,
(responseText) => addMessageToChat('assistant', responseText),
(errorText) => addMessageToChat('error', errorText),
buildMidiContext()
);
// Re-enable input
aiPromptInput.disabled = false;
aiPromptInput.focus();
}
function addMessageToChat(role, content) {
if (!aiHistoryDiv) return;
const messageDiv = document.createElement('div');
messageDiv.className = `ai-message ai-message-${role}`;
messageDiv.textContent = content;
aiHistoryDiv.appendChild(messageDiv);
// Scroll to bottom
aiHistoryDiv.scrollTop = aiHistoryDiv.scrollHeight;
}
function handlePlay() {
try {
console.log('[Strudel] Play button clicked');
if (strudelEditorElement) {
// Access the editor instance from the strudel-editor element
const editor = strudelEditorElement.editor;
if (editor) {
console.log('[Strudel] Found editor instance, calling evaluate()');
// Call the evaluate method directly
editor.evaluate();
} else {
// Fallback: dispatch repl-evaluate custom event
const editorContainer = strudelEditorElement.nextElementSibling;
const cmEditor = editorContainer?.querySelector('.cm-editor');
if (cmEditor) {
console.log('[Strudel] Dispatching repl-evaluate event');
const event = new CustomEvent('repl-evaluate', {
detail: { view: cmEditor },
bubbles: true,
cancelable: true
});
document.dispatchEvent(event);
}
}
isPlaying = true;
updateStatus('playing');
updateButtonStates(true);
}
} catch (error) {
console.error('[Strudel] Error playing:', error);
showError('Error: ' + error.message);
}
}
function handleStop() {
try {
console.log('[Strudel] Stop button clicked');
if (strudelEditorElement) {
// Access the editor instance from the strudel-editor element
const editor = strudelEditorElement.editor;
if (editor && editor.stop) {
console.log('[Strudel] Found editor instance, calling stop()');
editor.stop();
} else if (typeof hush === 'function') {
// Fallback to global hush()
hush();
console.log('[Strudel] Called global hush()');
}
}
isPlaying = false;
updateStatus('stopped');
updateButtonStates(false);
} catch (error) {
console.error('[Strudel] Error stopping:', error);
showError('Error stopping: ' + error.message);
}
}
function handleReload() {
// Clear any previous error messages
lastErrorMessage = null;
clearErrorHighlights();
// Stop current playback
handleStop();
// Re-trigger play after a short delay
setTimeout(() => {
handlePlay();
// Explicitly update button to ensure it shows "Stop" when playing
isPlaying = true;
updateStatus('playing');
updateButtonStates(true);
}, 200);
console.log('[Strudel] Reloading current code');
}
/* ================================================================
INITIALIZATION
================================================================
Main initialization sequence:
1. Initialize hamburger menu
2. Set up hamburger click handler
3. Initialize NDK (handles authentication automatically)
4. Get authenticated pubkey
5. Set up relay activity listeners
6. Set up version bar button listeners
7. Initialize Strudel components
8. Start update loop
The initNDKPage() function:
- Checks if already authenticated (via nostr-login-lite)
- If not authenticated, shows login modal
- Connects to NDK SharedWorker
- Returns when authentication is complete
Authentication persists across pages via nostr-login-lite's
localStorage, so users only need to login once.
================================================================ */
(async function main() {
console.log("[template.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;
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
document.documentElement.classList.toggle('dark-mode', isDarkMode);
document.body.classList.toggle('dark-mode', isDarkMode);
syncStrudelTheme(isDarkMode);
if (themeToggleHamburger) {
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
}
});
}
if (logoutButton) {
logoutButton.addEventListener('click', async () => {
try {
await Logout();
} catch (error) {
console.error('Logout failed:', error);
}
});
}
// Initialize NDK (handles authentication automatically)
await initNDKPage();
// Get authenticated pubkey
currentPubkey = await getPubkey();
await injectHeaderAvatar(currentPubkey);
console.log("[template.html] Authenticated as:", currentPubkey);
// Initialize relay UI components
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
initAiSectionWithLocalConfig();
await UpdateFooter(); // Initial update before interval starts
// Listen for relay activity broadcasts from worker
window.addEventListener('ndkRelayActivity', (event) => {
const { relayUrl, activity, stats } = event.detail;
console.log(`[template.html] Relay activity: ${relayUrl} - ${activity}`, stats);
setRelayActivityState(relayUrl, activity);
});
// Listen for relay activity broadcasts from worker (alternative message format)
window.addEventListener('message', (event) => {
if (event.data && event.data.type === 'relayActivity') {
const { relayUrl, activity } = event.data;
console.log(`[template.html] Relay activity: ${relayUrl} - ${activity}`);
setRelayActivityState(relayUrl, activity);
}
});
/* ============================================================
EXAMPLE: Subscribe to events
============================================================
Uncomment to subscribe to user's notes:
const notesSub = subscribe(
{ kinds: [1], authors: [currentPubkey], limit: 10 },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
console.log("[template.html] Subscribed to kind 1");
============================================================ */
/* ============================================================
EXAMPLE: Listen for events from worker
============================================================
Uncomment to handle incoming events:
window.addEventListener('ndkEvent', (event) => {
const evt = event.detail;
console.log("[template.html] Received event:", evt.kind, evt.pubkey);
if (evt.pubkey === currentPubkey) {
if (evt.kind === 1) {
// Handle note event
console.log("Note:", evt.content);
}
}
});
============================================================ */
/* ============================================================
EXAMPLE: Listen for cached profile
============================================================
Uncomment to handle cached profile data:
window.addEventListener('ndkProfile', (event) => {
console.log("[template.html] Cached profile:", event.detail);
// event.detail contains profile object (name, about, etc.)
});
============================================================ */
// Start update loop (updates footer every second)
updateIntervalId = setInterval(UpdateFooter, 1000);
// Update version display
await updateVersionDisplay();
// Initialize Strudel components
console.log('[Strudel] Initializing Strudel components...');
// Get reference to strudel-editor element
strudelEditorElement = document.getElementById('strudelEditor');
console.log('[Strudel] strudelEditorElement:', strudelEditorElement);
// Wait for component to fully render
await new Promise(resolve => setTimeout(resolve, 1000));
// The strudel-editor component creates a sibling div for the actual editor
const editorContainer = strudelEditorElement.nextElementSibling;
console.log('[Strudel] Editor container (sibling):', editorContainer);
// Check if editor has rendered in the sibling container
const editorContent = editorContainer?.querySelector('.cm-editor') ||
editorContainer?.querySelector('.cm-content') ||
editorContainer?.querySelector('textarea');
console.log('[Strudel] Editor content element:', editorContent);
console.log('[Strudel] strudelEditorElement.innerHTML length:', strudelEditorElement.innerHTML.length);
console.log('[Strudel] strudelEditorElement children:', strudelEditorElement.children.length);
if (editorContainer) {
console.log('[Strudel] editorContainer children:', editorContainer.children.length);
}
// Setup button handlers
document.getElementById('strudelToggleButton').addEventListener('click', handleToggle);
document.getElementById('strudelReloadButton').addEventListener('click', handleReload);
// Add keyboard shortcuts for play/stop (Ctrl+Enter to reload/play, Ctrl+. to stop)
document.addEventListener('keydown', (e) => {
// Ctrl+Enter to reload and play (always re-evaluates code)
if (e.ctrlKey && e.key === 'Enter') {
e.preventDefault();
handleReload();
}
// Ctrl+. to stop
if (e.ctrlKey && e.key === '.') {
e.preventDefault();
handleStop();
}
});
// Listen for play/stop events from the strudel-editor
window.addEventListener('strudel:start', () => {
isPlaying = true;
updateStatus('playing');
updateButtonStates(true);
// Clear error message when code starts playing successfully
lastErrorMessage = null;
clearErrorHighlights();
});
window.addEventListener('strudel:stop', () => {
isPlaying = false;
updateStatus('stopped');
updateButtonStates(false);
});
// Listen for Strudel log events (errors, warnings, etc.)
document.addEventListener('strudel.log', (e) => {
const { message, type } = e.detail;
console.log('[Strudel Log]', message, type);
// Check if this is an error message
if (message && message.includes('error:')) {
console.log('[Strudel Log] Found error message, extracting line number...');
// Store the error message for display in footer
lastErrorMessage = message;
// Try multiple patterns to find line numbers:
// 1. (line:col) - syntax errors like "Unexpected token (9:14)"
// 2. line X - explicit line mentions
// 3. <anonymous>:line:col - runtime errors in eval
const patterns = [
/\((\d+):\d+\)/, // (9:14)
/line\s*(\d+)/i, // line 9
/<anonymous>:(\d+):\d+/ // <anonymous>:9:14
];
for (const pattern of patterns) {
const lineMatch = message.match(pattern);
if (lineMatch) {
const lineNum = parseInt(lineMatch[1], 10);
console.log(`[Strudel Log] Pattern ${pattern} matched, highlighting line ${lineNum}`);
highlightErrorLine(lineNum);
break;
}
}
}
});
// Also listen for successful eval to clear error highlights and footer message
window.addEventListener('strudel:eval', () => {
clearErrorHighlights();
lastErrorMessage = null;
});
// Hook into console.error to catch runtime errors with stack traces
const originalConsoleError = console.error;
console.error = function(...args) {
originalConsoleError.apply(console, args);
console.log('[Strudel Error Hook] Caught console.error:', args);
// Check if any argument contains a stack trace with line numbers
args.forEach((arg, i) => {
const str = arg?.toString?.() || '';
console.log(`[Strudel Error Hook] Arg ${i}:`, str.substring(0, 200));
// Try multiple patterns to find line numbers:
// 1. <anonymous>:line:col - runtime errors in eval
// 2. (line:col) - syntax errors like "Unexpected token (9:14)"
// 3. line X - explicit line mentions
const patterns = [
/<anonymous>:(\d+):\d+/g,
/\((\d+):\d+\)/g,
/line\s*(\d+)/gi
];
for (const pattern of patterns) {
const matches = str.matchAll(pattern);
const matchesArray = Array.from(matches);
if (matchesArray.length > 0) {
console.log(`[Strudel Error Hook] Pattern ${pattern} found ${matchesArray.length} matches`);
for (const match of matchesArray) {
const lineNum = parseInt(match[1], 10);
console.log(`[Strudel Error Hook] Highlighting line ${lineNum}`);
if (lineNum > 0) {
highlightErrorLine(lineNum);
}
}
}
}
});
};
// Sync Strudel theme with current day/night mode
syncStrudelTheme(isDarkMode);
// Initialize AI settings
initializeAISettings();
// Initialize MIDI section
initializeMIDISection();
// Initialize Songs/Instruments section
initializeSongsInstrumentsSection();
console.log('[Strudel] Initialization complete');
} catch (error) {
console.error('[template.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. Each page can be distributed independently
- Copy template.html and customize
- No dependencies on other pages
- Works standalone or as part of suite
5. 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
6. 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>