3258 lines
124 KiB
JavaScript
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
|
|
================================================================ */
|
|
|