feat: add settings option to hide Live TV categories (#11)
Add a liveChannelListMode setting (default 'grouped') with a Settings toggle to display the Live TV channel list flat, without category group sections. When enabled, logical channels are listed directly with their stream variants still grouped as in #9/#10. Cover the setting default and flat-list behavior with tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -454,6 +454,20 @@
|
|||||||
|
|
||||||
<!-- Player Tab -->
|
<!-- Player Tab -->
|
||||||
<div id="tab-player" class="tab-content">
|
<div id="tab-player" class="tab-content">
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Live TV</h3>
|
||||||
|
<div class="setting-item">
|
||||||
|
<label class="setting-toggle">
|
||||||
|
<input type="checkbox" id="setting-group-live-channels" checked>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">Group channels by category</span>
|
||||||
|
<span class="setting-hint">Turn off to show all channels in a single list. Stream variants remain grouped by channel.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>Keyboard Controls</h3>
|
<h3>Keyboard Controls</h3>
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
@ -1124,10 +1138,10 @@
|
|||||||
<!-- JavaScript -->
|
<!-- JavaScript -->
|
||||||
<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=3"></script>
|
<script src="/js/components/VideoPlayer.js?v=4"></script>
|
||||||
<script src="/js/services/channelLanguageDetection.js?v=1"></script>
|
<script src="/js/services/channelLanguageDetection.js?v=1"></script>
|
||||||
<script src="/js/services/channelVariantGrouping.js?v=2"></script>
|
<script src="/js/services/channelVariantGrouping.js?v=3"></script>
|
||||||
<script src="/js/components/ChannelList.js?v=5"></script>
|
<script src="/js/components/ChannelList.js?v=6"></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>
|
||||||
@ -1135,7 +1149,7 @@
|
|||||||
<script src="/js/pages/Guide.js?v=2"></script>
|
<script src="/js/pages/Guide.js?v=2"></script>
|
||||||
<script src="/js/pages/MoviesPage.js?v=4"></script>
|
<script src="/js/pages/MoviesPage.js?v=4"></script>
|
||||||
<script src="/js/pages/SeriesPage.js?v=1"></script>
|
<script src="/js/pages/SeriesPage.js?v=1"></script>
|
||||||
<script src="/js/pages/Settings.js?v=2"></script>
|
<script src="/js/pages/Settings.js?v=3"></script>
|
||||||
<script src="/js/pages/WatchPage.js?v=1"></script>
|
<script src="/js/pages/WatchPage.js?v=1"></script>
|
||||||
<script src="/js/app.js?v=4"></script>
|
<script src="/js/app.js?v=4"></script>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@ -23,6 +23,7 @@ class ChannelList {
|
|||||||
this.sources = [];
|
this.sources = [];
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
this.renderedChannels = [];
|
this.renderedChannels = [];
|
||||||
|
this.listMode = 'grouped';
|
||||||
this.logicalChannels = [];
|
this.logicalChannels = [];
|
||||||
this.selectedVariants = new Map();
|
this.selectedVariants = new Map();
|
||||||
this.expandedVariants = new Set();
|
this.expandedVariants = new Set();
|
||||||
@ -189,6 +190,10 @@ class ChannelList {
|
|||||||
// Source filter handler
|
// Source filter handler
|
||||||
this.sourceSelect.addEventListener('change', () => this.loadChannels());
|
this.sourceSelect.addEventListener('change', () => this.loadChannels());
|
||||||
|
|
||||||
|
this.toggleGroupsBtn?.addEventListener('click', () => {
|
||||||
|
if (this.listMode === 'grouped') this.toggleAllGroups();
|
||||||
|
});
|
||||||
|
|
||||||
// Show hidden toggle
|
// Show hidden toggle
|
||||||
if (this.showHiddenCheckbox) {
|
if (this.showHiddenCheckbox) {
|
||||||
this.showHiddenCheckbox.addEventListener('change', () => this.render());
|
this.showHiddenCheckbox.addEventListener('change', () => this.render());
|
||||||
@ -212,7 +217,8 @@ class ChannelList {
|
|||||||
// Intersection Observer for lazy loading
|
// Intersection Observer for lazy loading
|
||||||
this.observer = new IntersectionObserver((entries) => {
|
this.observer = new IntersectionObserver((entries) => {
|
||||||
if (entries[0].isIntersecting) {
|
if (entries[0].isIntersecting) {
|
||||||
this.renderNextBatch();
|
if (this.listMode === 'flat') this.renderNextFlatBatch();
|
||||||
|
else this.renderNextBatch();
|
||||||
}
|
}
|
||||||
}, { rootMargin: '100px' });
|
}, { rootMargin: '100px' });
|
||||||
|
|
||||||
@ -312,6 +318,16 @@ class ChannelList {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
escapeHtml(text) {
|
escapeHtml(text) {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
return text
|
return text
|
||||||
@ -328,6 +344,12 @@ class ChannelList {
|
|||||||
render() {
|
render() {
|
||||||
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.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, {
|
this.logicalChannels = ChannelVariantGrouping.groupChannels(this.channels, {
|
||||||
showHidden,
|
showHidden,
|
||||||
@ -359,6 +381,12 @@ class ChannelList {
|
|||||||
|
|
||||||
let filteredChannels = this.filteredChannels;
|
let filteredChannels = this.filteredChannels;
|
||||||
|
|
||||||
|
this.showHidden = showHidden;
|
||||||
|
if (this.listMode === 'flat') {
|
||||||
|
this.renderFlatList(filteredChannels, searchTerm);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Group
|
// 2. Group
|
||||||
filteredChannels.forEach(ch => {
|
filteredChannels.forEach(ch => {
|
||||||
const groupKey = ch.groupTitle || 'Uncategorized';
|
const groupKey = ch.groupTitle || 'Uncategorized';
|
||||||
@ -466,6 +494,57 @@ class ChannelList {
|
|||||||
this.observer.observe(this.loader);
|
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
|
* Render next batch of groups
|
||||||
*/
|
*/
|
||||||
@ -1002,6 +1081,8 @@ class ChannelList {
|
|||||||
* Update Favorites group in DOM and data
|
* Update Favorites group in DOM and data
|
||||||
*/
|
*/
|
||||||
updateFavoritesGroup(channel, isAdded) {
|
updateFavoritesGroup(channel, isAdded) {
|
||||||
|
if (!ChannelVariantGrouping.shouldUseFavoritesGroup(this.listMode) || !this.groupedChannels) return;
|
||||||
|
|
||||||
// 1. Update Data
|
// 1. Update Data
|
||||||
if (!this.groupedChannels['Favorites']) {
|
if (!this.groupedChannels['Favorites']) {
|
||||||
this.groupedChannels['Favorites'] = [];
|
this.groupedChannels['Favorites'] = [];
|
||||||
@ -1518,7 +1599,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() {
|
||||||
return this.logicalChannels;
|
return this.listMode === 'flat' ? this.renderedChannels : this.logicalChannels;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -57,6 +57,7 @@ class VideoPlayer {
|
|||||||
rememberVolume: true,
|
rememberVolume: true,
|
||||||
lastVolume: 80,
|
lastVolume: 80,
|
||||||
autoPlayNextEpisode: false,
|
autoPlayNextEpisode: false,
|
||||||
|
liveChannelListMode: 'grouped',
|
||||||
forceProxy: false,
|
forceProxy: false,
|
||||||
forceTranscode: false,
|
forceTranscode: false,
|
||||||
forceRemux: false,
|
forceRemux: false,
|
||||||
@ -94,6 +95,7 @@ class VideoPlayer {
|
|||||||
const serverSettings = await API.settings.get();
|
const serverSettings = await API.settings.get();
|
||||||
this.settings = { ...this.getDefaultSettings(), ...serverSettings };
|
this.settings = { ...this.getDefaultSettings(), ...serverSettings };
|
||||||
this.settingsLoaded = true;
|
this.settingsLoaded = true;
|
||||||
|
window.app?.channelList?.setListMode(this.settings.liveChannelListMode);
|
||||||
console.log('[Player] Settings loaded from server');
|
console.log('[Player] Settings loaded from server');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[Player] Failed to load settings from server, using defaults:', err.message);
|
console.warn('[Player] Failed to load settings from server, using defaults:', err.message);
|
||||||
|
|||||||
@ -34,6 +34,7 @@ class SettingsPage {
|
|||||||
const volumeValueDisplay = document.getElementById('volume-value');
|
const volumeValueDisplay = document.getElementById('volume-value');
|
||||||
const rememberVolumeToggle = document.getElementById('setting-remember-volume');
|
const rememberVolumeToggle = document.getElementById('setting-remember-volume');
|
||||||
const autoPlayNextToggle = document.getElementById('setting-autoplay-next');
|
const autoPlayNextToggle = document.getElementById('setting-autoplay-next');
|
||||||
|
const groupLiveChannelsToggle = document.getElementById('setting-group-live-channels');
|
||||||
|
|
||||||
// Load current settings
|
// Load current settings
|
||||||
if (this.app.player?.settings) {
|
if (this.app.player?.settings) {
|
||||||
@ -43,6 +44,7 @@ class SettingsPage {
|
|||||||
volumeValueDisplay.textContent = this.app.player.settings.defaultVolume + '%';
|
volumeValueDisplay.textContent = this.app.player.settings.defaultVolume + '%';
|
||||||
rememberVolumeToggle.checked = this.app.player.settings.rememberVolume;
|
rememberVolumeToggle.checked = this.app.player.settings.rememberVolume;
|
||||||
autoPlayNextToggle.checked = this.app.player.settings.autoPlayNextEpisode;
|
autoPlayNextToggle.checked = this.app.player.settings.autoPlayNextEpisode;
|
||||||
|
groupLiveChannelsToggle.checked = this.app.player.settings.liveChannelListMode !== 'flat';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Arrow keys toggle
|
// Arrow keys toggle
|
||||||
@ -77,6 +79,13 @@ class SettingsPage {
|
|||||||
this.app.player.saveSettings();
|
this.app.player.saveSettings();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
groupLiveChannelsToggle.addEventListener('change', () => {
|
||||||
|
const mode = groupLiveChannelsToggle.checked ? 'grouped' : 'flat';
|
||||||
|
this.app.player.settings.liveChannelListMode = mode;
|
||||||
|
this.app.channelList.setListMode(mode);
|
||||||
|
this.app.player.saveSettings();
|
||||||
|
});
|
||||||
|
|
||||||
// EPG refresh interval
|
// EPG refresh interval
|
||||||
const epgRefreshSelect = document.getElementById('epg-refresh-interval');
|
const epgRefreshSelect = document.getElementById('epg-refresh-interval');
|
||||||
if (epgRefreshSelect && this.app.player?.settings) {
|
if (epgRefreshSelect && this.app.player?.settings) {
|
||||||
@ -571,6 +580,7 @@ class SettingsPage {
|
|||||||
const volumeValueDisplay = document.getElementById('volume-value');
|
const volumeValueDisplay = document.getElementById('volume-value');
|
||||||
const rememberVolumeToggle = document.getElementById('setting-remember-volume');
|
const rememberVolumeToggle = document.getElementById('setting-remember-volume');
|
||||||
const autoPlayNextToggle = document.getElementById('setting-autoplay-next');
|
const autoPlayNextToggle = document.getElementById('setting-autoplay-next');
|
||||||
|
const groupLiveChannelsToggle = document.getElementById('setting-group-live-channels');
|
||||||
const forceProxyToggle = document.getElementById('setting-force-proxy');
|
const forceProxyToggle = document.getElementById('setting-force-proxy');
|
||||||
const forceTranscodeToggle = document.getElementById('setting-force-transcode');
|
const forceTranscodeToggle = document.getElementById('setting-force-transcode');
|
||||||
const forceRemuxToggle = document.getElementById('setting-force-remux');
|
const forceRemuxToggle = document.getElementById('setting-force-remux');
|
||||||
@ -584,6 +594,7 @@ class SettingsPage {
|
|||||||
if (volumeValueDisplay) volumeValueDisplay.textContent = s.defaultVolume + '%';
|
if (volumeValueDisplay) volumeValueDisplay.textContent = s.defaultVolume + '%';
|
||||||
if (rememberVolumeToggle) rememberVolumeToggle.checked = s.rememberVolume;
|
if (rememberVolumeToggle) rememberVolumeToggle.checked = s.rememberVolume;
|
||||||
if (autoPlayNextToggle) autoPlayNextToggle.checked = s.autoPlayNextEpisode;
|
if (autoPlayNextToggle) autoPlayNextToggle.checked = s.autoPlayNextEpisode;
|
||||||
|
if (groupLiveChannelsToggle) groupLiveChannelsToggle.checked = s.liveChannelListMode !== 'flat';
|
||||||
if (forceProxyToggle) forceProxyToggle.checked = s.forceProxy || false;
|
if (forceProxyToggle) forceProxyToggle.checked = s.forceProxy || false;
|
||||||
if (forceTranscodeToggle) forceTranscodeToggle.checked = s.forceTranscode || false;
|
if (forceTranscodeToggle) forceTranscodeToggle.checked = s.forceTranscode || false;
|
||||||
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false;
|
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false;
|
||||||
|
|||||||
@ -152,11 +152,24 @@
|
|||||||
}).filter(channel => showHidden || !channel.isHidden);
|
}).filter(channel => showHidden || !channel.isHidden);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function orderFlatChannels(channels) {
|
||||||
|
return channels.map((channel, index) => ({ channel, index })).sort((a, b) => {
|
||||||
|
const favoriteDifference = Number(b.channel.isFavorite) - Number(a.channel.isFavorite);
|
||||||
|
return favoriteDifference || a.index - b.index;
|
||||||
|
}).map(entry => entry.channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldUseFavoritesGroup(listMode) {
|
||||||
|
return listMode === 'grouped';
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
normalizeChannelName,
|
normalizeChannelName,
|
||||||
displayNameForFingerprint,
|
displayNameForFingerprint,
|
||||||
getVariantKey,
|
getVariantKey,
|
||||||
getQuality,
|
getQuality,
|
||||||
groupChannels
|
groupChannels,
|
||||||
|
orderFlatChannels,
|
||||||
|
shouldUseFavoritesGroup
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@ -62,6 +62,7 @@ function getDefaultSettings() {
|
|||||||
rememberVolume: true,
|
rememberVolume: true,
|
||||||
lastVolume: 80,
|
lastVolume: 80,
|
||||||
autoPlayNextEpisode: false,
|
autoPlayNextEpisode: false,
|
||||||
|
liveChannelListMode: 'grouped',
|
||||||
forceProxy: false,
|
forceProxy: false,
|
||||||
forceTranscode: false, // Force Audio Transcode
|
forceTranscode: false, // Force Audio Transcode
|
||||||
forceVideoTranscode: false, // Force Video Transcode
|
forceVideoTranscode: false, // Force Video Transcode
|
||||||
|
|||||||
@ -42,3 +42,20 @@ test('an explicit visible selection overrides the automatic quality preference',
|
|||||||
const logical = grouping.groupChannels([hd, uhd], { selectedVariants })[0];
|
const logical = grouping.groupChannels([hd, uhd], { selectedVariants })[0];
|
||||||
assert.equal(logical.preferredVariant.id, '1');
|
assert.equal(logical.preferredVariant.id, '1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('flat list ordering promotes favorites without duplicating channels', () => {
|
||||||
|
const channels = [
|
||||||
|
{ id: 'one', isFavorite: false },
|
||||||
|
{ id: 'two', isFavorite: true },
|
||||||
|
{ id: 'three', isFavorite: false },
|
||||||
|
{ id: 'four', isFavorite: true }
|
||||||
|
];
|
||||||
|
const ordered = grouping.orderFlatChannels(channels);
|
||||||
|
assert.deepEqual(ordered.map(item => item.id), ['two', 'four', 'one', 'three']);
|
||||||
|
assert.equal(new Set(ordered.map(item => item.id)).size, channels.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('favorites group updates are disabled for flat lists', () => {
|
||||||
|
assert.equal(grouping.shouldUseFavoritesGroup('flat'), false);
|
||||||
|
assert.equal(grouping.shouldUseFavoritesGroup('grouped'), true);
|
||||||
|
});
|
||||||
|
|||||||
7
test/settingsDefaults.test.js
Normal file
7
test/settingsDefaults.test.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { getDefaultSettings } = require('../server/db');
|
||||||
|
|
||||||
|
test('defaults Live TV channel lists to grouped mode', () => {
|
||||||
|
assert.equal(getDefaultSettings().liveChannelListMode, 'grouped');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user