The model was reported as ready while the AI runtime was actually unreachable, and the Download action stayed visible afterwards. Distinguish model-downloaded from runtime-available in layaStatusPresentation, derive the Download action from the real combined state, and align Settings and ChannelList rendering on that single source of truth. Cover the combined status cases with tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1691 lines
68 KiB
JavaScript
1691 lines
68 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.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.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.loadCollapsedState();
|
|
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.render();
|
|
}, 300);
|
|
});
|
|
|
|
// Source filter handler
|
|
this.sourceSelect.addEventListener('change', () => this.loadChannels());
|
|
|
|
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 programInfo = this.getProgramInfo(channel);
|
|
const programElement = item.querySelector('.channel-program');
|
|
if (programElement) {
|
|
programElement.textContent = programInfo || '';
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// ... (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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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') {
|
|
const requestId = ++this._languageDetectionRequest;
|
|
if (mode === 'algo') {
|
|
this.layaLanguageResults.clear();
|
|
this.languageDetectionNotice = '';
|
|
this.languageDetectionFallback = false;
|
|
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();
|
|
try {
|
|
const status = await API.laya.getStatus();
|
|
if (!status?.modelPresent || !status?.runtimeAvailable) {
|
|
if (requestId !== this._languageDetectionRequest) return;
|
|
this.layaLanguageResults.clear();
|
|
this.languageDetectionFallback = true;
|
|
this.languageDetectionNotice = 'Local AI service is unavailable. Standard channel matching is being used.';
|
|
this.render();
|
|
return;
|
|
}
|
|
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;
|
|
this.layaLanguageResults = results;
|
|
this.languageDetectionNotice = '';
|
|
this.languageDetectionFallback = false;
|
|
} catch (err) {
|
|
if (requestId !== this._languageDetectionRequest) return;
|
|
this.layaLanguageResults.clear();
|
|
this.languageDetectionFallback = true;
|
|
this.languageDetectionNotice = 'Local AI service is unavailable. Standard channel matching is being used.';
|
|
console.warn('[ChannelList] Local AI language detection unavailable:', err.message);
|
|
}
|
|
this.render();
|
|
}
|
|
|
|
escapeHtml(text) {
|
|
if (!text) return '';
|
|
return text
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
/**
|
|
* 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 = 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.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(settingsButton);
|
|
this.container.appendChild(notice);
|
|
}
|
|
|
|
// Filter and Group channels
|
|
const groupedChannels = {};
|
|
|
|
// 1. Filter
|
|
this.filteredChannels = this.logicalChannels;
|
|
if (searchTerm) {
|
|
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;
|
|
|
|
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('');
|
|
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)}">
|
|
<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.displayName)}</div>
|
|
<div class="channel-program">${this.escapeHtml(this.getProgramInfo(channel) || '')}</div></div>
|
|
${channel.variants.length > 1 ? `<button class="variant-toggle" type="button" aria-expanded="${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() {
|
|
if (this.isLoading) return;
|
|
this.isLoading = true;
|
|
this.currentRenderId = null; // Reset render tracking
|
|
|
|
const sourceValue = this.sourceSelect.value;
|
|
const self = this;
|
|
|
|
if (!sourceValue) {
|
|
// Load from all sources
|
|
await this.loadAllChannels();
|
|
this.isLoading = false;
|
|
return;
|
|
}
|
|
|
|
const [type, id] = sourceValue.split(':');
|
|
|
|
try {
|
|
this.container.innerHTML = '<div class="loading"></div>';
|
|
|
|
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();
|
|
void this.refreshLayaLanguages();
|
|
} catch (err) {
|
|
console.error('Error loading channels:', err);
|
|
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() {
|
|
this.channels = [];
|
|
this.groups = [];
|
|
|
|
try {
|
|
this.container.innerHTML = '<div class="loading"></div>';
|
|
|
|
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();
|
|
void this.refreshLayaLanguages();
|
|
} catch (err) {
|
|
console.error('Error loading all channels:', err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
await this.selectChannel({
|
|
logicalId: fingerprint,
|
|
variantId: logicalChannel.preferredVariant.id,
|
|
sourceId: logicalChannel.preferredVariant.sourceId
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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;
|