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

3258 lines
124 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,
publishRawEvent,
disconnect,
getVersion,
updateVersionDisplay,
getUserSettings,
patchUserSettings,
onUserSettings,
fetchCachedProfile
} 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 { sendAiChatJson } from './js/ai-chat.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'),
backBtn: document.getElementById('musicBackBtn'),
homeBtn: document.getElementById('musicHomeBtn'),
forwardBtn: document.getElementById('musicForwardBtn'),
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'),
playlistProfile: document.getElementById('musicPlaylistProfile'),
playlistProfileBanner: document.getElementById('musicPlaylistProfileBanner'),
playlistProfileAvatar: document.getElementById('musicPlaylistProfileAvatar'),
playlistProfileTitle: document.getElementById('musicPlaylistProfileTitle'),
playlistCreatorAvatar: document.getElementById('musicPlaylistCreatorAvatar'),
playlistCreatorText: document.getElementById('musicPlaylistCreatorText'),
playlistDescriptionText: document.getElementById('musicPlaylistDescriptionText'),
playlistEditBtn: document.getElementById('musicPlaylistEditBtn'),
playlistProfileEditor: document.getElementById('musicPlaylistProfileEditor'),
playlistTitleInput: document.getElementById('musicPlaylistTitleInput'),
playlistAvatarInput: document.getElementById('musicPlaylistAvatarInput'),
playlistBannerInput: document.getElementById('musicPlaylistBannerInput'),
playlistDescriptionInput: document.getElementById('musicPlaylistDescriptionInput'),
myPlaylistsList: document.getElementById('musicMyPlaylistsList'),
friendPlaylistsList: document.getElementById('musicFriendPlaylistsList'),
importDropZone: document.getElementById('musicImportDropZone'),
importInput: document.getElementById('musicImportInput'),
importProcessing: document.getElementById('musicImportProcessing'),
importStatus: document.getElementById('musicImportStatus'),
importResults: document.getElementById('musicImportResults'),
queueClearBtn: document.getElementById('musicQueueClearBtn'),
queueList: document.getElementById('musicQueueList'),
playbackModeBtn: document.getElementById('musicPlaybackModeBtn'),
shareModal: document.getElementById('musicShareModal'),
shareTarget: document.getElementById('musicShareTarget'),
shareRendered: document.getElementById('musicShareRendered'),
shareText: document.getElementById('musicShareText'),
shareCancelBtn: document.getElementById('musicShareCancelBtn'),
sharePublishBtn: document.getElementById('musicSharePublishBtn'),
};
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 activePlaylistView = null;
let isSyncingPlaylistProfileInputs = false;
let isPlaylistProfileEditing = false;
const creatorProfileCache = new Map();
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';
let importMatchedTracks = [];
let importMissingTracks = [];
let isImportRunning = false;
let importImageDataUrl = '';
let importImageName = '';
let importInputDebounceId = null;
let importDragDepth = 0;
let musicSearchState = {
query: '',
intent: 'track',
tracks: [],
albums: [],
artists: [],
};
let isHandlingRoute = false;
let playbackMode = 'normal';
let currentPlayingTrackId = '';
let pendingShareUrl = '';
let pendingShareArtUrl = '';
let pendingShareLabel = '';
const BLANK_IMAGE_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==';
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 updatePlayingResultHighlight() {
const isPlayerActive = !!currentPlayingTrackId && !!musicEls.audio && !musicEls.audio.paused;
if (musicEls.playerCover) {
musicEls.playerCover.classList.toggle('isPlaying', isPlayerActive);
}
if (musicEls.results) {
const rows = musicEls.results.querySelectorAll('.musicResultItem[data-index]');
rows.forEach((row) => {
const index = Number(row.dataset.index);
const track = Number.isInteger(index) ? musicTracks[index] : null;
const isPlaying = isPlayerActive && String(track?.id || '') === String(currentPlayingTrackId);
row.classList.toggle('isPlaying', isPlaying);
});
const albumCover = musicEls.results.querySelector('.musicAlbumCardImage[data-album-detail-cover="true"]');
if (albumCover) {
const albumTracks = Array.isArray(musicSearchState?.detailView?.payload?.tracks)
? musicSearchState.detailView.payload.tracks
: [];
const albumHasCurrentTrack = albumTracks.some((track) => String(track?.id || '') === String(currentPlayingTrackId));
albumCover.classList.toggle('isPlaying', isPlayerActive && albumHasCurrentTrack);
}
}
}
function updateMusicPlayerMeta(track) {
if (!track) {
currentPlayingTrackId = '';
musicEls.playerTitle.textContent = '';
musicEls.playerArtist.textContent = '';
musicEls.playerCover.removeAttribute('src');
musicEls.playerCover.style.display = 'none';
updatePlayingResultHighlight();
return;
}
currentPlayingTrackId = String(track.id || '');
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';
}
updatePlayingResultHighlight();
}
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 || '',
banner: playlist.banner || '',
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 || '',
banner: playlist.banner || '',
tracks: Array.isArray(playlist.tracks) ? playlist.tracks : [],
pubkey: currentPubkey || '',
eventId: playlist.eventId || '',
});
}
} catch (error) {
console.warn('[music] Could not load playlists locally:', error);
}
}
function getPlaybackModeMeta(mode) {
if (mode === 'loop-queue') return { label: '⟳🔁', title: 'Mode: Loop queue' };
if (mode === 'loop-track') return { label: '⟳🔂', title: 'Mode: Loop track' };
if (mode === 'shuffle') return { label: '⟳🔀', title: 'Mode: Shuffle' };
return { label: '⟳—', title: 'Mode: Normal' };
}
function updatePlaybackModeButton() {
if (!musicEls.playbackModeBtn) return;
const meta = getPlaybackModeMeta(playbackMode);
musicEls.playbackModeBtn.textContent = meta.label;
musicEls.playbackModeBtn.title = meta.title;
musicEls.playbackModeBtn.dataset.mode = playbackMode;
}
function setPlaybackMode(mode, { persist = true } = {}) {
const allowed = new Set(['normal', 'loop-queue', 'loop-track', 'shuffle']);
playbackMode = allowed.has(mode) ? mode : 'normal';
updatePlaybackModeButton();
if (persist) saveQueueLocal();
}
function cyclePlaybackMode() {
if (playbackMode === 'normal') {
setPlaybackMode('loop-queue');
} else if (playbackMode === 'loop-queue') {
setPlaybackMode('loop-track');
} else if (playbackMode === 'loop-track') {
setPlaybackMode('shuffle');
} else {
setPlaybackMode('normal');
}
setMusicStatus(`Playback mode: ${getPlaybackModeMeta(playbackMode).title.replace('Mode: ', '')}`, 'ok');
}
function saveQueueLocal() {
try {
const payload = {
currentIndex: queueCurrentIndex,
playbackMode,
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) {
setPlaybackMode('normal', { persist: false });
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;
setPlaybackMode(parsed?.playbackMode || 'normal', { persist: false });
} catch (error) {
console.warn('[music] Could not load queue locally:', error);
setPlaybackMode('normal', { persist: false });
}
}
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">
<img class="musicPlaylistCover" src="${escapeMusicHtml(getPlaylistAvatarUrl(playlist))}" alt="" loading="lazy" />
<div class="musicPlaylistText">
<span class="musicPlaylistTitle">${escapeMusicHtml(playlist.title || 'Untitled playlist')}</span>
<span class="musicPlaylistMeta">${(playlist.tracks || []).length} tracks</span>
</div>
</div>
<details class="musicItemMenu musicPlaylistItemActions" data-item-menu>
<summary class="btn musicMiniBtn musicMenuTrigger" data-menu-toggle aria-label="Playlist actions">⋯</summary>
<div class="musicMenuPanel">
<button class="btn musicMenuItem" type="button" data-play-playlist-id="${escapeMusicHtml(playlist.identifier)}">Play now</button>
<button class="btn musicMenuItem" type="button" data-queue-playlist-id="${escapeMusicHtml(playlist.identifier)}">Add to queue</button>
<button class="btn musicMenuItem" type="button" data-share-playlist-id="${escapeMusicHtml(playlist.identifier)}">Share</button>
<button class="btn musicMenuItem" type="button" data-delete-playlist-id="${escapeMusicHtml(playlist.identifier)}">Delete playlist</button>
</div>
</details>
</li>
`)
.join('');
}
function getFriendPlaylistRouteKey(playlist) {
if (!playlist || typeof playlist !== 'object') return '';
const pubkey = String(playlist.pubkey || '').trim();
const identifier = String(playlist.identifier || '').trim();
if (pubkey && identifier) return `${pubkey}:${identifier}`;
return String(playlist.eventId || '').trim() || identifier;
}
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 key = getFriendPlaylistRouteKey(playlist);
const creatorName = getPlaylistCreatorLabel(playlist);
return `
<li class="musicPlaylistItem" data-friend-playlist-key="${escapeMusicHtml(key)}">
<div class="musicPlaylistPrimary">
<img class="musicPlaylistCover" src="${escapeMusicHtml(getPlaylistAvatarUrl(playlist))}" alt="" loading="lazy" />
<div class="musicPlaylistText">
<span class="musicPlaylistTitle">${escapeMusicHtml(label)}</span>
<span class="musicPlaylistMeta">${escapeMusicHtml(creatorName)}</span>
</div>
</div>
<details class="musicItemMenu musicPlaylistItemActions" data-item-menu>
<summary class="btn musicMiniBtn musicMenuTrigger" data-menu-toggle aria-label="Friend playlist actions">⋯</summary>
<div class="musicMenuPanel">
<button class="btn musicMenuItem" type="button" data-play-friend-playlist-key="${escapeMusicHtml(key)}">Play now</button>
<button class="btn musicMenuItem" type="button" data-queue-friend-playlist-key="${escapeMusicHtml(key)}">Add to queue</button>
<button class="btn musicMenuItem" type="button" data-copy-friend-playlist-key="${escapeMusicHtml(key)}">Copy to my playlists</button>
</div>
</details>
</li>
`;
})
.join('');
}
function shortPubkey(value) {
if (!value) return 'unknown';
return `${String(value).slice(0, 8)}`;
}
function getPlaylistAvatarUrl(playlist) {
if (playlist?.image) return playlist.image;
if (Array.isArray(playlist?.tracks) && playlist.tracks.length > 0) {
const first = playlist.tracks.find((track) => track?.cover);
if (first?.cover) return musicApi.getCoverUrl(first.cover, 320);
}
return BLANK_IMAGE_PIXEL;
}
function getPlaylistBannerUrl(playlist) {
if (playlist?.banner) return playlist.banner;
return getPlaylistAvatarUrl(playlist);
}
function parseKind0Profile(profileLike) {
if (!profileLike || typeof profileLike !== 'object') return null;
if (typeof profileLike.picture === 'string' || typeof profileLike.name === 'string' || typeof profileLike.display_name === 'string') {
return profileLike;
}
const content = profileLike?.content;
if (typeof content !== 'string' || !content.trim()) return null;
try {
const parsed = JSON.parse(content);
return parsed && typeof parsed === 'object' ? parsed : null;
} catch {
return null;
}
}
async function ensureCreatorProfile(pubkey) {
if (!pubkey || creatorProfileCache.has(pubkey)) return;
try {
const profile = await fetchCachedProfile(pubkey);
const parsed = parseKind0Profile(profile);
if (!parsed) return;
creatorProfileCache.set(pubkey, parsed);
const activePubkey = activePlaylistView?.playlist?.pubkey || '';
if (activePubkey && activePubkey === pubkey) {
renderPlaylistProfileCard();
}
renderFriendPlaylists();
} catch (error) {
console.warn('[music] Could not load creator profile:', error);
}
}
function getPlaylistCreatorLabel(playlist) {
const pubkey = String(playlist?.pubkey || '').trim();
if (!pubkey) return 'unknown';
void ensureCreatorProfile(pubkey);
const creatorProfile = creatorProfileCache.get(pubkey) || null;
return creatorProfile?.display_name || creatorProfile?.name || shortPubkey(pubkey);
}
function getPlaylistCreatorAvatarUrl(playlist) {
const pubkey = playlist?.pubkey || '';
const creatorProfile = creatorProfileCache.get(pubkey) || null;
if (creatorProfile?.picture) return creatorProfile.picture;
if (pubkey) {
return `https://robohash.org/${encodeURIComponent(pubkey)}?set=set4&size=96x96`;
}
return getPlaylistAvatarUrl(playlist);
}
function isOwnPlaylistRecord(playlist) {
if (!playlist) return false;
if (!playlist.pubkey) return true;
return playlist.pubkey === currentPubkey;
}
async function publishPlaylistRecord(playlist) {
if (!playlist) 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();
} catch (error) {
console.error(error);
setMusicStatus(`Playlist publish failed: ${error.message || 'Unknown error'}`, 'error');
}
}
function renderPlaylistProfileCard() {
if (!musicEls.playlistProfile) return;
const playlist = activePlaylistView?.playlist || null;
if (!playlist) {
isPlaylistProfileEditing = false;
musicEls.playlistProfile.classList.add('hidden');
return;
}
const own = isOwnPlaylistRecord(playlist);
if (!own) isPlaylistProfileEditing = false;
musicEls.playlistProfile.classList.remove('hidden');
const creatorPubkey = playlist.pubkey || currentPubkey || '';
const creatorLabel = getPlaylistCreatorLabel({ ...playlist, pubkey: creatorPubkey });
musicEls.playlistProfileBanner.src = getPlaylistBannerUrl(playlist);
musicEls.playlistProfileAvatar.src = getPlaylistAvatarUrl(playlist);
musicEls.playlistProfileTitle.textContent = playlist.title || 'Untitled playlist';
musicEls.playlistCreatorAvatar.src = getPlaylistCreatorAvatarUrl({ ...playlist, pubkey: creatorPubkey });
musicEls.playlistCreatorText.textContent = creatorLabel;
musicEls.playlistDescriptionText.textContent = playlist.description || '';
musicEls.playlistDescriptionText.style.display = playlist.description ? '' : 'none';
isSyncingPlaylistProfileInputs = true;
musicEls.playlistTitleInput.value = playlist.title || '';
musicEls.playlistAvatarInput.value = playlist.image || '';
musicEls.playlistBannerInput.value = playlist.banner || '';
musicEls.playlistDescriptionInput.value = playlist.description || '';
isSyncingPlaylistProfileInputs = false;
musicEls.playlistTitleInput.disabled = !own;
musicEls.playlistAvatarInput.disabled = !own;
musicEls.playlistBannerInput.disabled = !own;
musicEls.playlistDescriptionInput.disabled = !own;
musicEls.playlistEditBtn.style.display = own ? '' : 'none';
musicEls.playlistEditBtn.textContent = isPlaylistProfileEditing ? 'Done' : 'Edit';
musicEls.playlistProfileEditor.classList.toggle('hidden', !own || !isPlaylistProfileEditing);
}
function selectMyPlaylist(identifier) {
const playlist = myPlaylists.get(identifier);
if (!playlist) return;
selectedPlaylistId = identifier;
activePlaylistView = { scope: 'my', key: identifier, playlist };
renderMyPlaylists();
renderPlaylistProfileCard();
setMusicStatus(`Selected playlist: ${playlist.title || 'Untitled playlist'}`, 'ok');
}
async function createNewPlaylist() {
const proposedName = musicEls.playlistNameInput.value.trim() || `Playlist ${myPlaylists.size + 1}`;
const identifier = createPlaylistIdentifier(proposedName);
const playlist = {
identifier,
title: proposedName,
description: '',
image: '',
banner: '',
tracks: [],
pubkey: currentPubkey || '',
eventId: '',
};
myPlaylists.set(identifier, playlist);
selectedPlaylistId = identifier;
activePlaylistView = { scope: 'my', key: identifier, playlist };
savePlaylistsLocal();
renderMyPlaylists();
renderPlaylistProfileCard();
await publishPlaylistRecord(playlist);
musicEls.playlistNameInput.value = '';
setMusicStatus(`Created playlist: ${proposedName}`, 'ok');
}
async function deleteMyPlaylist(identifier) {
const id = String(identifier || '').trim();
if (!id) return;
const playlist = myPlaylists.get(id);
if (!playlist) return;
const label = playlist.title || 'Untitled playlist';
const confirmed = window.confirm(`Delete playlist "${label}"?`);
if (!confirmed) return;
myPlaylists.delete(id);
if (selectedPlaylistId === id) {
selectedPlaylistId = '';
}
if (activePlaylistView?.scope === 'my' && activePlaylistView?.key === id) {
activePlaylistView = null;
}
savePlaylistsLocal();
renderMyPlaylists();
renderPlaylistProfileCard();
const { page, parts } = parseRoute();
if (page === 'playlist' && parts.length === 1 && parts[0] === id) {
navigate('/');
} else {
musicEls.results.innerHTML = '<li class="musicResultEmpty">Search songs, albums, artists…</li>';
setResultsContext('search');
}
setMusicStatus(`Deleted playlist: ${label}`, '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();
renderPlaylistProfileCard();
void publishPlaylistRecord(playlist);
setMusicStatus(`Added to ${playlist.title}: ${track.title}`, 'ok');
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function normalizeMatchText(value) {
return String(value || '')
.toLowerCase()
.replace(/\([^)]*\)/g, ' ')
.replace(/\[[^\]]*\]/g, ' ')
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
function parseImportTextFallback(rawText) {
return String(rawText || '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const csvParts = line.split(',').map((part) => part.trim()).filter(Boolean);
if (csvParts.length >= 2) {
return { title: csvParts[0], artist: csvParts[1] };
}
const dashParts = line.split(/\s[-–—]\s/).map((part) => part.trim()).filter(Boolean);
if (dashParts.length >= 2) {
return { artist: dashParts[0], title: dashParts[1] };
}
const byParts = line.split(/\s+by\s+/i).map((part) => part.trim()).filter(Boolean);
if (byParts.length >= 2) {
return { title: byParts[0], artist: byParts[1] };
}
return { title: line, artist: '' };
})
.filter((item) => item.title);
}
async function readImageFileAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(new Error('Failed to read image file.'));
reader.readAsDataURL(file);
});
}
async function readTextFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(new Error('Failed to read text file.'));
reader.readAsText(file);
});
}
function renderImportProcessing(active) {
musicEls.importDropZone?.classList.toggle('processing', Boolean(active));
musicEls.importProcessing?.classList.toggle('visible', Boolean(active));
if (musicEls.importProcessing) {
musicEls.importProcessing.setAttribute('aria-hidden', active ? 'false' : 'true');
}
}
function scheduleImportAutoRun(delayMs = 1500) {
if (importInputDebounceId) {
clearTimeout(importInputDebounceId);
importInputDebounceId = null;
}
importInputDebounceId = setTimeout(() => {
importInputDebounceId = null;
void runImportMatching();
}, delayMs);
}
function clearImportImageAttachment() {
importImageDataUrl = '';
importImageName = '';
console.log('[music import] image_attachment:cleared');
}
async function attachImportImageFile(file) {
importImageDataUrl = await readImageFileAsDataUrl(file);
importImageName = file.name || 'playlist-screenshot';
setImportStatus(`Image received: ${importImageName}`);
console.log('[music import] image_attachment:set', {
name: importImageName,
type: file.type,
size: file.size,
});
}
async function appendImportTextFile(file) {
const text = await readTextFile(file);
const current = String(musicEls.importInput?.value || '');
const next = current ? `${current}\n${text}` : text;
if (musicEls.importInput) {
musicEls.importInput.value = next;
}
setImportStatus(`Added text from file: ${file.name || 'text file'}`);
console.log('[music import] text_file:appended', {
name: file.name || '',
addedLength: text.length,
});
}
function setImportStatus(text) {
if (!musicEls.importStatus) return;
musicEls.importStatus.textContent = String(text || '');
}
function renderImportResults() {
if (!musicEls.importResults) return;
const matched = importMatchedTracks;
const missing = importMissingTracks;
if (!matched.length && !missing.length) {
musicEls.importResults.innerHTML = '';
return;
}
const matchedHtml = matched.length
? `<div><strong>Matched (${matched.length})</strong><ul>${matched
.map((track) => `<li>${escapeMusicHtml(track.title || 'Unknown')}${escapeMusicHtml(track.artist || 'Unknown')}</li>`)
.join('')}</ul></div>`
: '<div><strong>Matched (0)</strong></div>';
const actionsHtml = matched.length
? `<div class="musicImportActions"><button id="musicImportAddMatchesInlineBtn" class="btn" type="button" ${isImportRunning ? 'disabled' : ''}>Add Matches</button></div>`
: '';
const missingHtml = missing.length
? `<div><strong>Missing (${missing.length})</strong><ul>${missing
.map((item) => `<li>${escapeMusicHtml(item.title || 'Unknown')} ${item.artist ? `${escapeMusicHtml(item.artist)}` : ''}</li>`)
.join('')}</ul></div>`
: '<div><strong>Missing (0)</strong></div>';
musicEls.importResults.innerHTML = `${matchedHtml}${actionsHtml}${missingHtml}`;
}
async function extractTracksFromImportText(rawText, imageDataUrl = '') {
const system = 'Extract songs from user text and/or playlist screenshot image and return ONLY valid JSON with shape {"tracks":[{"title":"","artist":""}]}. Use OCR on image if present. No markdown, no prose.';
const textInput = String(rawText || '').trim();
const hasImage = Boolean(String(imageDataUrl || '').trim());
const userText = textInput
? `Extract track list from this text:\n\n${textInput}`
: 'Extract track list from the attached screenshot image only.';
console.log('[music import] extractTracksFromImportText:start', {
inputLength: textInput.length,
hasImage,
});
const userMessageContent = hasImage
? [
{ type: 'text', text: userText },
{ type: 'image_url', image_url: { url: imageDataUrl } },
]
: userText;
const aiResult = await sendAiChatJson({
messages: [
{ role: 'system', content: system },
{ role: 'user', content: userMessageContent },
],
maxTokens: 1000,
temperature: 0.1,
});
const tracks = Array.isArray(aiResult?.json?.tracks) ? aiResult.json.tracks : [];
const cleaned = tracks
.map((item) => ({
title: String(item?.title || '').trim(),
artist: String(item?.artist || '').trim(),
}))
.filter((item) => item.title);
console.log('[music import] extractTracksFromImportText:response', {
parsedTracks: tracks.length,
cleanedTracks: cleaned.length,
hasImage,
sample: cleaned.slice(0, 3),
});
if (!cleaned.length) {
throw new Error('AI returned no usable tracks.');
}
return cleaned;
}
function pickBestImportCandidate(items, wantedTitle, wantedArtist) {
if (!Array.isArray(items) || !items.length) return null;
if (!wantedArtist) return items[0];
const wantedArtistNorm = normalizeMatchText(wantedArtist);
const wantedTitleNorm = normalizeMatchText(wantedTitle);
let best = null;
let bestScore = -1;
for (const item of items) {
const artistNorm = normalizeMatchText(item?.artist || '');
const titleNorm = normalizeMatchText(item?.title || '');
let score = 0;
if (artistNorm && wantedArtistNorm && (artistNorm.includes(wantedArtistNorm) || wantedArtistNorm.includes(artistNorm))) score += 4;
if (titleNorm && wantedTitleNorm && (titleNorm.includes(wantedTitleNorm) || wantedTitleNorm.includes(titleNorm))) score += 3;
if (score > bestScore) {
best = item;
bestScore = score;
}
}
return best || items[0];
}
async function runImportMatching() {
if (isImportRunning) {
console.log('[music import] runImportMatching:blocked_already_running');
return;
}
const rawText = String(musicEls.importInput?.value || '').trim();
const hasImage = Boolean(importImageDataUrl);
console.log('[music import] runImportMatching:click', {
hasInput: Boolean(rawText),
inputLength: rawText.length,
hasImage,
});
if (!rawText && !hasImage) {
setImportStatus('Paste playlist text or upload a screenshot first.');
console.log('[music import] runImportMatching:aborted_no_text_or_image');
return;
}
isImportRunning = true;
renderImportProcessing(true);
importMatchedTracks = [];
importMissingTracks = [];
renderImportResults();
let extracted = [];
try {
setImportStatus('Extracting songs with AI...');
console.log('[music import] runImportMatching:ai_extract_start');
extracted = await extractTracksFromImportText(rawText, importImageDataUrl);
console.log('[music import] runImportMatching:ai_extract_success', {
extractedCount: extracted.length,
});
} catch (error) {
console.warn('[music import] AI extraction failed, falling back:', error);
extracted = hasImage && !rawText ? [] : parseImportTextFallback(rawText);
console.log('[music import] runImportMatching:fallback_parse', {
extractedCount: extracted.length,
hasImage,
sample: extracted.slice(0, 5),
});
if (hasImage && !rawText) {
setImportStatus('AI extraction unavailable for image-only import.');
} else {
setImportStatus(`AI extraction unavailable, using fallback parser (${extracted.length} lines).`);
}
}
if (!extracted.length) {
setImportStatus('No songs found in provided import content.');
isImportRunning = false;
renderImportProcessing(false);
renderImportResults();
console.log('[music import] runImportMatching:finished_no_extracted_tracks');
return;
}
for (let i = 0; i < extracted.length; i += 1) {
const item = extracted[i];
setImportStatus(`Matching ${i + 1}/${extracted.length}: ${item.title}`);
const query = `${item.title} ${item.artist || ''}`.trim();
let items = [];
try {
console.log('[music import] runImportMatching:search_start', {
index: i,
total: extracted.length,
query,
requested: item,
});
const res = await musicApi.searchTracks(query);
items = Array.isArray(res?.items) ? res.items : [];
console.log('[music import] runImportMatching:search_success', {
query,
resultsCount: items.length,
});
} catch (error) {
items = [];
console.warn('[music import] runImportMatching:search_error', {
query,
error: error?.message || String(error || ''),
});
}
const best = pickBestImportCandidate(items, item.title, item.artist);
if (best?.id) {
importMatchedTracks.push(best);
console.log('[music import] runImportMatching:match_found', {
requested: item,
matched: {
id: best.id,
title: best.title || '',
artist: best.artist || '',
},
});
} else {
importMissingTracks.push(item);
console.log('[music import] runImportMatching:match_missing', {
requested: item,
});
}
renderImportResults();
if (i < extracted.length - 1) {
await sleep(300);
}
}
setImportStatus(`Done: ${importMatchedTracks.length} matched, ${importMissingTracks.length} missing.`);
isImportRunning = false;
renderImportProcessing(false);
renderImportResults();
console.log('[music import] runImportMatching:done', {
matched: importMatchedTracks.length,
missing: importMissingTracks.length,
});
}
async function addImportMatchesToPlaylist() {
const playlist = getCurrentPlaylist();
console.log('[music import] addImportMatchesToPlaylist:start', {
matchedAvailable: importMatchedTracks.length,
playlistId: playlist?.identifier || null,
});
if (!playlist) {
setMusicStatus('Create or select a playlist first.', 'error');
console.log('[music import] addImportMatchesToPlaylist:aborted_no_playlist');
return;
}
const existingIds = new Set((playlist.tracks || []).map((track) => Number(track?.id)).filter((id) => Number.isFinite(id)));
let added = 0;
let skipped = 0;
for (const track of importMatchedTracks) {
const id = Number(track?.id);
if (!Number.isFinite(id) || existingIds.has(id)) {
skipped += 1;
continue;
}
existingIds.add(id);
playlist.tracks.push({
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);
}
added += 1;
}
console.log('[music import] addImportMatchesToPlaylist:mutated_playlist', {
added,
skipped,
newTrackCount: (playlist.tracks || []).length,
});
myPlaylists.set(playlist.identifier, playlist);
savePlaylistsLocal();
renderMyPlaylists();
renderPlaylistProfileCard();
if (musicResultsMode === 'my-playlist' && musicResultsPlaylistId === playlist.identifier) {
musicTracks = [...playlist.tracks];
renderMusicResults(musicTracks);
}
try {
await publishPlaylistRecord(playlist);
console.log('[music import] addImportMatchesToPlaylist:publish_success', {
playlistId: playlist.identifier,
});
} catch (error) {
console.error('[music import] addImportMatchesToPlaylist:publish_error', error);
throw error;
}
setMusicStatus(`Imported ${added} track${added === 1 ? '' : 's'} into ${playlist.title || 'playlist'}.`, 'ok');
setImportStatus(`Added ${added} track${added === 1 ? '' : 's'} to playlist.`);
console.log('[music import] addImportMatchesToPlaylist:done', { added, skipped });
}
function setResultsContext(mode, playlistId = '') {
musicResultsMode = mode;
musicResultsPlaylistId = playlistId;
if (mode !== 'my-playlist' && mode !== 'friend-playlist') {
activePlaylistView = null;
renderPlaylistProfileCard();
}
clearPlaylistDropIndicator();
}
function hydratePlaylistFromEvent(event, isFriend = false) {
const parsed = parsePlaylistEvent(event);
if (!parsed || !isMusicPlaylistEvent(event)) return;
if (isFriend) {
const key = getFriendPlaylistRouteKey(parsed);
if (key) 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,
banner: parsed.banner || '',
tracks: parsed.tracks,
pubkey: parsed.pubkey,
eventId: parsed.eventId,
createdAt: parsed.createdAt,
raw: parsed.raw,
...(existing || {}),
});
savePlaylistsLocal();
renderMyPlaylists();
}
if (activePlaylistView?.scope && activePlaylistView?.playlist?.identifier === parsed.identifier) {
activePlaylistView = { ...activePlaylistView, playlist: parsed };
renderPlaylistProfileCard();
}
}
function subscribeAllPlaylists() {
subscribe(
{ kinds: [PLAYLIST_KIND], '#t': ['music'], limit: 600 },
{ closeOnEose: false, cacheUsage: 'PARALLEL' }
);
}
function handleMusicNdkEvent(event) {
const evt = event?.detail;
if (!evt || typeof evt !== 'object') return;
if (evt.kind !== PLAYLIST_KIND || !isMusicPlaylistEvent(evt)) return;
if (evt.pubkey === currentPubkey) {
hydratePlaylistFromEvent(evt, false);
return;
}
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' : ''} ${index === queueCurrentIndex + 1 ? 'afterCurrent' : ''}" data-queue-index="${index}" draggable="true">
<div class="musicQueueTrack">
<img class="musicQueueCover" src="${escapeMusicHtml(musicApi.getCoverUrl(track.cover, 160))}" alt="" loading="lazy" />
<div class="musicQueueMeta">
<div class="musicQueueTitle">${escapeMusicHtml(track.title || 'Unknown title')}</div>
<div class="musicQueueSubtitle">${escapeMusicHtml(track.artist || 'Unknown artist')}</div>
</div>
</div>
<details class="musicItemMenu" data-item-menu>
<summary class="btn musicMiniBtn musicMenuTrigger" data-menu-toggle aria-label="Queue item actions">⋯</summary>
<div class="musicMenuPanel">
<button class="btn musicMenuItem" type="button" data-remove-queue-index="${index}">Remove</button>
</div>
</details>
</li>
`)
.join('');
}
async function playQueueAt(index) {
if (!Number.isInteger(index) || index < 0 || index >= musicQueue.length) return;
queueCurrentIndex = index;
const track = musicQueue[index];
currentPlayingTrackId = String(track?.id || '');
try {
setMusicStatus(`Loading queue item: ${track.title}`, 'neutral');
await musicPlayer.playTrack(track, resolveMusicTrack);
renderQueue();
updatePlayingResultHighlight();
saveQueueLocal();
setMusicStatus(`Playing queue: ${track.title}`, 'ok');
} catch (error) {
console.error(error);
setMusicStatus(`Queue playback failed: ${error.message || 'Unknown error'}`, 'error');
}
}
function getRandomQueueIndex(excludeIndex = -1) {
if (!musicQueue.length) return -1;
if (musicQueue.length === 1) return 0;
let next = excludeIndex;
let safety = 0;
while (next === excludeIndex && safety < 20) {
next = Math.floor(Math.random() * musicQueue.length);
safety += 1;
}
return next < 0 ? 0 : next;
}
async function playNextInQueue({ fromEnded = false } = {}) {
if (!musicQueue.length) return;
if (playbackMode === 'loop-track' && fromEnded) {
const current = queueCurrentIndex >= 0 ? queueCurrentIndex : 0;
await playQueueAt(current);
return;
}
if (playbackMode === 'shuffle') {
const next = getRandomQueueIndex(queueCurrentIndex);
if (next >= 0) await playQueueAt(next);
return;
}
if (playbackMode === 'loop-queue') {
const next = queueCurrentIndex >= 0
? (queueCurrentIndex + 1) % musicQueue.length
: 0;
await playQueueAt(next);
return;
}
const nextIndex = queueCurrentIndex >= 0 ? queueCurrentIndex + 1 : 0;
if (nextIndex >= musicQueue.length) {
if (fromEnded) {
setMusicStatus('Queue finished.', 'neutral');
}
return;
}
await playQueueAt(nextIndex);
}
async function playPrevInQueue() {
if (!musicQueue.length) return;
if (playbackMode === 'shuffle') {
const prev = getRandomQueueIndex(queueCurrentIndex);
if (prev >= 0) await playQueueAt(prev);
return;
}
if (playbackMode === 'loop-queue') {
const prevIndex = queueCurrentIndex >= 0
? (queueCurrentIndex - 1 + musicQueue.length) % musicQueue.length
: 0;
await playQueueAt(prevIndex);
return;
}
const prevIndex = queueCurrentIndex > 0 ? queueCurrentIndex - 1 : 0;
await playQueueAt(prevIndex);
}
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);
}
async function queuePlaylist(playlist, { playNow = false } = {}) {
const tracks = Array.isArray(playlist?.tracks) ? playlist.tracks : [];
if (!tracks.length) {
setMusicStatus('Playlist has no tracks.', 'error');
return;
}
if (playNow) {
await playAlbumTracksAtTop(tracks, playlist.title || 'Playlist');
return;
}
addTracksToQueue(tracks, { playNow: false, replace: false });
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 renderTrackRows(items, { allowReorder = false } = {}) {
return 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 || 'Unknown title')}</div>
<div class="musicResultSubtitle">${escapeMusicHtml(track.artist || 'Unknown artist')}${track.albumTitle ? `${escapeMusicHtml(track.albumTitle)}` : ''}</div>
</div>
<div class="musicResultDuration">${formatMusicDuration(track.duration || 0)}</div>
<div class="musicResultActions">
<details class="musicItemMenu" data-item-menu>
<summary class="btn musicMiniBtn musicMenuTrigger" data-menu-toggle aria-label="Track actions">⋯</summary>
<div class="musicMenuPanel">
<button class="btn musicMenuItem" type="button" data-add-index="${index}">Add to playlist</button>
<button class="btn musicMenuItem" type="button" data-queue-index="${index}">Add to queue</button>
<button class="btn musicMenuItem" type="button" data-queue-album-index="${index}">Add album to queue</button>
<button class="btn musicMenuItem" type="button" data-download-index="${index}">Download</button>
<button class="btn musicMenuItem" type="button" data-share-index="${index}">Share</button>
</div>
</details>
</div>
</li>
`
)
.join('');
}
function updateNavigationControls() {
if (musicEls.backBtn) {
musicEls.backBtn.disabled = window.history.length <= 1;
}
if (musicEls.forwardBtn) {
musicEls.forwardBtn.disabled = false;
}
if (musicEls.homeBtn) {
const hash = window.location.hash || '';
const clean = hash.startsWith('#') ? hash.slice(1) : hash;
musicEls.homeBtn.disabled = !clean || clean === '/';
}
}
function parseRoute() {
const hash = window.location.hash || '';
const clean = hash.startsWith('#') ? hash.slice(1) : hash;
if (!clean || clean === '/') return { page: 'home', parts: [] };
const parts = clean.split('/').filter(Boolean).map((part) => decodeURIComponent(part));
return { page: parts[0] || 'home', parts: parts.slice(1) };
}
function navigate(path) {
const finalPath = path.startsWith('/') ? path : `/${path}`;
const nextHash = `#${finalPath}`;
if (window.location.hash === nextHash) {
void handleRoute();
return;
}
window.location.hash = nextHash;
}
function renderMusicResults(items) {
if (!items.length) {
musicEls.results.innerHTML = '<li class="musicResultEmpty">No tracks found.</li>';
return;
}
const allowReorder = isPlaylistReorderActive();
musicEls.results.innerHTML = renderTrackRows(items, { allowReorder });
updatePlayingResultHighlight();
}
function classifySearchIntent(query, results) {
const q = String(query || '').trim().toLowerCase();
const words = q.split(/\s+/).filter(Boolean);
const artists = Array.isArray(results?.artists) ? results.artists : [];
const albums = Array.isArray(results?.albums) ? results.albums : [];
const tracks = Array.isArray(results?.tracks) ? results.tracks : [];
const exactArtist = artists.some((a) => String(a?.name || '').trim().toLowerCase() === q);
if (exactArtist && words.length <= 3) return 'artist';
if (words.length === 1 && artists.length > 0) return 'artist';
const exactAlbum = albums.some((a) => String(a?.title || '').trim().toLowerCase() === q);
if (exactAlbum) return 'album';
const exactTrack = tracks.some((t) => String(t?.title || '').trim().toLowerCase() === q);
if (exactTrack && words.length >= 2) return 'track';
return words.length >= 2 ? 'track' : 'artist';
}
function getIntentSectionOrder(intent) {
if (intent === 'artist') return ['artists', 'albums', 'tracks'];
if (intent === 'album') return ['albums', 'artists', 'tracks'];
return ['tracks', 'artists', 'albums'];
}
function renderArtistSection(items, prominent = false) {
if (!items.length) return '';
const wrapperClass = 'musicArtistStrip';
return `
<li class="musicSectionHeader">ARTISTS</li>
<li class="musicSectionBlock">
<div class="${wrapperClass}">
${items
.map((artist) => {
const pic = musicApi.getArtistPictureUrl(artist.picture, 320) || musicApi.getCoverUrl(artist.picture, 320);
return `
<div class="musicArtistCardWrap">
<button class="musicArtistCard" type="button" data-open-artist-id="${escapeMusicHtml(artist.id || '')}">
<img class="musicArtistCardImage" src="${escapeMusicHtml(pic)}" alt="" loading="lazy" />
<div class="musicArtistCardName">${escapeMusicHtml(artist.name || 'Unknown artist')}</div>
</button>
<button class="btn musicMiniBtn" type="button" data-share-artist-id="${escapeMusicHtml(artist.id || '')}" data-share-artist-name="${escapeMusicHtml(artist.name || 'Unknown artist')}" data-share-artist-picture="${escapeMusicHtml(artist.picture || '')}">Share</button>
</div>
`;
})
.join('')}
</div>
</li>
`;
}
function renderAlbumSection(items, prominent = false) {
if (!items.length) return '';
const wrapperClass = prominent ? 'musicArtistGrid' : 'musicAlbumStrip';
return `
<li class="musicSectionHeader">ALBUMS</li>
<li class="musicSectionBlock">
<div class="${wrapperClass}">
${items
.map((album) => {
const cover = musicApi.getCoverUrl(album.cover, 320);
const artistName = album.artist?.name || album.artistName || 'Unknown artist';
return `
<div class="musicAlbumCard" data-open-album-id="${escapeMusicHtml(album.id || '')}">
<img class="musicAlbumCardImage" src="${escapeMusicHtml(cover)}" alt="" loading="lazy" />
<div class="musicAlbumCardTitle">${escapeMusicHtml(album.title || 'Unknown album')}</div>
<div class="musicAlbumCardMeta">${escapeMusicHtml(artistName)}</div>
<div class="musicAlbumCardActions">
<details class="musicItemMenu" data-item-menu>
<summary class="btn musicMiniBtn musicMenuTrigger" data-menu-toggle aria-label="Album actions">⋯</summary>
<div class="musicMenuPanel">
<button class="btn musicMenuItem" type="button" data-queue-album-id="${escapeMusicHtml(album.id || '')}">Add to queue</button>
<button class="btn musicMenuItem" type="button" data-open-album-id="${escapeMusicHtml(album.id || '')}">Open album</button>
<button class="btn musicMenuItem" type="button" data-share-album-id="${escapeMusicHtml(album.id || '')}" data-share-album-title="${escapeMusicHtml(album.title || 'Unknown album')}" data-share-album-cover="${escapeMusicHtml(album.cover || '')}">Share</button>
</div>
</details>
</div>
</div>
`;
})
.join('')}
</div>
</li>
`;
}
function renderTrackSection(items) {
if (!items.length) return '';
return `
<li class="musicSectionHeader">TRACKS</li>
${renderTrackRows(items, { allowReorder: false })}
`;
}
function renderUnifiedResults(state) {
const tracks = Array.isArray(state?.tracks) ? state.tracks : [];
const albums = Array.isArray(state?.albums) ? state.albums : [];
const artists = Array.isArray(state?.artists) ? state.artists : [];
const total = tracks.length + albums.length + artists.length;
if (total === 0) {
musicEls.results.innerHTML = '<li class="musicResultEmpty">No results found.</li>';
return;
}
const order = getIntentSectionOrder(state?.intent || 'track');
const chunks = [];
for (const section of order) {
if (section === 'artists') {
const prominent = order[0] === 'artists';
const html = renderArtistSection(artists, prominent);
if (html) chunks.push(html);
}
if (section === 'albums') {
const prominent = order[0] === 'albums';
const html = renderAlbumSection(albums, prominent);
if (html) chunks.push(html);
}
if (section === 'tracks') {
const html = renderTrackSection(tracks);
if (html) chunks.push(html);
}
}
musicEls.results.innerHTML = chunks.join('<li class="musicSectionDivider"></li>');
updatePlayingResultHighlight();
}
async function queueAlbumById(albumId, { playNow = false } = {}) {
if (!albumId) return;
try {
const payload = await musicApi.getAlbum(albumId);
const tracks = Array.isArray(payload?.tracks) ? payload.tracks : [];
if (!tracks.length) {
setMusicStatus('Album has no tracks to queue.', 'error');
return;
}
if (playNow) {
await playAlbumTracksAtTop(tracks, payload?.album?.title || 'Album');
return;
}
addTracksToQueue(tracks, { playNow: false, replace: false });
} catch (error) {
setMusicStatus(`Could not queue album: ${error.message || 'Unknown error'}`, 'error');
}
}
async function playAlbumTracksAtTop(tracks, albumTitle = 'Album') {
const incoming = (Array.isArray(tracks) ? tracks : [])
.map((track) => normalizeQueueTrack(track))
.filter(Boolean);
if (!incoming.length) {
setMusicStatus('Album has no playable tracks.', 'error');
return;
}
musicQueue = [...incoming, ...musicQueue];
renderQueue();
saveQueueLocal();
try {
await playQueueAt(0);
setMusicStatus(`Playing album from queue top: ${albumTitle}`, 'ok');
} catch (error) {
setMusicStatus(`Album playback failed: ${error.message || 'Unknown error'}`, 'error');
}
}
async function playCurrentAlbumDetailNow() {
if (musicSearchState?.detailView?.type !== 'album') return;
const detail = musicSearchState.detailView.payload || {};
const tracks = Array.isArray(detail?.tracks) ? detail.tracks : [];
const title = detail?.album?.title || 'Album';
await playAlbumTracksAtTop(tracks, title);
}
function renderArtistDetail(artist) {
const topTracks = Array.isArray(artist?.tracks) ? artist.tracks : [];
const albums = Array.isArray(artist?.albums) ? artist.albums : [];
musicTracks = topTracks;
setResultsContext('artist-detail', artist?.id || '');
musicSearchState.detailView = { type: 'artist', payload: artist };
const header = `
<li class="musicSectionHeader">ARTIST • ${escapeMusicHtml(artist?.name || 'Unknown artist')}</li>
`;
const albumsHtml = renderAlbumSection(albums, false);
const tracksHtml = topTracks.length
? `<li class="musicSectionHeader">TOP TRACKS</li>${renderTrackRows(topTracks, { allowReorder: false })}`
: '<li class="musicResultEmpty">No top tracks found.</li>';
musicEls.results.innerHTML = `${header}${albumsHtml ? '<li class="musicSectionDivider"></li>' + albumsHtml : ''}<li class="musicSectionDivider"></li>${tracksHtml}`;
updatePlayingResultHighlight();
}
function renderAlbumDetail(album, tracks) {
const safeTracks = Array.isArray(tracks) ? tracks : [];
musicTracks = safeTracks;
setResultsContext('album-detail', album?.id || '');
musicSearchState.detailView = { type: 'album', payload: { album, tracks: safeTracks } };
const artistName = album?.artist?.name || album?.artistName || 'Unknown artist';
const releaseYear = album?.releaseDate ? new Date(album.releaseDate).getFullYear() : '';
const albumId = escapeMusicHtml(String(album?.id || ''));
musicEls.results.innerHTML = `
<li class="musicSectionHeader">ALBUM • ${escapeMusicHtml(album?.title || 'Unknown album')}</li>
<li class="musicSectionBlock">
<div class="musicAlbumCard" style="max-width: 360px;">
<img class="musicAlbumCardImage" src="${escapeMusicHtml(musicApi.getCoverUrl(album?.cover, 320))}" alt="" loading="lazy" data-play-album-now="${albumId}" data-album-detail-cover="true" style="cursor:pointer;" />
<div class="musicAlbumCardTitle">${escapeMusicHtml(album?.title || 'Unknown album')}</div>
<div class="musicAlbumCardMeta">${escapeMusicHtml(artistName)}${releaseYear ? `${escapeMusicHtml(String(releaseYear))}` : ''}</div>
<div class="musicAlbumCardActions">
<button class="btn musicAddBtn" type="button" data-share-album-id="${albumId}" data-share-album-title="${escapeMusicHtml(album?.title || 'Unknown album')}" data-share-album-cover="${escapeMusicHtml(album?.cover || '')}" title="Share album">Share</button>
</div>
</div>
</li>
<li class="musicSectionDivider"></li>
<li class="musicSectionHeader">TRACKS</li>
${safeTracks.length ? renderTrackRows(safeTracks, { allowReorder: false }) : '<li class="musicResultEmpty">No tracks found.</li>'}
`;
updatePlayingResultHighlight();
}
async function drillDownToArtist(artistId) {
if (!artistId) return;
setMusicStatus('Loading artist...', 'neutral');
try {
const artist = await musicApi.getArtist(artistId);
renderArtistDetail(artist);
setMusicStatus(`Loaded artist: ${artist?.name || 'Unknown artist'}`, 'ok');
} catch (error) {
setMusicStatus(`Artist load failed: ${error.message || 'Unknown error'}`, 'error');
}
}
async function drillDownToAlbum(albumId) {
if (!albumId) return;
setMusicStatus('Loading album...', 'neutral');
try {
const payload = await musicApi.getAlbum(albumId);
renderAlbumDetail(payload?.album || {}, payload?.tracks || []);
setMusicStatus(`Loaded album: ${payload?.album?.title || 'Unknown album'}`, 'ok');
} catch (error) {
setMusicStatus(`Album load failed: ${error.message || 'Unknown error'}`, 'error');
}
}
async function playTrackById(trackId) {
if (!trackId) return;
setMusicStatus('Loading track...', 'neutral');
try {
const res = await musicApi.searchTracks(trackId);
const items = Array.isArray(res?.items) ? res.items : [];
const exact = items.find((t) => String(t?.id || '') === String(trackId));
const track = exact || items[0] || null;
if (!track) {
setMusicStatus('Track not found.', 'error');
return;
}
const normalized = normalizeQueueTrack(track);
if (!normalized) {
setMusicStatus('Track could not be loaded.', 'error');
return;
}
musicQueue.unshift(normalized);
renderQueue();
saveQueueLocal();
await playQueueAt(0);
} catch (error) {
setMusicStatus(`Track load failed: ${error.message || 'Unknown error'}`, 'error');
}
}
function buildSharePath(kind, data = {}) {
if (kind === 'track') return `/track/${encodeURIComponent(String(data.id || ''))}`;
if (kind === 'album') return `/album/${encodeURIComponent(String(data.id || ''))}`;
if (kind === 'artist') return `/artist/${encodeURIComponent(String(data.id || ''))}`;
if (kind === 'playlist') {
if (data.pubkey) {
return `/playlist/${encodeURIComponent(String(data.pubkey || ''))}/${encodeURIComponent(String(data.identifier || ''))}`;
}
return `/playlist/${encodeURIComponent(String(data.identifier || ''))}`;
}
return '/';
}
function buildAbsoluteShareUrl(path) {
const hashPath = path.startsWith('/') ? path : `/${path}`;
return `${window.location.origin}${window.location.pathname}#${hashPath}`;
}
function isImageUrl(value = '') {
return /^https?:\/\/\S+\.(?:png|jpe?g|gif|webp|bmp|svg|avif|ico)(?:[?#]\S*)?$/i.test(value.trim());
}
function updateShareComposerPreview() {
if (!musicEls.shareRendered) return;
const raw = String(musicEls.shareText?.value || '').trim();
if (!raw) {
musicEls.shareRendered.innerHTML = '';
return;
}
const lines = raw.split(/\r?\n/);
const html = lines
.map((line) => {
const trimmed = line.trim();
if (!trimmed) return '<div class="musicShareRenderedSpacer"></div>';
if (/^https?:\/\/\S+$/i.test(trimmed)) {
const safeUrl = escapeMusicHtml(trimmed);
if (isImageUrl(trimmed)) {
return `<a class="musicShareRenderedLink" href="${safeUrl}" target="_blank" rel="noopener noreferrer">${safeUrl}</a><img class="musicShareRenderedImage" src="${safeUrl}" alt="Shared image" loading="lazy" />`;
}
return `<a class="musicShareRenderedLink" href="${safeUrl}" target="_blank" rel="noopener noreferrer">${safeUrl}</a>`;
}
return `<div class="musicShareRenderedLine">${escapeMusicHtml(line)}</div>`;
})
.join('');
musicEls.shareRendered.innerHTML = html;
}
function openShareModal(label, url, artUrl = '') {
pendingShareLabel = label || 'Item';
pendingShareUrl = url || '';
pendingShareArtUrl = artUrl || '';
if (!musicEls.shareModal || !musicEls.shareText || !musicEls.shareTarget) return;
musicEls.shareTarget.textContent = `Sharing: ${pendingShareLabel}`;
const seedLines = [pendingShareUrl, pendingShareArtUrl].filter(Boolean);
musicEls.shareText.value = `\n\n${seedLines.join('\n\n')}`;
updateShareComposerPreview();
musicEls.shareModal.classList.remove('hidden');
musicEls.shareModal.setAttribute('aria-hidden', 'false');
musicEls.shareText.focus();
musicEls.shareText.setSelectionRange(0, 0);
}
function closeShareModal() {
if (!musicEls.shareModal) return;
musicEls.shareModal.classList.add('hidden');
musicEls.shareModal.setAttribute('aria-hidden', 'true');
pendingShareLabel = '';
pendingShareUrl = '';
pendingShareArtUrl = '';
if (musicEls.shareRendered) {
musicEls.shareRendered.textContent = '';
}
}
function closeContainingItemMenu(target) {
const menu = target?.closest?.('[data-item-menu]');
if (menu && typeof menu.open === 'boolean') {
menu.open = false;
}
}
function resolveShareArtUrl(kind, data = {}) {
if (kind === 'track') return musicApi.getCoverUrl(data.cover || '', 320);
if (kind === 'album') return musicApi.getCoverUrl(data.cover || '', 320);
if (kind === 'artist') {
return musicApi.getArtistPictureUrl(data.picture || '', 320) || musicApi.getCoverUrl(data.picture || '', 320);
}
if (kind === 'playlist') {
if (typeof data.image === 'string' && data.image.startsWith('http')) return data.image;
return musicApi.getCoverUrl(data.cover || '', 320);
}
return '';
}
function openShareFor(kind, data = {}) {
const path = buildSharePath(kind, data);
const url = buildAbsoluteShareUrl(path);
const artUrl = resolveShareArtUrl(kind, data);
const label = data.label || kind;
openShareModal(label, url, artUrl);
}
async function publishShareNote() {
if (!pendingShareUrl) {
setMusicStatus('No share URL available.', 'error');
return;
}
const content = (musicEls.shareText?.value || '').trim();
const publishBtn = musicEls.sharePublishBtn;
const cancelBtn = musicEls.shareCancelBtn;
try {
if (publishBtn) publishBtn.disabled = true;
if (cancelBtn) cancelBtn.disabled = true;
if (!window.nostr || typeof window.nostr.signEvent !== 'function') {
throw new Error('window.nostr.signEvent is not available');
}
const unsignedEvent = {
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [
['r', pendingShareUrl],
...(pendingShareArtUrl ? [['r', pendingShareArtUrl]] : []),
['t', 'music']
],
content,
};
const signedEvent = await window.nostr.signEvent(unsignedEvent);
console.log('[music share debug] signed kind-1 event:', signedEvent);
const publishResult = await publishRawEvent(signedEvent);
console.log('[music share debug] publish result:', publishResult);
closeShareModal();
setMusicStatus('Share published; see console for relay results.', 'ok');
} catch (error) {
console.error(error);
setMusicStatus(`Share signing failed: ${error.message || 'Unknown error'}`, 'error');
} finally {
if (publishBtn) publishBtn.disabled = false;
if (cancelBtn) cancelBtn.disabled = false;
}
}
function showPlaylistFromRecord(playlist, mode, key = '') {
if (!playlist) {
setMusicStatus('Playlist not found.', 'error');
return;
}
if (mode === 'my-playlist') {
selectMyPlaylist(playlist.identifier);
} else {
activePlaylistView = { scope: 'friend', key: key || playlist.identifier || '', playlist };
renderPlaylistProfileCard();
}
musicTracks = Array.isArray(playlist.tracks) ? [...playlist.tracks] : [];
setResultsContext(mode, key || playlist.identifier || '');
renderMusicResults(musicTracks);
setMusicStatus(`Loaded playlist: ${playlist.title || 'Untitled playlist'}`, 'ok');
}
function openMyPlaylistByIdentifier(identifier) {
const playlist = myPlaylists.get(identifier);
showPlaylistFromRecord(playlist, 'my-playlist', identifier);
}
function getFriendPlaylistByKey(key) {
const needle = String(key || '').trim();
if (!needle) return null;
let playlist = friendPlaylists.get(needle) || null;
if (playlist) return playlist;
playlist = Array.from(friendPlaylists.values()).find((item) => {
if (!item) return false;
if (String(item.eventId || '') === needle) return true;
return getFriendPlaylistRouteKey(item) === needle;
}) || null;
return playlist;
}
function openFriendPlaylistByKey(key) {
const playlist = getFriendPlaylistByKey(key);
showPlaylistFromRecord(playlist, 'friend-playlist', key);
}
async function handleRoute() {
if (isHandlingRoute) return;
isHandlingRoute = true;
try {
const { page, parts } = parseRoute();
if (page === 'search') {
const query = parts.join('/').trim();
if (!query) {
musicEls.results.innerHTML = '<li class="musicResultEmpty">Search songs, albums, artists…</li>';
setResultsContext('search');
setMusicStatus('Ready.', 'ok');
updateNavigationControls();
return;
}
musicEls.input.value = query;
await runMusicSearch(query, { updateRoute: false });
updateNavigationControls();
return;
}
if (page === 'album') {
const albumId = parts[0] || '';
if (albumId) {
await drillDownToAlbum(albumId);
updateNavigationControls();
return;
}
}
if (page === 'artist') {
const artistId = parts[0] || '';
if (artistId) {
await drillDownToArtist(artistId);
updateNavigationControls();
return;
}
}
if (page === 'track') {
const trackId = parts[0] || '';
if (trackId) {
await playTrackById(trackId);
updateNavigationControls();
return;
}
}
if (page === 'playlist') {
if (parts.length >= 2) {
const key = `${parts[0]}:${parts.slice(1).join('/')}`;
openFriendPlaylistByKey(key);
updateNavigationControls();
return;
}
if (parts.length === 1) {
openMyPlaylistByIdentifier(parts[0]);
updateNavigationControls();
return;
}
}
setResultsContext('search');
musicTracks = [];
musicSearchState = {
query: '',
intent: 'track',
tracks: [],
albums: [],
artists: [],
detailView: null,
};
musicEls.results.innerHTML = '<li class="musicResultEmpty">Search songs, albums, artists…</li>';
setMusicStatus('Ready.', 'ok');
updateNavigationControls();
} finally {
isHandlingRoute = false;
}
}
function navigateBack() {
window.history.back();
}
function navigateForward() {
window.history.forward();
}
function navigateHome() {
navigate('/');
}
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');
}
function sanitizeFilenamePart(value) {
return String(value || '')
.replace(/[\\/:*?"<>|]+/g, '_')
.replace(/\s+/g, ' ')
.trim();
}
function extensionFromMime(mime = '') {
const value = String(mime || '').toLowerCase();
if (value.includes('flac')) return 'flac';
if (value.includes('mpeg') || value.includes('mp3')) return 'mp3';
if (value.includes('aac')) return 'aac';
if (value.includes('ogg')) return 'ogg';
return 'm4a';
}
async function downloadMusicByIndex(index) {
if (!Number.isInteger(index) || index < 0 || index >= musicTracks.length) return;
const track = musicTracks[index];
if (!track?.id) {
setMusicStatus('Track not available for download.', 'error');
return;
}
const title = sanitizeFilenamePart(track.title || 'Unknown title') || 'Unknown title';
const artist = sanitizeFilenamePart(track.artist || 'Unknown artist') || 'Unknown artist';
setMusicStatus(`Preparing download: ${title}`, 'neutral');
try {
const stream = await musicApi.getTrackStream(track.id, 'LOSSLESS');
const streamUrl = stream?.streamUrl;
if (!streamUrl) throw new Error('No stream URL for download');
let blob;
try {
const res = await fetch(streamUrl);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
blob = await res.blob();
} catch {
const fallback = document.createElement('a');
fallback.href = streamUrl;
fallback.target = '_blank';
fallback.rel = 'noopener noreferrer';
fallback.click();
setMusicStatus('Opened stream in a new tab; use browser save/download if direct download is blocked.', 'neutral');
return;
}
const ext = extensionFromMime(blob.type);
const fileName = `${artist} - ${title}.${ext}`;
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = fileName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(objectUrl);
setMusicStatus(`Downloaded: ${fileName}`, 'ok');
} catch (error) {
console.error(error);
setMusicStatus(`Download failed: ${error.message || 'Unknown error'}`, 'error');
}
}
async function playMusicByIndex(index) {
if (!Number.isInteger(index) || index < 0 || index >= musicTracks.length) return;
const normalized = normalizeQueueTrack(musicTracks[index]);
if (!normalized) return;
musicQueue.unshift(normalized);
renderQueue();
saveQueueLocal();
await playQueueAt(0);
}
async function runMusicSearch(query, { updateRoute = true } = {}) {
const trimmed = query.trim();
if (!trimmed) return;
if (updateRoute) {
navigate(`/search/${encodeURIComponent(trimmed)}`);
return;
}
setMusicSearchLoading(true);
setMusicStatus(`Searching for "${trimmed}"...`, 'neutral');
try {
const [tracksRes, albumsRes, artistsRes] = await Promise.all([
musicApi.searchTracks(trimmed).catch(() => ({ items: [] })),
musicApi.searchAlbums(trimmed).catch(() => ({ items: [] })),
musicApi.searchArtists(trimmed).catch(() => ({ items: [] })),
]);
const tracks = Array.isArray(tracksRes?.items) ? tracksRes.items : [];
const albums = Array.isArray(albumsRes?.items) ? albumsRes.items : [];
const artists = Array.isArray(artistsRes?.items) ? artistsRes.items : [];
const intent = classifySearchIntent(trimmed, { tracks, albums, artists });
musicTracks = tracks;
musicSearchState = {
query: trimmed,
intent,
tracks,
albums,
artists,
detailView: null,
};
setResultsContext('search');
renderUnifiedResults(musicSearchState);
const total = tracks.length + albums.length + artists.length;
setMusicStatus(`Found ${total} result${total === 1 ? '' : 's'}.`, 'ok');
} catch (error) {
console.error(error);
musicTracks = [];
musicSearchState = {
query: trimmed,
intent: 'track',
tracks: [],
albums: [],
artists: [],
detailView: null,
};
setResultsContext('search');
renderUnifiedResults(musicSearchState);
setMusicStatus(`Search failed: ${error.message || 'Unknown error'}`, 'error');
} finally {
setMusicSearchLoading(false);
}
}
function installImageFallbacks() {
document.addEventListener('error', (event) => {
const target = event.target;
if (!(target instanceof HTMLImageElement)) return;
if (target.src === BLANK_IMAGE_PIXEL) return;
target.src = BLANK_IMAGE_PIXEL;
}, true);
}
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');
}
installImageFallbacks();
musicPlayer.onTrackChanged = (track) => {
updateMusicPlayerMeta(track);
};
musicPlayer.onEnded = async () => {
await playNextInQueue({ fromEnded: true });
};
musicPlayer.setResolver(resolveMusicTrack);
musicEls.form.addEventListener('submit', async (event) => {
event.preventDefault();
await runMusicSearch(musicEls.input.value, { updateRoute: true });
});
musicEls.results.addEventListener('click', async (event) => {
const menuToggle = event.target.closest('[data-menu-toggle]');
if (menuToggle) {
event.stopPropagation();
return;
}
const menuAction = event.target.closest('.musicMenuItem');
if (menuAction) {
closeContainingItemMenu(menuAction);
}
const playAlbumNow = event.target.closest('[data-play-album-now]');
if (playAlbumNow) {
await playCurrentAlbumDetailNow();
return;
}
const queueAlbumIdBtn = event.target.closest('[data-queue-album-id]');
if (queueAlbumIdBtn) {
await queueAlbumById(queueAlbumIdBtn.dataset.queueAlbumId || '');
return;
}
const shareArtistBtn = event.target.closest('[data-share-artist-id]');
if (shareArtistBtn) {
openShareFor('artist', {
id: shareArtistBtn.dataset.shareArtistId || '',
label: shareArtistBtn.dataset.shareArtistName || 'Artist',
picture: shareArtistBtn.dataset.shareArtistPicture || '',
});
return;
}
const shareAlbumBtn = event.target.closest('[data-share-album-id]');
if (shareAlbumBtn) {
openShareFor('album', {
id: shareAlbumBtn.dataset.shareAlbumId || '',
label: shareAlbumBtn.dataset.shareAlbumTitle || 'Album',
cover: shareAlbumBtn.dataset.shareAlbumCover || '',
});
return;
}
const artistCard = event.target.closest('[data-open-artist-id]');
if (artistCard) {
navigate(`/artist/${encodeURIComponent(artistCard.dataset.openArtistId || '')}`);
return;
}
const albumOpen = event.target.closest('[data-open-album-id]');
if (albumOpen) {
navigate(`/album/${encodeURIComponent(albumOpen.dataset.openAlbumId || '')}`);
return;
}
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 downloadBtn = event.target.closest('[data-download-index]');
if (downloadBtn) {
const downloadIndex = Number(downloadBtn.dataset.downloadIndex);
await downloadMusicByIndex(downloadIndex);
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 shareTrackBtn = event.target.closest('[data-share-index]');
if (shareTrackBtn) {
const shareIndex = Number(shareTrackBtn.dataset.shareIndex);
const track = musicTracks[shareIndex];
if (track?.id) {
openShareFor('track', { id: track.id, label: track.title || 'Track', cover: track.cover || '' });
}
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', async (event) => {
const menuToggle = event.target.closest('[data-menu-toggle]');
if (menuToggle) {
event.stopPropagation();
return;
}
const menuAction = event.target.closest('.musicMenuItem');
if (menuAction) {
closeContainingItemMenu(menuAction);
}
const playBtn = event.target.closest('[data-play-playlist-id]');
if (playBtn) {
const playlist = myPlaylists.get(playBtn.dataset.playPlaylistId);
if (playlist) await 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 shareBtn = event.target.closest('[data-share-playlist-id]');
if (shareBtn) {
const playlist = myPlaylists.get(shareBtn.dataset.sharePlaylistId);
if (playlist) {
openShareFor('playlist', {
identifier: playlist.identifier,
label: playlist.title || 'Playlist',
image: playlist.image || '',
});
}
return;
}
const deleteBtn = event.target.closest('[data-delete-playlist-id]');
if (deleteBtn) {
await deleteMyPlaylist(deleteBtn.dataset.deletePlaylistId);
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);
navigate(`/playlist/${encodeURIComponent(playlist.identifier)}`);
});
musicEls.friendPlaylistsList.addEventListener('click', async (event) => {
const menuToggle = event.target.closest('[data-menu-toggle]');
if (menuToggle) {
event.stopPropagation();
return;
}
const menuAction = event.target.closest('.musicMenuItem');
if (menuAction) {
closeContainingItemMenu(menuAction);
}
const playBtn = event.target.closest('[data-play-friend-playlist-key]');
if (playBtn) {
const playlist = getFriendPlaylistByKey(playBtn.dataset.playFriendPlaylistKey);
if (playlist) await queuePlaylist(playlist, { playNow: true });
return;
}
const queueBtn = event.target.closest('[data-queue-friend-playlist-key]');
if (queueBtn) {
const playlist = getFriendPlaylistByKey(queueBtn.dataset.queueFriendPlaylistKey);
if (playlist) queuePlaylist(playlist, { playNow: false });
return;
}
const copyBtn = event.target.closest('[data-copy-friend-playlist-key]');
if (copyBtn) {
const playlist = getFriendPlaylistByKey(copyBtn.dataset.copyFriendPlaylistKey);
if (!playlist) {
setMusicStatus('Friend playlist not found.', 'error');
return;
}
const copyTitle = `${playlist.title || 'Untitled playlist'} (Copy)`;
const identifier = createPlaylistIdentifier(copyTitle);
const copiedTracks = (Array.isArray(playlist.tracks) ? playlist.tracks : [])
.map((track) => normalizeQueueTrack(track))
.filter(Boolean);
const copiedPlaylist = {
identifier,
title: copyTitle,
description: playlist.description || '',
image: playlist.image || '',
banner: playlist.banner || '',
tracks: copiedTracks,
pubkey: currentPubkey || '',
eventId: '',
};
myPlaylists.set(identifier, copiedPlaylist);
selectedPlaylistId = identifier;
activePlaylistView = { scope: 'my', key: identifier, playlist: copiedPlaylist };
savePlaylistsLocal();
renderMyPlaylists();
renderPlaylistProfileCard();
await publishPlaylistRecord(copiedPlaylist);
setMusicStatus(`Copied playlist: ${copyTitle}`, 'ok');
navigate(`/playlist/${encodeURIComponent(identifier)}`);
return;
}
const item = event.target.closest('[data-friend-playlist-key]');
if (!item) return;
const playlist = getFriendPlaylistByKey(item.dataset.friendPlaylistKey);
if (!playlist) return;
const pubkey = playlist.pubkey || '';
const identifier = playlist.identifier || '';
navigate(`/playlist/${encodeURIComponent(pubkey)}/${encodeURIComponent(identifier)}`);
});
musicEls.playlistNameInput?.addEventListener('keydown', async (event) => {
if (event.key !== 'Enter') return;
event.preventDefault();
await createNewPlaylist();
});
musicEls.playlistEditBtn?.addEventListener('click', () => {
const playlist = getCurrentPlaylist();
if (!playlist || !isOwnPlaylistRecord(playlist)) return;
isPlaylistProfileEditing = !isPlaylistProfileEditing;
renderPlaylistProfileCard();
});
const handlePlaylistProfileChange = async () => {
if (isSyncingPlaylistProfileInputs) return;
const playlist = getCurrentPlaylist();
if (!playlist || !isOwnPlaylistRecord(playlist)) return;
playlist.title = musicEls.playlistTitleInput?.value.trim() || 'Untitled playlist';
playlist.image = musicEls.playlistAvatarInput?.value.trim() || '';
playlist.banner = musicEls.playlistBannerInput?.value.trim() || '';
playlist.description = musicEls.playlistDescriptionInput?.value.trim() || '';
myPlaylists.set(playlist.identifier, playlist);
savePlaylistsLocal();
renderMyPlaylists();
renderPlaylistProfileCard();
await publishPlaylistRecord(playlist);
};
musicEls.playlistTitleInput?.addEventListener('change', () => { void handlePlaylistProfileChange(); });
musicEls.playlistAvatarInput?.addEventListener('change', () => { void handlePlaylistProfileChange(); });
musicEls.playlistBannerInput?.addEventListener('change', () => { void handlePlaylistProfileChange(); });
musicEls.playlistDescriptionInput?.addEventListener('change', () => { void handlePlaylistProfileChange(); });
musicEls.importInput?.addEventListener('input', () => {
scheduleImportAutoRun(1500);
});
musicEls.importInput?.addEventListener('paste', async (event) => {
const items = Array.from(event.clipboardData?.items || []);
const imageItem = items.find((item) => String(item.type || '').startsWith('image/'));
if (!imageItem) return;
event.preventDefault();
const file = imageItem.getAsFile();
if (!file) return;
try {
await attachImportImageFile(file);
scheduleImportAutoRun(200);
} catch (error) {
console.error('[music import] clipboard_image:read_error', error);
setImportStatus(`Clipboard image read failed: ${error?.message || 'Unknown error'}`);
}
});
musicEls.importDropZone?.addEventListener('dragenter', (event) => {
event.preventDefault();
importDragDepth += 1;
musicEls.importDropZone?.classList.add('dragover');
});
musicEls.importDropZone?.addEventListener('dragover', (event) => {
event.preventDefault();
});
musicEls.importDropZone?.addEventListener('dragleave', (event) => {
event.preventDefault();
importDragDepth = Math.max(0, importDragDepth - 1);
if (importDragDepth === 0) {
musicEls.importDropZone?.classList.remove('dragover');
}
});
musicEls.importDropZone?.addEventListener('drop', async (event) => {
event.preventDefault();
importDragDepth = 0;
musicEls.importDropZone?.classList.remove('dragover');
const files = Array.from(event.dataTransfer?.files || []);
if (!files.length) return;
for (const file of files) {
const isImage = String(file.type || '').startsWith('image/');
const isText = String(file.type || '').startsWith('text/') || /\.(txt|csv|md|json|m3u)$/i.test(file.name || '');
try {
if (isImage) {
await attachImportImageFile(file);
} else if (isText) {
await appendImportTextFile(file);
} else {
console.log('[music import] drop_file:unsupported', { name: file.name || '', type: file.type || '' });
}
} catch (error) {
console.error('[music import] drop_file:read_error', {
name: file.name || '',
error: error?.message || String(error || ''),
});
}
}
scheduleImportAutoRun(200);
});
musicEls.importResults?.addEventListener('click', async (event) => {
const addBtn = event.target.closest('#musicImportAddMatchesInlineBtn');
if (!addBtn) return;
console.log('[music import] importAddMatchesInlineBtn:clicked');
await addImportMatchesToPlaylist();
});
musicEls.shareCancelBtn?.addEventListener('click', closeShareModal);
musicEls.sharePublishBtn?.addEventListener('click', async () => {
await publishShareNote();
});
musicEls.shareText?.addEventListener('input', () => {
updateShareComposerPreview();
});
musicEls.shareText?.addEventListener('keydown', async (event) => {
if (event.key !== 'Enter') return;
if (event.shiftKey) return;
event.preventDefault();
await publishShareNote();
});
musicEls.shareModal?.addEventListener('click', (event) => {
if (event.target === musicEls.shareModal) {
closeShareModal();
}
});
musicEls.backBtn?.addEventListener('click', navigateBack);
musicEls.homeBtn?.addEventListener('click', navigateHome);
musicEls.forwardBtn?.addEventListener('click', navigateForward);
window.addEventListener('hashchange', () => {
void handleRoute();
});
musicEls.playPauseBtn.addEventListener('click', async () => {
try {
if (musicEls.audio.paused && musicQueue.length > 0) {
await playQueueAt(0);
return;
}
await musicPlayer.togglePlayPause();
} catch (error) {
console.error(error);
setMusicStatus(`Playback error: ${error.message || 'Unknown error'}`, 'error');
}
});
musicEls.prevBtn.addEventListener('click', async () => {
try {
await playPrevInQueue();
} catch (error) {
console.error(error);
setMusicStatus(`Previous track failed: ${error.message || 'Unknown error'}`, 'error');
}
});
musicEls.nextBtn.addEventListener('click', async () => {
try {
await playNextInQueue();
} catch (error) {
console.error(error);
setMusicStatus(`Next track failed: ${error.message || 'Unknown error'}`, 'error');
}
});
musicEls.audio.addEventListener('play', () => {
setMusicPlayButtonState();
updatePlayingResultHighlight();
});
musicEls.audio.addEventListener('pause', () => {
setMusicPlayButtonState();
updatePlayingResultHighlight();
});
musicEls.playbackModeBtn?.addEventListener('click', cyclePlaybackMode);
setMusicPlayButtonState();
updateMusicPlayerMeta(null);
updatePlaybackModeButton();
musicEls.queueClearBtn?.addEventListener('click', clearQueue);
musicEls.queueList?.addEventListener('click', async (event) => {
const menuToggle = event.target.closest('[data-menu-toggle]');
if (menuToggle) {
event.stopPropagation();
return;
}
const menuAction = event.target.closest('.musicMenuItem');
if (menuAction) {
closeContainingItemMenu(menuAction);
}
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();
subscribeAllPlaylists();
window.addEventListener('ndkEvent', handleMusicNdkEvent);
if (!window.location.hash) {
navigate('/');
} else {
await handleRoute();
}
}
/* ================================================================
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
================================================================ */