Files
client/build/music-inline-check.mjs
2026-04-17 16:52:51 -04:00

1576 lines
59 KiB
JavaScript

/* ================================================================
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,
subscribe,
publishEvent,
disconnect,
getVersion,
updateVersionDisplay,
getUserSettings,
patchUserSettings,
onUserSettings
} from './js/init-ndk.mjs';
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
import { GreyscaleAPI } from './js/greyscale-api.mjs';
import { SimplePlayer } from './js/greyscale-player.mjs';
import {
PLAYLIST_KIND,
buildPlaylistEvent,
createPlaylistIdentifier,
parsePlaylistEvent,
isMusicPlaylistEvent,
} from './js/greyscale-playlists.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;
// 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 musicEls = {
form: document.getElementById('musicSearchForm'),
input: document.getElementById('musicSearchInput'),
button: document.getElementById('musicSearchButton'),
status: document.getElementById('musicStatusText'),
results: document.getElementById('musicResultsList'),
audio: document.getElementById('musicAudio'),
playPauseBtn: document.getElementById('musicPlayPauseBtn'),
prevBtn: document.getElementById('musicPrevBtn'),
nextBtn: document.getElementById('musicNextBtn'),
playerCover: document.getElementById('musicPlayerCover'),
playerTitle: document.getElementById('musicPlayerTitle'),
playerArtist: document.getElementById('musicPlayerArtist'),
currentTime: document.getElementById('musicCurrentTime'),
duration: document.getElementById('musicDuration'),
progress: document.getElementById('musicProgress'),
playlistNameInput: document.getElementById('musicPlaylistNameInput'),
playlistNewBtn: document.getElementById('musicPlaylistNewBtn'),
playlistSaveBtn: document.getElementById('musicPlaylistSaveBtn'),
playlistPublishBtn: document.getElementById('musicPlaylistPublishBtn'),
playlistRefreshBtn: document.getElementById('musicPlaylistRefreshBtn'),
myPlaylistsList: document.getElementById('musicMyPlaylistsList'),
friendPlaylistsList: document.getElementById('musicFriendPlaylistsList'),
queuePlayNowBtn: document.getElementById('musicQueuePlayNowBtn'),
queueClearBtn: document.getElementById('musicQueueClearBtn'),
queueList: document.getElementById('musicQueueList'),
};
const musicApi = new GreyscaleAPI();
const musicPlayer = new SimplePlayer({
audio: musicEls.audio,
progressEl: musicEls.progress,
currentTimeEl: musicEls.currentTime,
durationEl: musicEls.duration,
});
let musicTracks = [];
let selectedPlaylistId = '';
let followsSet = new Set();
let didSubscribeFriendPlaylists = false;
const myPlaylists = new Map();
const friendPlaylists = new Map();
let musicQueue = [];
let queueCurrentIndex = -1;
let musicResultsMode = 'search';
let musicResultsPlaylistId = '';
let draggedPlaylistTrackIndex = -1;
let dropPlaylistTrackIndex = -1;
let dropPlaylistTrackPosition = 'before';
let draggedQueueIndex = -1;
let dropQueueIndex = -1;
let dropQueuePosition = 'before';
function formatMusicDuration(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, '0')}`;
}
function escapeMusicHtml(value) {
return String(value)
.replace(/&/g, '\u0026amp;')
.replace(/</g, '\u0026lt;')
.replace(/>/g, '\u0026gt;')
.replace(/"/g, '\u0026quot;')
.replace(/\u0027/g, '\u0026#39;');
}
function setMusicStatus(text, type = 'neutral') {
musicEls.status.textContent = text;
musicEls.status.dataset.type = type;
}
function setMusicSearchLoading(loading) {
musicEls.button.disabled = loading;
musicEls.input.disabled = loading;
musicEls.button.textContent = loading ? 'Searching...' : 'Search';
}
function setMusicPlayButtonState() {
musicEls.playPauseBtn.textContent = musicEls.audio.paused ? '▶' : '❚❚';
}
function updateMusicPlayerMeta(track) {
if (!track) {
musicEls.playerTitle.textContent = '';
musicEls.playerArtist.textContent = '';
musicEls.playerCover.removeAttribute('src');
musicEls.playerCover.style.display = 'none';
return;
}
musicEls.playerTitle.textContent = track.title || '';
musicEls.playerArtist.textContent = track.artist || '';
const cover = musicApi.getCoverUrl(track.cover, 320);
if (cover) {
musicEls.playerCover.src = cover;
musicEls.playerCover.style.display = 'block';
} else {
musicEls.playerCover.removeAttribute('src');
musicEls.playerCover.style.display = 'none';
}
}
function getPlaylistStorageKey() {
return `music-playlists:${currentPubkey || 'anon'}`;
}
function getQueueStorageKey() {
return `music-queue:${currentPubkey || 'anon'}`;
}
function normalizeQueueTrack(track) {
if (!track || typeof track !== 'object') return null;
return {
id: track.id,
title: track.title || 'Unknown title',
artist: track.artist || 'Unknown artist',
duration: track.duration || 0,
cover: track.cover || '',
albumTitle: track.albumTitle || '',
};
}
function getCurrentPlaylist() {
if (!selectedPlaylistId) return null;
return myPlaylists.get(selectedPlaylistId) || null;
}
function savePlaylistsLocal() {
try {
const payload = Array.from(myPlaylists.values()).map((playlist) => ({
identifier: playlist.identifier,
title: playlist.title,
description: playlist.description || '',
image: playlist.image || '',
tracks: Array.isArray(playlist.tracks) ? playlist.tracks : [],
}));
localStorage.setItem(getPlaylistStorageKey(), JSON.stringify(payload));
} catch (error) {
console.warn('[music] Could not save playlists locally:', error);
}
}
function loadPlaylistsLocal() {
try {
const raw = localStorage.getItem(getPlaylistStorageKey());
if (!raw) return;
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return;
for (const playlist of parsed) {
if (!playlist?.identifier) continue;
myPlaylists.set(playlist.identifier, {
identifier: playlist.identifier,
title: playlist.title || 'Untitled playlist',
description: playlist.description || '',
image: playlist.image || '',
tracks: Array.isArray(playlist.tracks) ? playlist.tracks : [],
pubkey: currentPubkey || '',
eventId: playlist.eventId || '',
});
}
} catch (error) {
console.warn('[music] Could not load playlists locally:', error);
}
}
function saveQueueLocal() {
try {
const payload = {
currentIndex: queueCurrentIndex,
items: musicQueue.map((track) => normalizeQueueTrack(track)).filter(Boolean),
};
localStorage.setItem(getQueueStorageKey(), JSON.stringify(payload));
} catch (error) {
console.warn('[music] Could not save queue locally:', error);
}
}
function loadQueueLocal() {
try {
const raw = localStorage.getItem(getQueueStorageKey());
if (!raw) return;
const parsed = JSON.parse(raw);
const items = Array.isArray(parsed?.items) ? parsed.items : [];
musicQueue = items.map((track) => normalizeQueueTrack(track)).filter(Boolean);
queueCurrentIndex = Number.isInteger(parsed?.currentIndex) ? parsed.currentIndex : -1;
if (queueCurrentIndex >= musicQueue.length) queueCurrentIndex = musicQueue.length - 1;
} catch (error) {
console.warn('[music] Could not load queue locally:', error);
}
}
function renderMyPlaylists() {
const items = Array.from(myPlaylists.values()).sort((a, b) => (a.title || '').localeCompare(b.title || ''));
if (!items.length) {
musicEls.myPlaylistsList.innerHTML = '<li class="musicPlaylistMeta">No playlists yet.</li>';
return;
}
musicEls.myPlaylistsList.innerHTML = items
.map((playlist) => `
<li class="musicPlaylistItem ${playlist.identifier === selectedPlaylistId ? 'active' : ''}" data-playlist-id="${escapeMusicHtml(playlist.identifier)}">
<div class="musicPlaylistPrimary">
<span>${escapeMusicHtml(playlist.title || 'Untitled playlist')}</span>
<span class="musicPlaylistMeta">${(playlist.tracks || []).length}</span>
</div>
<div class="musicPlaylistItemActions">
<button class="btn musicMiniBtn" type="button" data-play-playlist-id="${escapeMusicHtml(playlist.identifier)}">▶</button>
<button class="btn musicMiniBtn" type="button" data-queue-playlist-id="${escapeMusicHtml(playlist.identifier)}">+Q</button>
</div>
</li>
`)
.join('');
}
function renderFriendPlaylists() {
const items = Array.from(friendPlaylists.values()).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
if (!items.length) {
musicEls.friendPlaylistsList.innerHTML = '<li class="musicPlaylistMeta">No friend playlists found.</li>';
return;
}
musicEls.friendPlaylistsList.innerHTML = items
.map((playlist) => {
const label = playlist.title || 'Untitled playlist';
const shortPubkey = playlist.pubkey ? `${playlist.pubkey.slice(0, 8)}` : 'unknown';
return `
<li class="musicPlaylistItem" data-friend-playlist-key="${escapeMusicHtml(playlist.eventId || `${playlist.pubkey}:${playlist.identifier}`)}">
<div class="musicPlaylistPrimary">
<span>${escapeMusicHtml(label)}</span>
<span class="musicPlaylistMeta">${escapeMusicHtml(shortPubkey)}</span>
</div>
<div class="musicPlaylistItemActions">
<button class="btn musicMiniBtn" type="button" data-play-friend-playlist-key="${escapeMusicHtml(playlist.eventId || `${playlist.pubkey}:${playlist.identifier}`)}">▶</button>
<button class="btn musicMiniBtn" type="button" data-queue-friend-playlist-key="${escapeMusicHtml(playlist.eventId || `${playlist.pubkey}:${playlist.identifier}`)}">+Q</button>
</div>
</li>
`;
})
.join('');
}
function selectMyPlaylist(identifier) {
const playlist = myPlaylists.get(identifier);
if (!playlist) return;
selectedPlaylistId = identifier;
musicEls.playlistNameInput.value = playlist.title || '';
renderMyPlaylists();
setMusicStatus(`Selected playlist: ${playlist.title || 'Untitled playlist'}`, 'ok');
}
function createNewPlaylist() {
const proposedName = musicEls.playlistNameInput.value.trim() || `Playlist ${myPlaylists.size + 1}`;
const identifier = createPlaylistIdentifier(proposedName);
const playlist = {
identifier,
title: proposedName,
description: '',
image: '',
tracks: [],
pubkey: currentPubkey || '',
eventId: '',
};
myPlaylists.set(identifier, playlist);
selectedPlaylistId = identifier;
savePlaylistsLocal();
renderMyPlaylists();
setMusicStatus(`Created playlist: ${proposedName}`, 'ok');
}
function addTrackToCurrentPlaylist(track) {
const playlist = getCurrentPlaylist();
if (!playlist) {
setMusicStatus('Create or select a playlist first.', 'error');
return;
}
const exists = (playlist.tracks || []).some((item) => Number(item?.id) === Number(track?.id));
if (exists) {
setMusicStatus('Track already in playlist.', 'neutral');
return;
}
playlist.tracks.push({
id: track.id,
title: track.title || '',
artist: track.artist || '',
duration: track.duration || 0,
cover: track.cover || '',
albumTitle: track.albumTitle || '',
});
if (!playlist.image && track.cover) {
playlist.image = musicApi.getCoverUrl(track.cover, 320);
}
myPlaylists.set(playlist.identifier, playlist);
savePlaylistsLocal();
renderMyPlaylists();
setMusicStatus(`Added to ${playlist.title}: ${track.title}`, 'ok');
}
async function saveCurrentPlaylist() {
const playlist = getCurrentPlaylist();
if (!playlist) {
setMusicStatus('No playlist selected.', 'error');
return;
}
const renamedTitle = musicEls.playlistNameInput.value.trim();
if (renamedTitle) playlist.title = renamedTitle;
myPlaylists.set(playlist.identifier, playlist);
savePlaylistsLocal();
renderMyPlaylists();
setMusicStatus(`Saved playlist locally: ${playlist.title}`, 'ok');
}
function setResultsContext(mode, playlistId = '') {
musicResultsMode = mode;
musicResultsPlaylistId = playlistId;
clearPlaylistDropIndicator();
}
async function publishCurrentPlaylist() {
const playlist = getCurrentPlaylist();
if (!playlist) {
setMusicStatus('No playlist selected.', 'error');
return;
}
if (!playlist.tracks?.length) {
setMusicStatus('Playlist is empty.', 'error');
return;
}
try {
const event = buildPlaylistEvent(playlist);
const result = await publishEvent(event);
const publishedId = result?.eventId || result?.id || '';
if (publishedId) playlist.eventId = publishedId;
myPlaylists.set(playlist.identifier, playlist);
savePlaylistsLocal();
renderMyPlaylists();
setMusicStatus(`Published playlist: ${playlist.title}`, 'ok');
} catch (error) {
console.error(error);
setMusicStatus(`Publish failed: ${error.message || 'Unknown error'}`, 'error');
}
}
function hydratePlaylistFromEvent(event, isFriend = false) {
const parsed = parsePlaylistEvent(event);
if (!parsed || !isMusicPlaylistEvent(event)) return;
if (isFriend) {
const key = parsed.eventId || `${parsed.pubkey}:${parsed.identifier}`;
friendPlaylists.set(key, parsed);
renderFriendPlaylists();
} else {
const existing = myPlaylists.get(parsed.identifier);
myPlaylists.set(parsed.identifier, {
identifier: parsed.identifier,
title: parsed.title,
description: parsed.description,
image: parsed.image,
tracks: parsed.tracks,
pubkey: parsed.pubkey,
eventId: parsed.eventId,
createdAt: parsed.createdAt,
raw: parsed.raw,
...(existing || {}),
});
if (!selectedPlaylistId) selectedPlaylistId = parsed.identifier;
savePlaylistsLocal();
renderMyPlaylists();
}
}
function subscribeOwnPlaylists() {
if (!currentPubkey) return;
subscribe(
{ kinds: [PLAYLIST_KIND], authors: [currentPubkey], '#t': ['music'], limit: 200 },
{ closeOnEose: true, cacheUsage: 'CACHE_FIRST' }
);
}
function subscribeFollowList() {
if (!currentPubkey) return;
subscribe(
{ kinds: [3], authors: [currentPubkey], limit: 1 },
{ closeOnEose: true, cacheUsage: 'CACHE_FIRST' }
);
}
function subscribeFriendPlaylists() {
if (didSubscribeFriendPlaylists || followsSet.size === 0) return;
didSubscribeFriendPlaylists = true;
subscribe(
{ kinds: [PLAYLIST_KIND], authors: Array.from(followsSet), '#t': ['music'], limit: 400 },
{ closeOnEose: true, cacheUsage: 'CACHE_FIRST' }
);
}
function handleMusicNdkEvent(event) {
const evt = event?.detail;
if (!evt || typeof evt !== 'object') return;
if (evt.kind === 3 && evt.pubkey === currentPubkey && Array.isArray(evt.tags)) {
const follows = evt.tags
.filter((tag) => Array.isArray(tag) && tag[0] === 'p' && typeof tag[1] === 'string' && tag[1].length === 64)
.map((tag) => tag[1]);
followsSet = new Set(follows);
subscribeFriendPlaylists();
return;
}
if (evt.kind !== PLAYLIST_KIND || !isMusicPlaylistEvent(evt)) return;
if (evt.pubkey === currentPubkey) {
hydratePlaylistFromEvent(evt, false);
return;
}
if (followsSet.has(evt.pubkey)) {
hydratePlaylistFromEvent(evt, true);
}
}
function clearQueueDropIndicator() {
if (!musicEls.queueList) return;
musicEls.queueList.querySelectorAll('.musicQueueItem.dropBefore, .musicQueueItem.dropAfter, .musicQueueItem.dragging').forEach((el) => {
el.classList.remove('dropBefore', 'dropAfter', 'dragging');
});
}
function renderQueue() {
if (!musicEls.queueList) return;
if (!musicQueue.length) {
musicEls.queueList.innerHTML = '<li class="musicPlaylistMeta">Queue is empty.</li>';
return;
}
musicEls.queueList.innerHTML = musicQueue
.map((track, index) => `
<li class="musicQueueItem isReorderable ${index === queueCurrentIndex ? 'active' : ''}" data-queue-index="${index}" draggable="true">
<div class="musicQueueMeta">
<div class="musicQueueTitle">${escapeMusicHtml(track.title || 'Unknown title')}</div>
<div class="musicQueueSubtitle">${escapeMusicHtml(track.artist || 'Unknown artist')}</div>
</div>
<button class="btn musicMiniBtn" type="button" data-remove-queue-index="${index}">✕</button>
</li>
`)
.join('');
}
function syncQueueIndexFromPlayer(track = null) {
const activeTrack = track || musicPlayer.getCurrentTrack();
if (!activeTrack || !musicQueue.length) {
if (queueCurrentIndex >= musicQueue.length) queueCurrentIndex = musicQueue.length - 1;
return;
}
const idx = musicQueue.findIndex((item) => (
Number(item?.id) === Number(activeTrack?.id)
|| (
String(item?.title || '') === String(activeTrack?.title || '')
&& String(item?.artist || '') === String(activeTrack?.artist || '')
)
));
if (idx >= 0) queueCurrentIndex = idx;
}
async function playQueueAt(index) {
if (!Number.isInteger(index) || index < 0 || index >= musicQueue.length) return;
queueCurrentIndex = index;
musicPlayer.setQueue(musicQueue, index);
try {
setMusicStatus(`Loading queue item: ${musicQueue[index].title}`, 'neutral');
await musicPlayer.playCurrent(resolveMusicTrack);
syncQueueIndexFromPlayer();
renderQueue();
saveQueueLocal();
setMusicStatus(`Playing queue: ${musicQueue[index].title}`, 'ok');
} catch (error) {
console.error(error);
setMusicStatus(`Queue playback failed: ${error.message || 'Unknown error'}`, 'error');
}
}
function addTracksToQueue(tracks, { playNow = false, replace = false } = {}) {
const incoming = (Array.isArray(tracks) ? tracks : [])
.map((track) => normalizeQueueTrack(track))
.filter(Boolean);
if (!incoming.length) {
setMusicStatus('No playable tracks to queue.', 'error');
return;
}
if (replace) {
musicQueue = [];
queueCurrentIndex = -1;
}
const startIndex = musicQueue.length;
musicQueue.push(...incoming);
renderQueue();
saveQueueLocal();
if (playNow) {
void playQueueAt(startIndex);
} else {
setMusicStatus(`Queued ${incoming.length} track${incoming.length === 1 ? '' : 's'}.`, 'ok');
}
}
function addTrackToQueue(track, opts = {}) {
const item = normalizeQueueTrack(track);
if (!item) {
setMusicStatus('Invalid track for queue.', 'error');
return;
}
addTracksToQueue([item], opts);
}
function queuePlaylist(playlist, { playNow = false } = {}) {
const tracks = Array.isArray(playlist?.tracks) ? playlist.tracks : [];
if (!tracks.length) {
setMusicStatus('Playlist has no tracks.', 'error');
return;
}
addTracksToQueue(tracks, { playNow, replace: playNow });
if (!playNow) {
setMusicStatus(`Added playlist to queue: ${playlist.title || 'Untitled playlist'}`, 'ok');
}
}
function queueAlbumFromTrack(track, { playNow = false } = {}) {
const albumTitle = String(track?.albumTitle || '').trim();
if (!albumTitle) {
setMusicStatus('No album information for this track.', 'error');
return;
}
const tracks = musicTracks.filter((item) => String(item?.albumTitle || '').trim() === albumTitle);
if (!tracks.length) {
setMusicStatus('No album tracks available in current results.', 'error');
return;
}
addTracksToQueue(tracks, { playNow, replace: playNow });
if (!playNow) {
setMusicStatus(`Added album to queue: ${albumTitle}`, 'ok');
}
}
function removeQueueIndex(index) {
if (!Number.isInteger(index) || index < 0 || index >= musicQueue.length) return;
musicQueue.splice(index, 1);
if (!musicQueue.length) {
queueCurrentIndex = -1;
} else if (queueCurrentIndex > index) {
queueCurrentIndex -= 1;
} else if (queueCurrentIndex >= musicQueue.length) {
queueCurrentIndex = musicQueue.length - 1;
}
renderQueue();
saveQueueLocal();
setMusicStatus('Removed item from queue.', 'neutral');
}
function applyQueueReorder() {
if (!Number.isInteger(draggedQueueIndex) || draggedQueueIndex < 0) return;
if (!Number.isInteger(dropQueueIndex) || dropQueueIndex < 0) return;
if (draggedQueueIndex >= musicQueue.length || dropQueueIndex >= musicQueue.length) return;
let insertIndex = dropQueuePosition === 'after' ? dropQueueIndex + 1 : dropQueueIndex;
if (insertIndex > draggedQueueIndex) insertIndex -= 1;
if (insertIndex === draggedQueueIndex) return;
const movedTrack = musicQueue[draggedQueueIndex];
musicQueue.splice(draggedQueueIndex, 1);
musicQueue.splice(insertIndex, 0, movedTrack);
if (queueCurrentIndex === draggedQueueIndex) {
queueCurrentIndex = insertIndex;
} else if (draggedQueueIndex < queueCurrentIndex && insertIndex >= queueCurrentIndex) {
queueCurrentIndex -= 1;
} else if (draggedQueueIndex > queueCurrentIndex && insertIndex <= queueCurrentIndex) {
queueCurrentIndex += 1;
}
renderQueue();
saveQueueLocal();
setMusicStatus('Queue order updated.', 'ok');
}
function clearQueue() {
musicQueue = [];
queueCurrentIndex = -1;
draggedQueueIndex = -1;
dropQueueIndex = -1;
dropQueuePosition = 'before';
renderQueue();
saveQueueLocal();
setMusicStatus('Queue cleared.', 'neutral');
}
function clearPlaylistDropIndicator() {
if (!musicEls.results) return;
musicEls.results.querySelectorAll('.musicResultItem.dropBefore, .musicResultItem.dropAfter, .musicResultItem.dragging').forEach((el) => {
el.classList.remove('dropBefore', 'dropAfter', 'dragging');
});
}
function isPlaylistReorderActive() {
return musicResultsMode === 'my-playlist' && !!musicResultsPlaylistId && musicResultsPlaylistId === selectedPlaylistId;
}
function renderMusicResults(items) {
if (!items.length) {
musicEls.results.innerHTML = '<li class="musicResultEmpty">No tracks found.</li>';
return;
}
const allowReorder = isPlaylistReorderActive();
musicEls.results.innerHTML = items
.map(
(track, index) => `
<li class="musicResultItem ${allowReorder ? 'isReorderable' : ''}" data-index="${index}" draggable="${allowReorder ? 'true' : 'false'}">
<img class="musicResultCover" src="${escapeMusicHtml(musicApi.getCoverUrl(track.cover, 160))}" alt="" loading="lazy" />
<div class="musicResultMeta">
<div class="musicResultTitle">${escapeMusicHtml(track.title)}</div>
<div class="musicResultSubtitle">${escapeMusicHtml(track.artist)}${track.albumTitle ? `${escapeMusicHtml(track.albumTitle)}` : ''}</div>
</div>
<div class="musicResultDuration">${formatMusicDuration(track.duration)}</div>
<div class="musicResultActions">
<button class="btn musicAddBtn" type="button" data-add-index="${index}" title="Add to selected playlist">+P</button>
<button class="btn musicAddBtn" type="button" data-queue-index="${index}" title="Add to queue">+Q</button>
<button class="btn musicAddBtn" type="button" data-queue-album-index="${index}" title="Add album to queue">+A</button>
</div>
</li>
`
)
.join('');
}
function applyPlaylistTrackReorder() {
if (!isPlaylistReorderActive()) return;
if (!Number.isInteger(draggedPlaylistTrackIndex) || draggedPlaylistTrackIndex < 0) return;
if (!Number.isInteger(dropPlaylistTrackIndex) || dropPlaylistTrackIndex < 0) return;
const playlist = getCurrentPlaylist();
if (!playlist || !Array.isArray(playlist.tracks) || !playlist.tracks.length) return;
let insertIndex = dropPlaylistTrackPosition === 'after' ? dropPlaylistTrackIndex + 1 : dropPlaylistTrackIndex;
if (insertIndex > draggedPlaylistTrackIndex) insertIndex -= 1;
if (insertIndex === draggedPlaylistTrackIndex) return;
const tracks = [...playlist.tracks];
const [moved] = tracks.splice(draggedPlaylistTrackIndex, 1);
tracks.splice(insertIndex, 0, moved);
playlist.tracks = tracks;
myPlaylists.set(playlist.identifier, playlist);
savePlaylistsLocal();
renderMyPlaylists();
musicTracks = [...tracks];
setResultsContext('my-playlist', playlist.identifier);
renderMusicResults(musicTracks);
setMusicStatus('Playlist order updated.', 'ok');
}
async function resolveMusicTrack(track) {
return musicApi.getTrackStream(track.id, 'HI_RES_LOSSLESS');
}
async function playMusicByIndex(index) {
if (!Number.isInteger(index) || index < 0 || index >= musicTracks.length) return;
musicPlayer.setQueue(musicTracks, index);
try {
setMusicStatus(`Loading: ${musicTracks[index].title}`, 'neutral');
await musicPlayer.playCurrent(resolveMusicTrack);
setMusicStatus(`Playing: ${musicTracks[index].title}`, 'ok');
} catch (error) {
console.error(error);
setMusicStatus(`Playback failed: ${error.message || 'Unknown error'}`, 'error');
}
}
async function runMusicSearch(query) {
const trimmed = query.trim();
if (!trimmed) return;
setMusicSearchLoading(true);
setMusicStatus(`Searching for "${trimmed}"...`, 'neutral');
try {
musicTracks = await musicApi.searchTracks(trimmed);
setResultsContext('search');
renderMusicResults(musicTracks);
setMusicStatus(`Found ${musicTracks.length} track${musicTracks.length === 1 ? '' : 's'}.`, 'ok');
} catch (error) {
console.error(error);
musicTracks = [];
setResultsContext('search');
renderMusicResults([]);
setMusicStatus(`Search failed: ${error.message || 'Unknown error'}`, 'error');
} finally {
setMusicSearchLoading(false);
}
}
async function initMusicFeature() {
if (!musicEls.form || !musicEls.audio) return;
setMusicStatus('Loading API instances...', 'neutral');
try {
await musicApi.initInstances();
setMusicStatus('Ready.', 'ok');
} catch (error) {
console.error(error);
setMusicStatus('Instance loading failed, using fallback list.', 'error');
}
musicPlayer.onTrackChanged = (track) => {
updateMusicPlayerMeta(track);
};
musicPlayer.setResolver(resolveMusicTrack);
musicEls.form.addEventListener('submit', async (event) => {
event.preventDefault();
await runMusicSearch(musicEls.input.value);
});
musicEls.results.addEventListener('click', async (event) => {
const addBtn = event.target.closest('[data-add-index]');
if (addBtn) {
const addIndex = Number(addBtn.dataset.addIndex);
const track = musicTracks[addIndex];
if (track) addTrackToCurrentPlaylist(track);
return;
}
const queueBtn = event.target.closest('[data-queue-index]');
if (queueBtn) {
const queueIndex = Number(queueBtn.dataset.queueIndex);
const track = musicTracks[queueIndex];
if (track) addTrackToQueue(track);
return;
}
const albumBtn = event.target.closest('[data-queue-album-index]');
if (albumBtn) {
const albumIndex = Number(albumBtn.dataset.queueAlbumIndex);
const track = musicTracks[albumIndex];
if (track) queueAlbumFromTrack(track);
return;
}
const item = event.target.closest('.musicResultItem');
if (!item) return;
const index = Number(item.dataset.index);
await playMusicByIndex(index);
});
musicEls.results.addEventListener('dragstart', (event) => {
const item = event.target.closest('.musicResultItem[draggable="true"]');
if (!item || !isPlaylistReorderActive()) return;
draggedPlaylistTrackIndex = Number(item.dataset.index);
if (!Number.isInteger(draggedPlaylistTrackIndex) || draggedPlaylistTrackIndex < 0) {
draggedPlaylistTrackIndex = -1;
return;
}
event.dataTransfer.effectAllowed = 'move';
item.classList.add('dragging');
});
musicEls.results.addEventListener('dragover', (event) => {
if (!isPlaylistReorderActive() || draggedPlaylistTrackIndex < 0) return;
const item = event.target.closest('.musicResultItem[draggable="true"]');
if (!item) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
const idx = Number(item.dataset.index);
if (!Number.isInteger(idx) || idx < 0) return;
const rect = item.getBoundingClientRect();
const halfway = rect.top + rect.height / 2;
const position = event.clientY < halfway ? 'before' : 'after';
dropPlaylistTrackIndex = idx;
dropPlaylistTrackPosition = position;
clearPlaylistDropIndicator();
item.classList.add(position === 'before' ? 'dropBefore' : 'dropAfter');
});
musicEls.results.addEventListener('drop', (event) => {
if (!isPlaylistReorderActive() || draggedPlaylistTrackIndex < 0) return;
event.preventDefault();
applyPlaylistTrackReorder();
draggedPlaylistTrackIndex = -1;
dropPlaylistTrackIndex = -1;
dropPlaylistTrackPosition = 'before';
clearPlaylistDropIndicator();
});
musicEls.results.addEventListener('dragend', () => {
draggedPlaylistTrackIndex = -1;
dropPlaylistTrackIndex = -1;
dropPlaylistTrackPosition = 'before';
clearPlaylistDropIndicator();
});
musicEls.myPlaylistsList.addEventListener('click', (event) => {
const playBtn = event.target.closest('[data-play-playlist-id]');
if (playBtn) {
const playlist = myPlaylists.get(playBtn.dataset.playPlaylistId);
if (playlist) queuePlaylist(playlist, { playNow: true });
return;
}
const queueBtn = event.target.closest('[data-queue-playlist-id]');
if (queueBtn) {
const playlist = myPlaylists.get(queueBtn.dataset.queuePlaylistId);
if (playlist) queuePlaylist(playlist, { playNow: false });
return;
}
const item = event.target.closest('[data-playlist-id]');
if (!item) return;
const playlist = myPlaylists.get(item.dataset.playlistId);
if (!playlist) return;
selectMyPlaylist(playlist.identifier);
musicTracks = Array.isArray(playlist.tracks) ? [...playlist.tracks] : [];
setResultsContext('my-playlist', playlist.identifier);
renderMusicResults(musicTracks);
});
musicEls.friendPlaylistsList.addEventListener('click', (event) => {
const playBtn = event.target.closest('[data-play-friend-playlist-key]');
if (playBtn) {
const playlist = friendPlaylists.get(playBtn.dataset.playFriendPlaylistKey);
if (playlist) queuePlaylist(playlist, { playNow: true });
return;
}
const queueBtn = event.target.closest('[data-queue-friend-playlist-key]');
if (queueBtn) {
const playlist = friendPlaylists.get(queueBtn.dataset.queueFriendPlaylistKey);
if (playlist) queuePlaylist(playlist, { playNow: false });
return;
}
const item = event.target.closest('[data-friend-playlist-key]');
if (!item) return;
const playlist = friendPlaylists.get(item.dataset.friendPlaylistKey);
if (!playlist) return;
musicTracks = Array.isArray(playlist.tracks) ? [...playlist.tracks] : [];
setResultsContext('friend-playlist', item.dataset.friendPlaylistKey || '');
renderMusicResults(musicTracks);
setMusicStatus(`Loaded friend playlist: ${playlist.title || 'Untitled playlist'}`, 'ok');
});
musicEls.playlistNewBtn.addEventListener('click', createNewPlaylist);
musicEls.playlistSaveBtn.addEventListener('click', saveCurrentPlaylist);
musicEls.playlistPublishBtn.addEventListener('click', publishCurrentPlaylist);
musicEls.playlistRefreshBtn.addEventListener('click', () => {
subscribeOwnPlaylists();
subscribeFollowList();
if (followsSet.size > 0) {
didSubscribeFriendPlaylists = false;
subscribeFriendPlaylists();
}
setMusicStatus('Refreshing playlists from relays...', 'neutral');
});
musicEls.playPauseBtn.addEventListener('click', async () => {
try {
await musicPlayer.togglePlayPause();
} catch (error) {
console.error(error);
setMusicStatus(`Playback error: ${error.message || 'Unknown error'}`, 'error');
}
});
musicEls.prevBtn.addEventListener('click', async () => {
try {
if (musicQueue.length > 0) {
if (queueCurrentIndex < 0) {
await playQueueAt(0);
return;
}
await musicPlayer.playPrev(resolveMusicTrack);
syncQueueIndexFromPlayer();
renderQueue();
saveQueueLocal();
return;
}
await musicPlayer.playPrev(resolveMusicTrack);
} catch (error) {
console.error(error);
setMusicStatus(`Previous track failed: ${error.message || 'Unknown error'}`, 'error');
}
});
musicEls.nextBtn.addEventListener('click', async () => {
try {
if (musicQueue.length > 0) {
if (queueCurrentIndex < 0) {
await playQueueAt(0);
return;
}
await musicPlayer.playNext(resolveMusicTrack);
syncQueueIndexFromPlayer();
renderQueue();
saveQueueLocal();
return;
}
await musicPlayer.playNext(resolveMusicTrack);
} catch (error) {
console.error(error);
setMusicStatus(`Next track failed: ${error.message || 'Unknown error'}`, 'error');
}
});
musicEls.audio.addEventListener('play', setMusicPlayButtonState);
musicEls.audio.addEventListener('pause', setMusicPlayButtonState);
setMusicPlayButtonState();
updateMusicPlayerMeta(null);
musicEls.queuePlayNowBtn?.addEventListener('click', async () => {
if (!musicQueue.length) {
setMusicStatus('Queue is empty.', 'error');
return;
}
const start = queueCurrentIndex >= 0 ? queueCurrentIndex : 0;
await playQueueAt(start);
});
musicEls.queueClearBtn?.addEventListener('click', clearQueue);
musicEls.queueList?.addEventListener('click', async (event) => {
const removeBtn = event.target.closest('[data-remove-queue-index]');
if (removeBtn) {
removeQueueIndex(Number(removeBtn.dataset.removeQueueIndex));
return;
}
const item = event.target.closest('[data-queue-index]');
if (!item) return;
await playQueueAt(Number(item.dataset.queueIndex));
});
musicEls.queueList?.addEventListener('dragstart', (event) => {
const item = event.target.closest('.musicQueueItem[draggable="true"]');
if (!item) return;
draggedQueueIndex = Number(item.dataset.queueIndex);
if (!Number.isInteger(draggedQueueIndex) || draggedQueueIndex < 0) {
draggedQueueIndex = -1;
return;
}
event.dataTransfer.effectAllowed = 'move';
item.classList.add('dragging');
});
musicEls.queueList?.addEventListener('dragover', (event) => {
if (draggedQueueIndex < 0) return;
const item = event.target.closest('.musicQueueItem[draggable="true"]');
if (!item) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
const idx = Number(item.dataset.queueIndex);
if (!Number.isInteger(idx) || idx < 0) return;
const rect = item.getBoundingClientRect();
const halfway = rect.top + rect.height / 2;
const position = event.clientY < halfway ? 'before' : 'after';
dropQueueIndex = idx;
dropQueuePosition = position;
clearQueueDropIndicator();
item.classList.add(position === 'before' ? 'dropBefore' : 'dropAfter');
});
musicEls.queueList?.addEventListener('drop', (event) => {
if (draggedQueueIndex < 0) return;
event.preventDefault();
applyQueueReorder();
draggedQueueIndex = -1;
dropQueueIndex = -1;
dropQueuePosition = 'before';
clearQueueDropIndicator();
});
musicEls.queueList?.addEventListener('dragend', () => {
draggedQueueIndex = -1;
dropQueueIndex = -1;
dropQueuePosition = 'before';
clearQueueDropIndicator();
});
loadPlaylistsLocal();
loadQueueLocal();
renderMyPlaylists();
renderFriendPlaylists();
renderQueue();
if (myPlaylists.size === 0) {
createNewPlaylist();
} else if (!selectedPlaylistId) {
const first = Array.from(myPlaylists.keys())[0];
selectMyPlaylist(first);
}
subscribeOwnPlaylists();
subscribeFollowList();
window.addEventListener('ndkEvent', handleMusicNdkEvent);
}
/* ================================================================
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 = "50vw";
isNavOpen = true;
if (hamburgerInstance) {
hamburgerInstance.animateTo('arrow_left');
}
divSideNavBody.innerHTML = "Settings and options coming soon...";
// 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();
// Clear center and right sections
divFooterCenter.innerHTML = '';
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).
================================================================ */
/* ================================================================
USER SETTINGS EXAMPLE (NIP-78)
================================================================
Read/subscribe/write helper pattern for all pages:
// Read latest merged settings (cache + relay hydrated by worker)
const settings = await getUserSettings();
// Subscribe to cross-tab updates
const unsubscribe = onUserSettings((nextSettings) => {
// Re-render page from nextSettings
});
// Patch only your feature namespace
await patchUserSettings({
myFeature: {
someFlag: true
}
});
// On page teardown (if applicable)
// unsubscribe();
================================================================ */
/* ================================================================
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. Hydrate + subscribe user settings
6. Set up relay activity listeners
7. Set up version bar button listeners
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;
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 {
await Logout();
} catch (error) {
console.error('Logout failed:', error);
}
});
}
// Initialize NDK (handles authentication automatically)
await initNDKPage();
// Get authenticated pubkey
currentPubkey = await getPubkey();
console.log("[template.html] Authenticated as:", currentPubkey);
// Hydrate app-wide user settings for this page
try {
pageSettings = await getUserSettings();
} catch (error) {
console.warn('[template.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.
});
}
await initMusicFeature();
// Initialize relay UI components
initFooterRelayStatus();
initSidenavRelaySection();
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.)
});
============================================================ */
// Restore sidenav state if it was open before theme toggle
const sidenavWasOpen = localStorage.getItem('sidenavWasOpen');
if (sidenavWasOpen === 'true') {
localStorage.removeItem('sidenavWasOpen');
openNav();
}
// Start update loop (updates footer every second)
updateIntervalId = setInterval(UpdateFooter, 1000);
// Update version display
await updateVersionDisplay();
console.log('[template.html] 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. 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
================================================================ */