663 lines
27 KiB
JavaScript
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';
|
|
}
|
|
});
|
|
}
|