fix(live): keep channel search responsive (#25)

Filter projected live channels locally in the browser and apply server-side search and language filters before costly enrichment work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
blomios
2026-10-06 13:08:16 +02:00
parent 5baf8d5a33
commit 45d1fe29d3
7 changed files with 210 additions and 48 deletions

View File

@ -1214,9 +1214,9 @@
<script src="/js/services/channelLanguageFusion.js?v=1"></script>
<script src="/js/services/layaStatusPresentation.js?v=5"></script>
<script src="/js/services/liveAnalysisPresentation.js?v=1"></script>
<script src="/js/services/liveChannelProjection.js?v=2"></script>
<script src="/js/services/liveChannelProjection.js?v=3"></script>
<script src="/js/services/channelVariantGrouping.js?v=7"></script>
<script src="/js/components/ChannelList.js?v=12"></script>
<script src="/js/components/ChannelList.js?v=13"></script>
<script src="/js/components/SourceManager.js?v=4"></script>
<script src="/js/components/EpgGuide.js?v=3"></script>
<script src="/js/pages/HomePage.js?v=2"></script>

View File

@ -189,13 +189,14 @@ class ChannelList {
}
init() {
// Search handler (debounced)
// Search locally whenever a catalog is already available.
let searchTimeout;
this.searchInput.addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
this.loadChannels({ refreshLanguages: false });
}, 300);
if (this.projectedLogicalChannels || this.channels.length) this.render();
else this.loadChannels({ refreshLanguages: false });
}, this.projectedLogicalChannels || this.channels.length ? 75 : 250);
});
// Source filter handler
@ -205,7 +206,18 @@ class ChannelList {
});
document.addEventListener('keydown', event => {
if (event.key === 'Escape') this.closeLanguagePanel();
if (event.key !== 'Escape') return;
const panel = this.languageFilter?.querySelector('.language-more-panel');
if (panel && !panel.hidden) {
this.closeLanguagePanel();
return;
}
if (this.searchInput?.value) {
this.searchInput.value = '';
clearTimeout(searchTimeout);
this.render();
this.searchInput.focus();
}
});
this.toggleGroupsBtn?.addEventListener('click', () => {
@ -496,15 +508,10 @@ class ChannelList {
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))
);
}
this.filteredChannels = this.logicalChannels.filter(channel => {
const languageMatches = !this.selectedLanguages.size || this.selectedLanguages.has(channel.language?.code);
return languageMatches && LiveChannelProjection.matchesSearch(channel, searchTerm);
});
let filteredChannels = this.filteredChannels;
if (this.listStatus) this.listStatus.textContent = `${filteredChannels.length} channel entries shown`;
@ -525,7 +532,7 @@ class ChannelList {
});
// 3. Add Favorites
const favoritedChannels = this.logicalChannels.filter(ch => ch.isFavorite);
const favoritedChannels = this.filteredChannels.filter(ch => ch.isFavorite);
if (favoritedChannels.length > 0) {
favoritedChannels.sort((a, b) => a.name.localeCompare(b.name));
groupedChannels['Favorites'] = favoritedChannels;
@ -1057,33 +1064,22 @@ class ChannelList {
async loadProjectedChannels(sourceId = null) {
try {
let response = await API.live.getChannels({
const response = await API.live.getChannels({
sourceId,
includeHidden: true,
q: this.searchInput?.value?.trim() || '',
languages: [...this.selectedLanguages]
q: '',
languages: []
});
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);
const 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.reconcileLanguageSelection();
this.renderLanguageFilter();
this.visibleFavorites = mapped.favorites;
this.hiddenItems = mapped.hiddenItems;
@ -1127,7 +1123,8 @@ class ChannelList {
sessionStorage.setItem(this.languageStorageKey(), JSON.stringify([...this.selectedLanguages]));
} catch {}
this.renderLanguageFilter();
this.loadChannels({ refreshLanguages: false });
if (this.projectedLogicalChannels || this.channels.length) this.render();
else this.loadChannels({ refreshLanguages: false });
}
closeLanguagePanel() {

View File

@ -3,6 +3,26 @@
if (typeof module === 'object' && module.exports) module.exports = service;
if (root) root.LiveChannelProjection = service;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
function normalizeSearch(value) {
return String(value || '')
.normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
.split(/\s+/).filter(Boolean)
.map(token => token.length > 3 && token.endsWith('s') ? token.slice(0, -1) : token);
}
function matchesSearch(channel, query) {
const wanted = normalizeSearch(query);
if (!wanted.length) return true;
const corpus = normalizeSearch([
channel.displayName, channel.standardName, channel.fingerprint,
...(channel.variants || []).flatMap(variant => [
variant.name, variant.tvgId, variant.categoryName, variant.groupTitle
])
].filter(Boolean).join(' '));
return wanted.every(token => corpus.some(candidate => candidate.includes(token)));
}
function mapResponse(response, sources = []) {
if (!response || !Array.isArray(response.channels)) {
throw new Error('Invalid live channel projection response');
@ -89,5 +109,5 @@
return response.channels.length > 0 || !expectedChannelCount;
}
return { canUse, mapResponse };
return { canUse, mapResponse, matchesSearch, normalizeSearch };
});