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>
This commit is contained in:
@ -11,6 +11,8 @@ class ChannelList {
|
||||
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 = [];
|
||||
@ -25,6 +27,9 @@ class ChannelList {
|
||||
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';
|
||||
@ -38,6 +43,7 @@ class ChannelList {
|
||||
this.projectionNotice = '';
|
||||
|
||||
this.loadCollapsedState();
|
||||
this.restoreLanguageSelection();
|
||||
this.init();
|
||||
}
|
||||
|
||||
@ -188,12 +194,19 @@ class ChannelList {
|
||||
this.searchInput.addEventListener('input', () => {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
this.render();
|
||||
this.loadChannels({ refreshLanguages: false });
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Source filter handler
|
||||
this.sourceSelect.addEventListener('change', () => this.loadChannels());
|
||||
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();
|
||||
@ -263,10 +276,9 @@ class ChannelList {
|
||||
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 || '';
|
||||
programElement.textContent = this.getProgramStatusText(channel);
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -313,6 +325,14 @@ class ChannelList {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@ -430,7 +450,7 @@ class ChannelList {
|
||||
this.toggleGroupsBtn.classList.toggle('hidden', this.listMode === 'flat');
|
||||
}
|
||||
|
||||
this.logicalChannels = ChannelVariantGrouping.groupChannels(this.channels, {
|
||||
this.logicalChannels = this.projectedLogicalChannels || ChannelVariantGrouping.groupChannels(this.channels, {
|
||||
showHidden,
|
||||
selectedVariants: this.selectedVariants,
|
||||
preferredLanguage: this.preferredLanguage,
|
||||
@ -477,7 +497,7 @@ class ChannelList {
|
||||
|
||||
// 1. Filter
|
||||
this.filteredChannels = this.logicalChannels;
|
||||
if (searchTerm) {
|
||||
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)) ||
|
||||
@ -487,6 +507,7 @@ class ChannelList {
|
||||
}
|
||||
|
||||
let filteredChannels = this.filteredChannels;
|
||||
if (this.listStatus) this.listStatus.textContent = `${filteredChannels.length} channel entries shown`;
|
||||
|
||||
this.showHidden = showHidden;
|
||||
if (this.listMode === 'flat') {
|
||||
@ -819,15 +840,21 @@ class ChannelList {
|
||||
<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)}">
|
||||
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}" alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
|
||||
<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(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>` : ''}
|
||||
<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>` : ''}
|
||||
@ -1030,24 +1057,124 @@ class ChannelList {
|
||||
|
||||
async loadProjectedChannels(sourceId = null) {
|
||||
try {
|
||||
const response = await API.live.getChannels({ sourceId, includeHidden: true });
|
||||
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;
|
||||
}
|
||||
const mapped = LiveChannelProjection.mapResponse(response, this.sources);
|
||||
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
|
||||
*/
|
||||
|
||||
@ -19,6 +19,7 @@ class EpgGuide {
|
||||
this.timeOffset = 0; // Hours offset from now
|
||||
this.pixelsPerMinute = 6.67; // Width scaling (30min = 200px)
|
||||
this.favorites = new Set(); // Set<"sourceId:channelId">
|
||||
this.loadError = null;
|
||||
this.selectedGroup = 'Favorites'; // Default to Favorites
|
||||
|
||||
// Virtual scrolling properties
|
||||
@ -175,6 +176,7 @@ class EpgGuide {
|
||||
try {
|
||||
this.container.innerHTML = '<div class="loading"></div>';
|
||||
await this.fetchEpgData(forceRefresh);
|
||||
this.loadError = null;
|
||||
this.lastRefreshTime = new Date();
|
||||
this.render();
|
||||
|
||||
@ -182,6 +184,7 @@ class EpgGuide {
|
||||
// This ensures EPG data stays fresh while the app is open
|
||||
this.startBackgroundRefresh();
|
||||
} catch (err) {
|
||||
this.loadError = err;
|
||||
console.error('Error loading EPG:', err);
|
||||
this.container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
|
||||
@ -8,6 +8,7 @@ class SourceManager {
|
||||
this.xtreamList = document.getElementById('xtream-list');
|
||||
this.m3uList = document.getElementById('m3u-list');
|
||||
this.epgList = document.getElementById('epg-list');
|
||||
this.builtinEpgList = document.getElementById('builtin-epg-list');
|
||||
|
||||
// Content browser state
|
||||
this.contentType = 'channels'; // 'channels' or 'movies'
|
||||
@ -114,7 +115,9 @@ class SourceManager {
|
||||
|
||||
this.renderSourceList(this.xtreamList, sources.filter(s => s.type === 'xtream'), 'xtream');
|
||||
this.renderSourceList(this.m3uList, sources.filter(s => s.type === 'm3u'), 'm3u');
|
||||
this.renderSourceList(this.epgList, sources.filter(s => s.type === 'epg'), 'epg');
|
||||
const epgSources = sources.filter(s => s.type === 'epg');
|
||||
this.renderBuiltinEpgSources(epgSources.filter(source => source.builtinKey));
|
||||
this.renderSourceList(this.epgList, epgSources.filter(source => !source.builtinKey), 'epg');
|
||||
} catch (err) {
|
||||
console.error('Error loading sources:', err);
|
||||
}
|
||||
@ -162,6 +165,33 @@ class SourceManager {
|
||||
});
|
||||
}
|
||||
|
||||
renderBuiltinEpgSources(sources) {
|
||||
if (!this.builtinEpgList) return;
|
||||
if (!sources.length) {
|
||||
this.builtinEpgList.innerHTML = '<p class="hint">No built-in EPG sources available</p>';
|
||||
return;
|
||||
}
|
||||
this.builtinEpgList.innerHTML = sources.map(source => {
|
||||
const coverage = [source.language?.toUpperCase(), source.country].filter(Boolean).join(' · ');
|
||||
return `<div class="source-item builtin-source ${source.enabled ? '' : 'disabled'}" data-id="${source.id}">
|
||||
<span class="source-icon">${Icons.series}</span>
|
||||
<div class="source-info">
|
||||
<div class="source-name">${source.name} <span class="source-badge">Built-in</span></div>
|
||||
<div class="source-meta">Free EPG data${coverage ? ` · ${coverage}` : ''}</div>
|
||||
</div>
|
||||
<div class="source-actions">
|
||||
<button class="btn btn-sm btn-secondary" data-action="refresh" title="Refresh Data" aria-label="Refresh ${source.name}">${Icons.refresh}</button>
|
||||
<button class="btn btn-sm btn-secondary" data-action="toggle" aria-pressed="${source.enabled}" title="${source.enabled ? 'Disable' : 'Enable'}">${source.enabled ? 'Disable' : 'Enable'}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
this.builtinEpgList.querySelectorAll('.source-item').forEach(item => {
|
||||
const id = Number(item.dataset.id);
|
||||
item.querySelector('[data-action="refresh"]')?.addEventListener('click', () => this.refreshSource(id, 'epg'));
|
||||
item.querySelector('[data-action="toggle"]')?.addEventListener('click', () => this.toggleSource(id));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show add source modal
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user