Files
Nodecast/public/js/components/ChannelList.js
blomios 638a31d9ef feat(ui): surface live language filters and built-in EPG sources (#23)
Add language filter controls for projected live channels, preserve backend language views in the browser, and separate built-in EPG sources from custom sources.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-06 11:42:23 +02:00

1913 lines
79 KiB
JavaScript

/**
* Channel List Component
* Handles the sidebar channel list
*/
class ChannelList {
constructor() {
this.container = document.getElementById('channel-list');
this.searchInput = document.getElementById('channel-search');
this.sourceSelect = document.getElementById('source-select');
this.showHiddenCheckbox = document.getElementById('show-hidden');
this.toggleGroupsBtn = document.getElementById('toggle-groups');
this.contextMenu = document.getElementById('context-menu');
this.languageFilter = document.getElementById('channel-language-filter');
this.listStatus = document.getElementById('channel-list-status');
this.channels = [];
this.groups = [];
this.hiddenItems = new Set(); // Set<"type:sourceId:itemId">
this.collapsedGroups = new Set(); // Track collapsed groups
this._userExpandedGroups = new Set(); // Track groups user has explicitly expanded
this.favorites = []; // Array of favorite objects
this.visibleFavorites = new Set(); // Set<"sourceId:channelId">
this.currentChannel = null;
this.sources = [];
this.isLoading = false;
this.renderedChannels = [];
this.listMode = 'grouped';
this.logicalChannels = [];
this.projectedLogicalChannels = null;
this.availableLanguages = [];
this.selectedLanguages = new Set();
this.selectedVariants = new Map();
this.expandedVariants = new Set();
this.preferredLanguageKey = 'nodecast_tv_preferred_language';
this.channelLanguagePreferencesKey = 'nodecast_tv_channel_language_preferences';
this.preferredLanguage = this.loadPreferredLanguage();
this.channelLanguagePreferences = this.loadChannelLanguagePreferences();
this.layaLanguageResults = new Map();
this.languageDetectionNotice = '';
this.languageDetectionFallback = false;
this._languageDetectionRequest = 0;
this.projectionNotice = '';
this.loadCollapsedState();
this.restoreLanguageSelection();
this.init();
}
/**
* Get proxied image URL to avoid mixed content errors on HTTPS
* Only proxies HTTP URLs when on HTTPS page
*/
getProxiedImageUrl(url) {
if (!url || url.length === 0) return '/img/placeholder.png';
// Only proxy if we're on HTTPS and the image is HTTP
if (window.location.protocol === 'https:' && url.startsWith('http://')) {
return `/api/proxy/image?url=${encodeURIComponent(url)}`;
}
return url;
}
/**
* Load collapsed state from localStorage
*/
loadCollapsedState() {
try {
const saved = localStorage.getItem('nodecast_tv_collapsed_groups');
if (saved) {
this.collapsedGroups = new Set(JSON.parse(saved));
this._hasCollapsedState = true;
} else {
this._hasCollapsedState = false; // First load - will collapse all by default
}
} catch (err) {
console.error('Error loading collapsed state:', err);
this._hasCollapsedState = false;
}
}
loadPreferredLanguage() {
try {
return localStorage.getItem(this.preferredLanguageKey) || null;
} catch {
return null;
}
}
loadChannelLanguagePreferences() {
try {
return new Map(Object.entries(JSON.parse(localStorage.getItem(this.channelLanguagePreferencesKey) || '{}')));
} catch {
return new Map();
}
}
saveLanguagePreferences() {
try {
if (this.preferredLanguage) localStorage.setItem(this.preferredLanguageKey, this.preferredLanguage);
else localStorage.removeItem(this.preferredLanguageKey);
localStorage.setItem(this.channelLanguagePreferencesKey, JSON.stringify(Object.fromEntries(this.channelLanguagePreferences)));
} catch (err) {
console.warn('[ChannelList] Unable to persist language preferences:', err.message);
}
}
/**
* Save collapsed state to localStorage
*/
saveCollapsedState() {
try {
localStorage.setItem('nodecast_tv_collapsed_groups', JSON.stringify([...this.collapsedGroups]));
} catch (err) {
console.error('Error saving collapsed state:', err);
}
}
/**
* Toggle group collapsed state
*/
toggleGroup(groupName) {
if (this.collapsedGroups.has(groupName)) {
this.collapsedGroups.delete(groupName);
// Track that user explicitly expanded this group
this._userExpandedGroups.add(groupName);
} else {
this.collapsedGroups.add(groupName);
// User collapsed it, remove from expanded tracking
this._userExpandedGroups.delete(groupName);
}
this.saveCollapsedState();
}
/**
* Expand all groups
*/
expandAll() {
this.collapsedGroups.clear();
this.saveCollapsedState();
// Expand all and render channels for empty containers
this.container.querySelectorAll('.group-header.collapsed').forEach(h => {
h.classList.remove('collapsed');
const groupName = h.dataset.group;
const groupEl = h.closest('.channel-group');
const channelsContainer = groupEl?.querySelector('.group-channels');
if (channelsContainer && channelsContainer.children.length === 0) {
this.renderGroupChannels(groupName, channelsContainer);
}
});
// Update toggle button
if (this.toggleGroupsBtn) {
this.toggleGroupsBtn.innerHTML = Icons.collapseAll;
this.toggleGroupsBtn.title = 'Collapse All';
}
}
/**
* Collapse all groups
*/
collapseAll() {
this.container.querySelectorAll('.group-header').forEach(h => {
const groupName = h.dataset.group;
this.collapsedGroups.add(groupName);
h.classList.add('collapsed');
});
this.saveCollapsedState();
// Update toggle button
if (this.toggleGroupsBtn) {
this.toggleGroupsBtn.innerHTML = Icons.expandAll;
this.toggleGroupsBtn.title = 'Expand All';
}
}
/**
* Toggle between expand/collapse all
*/
toggleAllGroups() {
const allHeaders = this.container.querySelectorAll('.group-header');
const allCollapsed = [...allHeaders].every(h => h.classList.contains('collapsed'));
if (allCollapsed) {
this.expandAll();
} else {
this.collapseAll();
}
}
init() {
// Search handler (debounced)
let searchTimeout;
this.searchInput.addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
this.loadChannels({ refreshLanguages: false });
}, 300);
});
// Source filter handler
this.sourceSelect.addEventListener('change', () => {
this.restoreLanguageSelection();
this.loadChannels();
});
document.addEventListener('keydown', event => {
if (event.key === 'Escape') this.closeLanguagePanel();
});
this.toggleGroupsBtn?.addEventListener('click', () => {
if (this.listMode === 'grouped') this.toggleAllGroups();
});
// Show hidden toggle
if (this.showHiddenCheckbox) {
this.showHiddenCheckbox.addEventListener('change', () => this.render());
}
// Context menu handlers
document.addEventListener('click', (e) => {
// Don't close if clicking inside context menu
if (!this.contextMenu.contains(e.target)) {
this.hideContextMenu();
}
});
this.contextMenu.querySelectorAll('.context-item').forEach(item => {
item.addEventListener('click', (e) => {
e.stopPropagation(); // Prevent document click from firing
this.handleContextAction(e);
});
});
// Intersection Observer for lazy loading
this.observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
if (this.listMode === 'flat') this.renderNextFlatBatch();
else this.renderNextBatch();
}
}, { rootMargin: '100px' });
// Start EPG refresh timer (updates visible program info every 60 seconds)
this.startEpgRefreshTimer();
}
/**
* Start timer to refresh EPG info in visible channel items
* Updates every 60 seconds to keep "Now Playing" program info current
*/
startEpgRefreshTimer() {
// Clear any existing timer
if (this._epgRefreshTimer) {
clearInterval(this._epgRefreshTimer);
}
// Refresh every 60 seconds
this._epgRefreshTimer = setInterval(() => {
this.updateVisibleEpgInfo();
}, 60000);
}
/**
* Update EPG info for visible channel items without full re-render
* Only updates the program text, not the entire channel item
*/
updateVisibleEpgInfo() {
if (!window.app || !window.app.epgGuide) return;
// Clear the cache so we get fresh data
this.clearProgramInfoCache();
// Find all visible channel items and update their program info
const channelItems = this.container.querySelectorAll('.channel-item');
channelItems.forEach(item => {
const channel = this.logicalChannels.find(c => c.fingerprint === item.dataset.logicalId);
if (channel) {
const programElement = item.querySelector('.channel-program');
if (programElement) {
programElement.textContent = this.getProgramStatusText(channel);
}
}
});
}
// ... (loadSources, loadChannels, loadAllChannels, loadXtreamChannels, loadM3uChannels, loadHiddenItems, isHidden, loadFavorites, isFavorite, toggleFavorite methods remain same)
/**
* Get current program info string - cached for performance
*/
getProgramInfo(channel) {
try {
if (!window.app || !window.app.epgGuide) return null;
// Cache key: channel_id + current_minute (invalidate every minute)
const currentMinute = Math.floor(Date.now() / 60000);
const variant = channel.preferredVariant || channel;
const cacheKey = `${variant.tvgId || variant.name}:${currentMinute}`;
if (this._programInfoCache && this._programInfoCache.has(cacheKey)) {
return this._programInfoCache.get(cacheKey);
}
// Clear old cache entries if minute changed
if (!this._lastCacheMinute || this._lastCacheMinute !== currentMinute) {
this._programInfoCache = new Map();
this._lastCacheMinute = currentMinute;
}
let program = window.app.epgGuide.getCurrentProgram(variant.tvgId, variant.name);
if (!program && channel.variants) {
for (const fallback of channel.variants) {
program = window.app.epgGuide.getCurrentProgram(fallback.tvgId, fallback.name);
if (program) break;
}
}
const result = program ? program.title : null;
this._programInfoCache.set(cacheKey, result);
return result;
} catch (e) {
console.warn("Error in getProgramInfo", e);
return null;
}
}
getProgramStatusText(channel) {
const title = this.getProgramInfo(channel);
if (title) return `Live: ${title}`;
return window.app?.epgGuide?.loadError
? 'Program information unavailable'
: 'No program information';
}
/**
* Clear program info cache
* Useful when EPG data has been updated
*/
clearProgramInfoCache() {
if (this._programInfoCache) {
this._programInfoCache.clear();
}
}
setListMode(mode) {
this.listMode = mode === 'flat' ? 'flat' : 'grouped';
if (this.toggleGroupsBtn) {
this.toggleGroupsBtn.hidden = this.listMode === 'flat';
this.toggleGroupsBtn.disabled = this.listMode === 'flat';
this.toggleGroupsBtn.classList.toggle('hidden', this.listMode === 'flat');
}
if (this.channels.length > 0) this.render();
}
setLanguageDetectionMode(mode) {
const safeMode = ['algo', 'laya', 'hybrid'].includes(mode) ? mode : 'algo';
this.layaLanguageResults.clear();
this.languageDetectionNotice = '';
this.languageDetectionFallback = false;
if (this.channels.length > 0) {
this.render();
void this.refreshLayaLanguages(safeMode);
}
}
async refreshLayaLanguages(mode = window.app?.player?.settings?.languageDetectionMode || 'algo', options = {}) {
const requestId = ++this._languageDetectionRequest;
if (mode === 'algo') {
const hadWarning = Boolean(this.languageDetectionNotice || this.languageDetectionFallback);
this.layaLanguageResults.clear();
this.languageDetectionNotice = '';
this.languageDetectionFallback = false;
if (hadWarning) this.render();
return;
}
const descriptors = this.channels.map(variant => {
const algo = ChannelLanguageDetection.detectLanguage(variant, { sourceName: variant.sourceName });
return ChannelLanguageFusion.buildLayaDescriptor(
variant,
ChannelVariantGrouping.getVariantKey(variant),
algo
);
});
const results = new Map();
const totalBatches = Math.ceil(descriptors.length / 64);
try {
const status = await API.laya.getStatus();
const statusWarning = LayaStatusPresentation.channelListWarning(mode, status);
if (statusWarning) {
if (requestId !== this._languageDetectionRequest) return;
this.layaLanguageResults.clear();
this.languageDetectionFallback = true;
this.languageDetectionNotice = statusWarning;
this.render();
return;
}
if (requestId !== this._languageDetectionRequest) return;
this.languageDetectionNotice = '';
this.languageDetectionFallback = false;
this.render();
options.onProgress?.({ processed: 0, total: totalBatches });
for (let index = 0; index < descriptors.length; index += 64) {
const response = await API.laya.detectLanguages(descriptors.slice(index, index + 64));
(response.results || []).forEach(result => results.set(String(result.id), result));
if (requestId !== this._languageDetectionRequest) return;
options.onProgress?.({
processed: Math.floor(index / 64) + 1,
total: totalBatches
});
}
if (requestId !== this._languageDetectionRequest) return;
if (descriptors.length > 0 && results.size === 0) {
throw new Error('Local AI language detection returned no results');
}
this.layaLanguageResults = results;
this.languageDetectionNotice = '';
this.languageDetectionFallback = false;
} catch (err) {
if (requestId !== this._languageDetectionRequest) return;
this.layaLanguageResults.clear();
this.languageDetectionFallback = true;
this.languageDetectionNotice = LayaStatusPresentation.channelListWarning(mode, {}, true);
console.warn('[ChannelList] Local AI language detection unavailable:', err.message);
}
this.render();
}
escapeHtml(text) {
if (!text) return '';
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
/**
* Render channel list
*/
render() {
const searchTerm = this.searchInput.value.toLowerCase();
const showHidden = this.showHiddenCheckbox ? this.showHiddenCheckbox.checked : false;
this.listMode = window.app?.player?.settings?.liveChannelListMode === 'flat' ? 'flat' : 'grouped';
if (this.toggleGroupsBtn) {
this.toggleGroupsBtn.hidden = this.listMode === 'flat';
this.toggleGroupsBtn.disabled = this.listMode === 'flat';
this.toggleGroupsBtn.classList.toggle('hidden', this.listMode === 'flat');
}
this.logicalChannels = this.projectedLogicalChannels || ChannelVariantGrouping.groupChannels(this.channels, {
showHidden,
selectedVariants: this.selectedVariants,
preferredLanguage: this.preferredLanguage,
languagePreferences: this.channelLanguagePreferences,
languageMode: this.languageDetectionFallback
? 'algo'
: (window.app?.player?.settings?.languageDetectionMode || 'algo'),
languageResults: this.layaLanguageResults,
isFavorite: channel => this.isFavorite(channel.sourceId, channel.id),
isHidden: channel => this.isHidden('channel', channel.sourceId, channel.streamId || channel.id)
});
// Reset batching
this.currentBatch = 0;
this.batchSize = 100; // Number of groups to render per batch (increased to handle many hidden groups)
this.container.innerHTML = ''; // Clear container
if (this.projectionNotice) {
const notice = document.createElement('div');
notice.className = 'live-projection-notice';
notice.setAttribute('role', 'status');
notice.textContent = this.projectionNotice;
this.container.appendChild(notice);
}
if (this.languageDetectionNotice) {
const notice = document.createElement('div');
notice.className = 'laya-language-notice';
notice.setAttribute('role', 'status');
notice.textContent = this.languageDetectionNotice;
const settingsButton = document.createElement('button');
settingsButton.type = 'button';
settingsButton.className = 'btn btn-ghost laya-notice-action';
settingsButton.textContent = 'Open settings';
settingsButton.addEventListener('click', () => {
window.app?.navigateTo?.('settings');
window.app?.pages?.settings?.switchTab?.('player');
});
notice.appendChild(document.createTextNode(' '));
notice.appendChild(settingsButton);
this.container.appendChild(notice);
}
// Filter and Group channels
const groupedChannels = {};
// 1. Filter
this.filteredChannels = this.logicalChannels;
if (searchTerm && !this.projectedLogicalChannels) {
this.filteredChannels = this.logicalChannels.filter(ch =>
String(ch.displayName ?? '').toLowerCase().includes(searchTerm) ||
ch.variants.some(variant => String(variant.name ?? '').toLowerCase().includes(searchTerm)) ||
ch.variants.some(variant => String(variant.groupTitle ?? '').toLowerCase().includes(searchTerm)) ||
ch.variants.some(variant => `${variant.language.code} ${variant.language.label}`.toLowerCase().includes(searchTerm))
);
}
let filteredChannels = this.filteredChannels;
if (this.listStatus) this.listStatus.textContent = `${filteredChannels.length} channel entries shown`;
this.showHidden = showHidden;
if (this.listMode === 'flat') {
this.renderFlatList(filteredChannels, searchTerm);
return;
}
// 2. Group
filteredChannels.forEach(ch => {
const groupKey = ch.groupTitle || 'Uncategorized';
if (!groupedChannels[groupKey]) {
groupedChannels[groupKey] = [];
}
groupedChannels[groupKey].push(ch);
});
// 3. Add Favorites
const favoritedChannels = this.logicalChannels.filter(ch => ch.isFavorite);
if (favoritedChannels.length > 0) {
favoritedChannels.sort((a, b) => a.name.localeCompare(b.name));
groupedChannels['Favorites'] = favoritedChannels;
}
// 4. Sort Groups and filter to only those with visible channels
const allGroups = Object.keys(groupedChannels).sort((a, b) => {
if (a === 'Favorites') return -1;
if (b === 'Favorites') return 1;
return a.localeCompare(b);
});
// Pre-filter to only include groups with visible channels (so hidden groups don't consume batch slots)
this.sortedGroups = allGroups.filter(groupName => {
if (groupName === 'Favorites') return true;
const channels = groupedChannels[groupName];
// Check if any channel in this group is visible
return channels.some(channel => {
return !channel.isHidden || showHidden;
});
});
this.groupedChannels = groupedChannels;
this.showHidden = showHidden;
// Collapse all groups by default on first load (for large playlists)
// This prevents rendering 100K+ channel items on initial load
if (!this._hasCollapsedState && this.sortedGroups.length > 0) {
this.sortedGroups.forEach(groupName => {
if (groupName !== 'Favorites') {
this.collapsedGroups.add(groupName);
}
});
this._hasCollapsedState = true;
this.saveCollapsedState();
}
// Build rendered channel list for navigation (matches visual order)
this.renderedChannels = [];
this.sortedGroups.forEach(groupName => {
const channels = this.groupedChannels[groupName];
const isFavoritesGroup = groupName === 'Favorites';
const visibleChannels = channels.filter(channel => {
if (isFavoritesGroup) return true;
return !channel.isHidden || this.showHidden;
});
// Assign unique render IDs for linear navigation
visibleChannels.forEach(ch => {
// We clone the object for the rendered list to attach the unique ID
// ensuring no side effects on the main channel object
const renderedCh = {
...ch,
_renderId: `rid_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
_renderGroup: groupName // Track visual group for navigation
};
this.renderedChannels.push(renderedCh);
});
});
// Empty State
if (this.sortedGroups.length === 0) {
this.container.innerHTML = `
<div class="empty-state">
<p>${searchTerm ? 'No channels match your search' : 'No channels loaded'}</p>
<p class="hint">${searchTerm ? 'Try a different search term' : 'Add a source in Settings to get started'}</p>
</div>
`;
return;
}
// Wrap container content in a specific list div to append to
this.listContainer = document.createElement('div');
this.listContainer.className = 'channel-list-content';
this.container.appendChild(this.listContainer);
// Add loader element at bottom
this.loader = document.createElement('div');
this.loader.className = 'batch-loader';
this.loader.innerHTML = '<div class="loading-spinner"></div>';
this.loader.style.opacity = '0'; // Hide initially
this.container.appendChild(this.loader);
// Render initial batches - load just enough to fill visible area + buffer
// Reduced from 10 to 2 to significantly speed up initial load time for large lists
const maxInitialBatches = 2;
for (let i = 0; i < maxInitialBatches; i++) {
if (this.currentBatch * this.batchSize >= this.sortedGroups.length) break;
this.renderNextBatch();
}
// Start observing loader for additional batches
this.observer.observe(this.loader);
}
renderFlatList(channels, searchTerm) {
const orderedChannels = ChannelVariantGrouping.orderFlatChannels(channels);
this.renderedChannels = orderedChannels.map(channel => ({
...channel,
_renderId: `rid_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`,
_renderGroup: 'flat'
}));
if (this.renderedChannels.length === 0) {
this.container.innerHTML = `
<div class="empty-state">
<p>${searchTerm ? 'No channels match your search' : 'No channels loaded'}</p>
<p class="hint">${searchTerm ? 'Try a different search term' : 'Add a source in Settings to get started'}</p>
</div>`;
return;
}
this.currentBatch = 0;
this.batchSize = 100;
this.listContainer = document.createElement('div');
this.listContainer.className = 'channel-list-content flat-channel-list';
this.container.appendChild(this.listContainer);
this.loader = document.createElement('div');
this.loader.className = 'batch-loader';
this.loader.innerHTML = '<div class="loading-spinner"></div>';
this.container.appendChild(this.loader);
this.renderNextFlatBatch();
this.observer.observe(this.loader);
}
renderNextFlatBatch() {
if (!this.loader || !this.listContainer) return;
const start = this.currentBatch * this.batchSize;
const channels = this.renderedChannels.slice(start, start + this.batchSize);
if (channels.length === 0) {
this.loader.style.display = 'none';
return;
}
const temp = document.createElement('div');
temp.innerHTML = channels.map(channel => this.renderLogicalChannel(
channel,
channel._renderId,
'flat',
this.currentChannel?.id === channel.id,
this.currentRenderId === channel._renderId
)).join('');
this.attachChannelListeners(temp);
while (temp.firstElementChild) this.listContainer.appendChild(temp.firstElementChild);
this.currentBatch++;
if (start + channels.length >= this.renderedChannels.length) this.loader.style.display = 'none';
}
/**
* Render next batch of groups
*/
renderNextBatch() {
const start = this.currentBatch * this.batchSize;
const end = start + this.batchSize;
const groupsToRender = this.sortedGroups.slice(start, end);
if (groupsToRender.length === 0) {
// No more groups
this.loader.style.display = 'none';
return;
}
this.loader.style.opacity = '1';
let html = '';
let renderIndex = start; // Keep track of global index for mapping to renderedChannels
for (const groupName of groupsToRender) {
const channels = this.groupedChannels[groupName];
if (channels.length === 0) continue;
const isFavoritesGroup = groupName === 'Favorites';
// Pre-filter visible channels for this group
const visibleChannels = channels.filter(channel => {
if (isFavoritesGroup) return true;
return !channel.isHidden || this.showHidden;
});
// Skip group if no visible channels (derived visibility)
if (visibleChannels.length === 0) continue;
// Default new groups to collapsed (except Favorites)
// This handles groups loaded via scroll that weren't in the initial collapse
if (!isFavoritesGroup && !this.collapsedGroups.has(groupName) && !this._userExpandedGroups?.has(groupName)) {
this.collapsedGroups.add(groupName);
}
html += `
<div class="channel-group">
<div class="group-header ${this.collapsedGroups.has(groupName) ? 'collapsed' : ''} ${isFavoritesGroup ? 'favorites-group' : ''}" data-group="${groupName}">
<span class="group-toggle">${Icons.chevronDown}</span>
<span class="group-name">${groupName}</span>
<span class="group-count">${visibleChannels.length}</span>
</div>
<div class="group-channels">
`;
// Skip rendering channel items if group is collapsed (major performance optimization)
// Channels will be rendered when user expands the group
if (this.collapsedGroups.has(groupName)) {
html += '</div></div>';
continue;
}
for (const channel of visibleChannels) {
const isActive = this.currentChannel?.id === channel.id;
// Check if this specific instance is the "active" one for navigation purposes
const isRenderActive = this.currentRenderId && this.renderedChannels[renderIndex]?._renderId === this.currentRenderId;
const renderId = this.renderedChannels[renderIndex]?._renderId || '';
const renderGroup = this.renderedChannels[renderIndex]?._renderGroup || groupName;
renderIndex++;
html += this.renderLogicalChannel(channel, renderId, renderGroup, isActive, isRenderActive);
}
html += '</div></div>';
}
// Append to list container
// Use temp div to parse HTML string
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
while (tempDiv.firstElementChild) {
const groupEl = tempDiv.firstElementChild;
this.attachGroupListeners(groupEl);
this.listContainer.appendChild(groupEl);
}
this.currentBatch++;
// Hide loader if we might be done (next batch check will confirm)
if (end >= this.sortedGroups.length) {
this.loader.style.display = 'none';
}
}
attachGroupListeners(groupEl) {
const header = groupEl.querySelector('.group-header');
if (header) {
header.addEventListener('click', () => {
const groupName = header.dataset.group;
const isCollapsed = header.classList.contains('collapsed');
header.classList.toggle('collapsed');
this.toggleGroup(groupName);
// If expanding, render channels if they weren't rendered initially
if (isCollapsed) {
const channelsContainer = groupEl.querySelector('.group-channels');
if (channelsContainer && channelsContainer.children.length === 0) {
// Channels weren't rendered - render them now
this.renderGroupChannels(groupName, channelsContainer);
}
}
});
header.addEventListener('contextmenu', (e) => this.showContextMenu(e, 'group', header.dataset));
}
this.attachChannelListeners(groupEl);
}
attachChannelListeners(groupEl) {
groupEl.querySelectorAll('.channel-item').forEach(item => {
item.addEventListener('click', (e) => {
if (e.target.closest('.favorite-btn') || e.target.closest('.variant-toggle')) return;
this.selectChannel(item.dataset);
});
item.addEventListener('contextmenu', (e) => this.showContextMenu(e, 'channel', item.dataset));
const favBtn = item.querySelector('.favorite-btn');
if (favBtn) {
favBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleFavorite(parseInt(item.dataset.sourceId), item.dataset.variantId);
});
}
});
groupEl.querySelectorAll('.variant-toggle').forEach(button => button.addEventListener('click', (e) => {
e.stopPropagation();
const fingerprint = button.closest('.channel-item').dataset.logicalId;
this.expandedVariants.has(fingerprint) ? this.expandedVariants.delete(fingerprint) : this.expandedVariants.add(fingerprint);
this.render();
}));
groupEl.querySelectorAll('.channel-variant').forEach(item => item.addEventListener('click', (e) => {
e.stopPropagation();
this.selectChannel(item.dataset);
}));
}
renderLogicalChannel(channel, renderId, renderGroup, isActive = false, isRenderActive = false) {
const selected = channel.preferredVariant;
const selectedIsFavorite = this.isFavorite(selected.sourceId, selected.id);
const expanded = this.expandedVariants.has(channel.fingerprint);
const variants = channel.variants.map(variant => {
const hidden = this.isHidden('channel', variant.sourceId, variant.streamId || variant.id);
if (hidden && !this.showHidden) return '';
const source = this.sources.find(item => String(item.id) === String(variant.sourceId));
const language = variant.language;
const languageLabel = language.code === 'unknown'
? 'Unknown'
: `${language.code.toUpperCase()} ${language.confidence}%`;
const languageDescription = language.code === 'unknown'
? 'Unknown language'
: `${language.label} — ${language.confidence}% confidence`;
return `<button class="channel-variant ${hidden ? 'is-hidden' : ''}" type="button"
data-logical-id="${this.escapeHtml(channel.fingerprint)}" data-variant-id="${this.escapeHtml(String(variant.id))}"
data-source-id="${variant.sourceId}" data-render-id="${renderId}" data-render-group="${this.escapeHtml(renderGroup)}">
<span class="variant-quality">${ChannelVariantGrouping.getQuality(variant)}</span>
<span class="variant-language" title="${this.escapeHtml(languageDescription)}" aria-label="${this.escapeHtml(languageDescription)}">${languageLabel}</span>
<span class="variant-source">${this.escapeHtml(source?.name || variant.sourceType)}</span>
<span class="variant-name">${this.escapeHtml(variant.name)}</span>
</button>`;
}).join('');
const channelLanguage = channel.language || { code: 'und', short: 'UND', label: 'Unknown' };
const isUnknownLanguage = ['und', 'unknown'].includes(channelLanguage.code);
const languageBadge = isUnknownLanguage ? '?' : (channelLanguage.short || channelLanguage.code.toUpperCase());
const languageAccessibleName = isUnknownLanguage ? 'Unknown language' : channelLanguage.label;
const programLabel = this.getProgramStatusText(channel);
return `<div class="logical-channel ${expanded ? 'expanded' : ''}">
<div class="channel-item ${isActive ? 'active' : ''} ${isRenderActive ? 'nav-active' : ''} ${channel.isHidden ? 'hidden' : ''}"
data-logical-id="${this.escapeHtml(channel.fingerprint)}" data-variant-id="${this.escapeHtml(String(selected.id))}"
data-source-id="${selected.sourceId}" data-stream-id="${selected.streamId || ''}"
data-render-id="${renderId}" data-render-group="${this.escapeHtml(renderGroup)}">
<div class="channel-logo-wrap"><img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}" alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
<span class="channel-language-badge" aria-label="${this.escapeHtml(languageAccessibleName)}" title="${this.escapeHtml(languageAccessibleName)}">${this.escapeHtml(languageBadge)}</span></div>
<div class="channel-info"><div class="channel-name">${this.escapeHtml(channel.displayName)}</div>
<div class="channel-program">${this.escapeHtml(programLabel)}</div></div>
${channel.variants.length > 1 ? `<button class="variant-toggle" type="button" aria-expanded="${expanded}" aria-pressed="${expanded}" title="Show streams"><span>${channel.variants.length} streams</span><span class="variant-chevron">⌄</span></button>` : ''}
<button class="favorite-btn ${channel.isFavorite ? 'active' : ''}" title="${selectedIsFavorite ? 'Remove selected stream from Favorites' : 'Add selected stream to Favorites'}">${channel.isFavorite ? Icons.favorite : Icons.favoriteOutline}</button>
</div>
${channel.variants.length > 1 ? `<div class="channel-variants" ${expanded ? '' : 'hidden'}>${variants}</div>` : ''}
</div>`;
}
/**
* Render channels for a specific group (called when expanding a collapsed group)
*/
renderGroupChannels(groupName, container) {
const channels = this.groupedChannels[groupName];
if (!channels || channels.length === 0) return;
const isFavoritesGroup = groupName === 'Favorites';
// Filter visible channels
const visibleChannels = channels.filter(channel => {
if (isFavoritesGroup) return true;
return !channel.isHidden || this.showHidden;
});
let html = '';
for (const channel of visibleChannels) {
const isActive = this.currentChannel?.id === channel.id;
// Find the matching rendered channel to get its unique IDs
const renderedChannel = this.renderedChannels.find(rc =>
rc.id === channel.id && rc.sourceId === channel.sourceId && rc._renderGroup === groupName
);
const renderId = renderedChannel?._renderId || '';
const renderGroup = renderedChannel?._renderGroup || groupName;
html += this.renderLogicalChannel(channel, renderId, renderGroup, isActive);
}
container.innerHTML = html;
this.attachChannelListeners(container);
}
/**
* Load sources into dropdown
*/
async loadSources() {
try {
this.sources = await API.sources.getAll();
console.log('[ChannelList] loadSources: Got', this.sources?.length || 0, 'sources');
this.sourceSelect.innerHTML = '<option value="">All Sources</option>';
const xtreamSources = this.sources.filter(s => s.type === 'xtream' && s.enabled);
const m3uSources = this.sources.filter(s => s.type === 'm3u' && s.enabled);
if (xtreamSources.length > 0) {
const optgroup = document.createElement('optgroup');
optgroup.label = 'Xtream';
xtreamSources.forEach(s => {
const option = document.createElement('option');
option.value = `xtream:${s.id}`;
option.textContent = s.name;
optgroup.appendChild(option);
});
this.sourceSelect.appendChild(optgroup);
}
if (m3uSources.length > 0) {
const optgroup = document.createElement('optgroup');
optgroup.label = 'M3U';
m3uSources.forEach(s => {
const option = document.createElement('option');
option.value = `m3u:${s.id}`;
option.textContent = s.name;
optgroup.appendChild(option);
});
this.sourceSelect.appendChild(optgroup);
}
} catch (err) {
console.error('Error loading sources:', err);
}
}
/**
* Load channels from selected source
*/
async loadChannels({ refreshLanguages = true } = {}) {
if (this.isLoading) return;
this.isLoading = true;
this.currentRenderId = null; // Reset render tracking
const previousState = {
channels: this.channels,
groups: this.groups,
visibleFavorites: this.visibleFavorites,
hiddenItems: this.hiddenItems
};
const sourceValue = this.sourceSelect.value;
const self = this;
if (!sourceValue) {
// Load from all sources
await this.loadAllChannels({ refreshLanguages });
this.isLoading = false;
return;
}
const [type, id] = sourceValue.split(':');
try {
this.container.innerHTML = '<div class="loading"></div>';
if (await this.loadProjectedChannels(parseInt(id, 10))) {
this.render();
if (refreshLanguages) void this.refreshLayaLanguages();
return;
}
if (type === 'xtream') {
await this.loadXtreamChannels(parseInt(id));
} else if (type === 'm3u') {
await this.loadM3uChannels(parseInt(id));
}
// Load hidden items and favorites
await Promise.all([
this.loadHiddenItems(),
this.loadFavorites()
]);
this.render();
if (refreshLanguages) void this.refreshLayaLanguages();
} catch (err) {
console.error('Error loading channels:', err);
if (previousState.channels.length > 0) {
this.channels = previousState.channels;
this.groups = previousState.groups;
this.visibleFavorites = previousState.visibleFavorites;
this.hiddenItems = previousState.hiddenItems;
this.render();
} else {
this.container.innerHTML = `<div class="empty-state"><p>Error loading channels</p><p class="hint">${err.message}</p></div>`;
}
} finally {
this.isLoading = false;
}
}
/**
* Load channels from all enabled sources
*/
async loadAllChannels({ refreshLanguages = true } = {}) {
const previousState = {
channels: this.channels,
groups: this.groups,
visibleFavorites: this.visibleFavorites,
hiddenItems: this.hiddenItems
};
this.channels = [];
this.groups = [];
try {
this.container.innerHTML = '<div class="loading"></div>';
if (await this.loadProjectedChannels()) {
this.render();
if (refreshLanguages) void this.refreshLayaLanguages();
return;
}
const xtreamSources = this.sources.filter(s => s.type === 'xtream' && s.enabled);
const m3uSources = this.sources.filter(s => s.type === 'm3u' && s.enabled);
console.log('[ChannelList] loadAllChannels: xtream=', xtreamSources.length, 'm3u=', m3uSources.length);
for (const source of xtreamSources) {
await this.loadXtreamChannels(source.id, true);
}
for (const source of m3uSources) {
await this.loadM3uChannels(source.id, true);
}
await Promise.all([
this.loadHiddenItems(),
this.loadFavorites()
]);
this.render();
if (refreshLanguages) void this.refreshLayaLanguages();
} catch (err) {
console.error('Error loading all channels:', err);
if (previousState.channels.length > 0) {
this.channels = previousState.channels;
this.groups = previousState.groups;
this.visibleFavorites = previousState.visibleFavorites;
this.hiddenItems = previousState.hiddenItems;
this.render();
} else {
this.container.innerHTML = `<div class="empty-state"><p>Error loading channels</p><p class="hint">${err.message}</p></div>`;
}
}
}
async loadProjectedChannels(sourceId = null) {
try {
let response = await API.live.getChannels({
sourceId,
includeHidden: true,
q: this.searchInput?.value?.trim() || '',
languages: [...this.selectedLanguages]
});
if (!LiveChannelProjection.canUse(response)) {
this.projectedLogicalChannels = null;
this.projectionNotice = 'Channel list is updating. Using the standard channel list for now.';
return false;
}
let mapped = LiveChannelProjection.mapResponse(response, this.sources);
this.channels = mapped.channels;
this.projectedLogicalChannels = mapped.logicalChannels;
this.availableLanguages = mapped.availableLanguages;
if (this.reconcileLanguageSelection()) {
response = await API.live.getChannels({
sourceId,
includeHidden: true,
q: this.searchInput?.value?.trim() || '',
languages: [...this.selectedLanguages]
});
mapped = LiveChannelProjection.mapResponse(response, this.sources);
this.channels = mapped.channels;
this.projectedLogicalChannels = mapped.logicalChannels;
this.availableLanguages = mapped.availableLanguages;
}
this.renderLanguageFilter();
this.visibleFavorites = mapped.favorites;
this.hiddenItems = mapped.hiddenItems;
this.projectionNotice = '';
return true;
} catch (err) {
this.projectedLogicalChannels = null;
console.warn('[ChannelList] Live projection unavailable; using legacy loading:', err.message);
this.projectionNotice = '';
return false;
}
}
languageStorageKey() {
return `nodecast_tv_live_languages:${this.sourceSelect?.value || 'all'}`;
}
restoreLanguageSelection() {
try {
this.selectedLanguages = new Set(JSON.parse(sessionStorage.getItem(this.languageStorageKey()) || '[]'));
} catch {
this.selectedLanguages = new Set();
}
}
reconcileLanguageSelection() {
const previous = [...this.selectedLanguages];
const available = new Set(this.availableLanguages.map(language => language.code));
this.selectedLanguages = new Set([...this.selectedLanguages].filter(code => available.has(code)));
try {
sessionStorage.setItem(this.languageStorageKey(), JSON.stringify([...this.selectedLanguages]));
} catch {}
return previous.length !== this.selectedLanguages.size;
}
setLanguageSelection(code) {
if (code === 'all') this.selectedLanguages.clear();
else if (this.selectedLanguages.has(code)) this.selectedLanguages.delete(code);
else this.selectedLanguages.add(code);
try {
sessionStorage.setItem(this.languageStorageKey(), JSON.stringify([...this.selectedLanguages]));
} catch {}
this.renderLanguageFilter();
this.loadChannels({ refreshLanguages: false });
}
closeLanguagePanel() {
const panel = this.languageFilter?.querySelector('.language-more-panel');
const toggle = this.languageFilter?.querySelector('.language-more-toggle');
const wasOpen = panel && !panel.hidden;
if (panel) panel.hidden = true;
toggle?.setAttribute('aria-expanded', 'false');
if (wasOpen) toggle?.focus();
}
renderLanguageFilter() {
if (!this.languageFilter) return;
const quick = this.availableLanguages.slice(0, 3);
const selected = this.selectedLanguages;
const button = language => {
const unknown = language.code === 'und';
const label = unknown ? 'Unknown' : language.label;
const text = unknown ? '?' : (language.short || language.code.toUpperCase());
return `<button type="button" class="language-chip" data-language="${this.escapeHtml(language.code)}" aria-label="${this.escapeHtml(label)}" aria-pressed="${selected.has(language.code)}">${this.escapeHtml(text)}</button>`;
};
this.languageFilter.innerHTML = `
<span class="language-filter-label">Languages</span>
<button type="button" class="language-chip" data-language="all" aria-pressed="${selected.size === 0}">All</button>
${quick.map(button).join('')}
${this.availableLanguages.length > 3 ? `<button type="button" class="language-chip language-more-toggle" aria-expanded="false">More languages</button>
<div class="language-more-panel" hidden><input class="language-search" type="search" placeholder="Search languages…" aria-label="Search languages">
<div class="language-options">${this.availableLanguages.map(button).join('')}</div></div>` : ''}`;
this.languageFilter.querySelectorAll('[data-language]').forEach(element => {
element.addEventListener('click', () => this.setLanguageSelection(element.dataset.language));
});
const toggle = this.languageFilter.querySelector('.language-more-toggle');
const panel = this.languageFilter.querySelector('.language-more-panel');
toggle?.addEventListener('click', () => {
panel.hidden = !panel.hidden;
toggle.setAttribute('aria-expanded', String(!panel.hidden));
if (!panel.hidden) panel.querySelector('input')?.focus();
});
panel?.querySelector('.language-search')?.addEventListener('input', event => {
const query = event.target.value.toLowerCase();
panel.querySelectorAll('[data-language]').forEach(element => {
const language = this.availableLanguages.find(item => item.code === element.dataset.language);
element.hidden = !`${language?.code} ${language?.label}`.toLowerCase().includes(query);
});
});
}
/**
* Load Xtream channels
*/
async loadXtreamChannels(sourceId, append = false) {
if (!append) {
this.channels = [];
this.groups = [];
}
const categories = await API.proxy.xtream.liveCategories(sourceId);
const streams = await API.proxy.xtream.liveStreams(sourceId);
// Map categories to groups
const categoryGroups = categories.map(cat => ({
id: `xtream_${sourceId}_${cat.category_id}`,
name: cat.category_name,
sourceId,
sourceType: 'xtream',
sourceName: this.sources.find(source => String(source.id) === String(sourceId))?.name
}));
this.groups = this.groups.concat(categoryGroups);
// Map streams to channels
const channelList = streams.map(stream => ({
id: `xtream_${sourceId}_${stream.stream_id}`,
streamId: stream.stream_id,
name: stream.name,
tvgId: stream.epg_channel_id,
tvgLogo: stream.stream_icon,
groupId: `xtream_${sourceId}_${stream.category_id}`,
// Use string comparison to handle type mismatches (number vs string category_id)
groupTitle: categories.find(c => String(c.category_id) === String(stream.category_id))?.category_name || 'Uncategorized',
sourceId,
sourceType: 'xtream',
sourceName: this.sources.find(source => String(source.id) === String(sourceId))?.name
}));
this.channels = this.channels.concat(channelList);
}
/**
* Load M3U channels
* Now uses unified Xtream-style API endpoints (backend supports both source types)
*/
async loadM3uChannels(sourceId, append = false) {
if (!append) {
this.channels = [];
this.groups = [];
}
// Use Xtream API endpoints - backend now supports M3U sources too
const categories = await API.proxy.xtream.liveCategories(sourceId);
const streams = await API.proxy.xtream.liveStreams(sourceId);
// Map categories to groups (keeping m3u sourceType for downstream compatibility)
const m3uGroups = categories.map(cat => ({
id: `m3u_${sourceId}_${cat.category_id}`,
name: cat.category_name,
sourceId,
sourceType: 'm3u',
sourceName: this.sources.find(source => String(source.id) === String(sourceId))?.name
}));
this.groups = this.groups.concat(m3uGroups);
// Map streams to channels
const channelList = streams.map(stream => ({
id: `m3u_${sourceId}_${stream.stream_id}`,
streamId: stream.stream_id,
name: stream.name,
tvgId: stream.epg_channel_id,
tvgLogo: stream.stream_icon,
url: stream.stream_url, // M3U has direct URLs
groupId: `m3u_${sourceId}_${stream.category_id}`,
groupTitle: categories.find(c => String(c.category_id) === String(stream.category_id))?.category_name || 'Uncategorized',
sourceId,
sourceType: 'm3u',
sourceName: this.sources.find(source => String(source.id) === String(sourceId))?.name
}));
this.channels = this.channels.concat(channelList);
}
/**
* Load hidden items
*/
async loadHiddenItems() {
try {
const items = await API.channels.getHidden();
this.hiddenItems = new Set(items.map(i => `${i.item_type}:${i.source_id}:${i.item_id}`));
} catch (err) {
console.error('Error loading hidden items:', err);
}
}
/**
* Check if item is hidden
*/
isHidden(type, sourceId, itemId) {
return this.hiddenItems.has(`${type}:${sourceId}:${itemId}`);
}
/**
* Load favorites
*/
async loadFavorites() {
try {
// Get all favorites (filtered for channels or legacy items without type)
const allFavs = await API.favorites.getAll();
const channelFavs = allFavs.filter(f => !f.item_type || f.item_type === 'channel');
this.visibleFavorites = new Set(
channelFavs.map(f => `${f.source_id}:${f.item_id || f.channel_id}`)
);
} catch (err) {
console.error('Error loading favorites:', err);
}
}
/**
* Check if channel is favorite
*/
isFavorite(sourceId, channelId) {
return this.visibleFavorites.has(`${sourceId}:${channelId}`);
}
/**
* Toggle favorite status
*/
async toggleFavorite(sourceId, channelId) {
const key = `${sourceId}:${channelId}`;
const wasFavorite = this.visibleFavorites.has(key);
// Find all buttons for this channel in the DOM (it may appear in multiple groups)
const btns = document.querySelectorAll(`.channel-item[data-channel-id="${channelId}"][data-source-id="${sourceId}"] .favorite-btn`);
try {
// Optimistic update
if (wasFavorite) {
this.visibleFavorites.delete(key);
btns.forEach(btn => {
btn.classList.remove('active');
btn.innerHTML = Icons.favoriteOutline;
btn.title = 'Add to Favorites';
});
} else {
this.visibleFavorites.add(key);
btns.forEach(btn => {
btn.classList.add('active');
btn.innerHTML = Icons.favorite;
btn.title = 'Remove from Favorites';
});
}
// Updates Favorites Group DOM
const channel = this.channels.find(c => c.sourceId == sourceId && c.id == channelId);
if (channel) {
this.updateFavoritesGroup(channel, !wasFavorite);
}
this.render();
// Do NOT call this.render() - it causes lag
// Perform API call
if (wasFavorite) {
await API.favorites.remove(sourceId, channelId, 'channel');
} else {
await API.favorites.add(sourceId, channelId, 'channel');
}
// Sync to EPG Guide
if (window.app?.epgGuide) {
window.app.epgGuide.syncFavorite(sourceId, channelId, !wasFavorite);
}
} catch (err) {
console.error('Error toggling favorite:', err);
// Revert on error
if (wasFavorite) {
this.visibleFavorites.add(key);
btns.forEach(btn => {
btn.classList.add('active');
btn.innerHTML = Icons.favorite;
});
// Revert group update
const channel = this.channels.find(c => c.sourceId == sourceId && c.id == channelId);
if (channel) this.updateFavoritesGroup(channel, true);
} else {
this.visibleFavorites.delete(key);
btns.forEach(btn => {
btn.classList.remove('active');
btn.innerHTML = Icons.favoriteOutline;
});
// Revert group update
const channel = this.channels.find(c => c.sourceId == sourceId && c.id == channelId);
if (channel) this.updateFavoritesGroup(channel, false);
}
this.render();
}
}
/**
* Update Favorites group in DOM and data
*/
updateFavoritesGroup(channel, isAdded) {
if (!ChannelVariantGrouping.shouldUseFavoritesGroup(this.listMode) || !this.groupedChannels) return;
// 1. Update Data
if (!this.groupedChannels['Favorites']) {
this.groupedChannels['Favorites'] = [];
}
const favArray = this.groupedChannels['Favorites'];
const existingIdx = favArray.findIndex(c => c.id === channel.id && c.sourceId === channel.sourceId);
if (isAdded) {
if (existingIdx === -1) favArray.push(channel);
} else {
if (existingIdx !== -1) favArray.splice(existingIdx, 1);
}
// 2. Update DOM
const groupHeader = this.listContainer.querySelector('.group-header[data-group="Favorites"]');
if (!groupHeader) {
// If group doesn't exist and we're adding, we ideally should create it
// For now, simpler to just return. User will see it on next refresh.
// Or we could force a re-render if it's the first favorite?
if (isAdded && favArray.length === 1) {
this.render(); // This is the one case where full render is worth it
}
return;
}
const groupChannels = groupHeader.nextElementSibling; // .group-channels
const countSpan = groupHeader.querySelector('.group-count');
if (isAdded) {
// Check if already in DOM (to avoid dupes)
const existingEl = groupChannels.querySelector(`.channel-item[data-channel-id="${channel.id}"][data-source-id="${channel.sourceId}"]`);
if (!existingEl) {
const newEl = this.createChannelElement(channel);
groupChannels.appendChild(newEl);
}
} else {
const existingEl = groupChannels.querySelector(`.channel-item[data-channel-id="${channel.id}"][data-source-id="${channel.sourceId}"]`);
if (existingEl) {
existingEl.remove();
}
}
// Update count
if (countSpan) countSpan.textContent = favArray.length;
// Hide/Show group if empty?
if (favArray.length === 0) {
groupHeader.classList.add('hidden'); // Or remove
groupHeader.style.display = 'none';
} else {
groupHeader.classList.remove('hidden');
groupHeader.style.display = '';
}
}
createChannelElement(channel) {
const div = document.createElement('div');
const isActive = this.currentChannel?.id === channel.id;
// In Favorites group, it IS a favorite
const isFavorite = true;
div.className = `channel-item ${isActive ? 'active' : ''}`;
div.dataset.channelId = channel.id;
div.dataset.sourceId = channel.sourceId;
div.dataset.sourceType = channel.sourceType;
div.dataset.streamId = channel.streamId || '';
div.dataset.url = channel.url || '';
div.innerHTML = `
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}"
alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
<div class="channel-info">
<div class="channel-name">${this.escapeHtml(channel.name)}</div>
<div class="channel-program">${this.getProgramInfo(channel) || ''}</div>
</div>
<button class="favorite-btn active" title="Remove from Favorites">
❤️
</button>
`;
// Attach listeners
div.addEventListener('click', (e) => {
if (e.target.closest('.favorite-btn')) return;
// Pass the render ID from the dataset
this.selectChannel({ ...div.dataset, renderId: div.dataset.renderId });
});
div.addEventListener('contextmenu', (e) => this.showContextMenu(e, 'channel', div.dataset));
const favBtn = div.querySelector('.favorite-btn');
if (favBtn) {
favBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleFavorite(parseInt(div.dataset.sourceId), div.dataset.channelId);
});
}
return div;
}
/**
* Select and play a channel
*/
async selectChannel(dataset) {
const logicalChannel = this.logicalChannels.find(c => c.fingerprint === dataset.logicalId) ||
this.renderedChannels.find(c => c.fingerprint === dataset.logicalId) ||
this.logicalChannels.find(c => c.variants.some(variant =>
String(variant.id) === String(dataset.channelId) &&
(!dataset.sourceId || String(variant.sourceId) === String(dataset.sourceId))
));
if (!logicalChannel) return;
const channel = logicalChannel.variants.find(variant =>
String(variant.id) === String(dataset.variantId || dataset.channelId) &&
(!dataset.sourceId || String(variant.sourceId) === String(dataset.sourceId))
) || logicalChannel.preferredVariant;
this.selectedVariants.set(logicalChannel.fingerprint, ChannelVariantGrouping.getVariantKey(channel));
logicalChannel.preferredVariant = channel;
this.currentChannel = logicalChannel;
this.currentRenderId = dataset.renderId; // Track which visual instance is active
this.currentRenderGroup = dataset.renderGroup; // Track which group the selection came from
// Update active state in DOM
this.container.querySelectorAll('.channel-item.active').forEach(el => {
el.classList.remove('active');
el.classList.remove('nav-active');
});
// Try to find specific render instance first
let activeItem;
activeItem = this.container.querySelector(`[data-render-id="${this.currentRenderId}"]`);
// If not found in DOM, it might be in a future batch not yet rendered
// Render batches until we find it or run out
if (!activeItem && this.renderedChannels.length > 0) {
let safety = 0;
while (!activeItem && this.currentBatch * this.batchSize < this.sortedGroups.length && safety < 20) {
this.renderNextBatch();
if (this.currentRenderId) {
activeItem = this.container.querySelector(`[data-render-id="${this.currentRenderId}"]`);
}
safety++;
}
}
// Fallback checks if still not found
if (!activeItem) {
activeItem = Array.from(this.container.querySelectorAll('.channel-item'))
.find(item => item.dataset.logicalId === logicalChannel.fingerprint);
// If we fell back to channel ID, update currentRenderId to match what we found
if (activeItem && activeItem.dataset.renderId) {
this.currentRenderId = activeItem.dataset.renderId;
}
}
if (activeItem) {
activeItem.classList.add('active');
activeItem.classList.add('nav-active'); // Add specific class for navigation tracking
// Handle Group Expansion & Scrolling (Focus Mode)
const groupHeader = activeItem.closest('.channel-group')?.querySelector('.group-header');
if (groupHeader) {
const groupName = groupHeader.dataset.group;
// 1. Expand current group if needed
if (this.collapsedGroups.has(groupName)) {
this.collapsedGroups.delete(groupName);
// Update DOM directly for immediate feedback
groupHeader.classList.remove('collapsed');
this.saveCollapsedState();
}
// 2. Collapse ALL other groups (Focus Mode)
document.querySelectorAll('.group-header').forEach(header => {
if (header !== groupHeader && !header.classList.contains('collapsed')) {
const otherGroup = header.dataset.group;
this.collapsedGroups.add(otherGroup);
header.classList.add('collapsed');
}
});
this.saveCollapsedState();
// 3. Scroll Group to Top
// Use a small timeout to allow layout updates (e.g. collapse animations) to start
setTimeout(() => {
groupHeader.scrollIntoView({ behavior: 'smooth', block: 'start' });
// Ensure active item is visible within the group
setTimeout(() => {
activeItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 50);
}, 50);
} else {
// Fallback for non-grouped items or flat list
activeItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
// Get stream URL
let streamUrl;
if (channel.sourceType === 'xtream') {
// Get stream format from player settings (server-side) or fallback
const streamFormat = window.app?.player?.settings?.streamFormat || 'm3u8';
const result = await API.proxy.xtream.getStreamUrl(channel.sourceId, channel.streamId, 'live', streamFormat);
streamUrl = result.url;
} else {
streamUrl = channel.url;
}
// Play channel
if (window.app?.player) {
window.app.player.updateLanguageControl?.(logicalChannel);
window.app.player.play(channel, streamUrl);
}
}
async selectLanguageForCurrent(languageCode) {
if (!this.currentChannel) return;
const fingerprint = this.currentChannel.fingerprint;
if (!languageCode || languageCode === 'auto') {
this.channelLanguagePreferences.delete(fingerprint);
this.preferredLanguage = null;
} else {
this.channelLanguagePreferences.set(fingerprint, languageCode);
this.preferredLanguage = languageCode;
}
this.selectedVariants.delete(fingerprint);
this.saveLanguagePreferences();
this.render();
const logicalChannel = this.logicalChannels.find(channel => channel.fingerprint === fingerprint);
if (!logicalChannel) return;
const variant = ChannelVariantGrouping.selectVariantByLanguage(logicalChannel, languageCode, {
isFavorite: item => this.isFavorite(item.sourceId, item.id),
isHidden: item => this.isHidden('channel', item.sourceId, item.streamId || item.id)
});
if (!variant) return;
await this.selectChannel({
logicalId: fingerprint,
variantId: variant.id,
sourceId: variant.sourceId,
renderId: this.currentRenderId,
renderGroup: this.currentRenderGroup
});
}
/**
* Show context menu
*/
showContextMenu(e, type, data) {
e.preventDefault();
const logicalChannel = type === 'channel'
? this.logicalChannels.find(channel => channel.fingerprint === data.logicalId)
: null;
const variant = logicalChannel?.variants.find(item =>
String(item.id) === String(data.variantId) && String(item.sourceId) === String(data.sourceId)
) || logicalChannel?.preferredVariant;
this.contextMenu.dataset.type = type;
this.contextMenu.dataset.sourceId = variant?.sourceId ?? data.sourceId;
this.contextMenu.dataset.itemId = type === 'group' ? data.group : (variant?.id || data.variantId);
this.contextMenu.dataset.streamId = variant?.streamId || data.streamId || '';
this.contextMenu.dataset.logicalId = data.logicalId || '';
this.contextMenu.style.left = `${e.clientX}px`;
this.contextMenu.style.top = `${e.clientY}px`;
this.contextMenu.classList.add('active');
}
/**
* Hide context menu
*/
hideContextMenu() {
this.contextMenu.classList.remove('active');
}
/**
* Handle context menu action
*/
async handleContextAction(e) {
const action = e.target.dataset.action;
const { type, sourceId, itemId, streamId, logicalId } = this.contextMenu.dataset;
switch (action) {
case 'play':
if (type === 'channel') {
await this.selectChannel({ logicalId, variantId: itemId, sourceId });
}
break;
case 'hide':
// Use streamId for hiding Xtream channels (raw ID, not composite)
// Server expects 'channel' type, not 'live'
const hideId = streamId || itemId;
await API.channels.hide(parseInt(sourceId), 'channel', hideId);
this.hiddenItems.add(`channel:${sourceId}:${hideId}`);
this.render();
break;
case 'epg':
// Show EPG info modal
this.showEpgInfo(sourceId, itemId, streamId);
break;
}
this.hideContextMenu();
}
/**
* Show EPG info for a channel
*/
showEpgInfo(sourceId, channelId, streamId) {
const channel = this.channels.find(c => c.id === channelId);
if (!channel) {
alert('Channel not found');
return;
}
const modal = document.getElementById('modal');
const modalTitle = document.getElementById('modal-title');
const modalBody = document.getElementById('modal-body');
if (!modal || !modalTitle || !modalBody) return;
modalTitle.textContent = `📋 ${channel.name} - EPG Info`;
// Get current and upcoming programs
let programsHtml = '<p class="no-programs">No EPG data available for this channel.</p>';
if (window.app?.epgGuide) {
const tvgKey = channel.tvgId || channel.name;
const currentProgram = window.app.epgGuide.getCurrentProgram(channel.tvgId, channel.name);
const programs = window.app.epgGuide.getChannelPrograms?.(tvgKey) || [];
if (currentProgram || programs.length > 0) {
programsHtml = '<div class="epg-program-list">';
// Show current program
if (currentProgram) {
const startTime = new Date(currentProgram.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const endTime = new Date(currentProgram.end).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
programsHtml += `
<div class="epg-program current">
<div class="epg-program-time">${startTime} - ${endTime}</div>
<div class="epg-program-title">▶ ${this.escapeHtml(currentProgram.title)}</div>
${currentProgram.description ? `<div class="epg-program-desc">${this.escapeHtml(currentProgram.description)}</div>` : ''}
</div>
`;
}
// Show upcoming programs (next 5)
const now = Date.now();
const upcoming = programs
.filter(p => new Date(p.start).getTime() > now)
.slice(0, 5);
upcoming.forEach(prog => {
const startTime = new Date(prog.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const endTime = new Date(prog.end).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
programsHtml += `
<div class="epg-program">
<div class="epg-program-time">${startTime} - ${endTime}</div>
<div class="epg-program-title">${this.escapeHtml(prog.title)}</div>
</div>
`;
});
programsHtml += '</div>';
}
}
modalBody.innerHTML = `
<div class="epg-info-modal">
<div class="channel-details">
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}"
onerror="this.onerror=null;this.src='/img/placeholder.png'" />
<div class="channel-meta">
<p><strong>Group:</strong> ${this.escapeHtml(channel.groupTitle || 'Uncategorized')}</p>
<p><strong>Source:</strong> ${channel.sourceType}</p>
${channel.tvgId ? `<p><strong>TVG ID:</strong> ${this.escapeHtml(channel.tvgId)}</p>` : ''}
</div>
</div>
<h4>Program Schedule</h4>
${programsHtml}
</div>
`;
modal.classList.add('active');
}
/**
* Sync favorite status from external source (e.g. EPG) without API call
*/
syncFavorite(sourceId, channelId, isFavorite) {
const key = `${sourceId}:${channelId}`;
const currentlyFav = this.visibleFavorites.has(key);
if (currentlyFav === isFavorite) return; // No change needed
// Update State
if (isFavorite) {
this.visibleFavorites.add(key);
} else {
this.visibleFavorites.delete(key);
}
// Update DOM (All instances)
const btns = document.querySelectorAll(`.channel-item[data-channel-id="${channelId}"][data-source-id="${sourceId}"] .favorite-btn`);
btns.forEach(btn => {
if (isFavorite) {
btn.classList.add('active');
btn.innerHTML = '❤️';
btn.title = 'Remove from Favorites';
} else {
btn.classList.remove('active');
btn.innerHTML = '♡';
btn.title = 'Add to Favorites';
}
});
// Update Favorites Group
const channel = this.channels.find(c => c.sourceId == sourceId && c.id == channelId);
if (channel) {
this.updateFavoritesGroup(channel, isFavorite);
}
this.render();
}
/**
* Select next channel in the current list
*/
selectNextChannel() {
if (!this.currentChannel || !this.renderedChannels || this.renderedChannels.length === 0) return;
let currentIndex = -1;
// Try to find by render ID first (strict visual order)
if (this.currentRenderId) {
currentIndex = this.renderedChannels.findIndex(c => c._renderId === this.currentRenderId);
}
// Fallback: Find matching channel ID, prioritizing same render group
if (currentIndex === -1) {
// First try to find in same group (for Favorites containing duplicates)
if (this.currentRenderGroup) {
currentIndex = this.renderedChannels.findIndex(c =>
c.id === this.currentChannel.id && c.sourceId === this.currentChannel.sourceId && c._renderGroup === this.currentRenderGroup
);
}
// Final fallback: any matching channel
if (currentIndex === -1) {
currentIndex = this.renderedChannels.findIndex(c =>
c.id === this.currentChannel.id && c.sourceId === this.currentChannel.sourceId
);
}
}
if (currentIndex === -1) return;
const nextIndex = (currentIndex + 1) % this.renderedChannels.length;
const nextChannel = this.renderedChannels[nextIndex];
this.selectChannel({
logicalId: nextChannel.fingerprint,
variantId: nextChannel.preferredVariant.id,
sourceId: nextChannel.preferredVariant.sourceId,
renderId: nextChannel._renderId // Pass the unique render ID
});
}
/**
* Select previous channel in the current list
*/
selectPrevChannel() {
if (!this.currentChannel || !this.renderedChannels || this.renderedChannels.length === 0) return;
let currentIndex = -1;
if (this.currentRenderId) {
currentIndex = this.renderedChannels.findIndex(c => c._renderId === this.currentRenderId);
}
// Fallback: Find matching channel ID, prioritizing same render group
if (currentIndex === -1) {
// First try to find in same group (for Favorites containing duplicates)
if (this.currentRenderGroup) {
currentIndex = this.renderedChannels.findIndex(c =>
c.id === this.currentChannel.id && c.sourceId === this.currentChannel.sourceId && c._renderGroup === this.currentRenderGroup
);
}
// Final fallback: any matching channel
if (currentIndex === -1) {
currentIndex = this.renderedChannels.findIndex(c =>
c.id === this.currentChannel.id && c.sourceId === this.currentChannel.sourceId
);
}
}
if (currentIndex === -1) return;
const prevIndex = (currentIndex - 1 + this.renderedChannels.length) % this.renderedChannels.length;
const prevChannel = this.renderedChannels[prevIndex];
this.selectChannel({
logicalId: prevChannel.fingerprint,
variantId: prevChannel.preferredVariant.id,
sourceId: prevChannel.preferredVariant.sourceId,
renderId: prevChannel._renderId
});
}
/**
* Show EPG info for channel
*/
async showEpgInfo(channelId) {
const channel = this.channels.find(c => c.id === channelId);
if (!channel) return;
// This would show a modal with EPG info
console.log('Show EPG for:', channel);
}
/**
* Get list of visible (non-hidden) channels in display order
*/
getVisibleChannels() {
return this.listMode === 'flat' ? this.renderedChannels : this.logicalChannels;
}
}
// Export
window.ChannelList = ChannelList;