feat: group variants of the same IPTV channel into a logical channel (#9)
Add a channelVariantGrouping service that normalizes channel names (strips region prefixes and quality tokens) to fingerprint duplicate stream entries and cluster them under a single logical channel with quality/source variants. Wire it into the Live TV channel list and player variant selection, reducing duplicates without destructively merging streams. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -667,6 +667,60 @@ h4 {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.variant-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logical-channel.expanded .variant-chevron {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-variants {
|
||||||
|
margin: 0 var(--space-md) var(--space-xs) 56px;
|
||||||
|
border-left: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-variant {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(52px, auto) minmax(64px, auto) 1fr;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: 6px var(--space-sm);
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-variant:hover {
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-variant.is-hidden {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-quality {
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* =====================================================
|
/* =====================================================
|
||||||
Video Player Section
|
Video Player Section
|
||||||
===================================================== */
|
===================================================== */
|
||||||
|
|||||||
@ -1125,7 +1125,8 @@
|
|||||||
<script src="/js/icons.js"></script>
|
<script src="/js/icons.js"></script>
|
||||||
<script src="/js/api.js?v=2"></script>
|
<script src="/js/api.js?v=2"></script>
|
||||||
<script src="/js/components/VideoPlayer.js?v=2"></script>
|
<script src="/js/components/VideoPlayer.js?v=2"></script>
|
||||||
<script src="/js/components/ChannelList.js?v=3"></script>
|
<script src="/js/services/channelVariantGrouping.js?v=1"></script>
|
||||||
|
<script src="/js/components/ChannelList.js?v=4"></script>
|
||||||
<script src="/js/components/SourceManager.js?v=2"></script>
|
<script src="/js/components/SourceManager.js?v=2"></script>
|
||||||
<script src="/js/components/EpgGuide.js?v=2"></script>
|
<script src="/js/components/EpgGuide.js?v=2"></script>
|
||||||
<script src="/js/pages/HomePage.js?v=2"></script>
|
<script src="/js/pages/HomePage.js?v=2"></script>
|
||||||
|
|||||||
@ -23,6 +23,9 @@ class ChannelList {
|
|||||||
this.sources = [];
|
this.sources = [];
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
this.renderedChannels = [];
|
this.renderedChannels = [];
|
||||||
|
this.logicalChannels = [];
|
||||||
|
this.selectedVariants = new Map();
|
||||||
|
this.expandedVariants = new Set();
|
||||||
|
|
||||||
this.loadCollapsedState();
|
this.loadCollapsedState();
|
||||||
this.init();
|
this.init();
|
||||||
@ -216,14 +219,7 @@ class ChannelList {
|
|||||||
// Find all visible channel items and update their program info
|
// Find all visible channel items and update their program info
|
||||||
const channelItems = this.container.querySelectorAll('.channel-item');
|
const channelItems = this.container.querySelectorAll('.channel-item');
|
||||||
channelItems.forEach(item => {
|
channelItems.forEach(item => {
|
||||||
const channelId = item.dataset.channelId;
|
const channel = this.logicalChannels.find(c => c.fingerprint === item.dataset.logicalId);
|
||||||
const sourceId = item.dataset.sourceId;
|
|
||||||
|
|
||||||
// Find the channel data
|
|
||||||
const channel = this.channels.find(c =>
|
|
||||||
String(c.id) === String(channelId) &&
|
|
||||||
String(c.sourceId) === String(sourceId)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (channel) {
|
if (channel) {
|
||||||
const programInfo = this.getProgramInfo(channel);
|
const programInfo = this.getProgramInfo(channel);
|
||||||
@ -246,7 +242,8 @@ class ChannelList {
|
|||||||
|
|
||||||
// Cache key: channel_id + current_minute (invalidate every minute)
|
// Cache key: channel_id + current_minute (invalidate every minute)
|
||||||
const currentMinute = Math.floor(Date.now() / 60000);
|
const currentMinute = Math.floor(Date.now() / 60000);
|
||||||
const cacheKey = `${channel.tvgId || channel.name}:${currentMinute}`;
|
const variant = channel.preferredVariant || channel;
|
||||||
|
const cacheKey = `${variant.tvgId || variant.name}:${currentMinute}`;
|
||||||
|
|
||||||
if (this._programInfoCache && this._programInfoCache.has(cacheKey)) {
|
if (this._programInfoCache && this._programInfoCache.has(cacheKey)) {
|
||||||
return this._programInfoCache.get(cacheKey);
|
return this._programInfoCache.get(cacheKey);
|
||||||
@ -258,7 +255,13 @@ class ChannelList {
|
|||||||
this._lastCacheMinute = currentMinute;
|
this._lastCacheMinute = currentMinute;
|
||||||
}
|
}
|
||||||
|
|
||||||
const program = window.app.epgGuide.getCurrentProgram(channel.tvgId, channel.name);
|
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;
|
const result = program ? program.title : null;
|
||||||
|
|
||||||
this._programInfoCache.set(cacheKey, result);
|
this._programInfoCache.set(cacheKey, result);
|
||||||
@ -296,6 +299,13 @@ class ChannelList {
|
|||||||
const searchTerm = this.searchInput.value.toLowerCase();
|
const searchTerm = this.searchInput.value.toLowerCase();
|
||||||
const showHidden = this.showHiddenCheckbox ? this.showHiddenCheckbox.checked : false;
|
const showHidden = this.showHiddenCheckbox ? this.showHiddenCheckbox.checked : false;
|
||||||
|
|
||||||
|
this.logicalChannels = ChannelVariantGrouping.groupChannels(this.channels, {
|
||||||
|
showHidden,
|
||||||
|
selectedVariants: this.selectedVariants,
|
||||||
|
isFavorite: channel => this.isFavorite(channel.sourceId, channel.id),
|
||||||
|
isHidden: channel => this.isHidden('channel', channel.sourceId, channel.streamId || channel.id)
|
||||||
|
});
|
||||||
|
|
||||||
// Reset batching
|
// Reset batching
|
||||||
this.currentBatch = 0;
|
this.currentBatch = 0;
|
||||||
this.batchSize = 100; // Number of groups to render per batch (increased to handle many hidden groups)
|
this.batchSize = 100; // Number of groups to render per batch (increased to handle many hidden groups)
|
||||||
@ -305,11 +315,12 @@ class ChannelList {
|
|||||||
const groupedChannels = {};
|
const groupedChannels = {};
|
||||||
|
|
||||||
// 1. Filter
|
// 1. Filter
|
||||||
this.filteredChannels = this.channels;
|
this.filteredChannels = this.logicalChannels;
|
||||||
if (searchTerm) {
|
if (searchTerm) {
|
||||||
this.filteredChannels = this.channels.filter(ch =>
|
this.filteredChannels = this.logicalChannels.filter(ch =>
|
||||||
String(ch.name ?? "").toLowerCase().includes(searchTerm) ||
|
String(ch.displayName ?? '').toLowerCase().includes(searchTerm) ||
|
||||||
String(ch.groupTitle ?? "").toLowerCase().includes(searchTerm)
|
ch.variants.some(variant => String(variant.name ?? '').toLowerCase().includes(searchTerm)) ||
|
||||||
|
ch.variants.some(variant => String(variant.groupTitle ?? '').toLowerCase().includes(searchTerm))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -325,7 +336,7 @@ class ChannelList {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 3. Add Favorites
|
// 3. Add Favorites
|
||||||
const favoritedChannels = this.channels.filter(ch => this.isFavorite(ch.sourceId, ch.id));
|
const favoritedChannels = this.logicalChannels.filter(ch => ch.isFavorite);
|
||||||
if (favoritedChannels.length > 0) {
|
if (favoritedChannels.length > 0) {
|
||||||
favoritedChannels.sort((a, b) => a.name.localeCompare(b.name));
|
favoritedChannels.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
groupedChannels['Favorites'] = favoritedChannels;
|
groupedChannels['Favorites'] = favoritedChannels;
|
||||||
@ -344,9 +355,7 @@ class ChannelList {
|
|||||||
const channels = groupedChannels[groupName];
|
const channels = groupedChannels[groupName];
|
||||||
// Check if any channel in this group is visible
|
// Check if any channel in this group is visible
|
||||||
return channels.some(channel => {
|
return channels.some(channel => {
|
||||||
const rawChannelId = channel.streamId || channel.id;
|
return !channel.isHidden || showHidden;
|
||||||
const isHidden = this.isHidden('channel', channel.sourceId, rawChannelId);
|
|
||||||
return !isHidden || showHidden;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -373,9 +382,7 @@ class ChannelList {
|
|||||||
|
|
||||||
const visibleChannels = channels.filter(channel => {
|
const visibleChannels = channels.filter(channel => {
|
||||||
if (isFavoritesGroup) return true;
|
if (isFavoritesGroup) return true;
|
||||||
const rawChannelId = channel.streamId || channel.id;
|
return !channel.isHidden || this.showHidden;
|
||||||
const channelHidden = this.isHidden('channel', channel.sourceId, rawChannelId);
|
|
||||||
return !channelHidden || this.showHidden;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Assign unique render IDs for linear navigation
|
// Assign unique render IDs for linear navigation
|
||||||
@ -454,9 +461,7 @@ class ChannelList {
|
|||||||
// Pre-filter visible channels for this group
|
// Pre-filter visible channels for this group
|
||||||
const visibleChannels = channels.filter(channel => {
|
const visibleChannels = channels.filter(channel => {
|
||||||
if (isFavoritesGroup) return true;
|
if (isFavoritesGroup) return true;
|
||||||
const rawChannelId = channel.streamId || channel.id;
|
return !channel.isHidden || this.showHidden;
|
||||||
const channelHidden = this.isHidden('channel', channel.sourceId, rawChannelId);
|
|
||||||
return !channelHidden || this.showHidden;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Skip group if no visible channels (derived visibility)
|
// Skip group if no visible channels (derived visibility)
|
||||||
@ -487,39 +492,14 @@ class ChannelList {
|
|||||||
|
|
||||||
|
|
||||||
for (const channel of visibleChannels) {
|
for (const channel of visibleChannels) {
|
||||||
// Check hidden again for styling (showHidden mode)
|
|
||||||
const rawChannelId = channel.streamId || channel.id;
|
|
||||||
const channelHidden = !isFavoritesGroup && this.isHidden('channel', channel.sourceId, rawChannelId);
|
|
||||||
|
|
||||||
const isActive = this.currentChannel?.id === channel.id;
|
const isActive = this.currentChannel?.id === channel.id;
|
||||||
// Check if this specific instance is the "active" one for navigation purposes
|
// Check if this specific instance is the "active" one for navigation purposes
|
||||||
const isRenderActive = this.currentRenderId && this.renderedChannels[renderIndex]?._renderId === this.currentRenderId;
|
const isRenderActive = this.currentRenderId && this.renderedChannels[renderIndex]?._renderId === this.currentRenderId;
|
||||||
|
|
||||||
const isFavorite = this.isFavorite(channel.sourceId, channel.id);
|
|
||||||
const renderId = this.renderedChannels[renderIndex]?._renderId || '';
|
const renderId = this.renderedChannels[renderIndex]?._renderId || '';
|
||||||
const renderGroup = this.renderedChannels[renderIndex]?._renderGroup || groupName;
|
const renderGroup = this.renderedChannels[renderIndex]?._renderGroup || groupName;
|
||||||
renderIndex++;
|
renderIndex++;
|
||||||
|
html += this.renderLogicalChannel(channel, renderId, renderGroup, isActive, isRenderActive);
|
||||||
html += `
|
|
||||||
<div class="channel-item ${isActive ? 'active' : ''} ${isRenderActive ? 'nav-active' : ''} ${channelHidden ? 'hidden' : ''}"
|
|
||||||
data-channel-id="${channel.id}"
|
|
||||||
data-source-id="${channel.sourceId}"
|
|
||||||
data-source-type="${channel.sourceType}"
|
|
||||||
data-stream-id="${channel.streamId || ''}"
|
|
||||||
data-url="${channel.url || ''}"
|
|
||||||
data-render-id="${renderId}"
|
|
||||||
data-render-group="${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.name)}</div>
|
|
||||||
<div class="channel-program">${this.escapeHtml(this.getProgramInfo(channel) || '')}</div>
|
|
||||||
</div>
|
|
||||||
<button class="favorite-btn ${isFavorite ? 'active' : ''}" title="${isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}">
|
|
||||||
${isFavorite ? Icons.favorite : Icons.favoriteOutline}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
html += '</div></div>';
|
html += '</div></div>';
|
||||||
}
|
}
|
||||||
@ -565,9 +545,13 @@ class ChannelList {
|
|||||||
header.addEventListener('contextmenu', (e) => this.showContextMenu(e, 'group', header.dataset));
|
header.addEventListener('contextmenu', (e) => this.showContextMenu(e, 'group', header.dataset));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.attachChannelListeners(groupEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
attachChannelListeners(groupEl) {
|
||||||
groupEl.querySelectorAll('.channel-item').forEach(item => {
|
groupEl.querySelectorAll('.channel-item').forEach(item => {
|
||||||
item.addEventListener('click', (e) => {
|
item.addEventListener('click', (e) => {
|
||||||
if (e.target.closest('.favorite-btn')) return;
|
if (e.target.closest('.favorite-btn') || e.target.closest('.variant-toggle')) return;
|
||||||
this.selectChannel(item.dataset);
|
this.selectChannel(item.dataset);
|
||||||
});
|
});
|
||||||
item.addEventListener('contextmenu', (e) => this.showContextMenu(e, 'channel', item.dataset));
|
item.addEventListener('contextmenu', (e) => this.showContextMenu(e, 'channel', item.dataset));
|
||||||
@ -576,10 +560,51 @@ class ChannelList {
|
|||||||
if (favBtn) {
|
if (favBtn) {
|
||||||
favBtn.addEventListener('click', (e) => {
|
favBtn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.toggleFavorite(parseInt(item.dataset.sourceId), item.dataset.channelId);
|
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));
|
||||||
|
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-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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -594,17 +619,12 @@ class ChannelList {
|
|||||||
// Filter visible channels
|
// Filter visible channels
|
||||||
const visibleChannels = channels.filter(channel => {
|
const visibleChannels = channels.filter(channel => {
|
||||||
if (isFavoritesGroup) return true;
|
if (isFavoritesGroup) return true;
|
||||||
const rawChannelId = channel.streamId || channel.id;
|
return !channel.isHidden || this.showHidden;
|
||||||
const channelHidden = this.isHidden('channel', channel.sourceId, rawChannelId);
|
|
||||||
return !channelHidden || this.showHidden;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
for (const channel of visibleChannels) {
|
for (const channel of visibleChannels) {
|
||||||
const rawChannelId = channel.streamId || channel.id;
|
|
||||||
const channelHidden = !isFavoritesGroup && this.isHidden('channel', channel.sourceId, rawChannelId);
|
|
||||||
const isActive = this.currentChannel?.id === channel.id;
|
const isActive = this.currentChannel?.id === channel.id;
|
||||||
const isFavorite = this.isFavorite(channel.sourceId, channel.id);
|
|
||||||
|
|
||||||
// Find the matching rendered channel to get its unique IDs
|
// Find the matching rendered channel to get its unique IDs
|
||||||
const renderedChannel = this.renderedChannels.find(rc =>
|
const renderedChannel = this.renderedChannels.find(rc =>
|
||||||
@ -613,46 +633,12 @@ class ChannelList {
|
|||||||
const renderId = renderedChannel?._renderId || '';
|
const renderId = renderedChannel?._renderId || '';
|
||||||
const renderGroup = renderedChannel?._renderGroup || groupName;
|
const renderGroup = renderedChannel?._renderGroup || groupName;
|
||||||
|
|
||||||
html += `
|
html += this.renderLogicalChannel(channel, renderId, renderGroup, isActive);
|
||||||
<div class="channel-item ${isActive ? 'active' : ''} ${channelHidden ? 'hidden' : ''}"
|
|
||||||
data-channel-id="${channel.id}"
|
|
||||||
data-source-id="${channel.sourceId}"
|
|
||||||
data-source-type="${channel.sourceType}"
|
|
||||||
data-stream-id="${channel.streamId || ''}"
|
|
||||||
data-url="${channel.url || ''}"
|
|
||||||
data-render-id="${renderId}"
|
|
||||||
data-render-group="${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.name)}</div>
|
|
||||||
<div class="channel-program">${this.escapeHtml(this.getProgramInfo(channel) || '')}</div>
|
|
||||||
</div>
|
|
||||||
<button class="favorite-btn ${isFavorite ? 'active' : ''}" title="${isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}">
|
|
||||||
${isFavorite ? Icons.favorite : Icons.favoriteOutline}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
|
|
||||||
// Attach listeners to the new channel items
|
this.attachChannelListeners(container);
|
||||||
container.querySelectorAll('.channel-item').forEach(item => {
|
|
||||||
item.addEventListener('click', (e) => {
|
|
||||||
if (e.target.closest('.favorite-btn')) 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.channelId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -927,6 +913,7 @@ class ChannelList {
|
|||||||
if (channel) {
|
if (channel) {
|
||||||
this.updateFavoritesGroup(channel, !wasFavorite);
|
this.updateFavoritesGroup(channel, !wasFavorite);
|
||||||
}
|
}
|
||||||
|
this.render();
|
||||||
// Do NOT call this.render() - it causes lag
|
// Do NOT call this.render() - it causes lag
|
||||||
|
|
||||||
// Perform API call
|
// Perform API call
|
||||||
@ -962,6 +949,7 @@ class ChannelList {
|
|||||||
const channel = this.channels.find(c => c.sourceId == sourceId && c.id == channelId);
|
const channel = this.channels.find(c => c.sourceId == sourceId && c.id == channelId);
|
||||||
if (channel) this.updateFavoritesGroup(channel, false);
|
if (channel) this.updateFavoritesGroup(channel, false);
|
||||||
}
|
}
|
||||||
|
this.render();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1074,10 +1062,21 @@ class ChannelList {
|
|||||||
* Select and play a channel
|
* Select and play a channel
|
||||||
*/
|
*/
|
||||||
async selectChannel(dataset) {
|
async selectChannel(dataset) {
|
||||||
const channel = this.channels.find(c => c.id === dataset.channelId);
|
const logicalChannel = this.logicalChannels.find(c => c.fingerprint === dataset.logicalId) ||
|
||||||
if (!channel) return;
|
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 = channel;
|
this.currentChannel = logicalChannel;
|
||||||
this.currentRenderId = dataset.renderId; // Track which visual instance is active
|
this.currentRenderId = dataset.renderId; // Track which visual instance is active
|
||||||
this.currentRenderGroup = dataset.renderGroup; // Track which group the selection came from
|
this.currentRenderGroup = dataset.renderGroup; // Track which group the selection came from
|
||||||
|
|
||||||
@ -1106,7 +1105,8 @@ class ChannelList {
|
|||||||
|
|
||||||
// Fallback checks if still not found
|
// Fallback checks if still not found
|
||||||
if (!activeItem) {
|
if (!activeItem) {
|
||||||
activeItem = this.container.querySelector(`[data-channel-id="${channel.id}"]`);
|
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 we fell back to channel ID, update currentRenderId to match what we found
|
||||||
if (activeItem && activeItem.dataset.renderId) {
|
if (activeItem && activeItem.dataset.renderId) {
|
||||||
this.currentRenderId = activeItem.dataset.renderId;
|
this.currentRenderId = activeItem.dataset.renderId;
|
||||||
@ -1177,10 +1177,17 @@ class ChannelList {
|
|||||||
*/
|
*/
|
||||||
showContextMenu(e, type, data) {
|
showContextMenu(e, type, data) {
|
||||||
e.preventDefault();
|
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.type = type;
|
||||||
this.contextMenu.dataset.sourceId = data.sourceId;
|
this.contextMenu.dataset.sourceId = variant?.sourceId ?? data.sourceId;
|
||||||
this.contextMenu.dataset.itemId = type === 'group' ? data.group : data.channelId;
|
this.contextMenu.dataset.itemId = type === 'group' ? data.group : (variant?.id || data.variantId);
|
||||||
this.contextMenu.dataset.streamId = data.streamId || '';
|
this.contextMenu.dataset.streamId = variant?.streamId || data.streamId || '';
|
||||||
|
this.contextMenu.dataset.logicalId = data.logicalId || '';
|
||||||
|
|
||||||
this.contextMenu.style.left = `${e.clientX}px`;
|
this.contextMenu.style.left = `${e.clientX}px`;
|
||||||
this.contextMenu.style.top = `${e.clientY}px`;
|
this.contextMenu.style.top = `${e.clientY}px`;
|
||||||
@ -1199,15 +1206,12 @@ class ChannelList {
|
|||||||
*/
|
*/
|
||||||
async handleContextAction(e) {
|
async handleContextAction(e) {
|
||||||
const action = e.target.dataset.action;
|
const action = e.target.dataset.action;
|
||||||
const { type, sourceId, itemId, streamId } = this.contextMenu.dataset;
|
const { type, sourceId, itemId, streamId, logicalId } = this.contextMenu.dataset;
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'play':
|
case 'play':
|
||||||
if (type === 'channel') {
|
if (type === 'channel') {
|
||||||
const channel = this.channels.find(c => c.id === itemId);
|
await this.selectChannel({ logicalId, variantId: itemId, sourceId });
|
||||||
if (channel) {
|
|
||||||
await this.selectChannel({ channelId: channel.id });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'hide':
|
case 'hide':
|
||||||
@ -1345,6 +1349,7 @@ class ChannelList {
|
|||||||
if (channel) {
|
if (channel) {
|
||||||
this.updateFavoritesGroup(channel, isFavorite);
|
this.updateFavoritesGroup(channel, isFavorite);
|
||||||
}
|
}
|
||||||
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1382,11 +1387,9 @@ class ChannelList {
|
|||||||
const nextChannel = this.renderedChannels[nextIndex];
|
const nextChannel = this.renderedChannels[nextIndex];
|
||||||
|
|
||||||
this.selectChannel({
|
this.selectChannel({
|
||||||
channelId: nextChannel.id,
|
logicalId: nextChannel.fingerprint,
|
||||||
sourceId: nextChannel.sourceId,
|
variantId: nextChannel.preferredVariant.id,
|
||||||
sourceType: nextChannel.sourceType,
|
sourceId: nextChannel.preferredVariant.sourceId,
|
||||||
streamId: nextChannel.streamId,
|
|
||||||
url: nextChannel.url,
|
|
||||||
renderId: nextChannel._renderId // Pass the unique render ID
|
renderId: nextChannel._renderId // Pass the unique render ID
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -1425,11 +1428,9 @@ class ChannelList {
|
|||||||
const prevChannel = this.renderedChannels[prevIndex];
|
const prevChannel = this.renderedChannels[prevIndex];
|
||||||
|
|
||||||
this.selectChannel({
|
this.selectChannel({
|
||||||
channelId: prevChannel.id,
|
logicalId: prevChannel.fingerprint,
|
||||||
sourceId: prevChannel.sourceId,
|
variantId: prevChannel.preferredVariant.id,
|
||||||
sourceType: prevChannel.sourceType,
|
sourceId: prevChannel.preferredVariant.sourceId,
|
||||||
streamId: prevChannel.streamId,
|
|
||||||
url: prevChannel.url,
|
|
||||||
renderId: prevChannel._renderId
|
renderId: prevChannel._renderId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -1449,13 +1450,7 @@ class ChannelList {
|
|||||||
* Get list of visible (non-hidden) channels in display order
|
* Get list of visible (non-hidden) channels in display order
|
||||||
*/
|
*/
|
||||||
getVisibleChannels() {
|
getVisibleChannels() {
|
||||||
const showHidden = this.showHiddenCheckbox?.checked ?? false;
|
return this.logicalChannels;
|
||||||
return this.channels.filter(ch => {
|
|
||||||
if (showHidden) return true;
|
|
||||||
const channelHidden = this.isHidden('channel', ch.sourceId, ch.id);
|
|
||||||
const groupHidden = this.isHidden('group', ch.sourceId, ch.groupTitle);
|
|
||||||
return !channelHidden && !groupHidden;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1819,11 +1819,18 @@ class VideoPlayer {
|
|||||||
if (channels.length === 0) return;
|
if (channels.length === 0) return;
|
||||||
|
|
||||||
const currentIdx = this.currentChannel
|
const currentIdx = this.currentChannel
|
||||||
? channels.findIndex(c => c.id === this.currentChannel.id)
|
? channels.findIndex(c => c.variants.some(variant =>
|
||||||
|
variant.id === this.currentChannel.id && variant.sourceId === this.currentChannel.sourceId
|
||||||
|
))
|
||||||
: -1;
|
: -1;
|
||||||
|
|
||||||
const prevIdx = currentIdx <= 0 ? channels.length - 1 : currentIdx - 1;
|
const prevIdx = currentIdx <= 0 ? channels.length - 1 : currentIdx - 1;
|
||||||
window.app.channelList.selectChannel({ channelId: channels[prevIdx].id });
|
const previous = channels[prevIdx];
|
||||||
|
window.app.channelList.selectChannel({
|
||||||
|
logicalId: previous.fingerprint,
|
||||||
|
variantId: previous.preferredVariant.id,
|
||||||
|
sourceId: previous.preferredVariant.sourceId
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1835,11 +1842,18 @@ class VideoPlayer {
|
|||||||
if (channels.length === 0) return;
|
if (channels.length === 0) return;
|
||||||
|
|
||||||
const currentIdx = this.currentChannel
|
const currentIdx = this.currentChannel
|
||||||
? channels.findIndex(c => c.id === this.currentChannel.id)
|
? channels.findIndex(c => c.variants.some(variant =>
|
||||||
|
variant.id === this.currentChannel.id && variant.sourceId === this.currentChannel.sourceId
|
||||||
|
))
|
||||||
: -1;
|
: -1;
|
||||||
|
|
||||||
const nextIdx = currentIdx >= channels.length - 1 ? 0 : currentIdx + 1;
|
const nextIdx = currentIdx >= channels.length - 1 ? 0 : currentIdx + 1;
|
||||||
window.app.channelList.selectChannel({ channelId: channels[nextIdx].id });
|
const next = channels[nextIdx];
|
||||||
|
window.app.channelList.selectChannel({
|
||||||
|
logicalId: next.fingerprint,
|
||||||
|
variantId: next.preferredVariant.id,
|
||||||
|
sourceId: next.preferredVariant.sourceId
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -36,7 +36,7 @@ class LivePage {
|
|||||||
|
|
||||||
// Build a map for O(1) channel lookups
|
// Build a map for O(1) channel lookups
|
||||||
const channelMap = new Map();
|
const channelMap = new Map();
|
||||||
this.app.channelList.channels.forEach(c => channelMap.set(c.id, c));
|
this.app.channelList.logicalChannels.forEach(c => channelMap.set(c.fingerprint, c));
|
||||||
|
|
||||||
// Process in small batches to avoid blocking UI
|
// Process in small batches to avoid blocking UI
|
||||||
const BATCH_SIZE = 50;
|
const BATCH_SIZE = 50;
|
||||||
@ -47,8 +47,7 @@ class LivePage {
|
|||||||
|
|
||||||
for (let i = index; i < end; i++) {
|
for (let i = index; i < end; i++) {
|
||||||
const item = channelItems[i];
|
const item = channelItems[i];
|
||||||
const channelId = item.dataset.channelId;
|
const channel = channelMap.get(item.dataset.logicalId);
|
||||||
const channel = channelMap.get(channelId);
|
|
||||||
|
|
||||||
if (channel) {
|
if (channel) {
|
||||||
const programDiv = item.querySelector('.channel-program');
|
const programDiv = item.querySelector('.channel-program');
|
||||||
|
|||||||
111
public/js/services/channelVariantGrouping.js
Normal file
111
public/js/services/channelVariantGrouping.js
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
(function (root, factory) {
|
||||||
|
const service = factory();
|
||||||
|
if (typeof module === 'object' && module.exports) module.exports = service;
|
||||||
|
if (root) root.ChannelVariantGrouping = service;
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
|
const QUALITY_TOKENS = new Set([
|
||||||
|
'4k', 'uhd', 'fhd', 'fullhd', 'hd', 'sd', 'hevc', 'h265', 'h264',
|
||||||
|
'1080p', '1080i', '720p', '576p', '480p'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const REGION_PREFIX = /^(?:(?:\|\s*[a-z]{2,3}\s*\|)|(?:[a-z]{2,3}(?:-[a-z]{2,3})?\s*\|))\s*/i;
|
||||||
|
|
||||||
|
function words(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(REGION_PREFIX, '')
|
||||||
|
.replace(/[|()[\]{}_.,:;+/\\-]+/g, ' ')
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeChannelName(name) {
|
||||||
|
return words(name).filter(token => !QUALITY_TOKENS.has(token)).join(' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayNameForFingerprint(fingerprint) {
|
||||||
|
return fingerprint.split(' ').map(token => {
|
||||||
|
if (token === 'bein') return 'beIN';
|
||||||
|
if (/^\d+$/.test(token)) return token;
|
||||||
|
return token.charAt(0).toUpperCase() + token.slice(1);
|
||||||
|
}).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVariantKey(channel) {
|
||||||
|
return `${channel.sourceId}:${channel.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuality(channel) {
|
||||||
|
const tokens = words(channel.name);
|
||||||
|
if (tokens.includes('4k') || tokens.includes('uhd')) return '4K';
|
||||||
|
if (tokens.includes('fhd') || tokens.includes('fullhd') || tokens.includes('1080p') || tokens.includes('1080i')) return 'Full HD';
|
||||||
|
if (tokens.includes('hd') || tokens.includes('720p')) return 'HD';
|
||||||
|
if (tokens.includes('sd') || tokens.includes('576p') || tokens.includes('480p')) return 'SD';
|
||||||
|
return 'Standard';
|
||||||
|
}
|
||||||
|
|
||||||
|
function qualityScore(channel) {
|
||||||
|
return { '4K': 4, 'Full HD': 3, HD: 2, Standard: 1, SD: 0 }[getQuality(channel)] ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function choosePreferredVariant(variants, options) {
|
||||||
|
const selectedKey = options.selectedVariantKey;
|
||||||
|
const selected = variants.find(variant => getVariantKey(variant) === selectedKey);
|
||||||
|
if (selected && (options.showHidden || !options.isHidden(selected))) return selected;
|
||||||
|
|
||||||
|
return variants.slice().sort((a, b) => {
|
||||||
|
const hiddenDifference = Number(options.isHidden(a)) - Number(options.isHidden(b));
|
||||||
|
if (!options.showHidden && hiddenDifference) return hiddenDifference;
|
||||||
|
return qualityScore(b) - qualityScore(a);
|
||||||
|
})[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupChannels(channels, options = {}) {
|
||||||
|
const isFavorite = options.isFavorite || (() => false);
|
||||||
|
const isHidden = options.isHidden || (() => false);
|
||||||
|
const showHidden = Boolean(options.showHidden);
|
||||||
|
const selectedVariants = options.selectedVariants || new Map();
|
||||||
|
const buckets = new Map();
|
||||||
|
|
||||||
|
channels.forEach(channel => {
|
||||||
|
const fingerprint = normalizeChannelName(channel.name) || `channel-${getVariantKey(channel)}`;
|
||||||
|
if (!buckets.has(fingerprint)) buckets.set(fingerprint, []);
|
||||||
|
buckets.get(fingerprint).push(channel);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(buckets, ([fingerprint, variants]) => {
|
||||||
|
const visibleVariants = variants.filter(variant => !isHidden(variant));
|
||||||
|
const preferredVariant = choosePreferredVariant(variants, {
|
||||||
|
isHidden,
|
||||||
|
showHidden,
|
||||||
|
selectedVariantKey: selectedVariants.get(fingerprint)
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...preferredVariant,
|
||||||
|
id: `logical:${fingerprint}`,
|
||||||
|
fingerprint,
|
||||||
|
displayName: displayNameForFingerprint(fingerprint),
|
||||||
|
name: displayNameForFingerprint(fingerprint),
|
||||||
|
variants,
|
||||||
|
visibleVariants,
|
||||||
|
preferredVariant,
|
||||||
|
selectedVariantId: getVariantKey(preferredVariant),
|
||||||
|
isFavorite: variants.some(isFavorite),
|
||||||
|
isHidden: visibleVariants.length === 0,
|
||||||
|
groupTitle: preferredVariant.groupTitle,
|
||||||
|
tvgLogo: preferredVariant.tvgLogo,
|
||||||
|
tvgId: preferredVariant.tvgId
|
||||||
|
};
|
||||||
|
}).filter(channel => showHidden || !channel.isHidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
normalizeChannelName,
|
||||||
|
displayNameForFingerprint,
|
||||||
|
getVariantKey,
|
||||||
|
getQuality,
|
||||||
|
groupChannels
|
||||||
|
};
|
||||||
|
});
|
||||||
44
test/channelVariantGrouping.test.js
Normal file
44
test/channelVariantGrouping.test.js
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const grouping = require('../public/js/services/channelVariantGrouping.js');
|
||||||
|
|
||||||
|
const channel = (id, name, extra = {}) => ({ id, streamId: id, sourceId: 1, name, groupTitle: 'Sports', ...extra });
|
||||||
|
|
||||||
|
test('normalizes regional prefixes and quality markers into one logical channel', () => {
|
||||||
|
const names = ['|FR| BEIN SPORTS uhd 1', '|FR| BEIN SPORTS 1 4K', 'FR-CAR| bein sports 1'];
|
||||||
|
assert.deepEqual(names.map(grouping.normalizeChannelName), ['bein sports 1', 'bein sports 1', 'bein sports 1']);
|
||||||
|
const logical = grouping.groupChannels(names.map((name, index) => channel(String(index + 1), name)));
|
||||||
|
assert.equal(logical.length, 1);
|
||||||
|
assert.equal(logical[0].displayName, 'beIN Sports 1');
|
||||||
|
assert.equal(logical[0].variants.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not merge distinct numbered channels', () => {
|
||||||
|
const logical = grouping.groupChannels([
|
||||||
|
channel('1', 'beIN Sports 1 HD'),
|
||||||
|
channel('2', 'beIN Sports 2 HD')
|
||||||
|
]);
|
||||||
|
assert.deepEqual(logical.map(item => item.fingerprint), ['bein sports 1', 'bein sports 2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('logical favorite and hidden state are derived from individual streams', () => {
|
||||||
|
const variants = [channel('1', 'BEIN SPORTS 1 HD'), channel('2', 'BEIN SPORTS 1 4K')];
|
||||||
|
const logical = grouping.groupChannels(variants, {
|
||||||
|
isFavorite: item => item.id === '1',
|
||||||
|
isHidden: item => item.id === '2'
|
||||||
|
})[0];
|
||||||
|
assert.equal(logical.isFavorite, true);
|
||||||
|
assert.equal(logical.isHidden, false);
|
||||||
|
assert.equal(logical.preferredVariant.id, '1');
|
||||||
|
|
||||||
|
assert.equal(grouping.groupChannels(variants, { isHidden: () => true }).length, 0);
|
||||||
|
assert.equal(grouping.groupChannels(variants, { isHidden: () => true, showHidden: true })[0].isHidden, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an explicit visible selection overrides the automatic quality preference', () => {
|
||||||
|
const hd = channel('1', 'BEIN SPORTS 1 HD');
|
||||||
|
const uhd = channel('2', 'BEIN SPORTS 1 UHD');
|
||||||
|
const selectedVariants = new Map([['bein sports 1', grouping.getVariantKey(hd)]]);
|
||||||
|
const logical = grouping.groupChannels([hd, uhd], { selectedVariants })[0];
|
||||||
|
assert.equal(logical.preferredVariant.id, '1');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user