1576 lines
59 KiB
JavaScript
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
|
|
================================================================ */
|
|
|