fix(playlists): queue loading in chunks for large playlists

This commit is contained in:
Samidy 2026-03-11 07:02:56 +03:00
parent 57a72ac5d7
commit b75245648d
2 changed files with 223 additions and 132 deletions

View file

@ -61,14 +61,16 @@ export class SidePanelManager {
return this.currentView === view && this.panel.classList.contains('active'); return this.currentView === view && this.panel.classList.contains('active');
} }
refresh(view, renderControlsCallback, renderContentCallback) { refresh(view, renderControlsCallback, renderContentCallback, options = {}) {
if (this.isActive(view)) { if (this.isActive(view)) {
if (renderControlsCallback) { if (renderControlsCallback) {
this.controlsElement.innerHTML = ''; this.controlsElement.innerHTML = '';
renderControlsCallback(this.controlsElement); renderControlsCallback(this.controlsElement);
} }
if (renderContentCallback) { if (renderContentCallback) {
if (!options.noClear) {
this.contentElement.innerHTML = ''; this.contentElement.innerHTML = '';
}
renderContentCallback(this.contentElement); renderContentCallback(this.contentElement);
} }
} }

View file

@ -75,6 +75,15 @@ export function initializeUIInteractions(player, api, ui) {
} }
let draggedQueueIndex = null; 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 // Sidebar mobile
hamburgerBtn.addEventListener('click', () => { hamburgerBtn.addEventListener('click', () => {
@ -232,16 +241,7 @@ export function initializeUIInteractions(player, api, ui) {
} }
}; };
const renderQueueContent = (container) => { const renderQueueItemHTML = (track, index) => {
const currentQueue = player.getCurrentQueue();
if (currentQueue.length === 0) {
container.innerHTML = '<div class="placeholder-text">Queue is empty.</div>';
return;
}
const html = currentQueue
.map((track, index) => {
const isPlaying = index === player.currentQueueIndex; const isPlaying = index === player.currentQueueIndex;
const isBlocked = contentBlockingSettings?.shouldHideTrack(track); const isBlocked = contentBlockingSettings?.shouldHideTrack(track);
const trackTitle = getTrackTitle(track); const trackTitle = getTrackTitle(track);
@ -282,26 +282,16 @@ export function initializeUIInteractions(player, api, ui) {
</button> </button>
</div> </div>
`; `;
}) };
.join('');
container.innerHTML = html; const attachQueueListeners = (container) => {
if (container._queueListenersAttached) return;
container.addEventListener('click', async (e) => {
const item = e.target.closest('.queue-track-item');
if (!item) return;
container.querySelectorAll('.queue-track-item').forEach(async (item) => {
const index = parseInt(item.dataset.queueIndex); const index = parseInt(item.dataset.queueIndex);
const track = player.getCurrentQueue()[index];
// Update like button state
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.replace('class="heart-icon"', 'class="heart-icon filled"')
: SVG_HEART;
}
item.addEventListener('click', async (e) => {
const removeBtn = e.target.closest('.queue-remove-btn'); const removeBtn = e.target.closest('.queue-remove-btn');
if (removeBtn) { if (removeBtn) {
e.stopPropagation(); e.stopPropagation();
@ -318,7 +308,6 @@ export function initializeUIInteractions(player, api, ui) {
const added = await db.toggleFavorite('track', track); const added = await db.toggleFavorite('track', track);
syncManager.syncLibraryItem('track', track, added); syncManager.syncLibraryItem('track', track, added);
// Update button state
likeBtn.classList.toggle('active', added); likeBtn.classList.toggle('active', added);
likeBtn.innerHTML = added likeBtn.innerHTML = added
? SVG_HEART.replace('class="heart-icon"', 'class="heart-icon filled"') ? SVG_HEART.replace('class="heart-icon"', 'class="heart-icon filled"')
@ -331,17 +320,18 @@ export function initializeUIInteractions(player, api, ui) {
return; return;
} }
// Don't play blocked tracks if (item.classList.contains('blocked')) return;
if (item.classList.contains('blocked')) {
return;
}
player.playAtIndex(index); player.playAtIndex(index);
refreshQueuePanel(); refreshQueuePanel();
}); });
item.addEventListener('contextmenu', async (e) => { container.addEventListener('contextmenu', async (e) => {
const item = e.target.closest('.queue-track-item');
if (!item) return;
e.preventDefault(); e.preventDefault();
const index = parseInt(item.dataset.queueIndex);
const contextMenu = document.getElementById('context-menu'); const contextMenu = document.getElementById('context-menu');
if (contextMenu) { if (contextMenu) {
const track = player.getCurrentQueue()[index]; const track = player.getCurrentQueue()[index];
@ -359,42 +349,141 @@ export function initializeUIInteractions(player, api, ui) {
} }
positionMenu(contextMenu, e.clientX, e.clientY); positionMenu(contextMenu, e.clientX, e.clientY);
contextMenu._contextTrack = track; contextMenu._contextTrack = track;
} }
} }
}); });
item.addEventListener('dragstart', () => { container.addEventListener('dragstart', (e) => {
draggedQueueIndex = index; const item = e.target.closest('.queue-track-item');
if (item) {
draggedQueueIndex = parseInt(item.dataset.queueIndex);
item.style.opacity = '0.5'; item.style.opacity = '0.5';
}
}); });
item.addEventListener('dragend', () => { container.addEventListener('dragend', (e) => {
const item = e.target.closest('.queue-track-item');
if (item) {
item.style.opacity = '1'; item.style.opacity = '1';
}
}); });
item.addEventListener('dragover', (e) => { container.addEventListener('dragover', (e) => {
e.preventDefault(); e.preventDefault();
}); });
item.addEventListener('drop', (e) => { container.addEventListener('drop', (e) => {
e.preventDefault(); e.preventDefault();
if (draggedQueueIndex !== null && draggedQueueIndex !== index) { 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); player.moveInQueue(draggedQueueIndex, index);
refreshQueuePanel(); refreshQueuePanel();
} }
}
}); });
container._queueListenersAttached = true;
};
const renderQueueContent = (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;
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((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 = `<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.replace('class="heart-icon"', 'class="heart-icon filled"')
: SVG_HEART;
}
}); });
isQueueRendering = false;
}; };
const refreshQueuePanel = () => { const refreshQueuePanel = () => {
sidePanelManager.refresh('queue', renderQueueControls, renderQueueContent); sidePanelManager.refresh('queue', renderQueueControls, renderQueueContent, { noClear: true });
}; };
const openQueuePanel = () => { const openQueuePanel = () => {
trackOpenQueue(); trackOpenQueue();
sidePanelManager.open('queue', 'Queue', renderQueueControls, renderQueueContent); 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); queueBtn.addEventListener('click', openQueuePanel);