Implemented virutal scrolling for epg, optimized database batching, moved sync timer to server

This commit is contained in:
Trevor Mears
2026-01-04 00:46:51 -08:00
parent ca87a5f143
commit a00082b0ef
7 changed files with 293 additions and 131 deletions
+121 -119
View File
@@ -21,15 +21,19 @@ class EpgGuide {
this.favorites = new Set(); // Set<"sourceId:channelId">
this.selectedGroup = 'Favorites'; // Default to Favorites
// Lazy loading properties
// Virtual scrolling properties
this.filteredChannels = [];
this.currentBatch = 0;
this.batchSize = 20; // Channels per batch
this.rowHeight = 60; // Height of each channel row in pixels
this.bufferRows = 5; // Extra rows to render above/below viewport
this.startTime = null;
this.endTime = null;
this.epgContainer = null;
this.epgLoader = null;
this.epgObserver = null;
this.epgSpacer = null;
this.scrollContainer = null;
this.visibleRows = new Map(); // Map<index, rowElement>
this._scrollHandler = null;
this._lastVisibleStart = -1;
this._lastVisibleEnd = -1;
this.init();
}
@@ -117,43 +121,23 @@ class EpgGuide {
}
/**
* Get EPG refresh interval from settings (in hours)
*/
getRefreshInterval() {
// Read from server-side player settings (synced via Settings page)
if (window.app?.player?.settings?.epgRefreshInterval) {
return parseFloat(window.app.player.settings.epgRefreshInterval);
}
return 24; // Default 24 hours
}
/**
* Start background EPG refresh timer
* Automatically fetches fresh EPG data at the configured interval
* Start background EPG display refresh timer
* This only refreshes the UI from cached server data.
* The actual sync runs on the server independently.
*/
startBackgroundRefresh() {
// Clear any existing timer
this.stopBackgroundRefresh();
const intervalHours = this.getRefreshInterval();
// Refresh display from cache every 5 minutes to pick up server-side sync results
const refreshIntervalMs = 5 * 60 * 1000; // 5 minutes
// If interval is 0 or invalid, don't start timer (manual refresh only)
if (!intervalHours || intervalHours <= 0) {
console.log('[EPG] Background refresh disabled (manual only mode)');
this._currentRefreshInterval = 0;
return;
}
const intervalMs = intervalHours * 60 * 60 * 1000; // Convert hours to milliseconds
console.log(`[EPG] Starting background refresh timer: every ${intervalHours} hours (${Math.round(intervalMs / 1000)}s)`);
console.log('[EPG] Starting display refresh timer: every 5 minutes');
this._backgroundRefreshTimer = setInterval(async () => {
console.log('[EPG] Background refresh triggered');
console.log('[EPG] Refreshing EPG display from cache');
try {
await this.fetchEpgData(true); // Force refresh
this.lastRefreshTime = new Date();
console.log('[EPG] Background refresh complete');
await this.fetchEpgData(false); // Fetch cached data (no force refresh)
// Update channel list program info if visible
if (window.app?.channelList) {
@@ -161,12 +145,9 @@ class EpgGuide {
window.app.channelList.updateVisibleEpgInfo?.();
}
} catch (err) {
console.error('[EPG] Background refresh failed:', err);
console.error('[EPG] Display refresh failed:', err);
}
}, intervalMs);
// Store current interval so we can detect changes
this._currentRefreshInterval = intervalHours;
}, refreshIntervalMs);
}
/**
@@ -180,18 +161,6 @@ class EpgGuide {
}
}
/**
* Restart background refresh if interval has changed
* Called when settings change
*/
restartBackgroundRefreshIfNeeded() {
const newInterval = this.getRefreshInterval();
if (this._currentRefreshInterval !== newInterval) {
console.log(`[EPG] Refresh interval changed: ${this._currentRefreshInterval}h -> ${newInterval}h`);
this.startBackgroundRefresh();
}
}
/**
* Get last refresh time for display
*/
@@ -456,7 +425,10 @@ class EpgGuide {
// Generate time slots
const timeSlots = this.generateTimeSlots(this.startTime, this.endTime);
// Build initial HTML structure
// Calculate total height for virtual scrolling
const totalHeight = this.filteredChannels.length * this.rowHeight;
// Build HTML structure with virtual scroll container
this.container.innerHTML = `
<div class="epg-container" style="position: relative;">
<div class="epg-time-header">
@@ -466,80 +438,125 @@ class EpgGuide {
</div>
`).join('')}
</div>
<div class="epg-channel-rows"></div>
<div class="epg-loader" style="height: 50px; display: flex; align-items: center; justify-content: center;">
<div class="loading-spinner"></div>
<div class="epg-scroll-container" style="overflow-y: auto; max-height: calc(100vh - 200px);">
<div class="epg-spacer" style="height: ${totalHeight}px; position: relative;">
<div class="epg-channel-rows" style="position: absolute; top: 0; left: 0; right: 0;"></div>
</div>
</div>
</div>
`;
// Get references for batch rendering
// Get references for virtual scrolling
this.scrollContainer = this.container.querySelector('.epg-scroll-container');
this.epgSpacer = this.container.querySelector('.epg-spacer');
this.epgContainer = this.container.querySelector('.epg-channel-rows');
this.epgLoader = this.container.querySelector('.epg-loader');
// Reset batch state
this.currentBatch = 0;
// Clear visible rows cache
this.visibleRows.clear();
this._lastVisibleStart = -1;
this._lastVisibleEnd = -1;
// Set up IntersectionObserver for lazy loading
if (this.epgObserver) {
this.epgObserver.disconnect();
}
this.epgObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
this.renderNextEpgBatch();
}
}, { rootMargin: '200px' });
// Render initial batches (render enough to fill viewport)
for (let i = 0; i < 3; i++) {
this.renderNextEpgBatch();
// Remove old scroll handler if exists
if (this._scrollHandler) {
this.scrollContainer?.removeEventListener('scroll', this._scrollHandler);
}
// Start observing loader
this.epgObserver.observe(this.epgLoader);
// Set up scroll handler for virtual scrolling
this._scrollHandler = this.debounce(() => this.updateVisibleRows(), 16); // ~60fps
this.scrollContainer.addEventListener('scroll', this._scrollHandler);
// Initial render of visible rows
this.updateVisibleRows();
// Add now indicator
this.updateNowIndicator();
}
/**
* Render next batch of EPG channel rows
* Update visible rows based on scroll position (Virtual Scrolling)
*/
renderNextEpgBatch() {
const start = this.currentBatch * this.batchSize;
const end = start + this.batchSize;
const batch = this.filteredChannels.slice(start, end);
updateVisibleRows() {
if (!this.scrollContainer || !this.epgContainer) return;
if (batch.length === 0) {
this.epgLoader.style.display = 'none';
const scrollTop = this.scrollContainer.scrollTop;
const viewportHeight = this.scrollContainer.clientHeight;
// Calculate visible range
const startIndex = Math.max(0, Math.floor(scrollTop / this.rowHeight) - this.bufferRows);
const endIndex = Math.min(
this.filteredChannels.length - 1,
Math.ceil((scrollTop + viewportHeight) / this.rowHeight) + this.bufferRows
);
// Skip if nothing changed
if (startIndex === this._lastVisibleStart && endIndex === this._lastVisibleEnd) {
return;
}
let html = '';
for (const { epgChannel, sourceChannel } of batch) {
const isFavorite = this.favorites.has(`${sourceChannel.sourceId}:${sourceChannel.id}`);
this._lastVisibleStart = startIndex;
this._lastVisibleEnd = endIndex;
// Get programs if EPG data exists
let channelProgrammes = [];
if (epgChannel) {
channelProgrammes = this.programmes
.filter(p => p.channelId === epgChannel.id)
.filter(p => {
const start = new Date(p.start);
const stop = new Date(p.stop);
return start < this.endTime && stop > this.startTime;
})
.sort((a, b) => new Date(a.start) - new Date(b.start));
// Determine which rows to add and remove
const newVisibleSet = new Set();
for (let i = startIndex; i <= endIndex; i++) {
newVisibleSet.add(i);
}
// Remove rows that are no longer visible
for (const [index, row] of this.visibleRows) {
if (!newVisibleSet.has(index)) {
row.remove();
this.visibleRows.delete(index);
}
}
// Fallback values if EPG channel is missing
const logo = this.getProxiedImageUrl(sourceChannel.tvgLogo || (epgChannel && epgChannel.icon));
const name = sourceChannel.name || (epgChannel && epgChannel.name);
// Add new visible rows
for (let i = startIndex; i <= endIndex; i++) {
if (!this.visibleRows.has(i) && i < this.filteredChannels.length) {
const row = this.createChannelRow(i);
this.visibleRows.set(i, row);
this.epgContainer.appendChild(row);
}
}
}
html += `
<div class="epg-channel-row"
data-channel-id="${sourceChannel.id}"
data-source-id="${sourceChannel.sourceId}">
/**
* Create a channel row element for virtual scrolling
*/
createChannelRow(index) {
const { epgChannel, sourceChannel } = this.filteredChannels[index];
const isFavorite = this.favorites.has(`${sourceChannel.sourceId}:${sourceChannel.id}`);
// Get programs if EPG data exists
let channelProgrammes = [];
if (epgChannel) {
channelProgrammes = this.programmes
.filter(p => p.channelId === epgChannel.id)
.filter(p => {
const start = new Date(p.start);
const stop = new Date(p.stop);
return start < this.endTime && stop > this.startTime;
})
.sort((a, b) => new Date(a.start) - new Date(b.start));
}
// Fallback values if EPG channel is missing
const logo = this.getProxiedImageUrl(sourceChannel.tvgLogo || (epgChannel && epgChannel.icon));
const name = sourceChannel.name || (epgChannel && epgChannel.name);
const row = document.createElement('div');
row.className = 'epg-channel-row';
row.dataset.channelId = sourceChannel.id;
row.dataset.sourceId = sourceChannel.sourceId;
row.dataset.index = index;
// Position absolutely for virtual scrolling
row.style.position = 'absolute';
row.style.top = `${index * this.rowHeight}px`;
row.style.left = '0';
row.style.right = '0';
row.style.height = `${this.rowHeight}px`;
row.innerHTML = `
<div class="epg-channel-info">
<button class="favorite-btn ${isFavorite ? 'active' : ''}" title="${isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}">
${isFavorite ? Icons.favorite : Icons.favoriteOutline}
@@ -552,25 +569,10 @@ class EpgGuide {
<div class="epg-programs">
${this.renderProgrammes(channelProgrammes, this.startTime, this.endTime)}
</div>
</div>
`;
}
`;
// Append to container
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
while (tempDiv.firstElementChild) {
const row = tempDiv.firstElementChild;
this.attachRowListeners(row);
this.epgContainer.appendChild(row);
}
this.currentBatch++;
// Hide loader if no more batches
if (end >= this.filteredChannels.length) {
this.epgLoader.style.display = 'none';
}
this.attachRowListeners(row);
return row;
}
/**