kv-music/js/ui-interactions.js
2026-04-04 01:37:47 +03:00

663 lines
27 KiB
JavaScript

//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);
await 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 = async (container) => {
const currentQueue = player.getCurrentQueue();
const showActionBtns = currentQueue.length > 0;
container.innerHTML = `
<button id="download-queue-btn" class="btn-icon" title="Download Queue" style="display: ${showActionBtns ? 'flex' : 'none'}">
${SVG_DOWNLOAD(20)}
</button>
<button id="like-queue-btn" class="btn-icon" title="Add Queue to Liked" style="display: ${showActionBtns ? 'flex' : 'none'}">
${SVG_HEART(20)}
</button>
<button id="add-queue-to-playlist-btn" class="btn-icon" title="Add Queue to Playlist" style="display: ${showActionBtns ? 'flex' : 'none'}">
${SVG_SQUARE_PEN(20)}
</button>
<button id="clear-queue-btn" class="btn-icon" title="Clear Queue" style="display: ${showActionBtns ? 'flex' : 'none'}">
${SVG_TRASH(20)}
</button>
<button id="close-side-panel-btn" class="btn-icon" title="Close">
${SVG_CLOSE(20)}
</button>
`;
container.querySelector('#close-side-panel-btn').addEventListener('click', () => {
sidePanelManager.close();
});
const downloadBtn = container.querySelector('#download-queue-btn');
if (downloadBtn) {
downloadBtn.addEventListener('click', async () => {
await 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) {
await 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');
}
await 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 = `
<div class="modal-overlay"></div>
<div class="modal-content">
<h3>Add Queue to Playlist</h3>
<div class="modal-list">
${playlists
.map(
(p) => `
<div class="modal-option" data-id="${p.id}">${escapeHtml(p.name)}</div>
`
)
.join('')}
</div>
<div class="modal-actions">
<button class="btn-secondary cancel-btn">Cancel</button>
</div>
</div>
`;
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);
await 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', async () => {
player.clearQueue();
await 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 `
<div class="queue-track-item ${isPlaying ? 'playing' : ''} ${isBlocked ? 'blocked' : ''}" data-queue-index="${index}" data-track-id="${track.id}" draggable="${isBlocked ? 'false' : 'true'}" ${blockedTitle}>
<div class="drag-handle">
${SVG_EQUAL(16)}
</div>
<div class="track-item-info">
<img src="${coverUrl}"
class="track-item-cover" loading="lazy">
<div class="track-item-details">
<div class="title">${escapeHtml(trackTitle)} ${qualityBadge}</div>
<div class="artist">${escapeHtml(trackArtists)}</div>
</div>
</div>
<div class="track-item-duration">${isBlocked ? '--:--' : formatTime(track.duration)}</div>
<button class="queue-like-btn" data-action="toggle-like" title="Add to Liked">
${SVG_HEART(20)}
</button>
<button class="queue-remove-btn" data-track-index="${index}" title="Remove from queue">
${SVG_BIN(20)}
</button>
</div>
`;
};
const attachQueueListeners = async (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);
await 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);
await syncManager.syncLibraryItem('track', track, added);
likeBtn.classList.toggle('active', added);
likeBtn.innerHTML = added ? SVG_HEART_FILLED(20) : SVG_HEART(20);
await hapticSuccess();
showNotification(added ? `Added to Liked: ${track.title}` : `Removed from Liked: ${track.title}`);
}
return;
}
if (item.classList.contains('blocked')) return;
player.playAtIndex(index);
await 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', async (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);
await refreshQueuePanel();
}
}
});
container._queueListenersAttached = true;
};
const renderQueueContent = async (container, isUpdate = false) => {
const currentQueue = player.getCurrentQueue();
if (currentQueue.length === 0) {
container.innerHTML = '<div class="placeholder-text">Queue is empty.</div>';
queueStartIndex = 0;
queueEndIndex = QUEUE_MAX_RENDERED;
return;
}
isQueueRendering = true;
await 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 = `
<div class="queue-virtual-container" style="padding: 0.5rem">
<div id="queue-top-sentinel" style="height: 20px; margin-top: ${topSpacerHeight}px"></div>
<div class="queue-items-wrapper">
${visibleTracks.map((track, i) => renderQueueItemHTML(track, queueStartIndex + i)).join('')}
</div>
<div id="queue-bottom-sentinel" style="height: 20px; margin-bottom: ${bottomSpacerHeight}px"></div>
</div>
`;
if (topObserver) topObserver.disconnect();
if (bottomObserver) bottomObserver.disconnect();
bottomObserver = new IntersectionObserver(
async (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;
}
await renderQueueContent(container, true);
}
},
{ root: container, rootMargin: '200px' }
);
topObserver = new IntersectionObserver(
async (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;
}
await renderQueueContent(container, true);
}
},
{ root: container, rootMargin: '200px' }
);
topObserver.observe(container.querySelector('#queue-top-sentinel'));
bottomObserver.observe(container.querySelector('#queue-bottom-sentinel'));
} else {
container.innerHTML = `<div style="padding: 0.5rem">${currentQueue.map((track, index) => renderQueueItemHTML(track, index)).join('')}</div>`;
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 = async () => {
await 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 = async () => {
if (sidePanelManager.isActive('queue')) {
await 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);
await 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);
})
.catch(console.error);
});
});
// 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';
}
});
}