//js/ui-interactions.js import { formatTime, getTrackTitle, getTrackArtists, escapeHtml, createQualityBadgeHTML, positionMenu, } from './utils.js'; import { sidePanelManager } from './side-panel.js'; import { downloadQualitySettings, contentBlockingSettings } from './storage.js'; import { db } from './db.js'; import { syncManager } from './accounts/pocketbase.js'; import { showNotification, downloadTracks } from './downloads.js'; import { trackSearchTabChange, trackOpenQueue } from './analytics.js'; import { SVG_CLOSE, SVG_BIN, SVG_HEART, SVG_DOWNLOAD, SVG_HEART_FILLED, SVG_SQUARE_PEN, SVG_TRASH, SVG_EQUAL, } from './icons.js'; import { hapticSuccess } from './haptics.js'; export function initializeUIInteractions(player, api, ui) { const sidebar = document.querySelector('.sidebar'); const sidebarOverlay = document.getElementById('sidebar-overlay'); const hamburgerBtn = document.getElementById('hamburger-btn'); const queueBtn = document.getElementById('queue-btn'); const libraryPage = document.getElementById('page-library'); if (libraryPage) { libraryPage.addEventListener('dragstart', (e) => { const playlistCard = e.target.closest('.card.user-playlist'); if (playlistCard) { e.dataTransfer.setData('text/playlist-id', playlistCard.dataset.userPlaylistId); e.dataTransfer.effectAllowed = 'move'; } }); const handleDragOver = (e) => { const folderCard = e.target.closest('.card[data-folder-id]'); if (folderCard && e.dataTransfer.types.includes('text/playlist-id')) { e.preventDefault(); folderCard.classList.add('drag-over'); } }; const handleDragLeave = (e) => { const folderCard = e.target.closest('.card[data-folder-id]'); if (folderCard) { folderCard.classList.remove('drag-over'); } }; const handleDrop = async (e) => { e.preventDefault(); const folderCard = e.target.closest('.card[data-folder-id]'); if (folderCard) { folderCard.classList.remove('drag-over'); const playlistId = e.dataTransfer.getData('text/playlist-id'); const folderId = folderCard.dataset.folderId; if (playlistId && folderId) { const updatedFolder = await db.addPlaylistToFolder(folderId, playlistId); syncManager.syncUserFolder(updatedFolder, 'update'); const subtitle = folderCard.querySelector('.card-subtitle'); if (subtitle) { subtitle.textContent = `${updatedFolder.playlists.length} playlists`; } showNotification('Playlist added to folder'); } } }; libraryPage.addEventListener('dragover', handleDragOver); libraryPage.addEventListener('dragleave', handleDragLeave); libraryPage.addEventListener('drop', handleDrop); } let draggedQueueIndex = null; let queueStartIndex = 0; let queueEndIndex = 1000; let isQueueRendering = false; let topObserver = null; let bottomObserver = null; const QUEUE_VIRTUALIZATION_THRESHOLD = 1500; const QUEUE_MAX_RENDERED = 1000; const QUEUE_CHUNK_SIZE = 200; const ESTIMATED_ITEM_HEIGHT = 58; // Sidebar mobile hamburgerBtn.addEventListener('click', () => { sidebar.classList.add('is-open'); sidebarOverlay.classList.add('is-visible'); }); const closeSidebar = () => { sidebar.classList.remove('is-open'); sidebarOverlay.classList.remove('is-visible'); }; sidebarOverlay.addEventListener('click', closeSidebar); sidebar.addEventListener('click', (e) => { if (e.target.closest('a')) { closeSidebar(); } }); // Queue panel const renderQueueControls = (container) => { const currentQueue = player.getCurrentQueue(); const showActionBtns = currentQueue.length > 0; container.innerHTML = ` `; container.querySelector('#close-side-panel-btn').addEventListener('click', () => { sidePanelManager.close(); }); const downloadBtn = container.querySelector('#download-queue-btn'); if (downloadBtn) { downloadBtn.addEventListener('click', async () => { downloadTracks(currentQueue, api, downloadQualitySettings.getQuality()); }); } const likeBtn = container.querySelector('#like-queue-btn'); if (likeBtn) { likeBtn.addEventListener('click', async () => { let addedCount = 0; for (const track of currentQueue) { const wasAdded = await db.toggleFavorite('track', track); if (wasAdded) { syncManager.syncLibraryItem('track', track, true); addedCount++; } } if (addedCount > 0) { showNotification(`Added ${addedCount} track${addedCount > 1 ? 's' : ''} to Liked`); } else { showNotification('All tracks in queue are already liked'); } refreshQueuePanel(); }); } const addToPlaylistBtn = container.querySelector('#add-queue-to-playlist-btn'); if (addToPlaylistBtn) { addToPlaylistBtn.addEventListener('click', async () => { const playlists = await db.getPlaylists(); if (playlists.length === 0) { showNotification('No playlists yet. Create one first.'); return; } const modal = document.createElement('div'); modal.className = 'modal active'; modal.innerHTML = ` `; document.body.appendChild(modal); const closeModal = () => { modal.remove(); }; modal.addEventListener('click', async (e) => { if (e.target.classList.contains('modal-overlay') || e.target.classList.contains('cancel-btn')) { closeModal(); return; } const option = e.target.closest('.modal-option'); if (option) { const playlistId = option.dataset.id; const playlistName = option.textContent; try { let addedCount = 0; for (const track of currentQueue) { await db.addTrackToPlaylist(playlistId, track); addedCount++; } const updatedPlaylist = await db.getPlaylist(playlistId); syncManager.syncUserPlaylist(updatedPlaylist, 'update'); showNotification(`Added ${addedCount} tracks to playlist: ${playlistName}`); } catch (error) { console.error('Failed to add tracks to playlist:', error); showNotification('Failed to add tracks to playlist'); } closeModal(); } }); }); } const clearBtn = container.querySelector('#clear-queue-btn'); if (clearBtn) { clearBtn.addEventListener('click', () => { player.clearQueue(); refreshQueuePanel(); }); } }; const renderQueueItemHTML = (track, index) => { const isPlaying = index === player.currentQueueIndex; const isBlocked = contentBlockingSettings?.shouldHideTrack(track); const trackTitle = getTrackTitle(track); const trackArtists = getTrackArtists(track, { fallback: 'Unknown' }); const qualityBadge = createQualityBadgeHTML(track); const blockedTitle = isBlocked ? `title="Blocked: ${contentBlockingSettings.isTrackBlocked(track.id) ? 'Track blocked' : contentBlockingSettings.isArtistBlocked(track.artist?.id) ? 'Artist blocked' : 'Album blocked'}"` : ''; const isVideo = track.type === 'video'; const coverUrl = isVideo && track.imageId ? api.getVideoCoverUrl(track.imageId) : api.getCoverUrl(track.album?.cover); return `
${SVG_EQUAL(16)}
${escapeHtml(trackTitle)} ${qualityBadge}
${escapeHtml(trackArtists)}
${isBlocked ? '--:--' : formatTime(track.duration)}
`; }; const attachQueueListeners = (container) => { if (container._queueListenersAttached) return; container.addEventListener('click', async (e) => { const item = e.target.closest('.queue-track-item'); if (!item) return; const index = parseInt(item.dataset.queueIndex); const removeBtn = e.target.closest('.queue-remove-btn'); if (removeBtn) { e.stopPropagation(); player.removeFromQueue(index); refreshQueuePanel(); return; } const likeBtn = e.target.closest('.queue-like-btn'); if (likeBtn && likeBtn.dataset.action === 'toggle-like') { e.stopPropagation(); const track = player.getCurrentQueue()[index]; if (track) { const added = await db.toggleFavorite('track', track); syncManager.syncLibraryItem('track', track, added); likeBtn.classList.toggle('active', added); likeBtn.innerHTML = added ? SVG_HEART_FILLED(20) : SVG_HEART(20); hapticSuccess(); showNotification(added ? `Added to Liked: ${track.title}` : `Removed from Liked: ${track.title}`); } return; } if (item.classList.contains('blocked')) return; player.playAtIndex(index); refreshQueuePanel(); }); container.addEventListener('contextmenu', async (e) => { const item = e.target.closest('.queue-track-item'); if (!item) return; e.preventDefault(); const index = parseInt(item.dataset.queueIndex); const contextMenu = document.getElementById('context-menu'); if (contextMenu) { const track = player.getCurrentQueue()[index]; if (track) { const isLiked = await db.isFavorite('track', track.id); const likeItem = contextMenu.querySelector('li[data-action="toggle-like"]'); if (likeItem) { likeItem.textContent = isLiked ? 'Unlike' : 'Like'; } const trackMixItem = contextMenu.querySelector('li[data-action="track-mix"]'); if (trackMixItem) { const hasMix = track.mixes && track.mixes.TRACK_MIX; trackMixItem.style.display = hasMix ? 'block' : 'none'; } positionMenu(contextMenu, e.clientX, e.clientY); contextMenu._contextTrack = track; } } }); container.addEventListener('dragstart', (e) => { const item = e.target.closest('.queue-track-item'); if (item) { draggedQueueIndex = parseInt(item.dataset.queueIndex); item.style.opacity = '0.5'; } }); container.addEventListener('dragend', (e) => { const item = e.target.closest('.queue-track-item'); if (item) { item.style.opacity = '1'; } }); container.addEventListener('dragover', (e) => { e.preventDefault(); }); container.addEventListener('drop', (e) => { e.preventDefault(); const item = e.target.closest('.queue-track-item'); if (item && draggedQueueIndex !== null) { const index = parseInt(item.dataset.queueIndex); if (draggedQueueIndex !== index) { player.moveInQueue(draggedQueueIndex, index); refreshQueuePanel(); } } }); container._queueListenersAttached = true; }; const renderQueueContent = (container, isUpdate = false) => { const currentQueue = player.getCurrentQueue(); if (currentQueue.length === 0) { container.innerHTML = '
Queue is empty.
'; queueStartIndex = 0; queueEndIndex = QUEUE_MAX_RENDERED; return; } isQueueRendering = true; attachQueueListeners(container); if (currentQueue.length > QUEUE_VIRTUALIZATION_THRESHOLD) { if (!isUpdate) { const currentIndex = player.currentQueueIndex || 0; queueStartIndex = Math.max(0, Math.floor((currentIndex - QUEUE_MAX_RENDERED / 2) / 100) * 100); queueEndIndex = Math.min(currentQueue.length, queueStartIndex + QUEUE_MAX_RENDERED); } const visibleTracks = currentQueue.slice(queueStartIndex, queueEndIndex); const topSpacerHeight = queueStartIndex * ESTIMATED_ITEM_HEIGHT; const bottomSpacerHeight = (currentQueue.length - queueEndIndex) * ESTIMATED_ITEM_HEIGHT; container.innerHTML = `
${visibleTracks.map((track, i) => renderQueueItemHTML(track, queueStartIndex + i)).join('')}
`; if (topObserver) topObserver.disconnect(); if (bottomObserver) bottomObserver.disconnect(); bottomObserver = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && !isQueueRendering && queueEndIndex < currentQueue.length) { queueEndIndex = Math.min(currentQueue.length, queueEndIndex + QUEUE_CHUNK_SIZE); if (queueEndIndex - queueStartIndex > QUEUE_MAX_RENDERED) { queueStartIndex += QUEUE_CHUNK_SIZE; } renderQueueContent(container, true); } }, { root: container, rootMargin: '200px' } ); topObserver = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && !isQueueRendering && queueStartIndex > 0) { queueStartIndex = Math.max(0, queueStartIndex - QUEUE_CHUNK_SIZE); if (queueEndIndex - queueStartIndex > QUEUE_MAX_RENDERED) { queueEndIndex -= QUEUE_CHUNK_SIZE; } renderQueueContent(container, true); } }, { root: container, rootMargin: '200px' } ); topObserver.observe(container.querySelector('#queue-top-sentinel')); bottomObserver.observe(container.querySelector('#queue-bottom-sentinel')); } else { container.innerHTML = `
${currentQueue.map((track, index) => renderQueueItemHTML(track, index)).join('')}
`; if (topObserver) topObserver.disconnect(); if (bottomObserver) bottomObserver.disconnect(); } container.querySelectorAll('.queue-track-item').forEach(async (item) => { const index = parseInt(item.dataset.queueIndex); const track = currentQueue[index]; const likeBtn = item.querySelector('.queue-like-btn'); if (likeBtn && track) { const isLiked = await db.isFavorite('track', track.id); likeBtn.classList.toggle('active', isLiked); likeBtn.innerHTML = isLiked ? SVG_HEART_FILLED(20) : SVG_HEART(20); } }); isQueueRendering = false; }; const refreshQueuePanel = () => { sidePanelManager.refresh('queue', renderQueueControls, renderQueueContent, { noClear: true }); }; const openQueuePanel = () => { trackOpenQueue(); sidePanelManager.open('queue', 'Queue', renderQueueControls, renderQueueContent); setTimeout(() => { const container = document.getElementById('side-panel-content'); const playingItem = container?.querySelector('.queue-track-item.playing'); if (playingItem) { playingItem.scrollIntoView({ block: 'center', behavior: 'auto' }); } }, 100); }; queueBtn.addEventListener('click', openQueuePanel); // Expose renderQueue for external updates (e.g. shuffle, add to queue) window.renderQueueFunction = () => { if (sidePanelManager.isActive('queue')) { refreshQueuePanel(); } const overlay = document.getElementById('fullscreen-cover-overlay'); if (overlay && getComputedStyle(overlay).display !== 'none') { ui.updateFullscreenMetadata(player.currentTrack, player.getNextTrack()); } }; const folderPage = document.getElementById('page-folder'); if (folderPage) { folderPage.addEventListener('dragover', (e) => { if (e.dataTransfer.types.includes('text/playlist-id')) { e.preventDefault(); folderPage.classList.add('drag-over-folder-page'); } }); folderPage.addEventListener('dragleave', () => { folderPage.classList.remove('drag-over-folder-page'); }); folderPage.addEventListener('drop', async (e) => { e.preventDefault(); folderPage.classList.remove('drag-over-folder-page'); const playlistId = e.dataTransfer.getData('text/playlist-id'); const folderId = window.location.pathname.split('/')[2]; if (playlistId && folderId) { try { const updatedFolder = await db.addPlaylistToFolder(folderId, playlistId); syncManager.syncUserFolder(updatedFolder, 'update'); window.dispatchEvent(new HashChangeEvent('hashchange')); showNotification('Playlist added to folder'); } catch (error) { console.error('Failed to add playlist to folder:', error); showNotification('Failed to add playlist to folder', 'error'); } } }); } // Search and Library tabs document.querySelectorAll('.search-tab').forEach((tab) => { tab.addEventListener('click', () => { const page = tab.closest('.page'); if (!page) return; // Track tab change trackSearchTabChange(tab.dataset.tab); page.querySelectorAll('.search-tab').forEach((t) => t.classList.remove('active')); page.querySelectorAll('.search-tab-content').forEach((c) => c.classList.remove('active')); tab.classList.add('active'); const prefix = page.id === 'page-library' ? 'library-tab-' : 'search-tab-'; const contentId = `${prefix}${tab.dataset.tab}`; document.getElementById(contentId)?.classList.add('active'); }); }); // Settings tabs document.querySelectorAll('.settings-tab').forEach((tab) => { tab.addEventListener('click', () => { document.querySelectorAll('.settings-tab').forEach((t) => t.classList.remove('active')); document.querySelectorAll('.settings-tab-content').forEach((c) => c.classList.remove('active')); tab.classList.add('active'); const contentId = `settings-tab-${tab.dataset.tab}`; document.getElementById(contentId)?.classList.add('active'); // Save active tab import('./storage.js').then(({ settingsUiState }) => { settingsUiState.setActiveTab(tab.dataset.tab); }); }); }); // Tooltip for truncated text (desktop hover only) const canUseHoverTooltips = window.matchMedia('(hover: hover) and (pointer: fine)').matches; let tooltipEl = null; if (canUseHoverTooltips) { tooltipEl = document.getElementById('custom-tooltip'); if (!tooltipEl) { tooltipEl = document.createElement('div'); tooltipEl.id = 'custom-tooltip'; document.body.appendChild(tooltipEl); } const updateTooltipPosition = (e) => { const x = e.clientX + 15; const y = e.clientY + 15; // Prevent going off-screen const rect = tooltipEl.getBoundingClientRect(); const winWidth = window.innerWidth; const winHeight = window.innerHeight; let finalX = x; let finalY = y; if (x + rect.width > winWidth) { finalX = e.clientX - rect.width - 10; } if (y + rect.height > winHeight) { finalY = e.clientY - rect.height - 10; } // Ensure it stays within viewport if (finalX < 5) finalX = 5; if (finalY < 5) finalY = 5; if (finalX + rect.width > winWidth - 5) finalX = winWidth - rect.width - 5; if (finalY + rect.height > winHeight - 5) finalY = winHeight - rect.height - 5; tooltipEl.style.transform = `translate(${finalX}px, ${finalY}px)`; // Reset top/left to 0 since we use transform tooltipEl.style.top = '0'; tooltipEl.style.left = '0'; }; document.body.addEventListener('mouseover', (e) => { const selector = '.card-title, .card-subtitle, .track-item-details .title, .track-item-details .artist, .now-playing-bar .title, .now-playing-bar .artist, .now-playing-bar .album, .pinned-item-name'; const target = e.target.closest(selector); if (target) { // Remove native title if present to avoid double tooltip if (target.hasAttribute('title')) { target.removeAttribute('title'); } if (target.scrollWidth > target.clientWidth) { tooltipEl.innerHTML = target.innerHTML.trim(); tooltipEl.classList.add('visible'); updateTooltipPosition(e); const moveHandler = (moveEvent) => { updateTooltipPosition(moveEvent); }; const outHandler = () => { tooltipEl.classList.remove('visible'); target.removeEventListener('mousemove', moveHandler); target.removeEventListener('mouseleave', outHandler); target.removeEventListener('click', outHandler); }; target.addEventListener('mousemove', moveHandler); target.addEventListener('mouseleave', outHandler); target.addEventListener('click', outHandler); } } }); } // Hide tooltip and context menu on any click to be safe document.addEventListener('mousedown', (e) => { if (tooltipEl) { tooltipEl.classList.remove('visible'); } const contextMenu = document.getElementById('context-menu'); if (contextMenu && contextMenu.style.display === 'block' && !contextMenu.contains(e.target)) { contextMenu.style.display = 'none'; } }); }