Initial commit

This commit is contained in:
Trevor Mears
2025-12-27 14:16:40 -08:00
commit 4f9ec55874
30 changed files with 9148 additions and 0 deletions
+964
View File
@@ -0,0 +1,964 @@
/**
* 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.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.favorites = []; // Array of favorite objects
this.visibleFavorites = new Set(); // Set<"sourceId:channelId">
this.currentChannel = null;
this.sources = [];
this.isLoading = false;
this.loadCollapsedState();
this.init();
}
/**
* Load collapsed state from localStorage
*/
loadCollapsedState() {
try {
const saved = localStorage.getItem('nodecast_tv_collapsed_groups');
if (saved) {
this.collapsedGroups = new Set(JSON.parse(saved));
}
} catch (err) {
console.error('Error loading collapsed state:', err);
}
}
/**
* 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);
} else {
this.collapsedGroups.add(groupName);
}
this.saveCollapsedState();
}
/**
* Expand all groups
*/
expandAll() {
this.collapsedGroups.clear();
this.saveCollapsedState();
this.container.querySelectorAll('.group-header.collapsed').forEach(h => h.classList.remove('collapsed'));
}
/**
* 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();
}
/**
* 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());
// Show hidden toggle
if (this.showHiddenCheckbox) {
this.showHiddenCheckbox.addEventListener('change', () => this.render());
}
// Context menu handlers
document.addEventListener('click', () => this.hideContextMenu());
this.contextMenu.querySelectorAll('.context-item').forEach(item => {
item.addEventListener('click', (e) => this.handleContextAction(e));
});
// Intersection Observer for lazy loading
this.observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
this.renderNextBatch();
}
}, { rootMargin: '100px' });
}
// ... (loadSources, loadChannels, loadAllChannels, loadXtreamChannels, loadM3uChannels, loadHiddenItems, isHidden, loadFavorites, isFavorite, toggleFavorite methods remain same)
/**
* Get current program info string
*/
getProgramInfo(channel) {
try {
if (!window.app || !window.app.epgGuide) return null;
const program = window.app.epgGuide.getCurrentProgram(channel.tvgId, channel.name);
return program ? program.title : null;
} catch (e) {
console.warn("Error in getProgramInfo", e);
return null;
}
}
escapeHtml(text) {
if (!text) return '';
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
/**
* Render channel list
*/
render() {
const searchTerm = this.searchInput.value.toLowerCase();
const showHidden = this.showHiddenCheckbox ? this.showHiddenCheckbox.checked : false;
// 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
// Filter and Group channels
const groupedChannels = {};
// 1. Filter
this.filteredChannels = this.channels;
if (searchTerm) {
this.filteredChannels = this.channels.filter(ch =>
ch.name.toLowerCase().includes(searchTerm) ||
(ch.groupTitle && ch.groupTitle.toLowerCase().includes(searchTerm))
);
}
let filteredChannels = this.filteredChannels;
// 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.channels.filter(ch => this.isFavorite(ch.sourceId, ch.id));
if (favoritedChannels.length > 0) {
favoritedChannels.sort((a, b) => a.name.localeCompare(b.name));
groupedChannels['Favorites'] = favoritedChannels;
}
// 4. Sort Groups
this.sortedGroups = Object.keys(groupedChannels).sort((a, b) => {
if (a === 'Favorites') return -1;
if (b === 'Favorites') return 1;
return a.localeCompare(b);
});
this.groupedChannels = groupedChannels;
this.showHidden = showHidden;
// 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 enough to create scrollable content
// With many hidden groups, we may need multiple batches to get enough visible groups
const maxInitialBatches = 10; // Load up to 200 groups (10 batches x 20 groups)
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);
}
/**
* 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 = '';
for (const groupName of groupsToRender) {
const channels = this.groupedChannels[groupName];
if (channels.length === 0) continue;
const isFavoritesGroup = groupName === 'Favorites';
const groupHidden = !isFavoritesGroup && channels.length > 0 && this.isHidden('group', channels[0].sourceId, groupName);
if (groupHidden && !this.showHidden) continue;
html += `
<div class="channel-group">
<div class="group-header ${groupHidden ? 'hidden' : ''} ${this.collapsedGroups.has(groupName) ? 'collapsed' : ''} ${isFavoritesGroup ? 'favorites-group' : ''}" data-group="${groupName}">
<span class="group-toggle">▼</span>
<span class="group-name">${groupName}</span>
<span class="group-count">${channels.length}</span>
</div>
<div class="group-channels">
`;
for (const channel of channels) {
const channelHidden = !isFavoritesGroup && this.isHidden('channel', channel.sourceId, channel.id);
if (channelHidden && !this.showHidden) continue;
const isActive = this.currentChannel?.id === channel.id;
const isFavorite = this.isFavorite(channel.sourceId, channel.id);
html += `
<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 || ''}">
<img class="channel-logo" src="${channel.tvgLogo || '/img/placeholder.png'}"
alt="" onerror="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 ? '❤️' : '♡'}
</button>
</div>
`;
}
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', () => {
header.classList.toggle('collapsed');
this.toggleGroup(header.dataset.group);
});
header.addEventListener('contextmenu', (e) => this.showContextMenu(e, 'group', header.dataset));
}
groupEl.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);
});
}
});
}
/**
* 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;
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();
} 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();
} 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'
}));
this.groups.push(...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}`,
groupTitle: categories.find(c => c.category_id === stream.category_id)?.category_name || 'Uncategorized',
sourceId,
sourceType: 'xtream'
}));
// Deduplicate by name within the same group (Xtream often sends backup streams with same name)
const seen = new Set();
const uniqueChannels = [];
for (const ch of channelList) {
const key = `${ch.groupTitle}|${ch.name}`;
if (!seen.has(key)) {
seen.add(key);
uniqueChannels.push(ch);
}
}
this.channels.push(...uniqueChannels);
}
/**
* Load M3U channels
*/
async loadM3uChannels(sourceId, append = false) {
if (!append) {
this.channels = [];
this.groups = [];
}
const data = await API.proxy.m3u.get(sourceId);
// Add groups
const m3uGroups = data.groups.map(g => ({
...g,
id: `m3u_${sourceId}_${g.id}`,
sourceId,
sourceType: 'm3u'
}));
this.groups.push(...m3uGroups);
// Add channels - use the stable id from the server
const channelList = data.channels.map(ch => ({
...ch,
// Use the stable id provided by the server (from tvgId or hash)
id: ch.id,
groupId: `m3u_${sourceId}_group_${data.groups.findIndex(g => g.name === ch.groupTitle)}`,
sourceId,
sourceType: 'm3u'
}));
this.channels.push(...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 = '♡';
btn.title = 'Add to Favorites';
});
} else {
this.visibleFavorites.add(key);
btns.forEach(btn => {
btn.classList.add('active');
btn.innerHTML = '❤️';
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);
}
// 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');
}
} 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 = '❤️';
});
// 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 = '♡';
});
// Revert group update
const channel = this.channels.find(c => c.sourceId == sourceId && c.id == channelId);
if (channel) this.updateFavoritesGroup(channel, false);
}
}
}
/**
* Update Favorites group in DOM and data
*/
updateFavoritesGroup(channel, isAdded) {
// 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="${channel.tvgLogo || '/img/placeholder.png'}"
alt="" onerror="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;
this.selectChannel(div.dataset);
});
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 channel = this.channels.find(c => c.id === dataset.channelId);
if (!channel) return;
this.currentChannel = channel;
// Update active state in DOM without full re-render
this.container.querySelectorAll('.channel-item.active').forEach(el => {
el.classList.remove('active');
});
const activeItem = this.container.querySelector(`[data-channel-id="${channel.id}"]`);
if (activeItem) {
activeItem.classList.add('active');
}
// Get stream URL
let streamUrl;
if (channel.sourceType === 'xtream') {
const result = await API.proxy.xtream.getStreamUrl(channel.sourceId, channel.streamId, 'live');
streamUrl = result.url;
} else {
streamUrl = channel.url;
}
// Play channel
if (window.app?.player) {
window.app.player.play(channel, streamUrl);
}
}
/**
* Show context menu
*/
showContextMenu(e, type, data) {
e.preventDefault();
this.contextMenu.dataset.type = type;
this.contextMenu.dataset.sourceId = data.sourceId;
this.contextMenu.dataset.itemId = type === 'group' ? data.group : data.channelId;
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 } = this.contextMenu.dataset;
switch (action) {
case 'play':
if (type === 'channel') {
const channel = this.channels.find(c => c.id === itemId);
if (channel) {
await this.selectChannel({ channelId: channel.id });
}
}
break;
case 'hide':
await API.channels.hide(parseInt(sourceId), type, itemId);
this.hiddenItems.add(`${type}:${sourceId}:${itemId}`);
this.render();
break;
case 'epg':
// Show EPG info modal
this.showEpgInfo(itemId);
break;
}
this.hideContextMenu();
}
/**
* 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);
}
}
/**
* Select next channel in the current list
*/
selectNextChannel() {
if (!this.currentChannel || !this.filteredChannels || this.filteredChannels.length === 0) return;
const currentIndex = this.filteredChannels.findIndex(c =>
c.id === this.currentChannel.id && c.sourceId === this.currentChannel.sourceId
);
if (currentIndex === -1) return;
const nextIndex = (currentIndex + 1) % this.filteredChannels.length;
const nextChannel = this.filteredChannels[nextIndex];
this.selectChannel({
channelId: nextChannel.id,
sourceId: nextChannel.sourceId,
sourceType: nextChannel.sourceType,
streamId: nextChannel.streamId,
url: nextChannel.url
});
}
/**
* Select previous channel in the current list
*/
selectPrevChannel() {
if (!this.currentChannel || !this.filteredChannels || this.filteredChannels.length === 0) return;
const currentIndex = this.filteredChannels.findIndex(c =>
c.id === this.currentChannel.id && c.sourceId === this.currentChannel.sourceId
);
if (currentIndex === -1) return;
const prevIndex = (currentIndex - 1 + this.filteredChannels.length) % this.filteredChannels.length;
const prevChannel = this.filteredChannels[prevIndex];
this.selectChannel({
channelId: prevChannel.id,
sourceId: prevChannel.sourceId,
sourceType: prevChannel.sourceType,
streamId: prevChannel.streamId,
url: prevChannel.url
});
}
/**
* 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() {
const showHidden = this.showHiddenCheckbox.checked;
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;
});
}
}
// Export
window.ChannelList = ChannelList;
+756
View File
@@ -0,0 +1,756 @@
/**
* EPG Guide Component
* Cable TV-style program guide grid
*/
class EpgGuide {
constructor() {
this.container = document.getElementById('epg-grid');
this.dateDisplay = document.getElementById('guide-date');
this.prevBtn = document.getElementById('guide-prev');
this.nextBtn = document.getElementById('guide-next');
this.nextBtn = document.getElementById('guide-next');
this.groupSelect = document.getElementById('epg-group-select');
this.searchInput = document.getElementById('epg-search');
this.channels = [];
this.programmes = [];
this.currentDate = new Date();
this.timeOffset = 0; // Hours offset from now
this.pixelsPerMinute = 3.5; // Width scaling
this.favorites = new Set(); // Set<"sourceId:channelId">
this.selectedGroup = 'Favorites'; // Default to Favorites
// Lazy loading properties
this.filteredChannels = [];
this.currentBatch = 0;
this.batchSize = 20; // Channels per batch
this.startTime = null;
this.endTime = null;
this.epgContainer = null;
this.epgLoader = null;
this.epgObserver = null;
this.init();
}
init() {
this.prevBtn.addEventListener('click', () => this.navigate(-2));
this.nextBtn.addEventListener('click', () => this.navigate(2));
// Group filter change
this.groupSelect?.addEventListener('change', () => {
this.selectedGroup = this.groupSelect.value;
this.render();
});
// Search input
if (this.searchInput) {
this.searchInput.addEventListener('input', this.debounce(() => {
this.render();
}, 300));
}
// Update current time indicator every minute
setInterval(() => this.updateNowIndicator(), 60000);
this.initResizer();
}
/**
* Initialize sidebar resizer
*/
initResizer() {
let isResizing = false;
let startX, startWidth;
document.addEventListener('mousedown', (e) => {
if (e.target.classList.contains('resize-handle')) {
isResizing = true;
startX = e.clientX;
const sidebar = document.querySelector('.epg-channel-info');
startWidth = sidebar.getBoundingClientRect().width;
e.target.classList.add('active');
document.body.style.cursor = 'col-resize';
e.preventDefault(); // Prevent text selection
}
});
document.addEventListener('mousemove', (e) => {
if (!isResizing) return;
const diff = e.clientX - startX;
const newWidth = Math.max(150, Math.min(500, startWidth + diff)); // Min 150px, Max 500px
document.documentElement.style.setProperty('--epg-sidebar-width', `${newWidth}px`);
this.updateNowIndicator(); // Update line position
});
document.addEventListener('mouseup', () => {
if (isResizing) {
isResizing = false;
document.querySelectorAll('.resize-handle').forEach(h => h.classList.remove('active'));
document.body.style.cursor = '';
}
});
}
/**
* Navigate time
*/
navigate(hours) {
this.timeOffset += hours;
this.render();
}
/**
* Get EPG refresh interval from settings (in hours)
*/
getRefreshInterval() {
const saved = localStorage.getItem('nodecast_tv_epg_refresh_interval');
return saved ? parseInt(saved) : 24; // Default 24 hours
}
/**
* Load EPG data (server-side caching)
*/
async loadEpg(forceRefresh = false) {
try {
this.container.innerHTML = '<div class="loading"></div>';
await this.fetchEpgData(forceRefresh);
this.render();
} catch (err) {
console.error('Error loading EPG:', err);
this.container.innerHTML = `
<div class="empty-state">
<p>Error loading EPG</p>
<p class="hint">${err.message}</p>
</div>
`;
}
}
/**
* Fetch EPG data from sources
*/
async fetchEpgData(forceRefresh = false) {
// Get ALL sources and filter for EPG-capable types
const allSources = await API.sources.getAll();
const sources = allSources.filter(s => (s.type === 'epg' || s.type === 'xtream') && s.enabled);
if (sources.length === 0) {
throw new Error('No EPG sources or Xtream accounts configured');
}
// Build query params for server-side caching
const maxAge = this.getRefreshInterval();
const queryParams = forceRefresh ? '?refresh=1' : `?maxAge=${maxAge}`;
// Load EPG from ALL sources in parallel
const fetchPromises = sources.map(async (source) => {
try {
const response = await fetch(`/api/proxy/epg/${source.id}${queryParams}`);
if (!response.ok) throw new Error(`Status ${response.status}`);
return await response.json();
} catch (e) {
console.warn(`Failed to load EPG for source ${source.name}:`, e);
return null;
}
});
const results = await Promise.all(fetchPromises);
// Merge results
this.channels = [];
this.programmes = [];
let hasData = false;
results.forEach(data => {
if (data) {
if (data.channels && data.channels.length > 0) {
this.channels = this.channels.concat(data.channels);
}
if (data.programmes && data.programmes.length > 0) {
this.programmes = this.programmes.concat(data.programmes);
}
if (data.channels || data.programmes) {
hasData = true;
}
}
});
if (!hasData) {
throw new Error('Failed to load EPG data from any source');
}
// Build secondary indexes for faster lookup
this.channelMap = new Map();
// Index by ID
this.channels.forEach(ch => {
this.channelMap.set(ch.id, ch);
// Also index by name (normalized) for fallback matching
if (ch.name) {
this.channelMap.set(ch.name.toLowerCase(), ch);
}
});
// Load favorites
const favs = await API.favorites.getAll();
this.favorites = new Set(favs.map(f => `${f.source_id}:${f.channel_id}`));
}
/**
* Get current program for a channel
* @param {string} tvgId - The EPG channel ID (tvg-id)
* @param {string} channelName - The channel name (for fallback)
* @returns {object|null} Program object with title, start, stop
*/
getCurrentProgram(tvgId, channelName) {
if (!this.programmes || this.programmes.length === 0) return null;
// Find EPG channel using fast map lookup
let epgChannel = null;
if (tvgId && this.channelMap && this.channelMap.has(tvgId)) {
epgChannel = this.channelMap.get(tvgId);
} else if (channelName && this.channelMap) {
epgChannel = this.channelMap.get(channelName.toLowerCase());
} else {
// Fallback to slow search if map fails or not built yet
epgChannel = this.channels.find(epg =>
(tvgId && epg.id === tvgId) || epg.name === channelName
);
}
if (!epgChannel) return null;
const now = new Date();
const nowTime = now.getTime();
// Filter programs for this channel
const current = this.programmes.find(p => {
if (p.channelId !== epgChannel.id) return false;
const start = new Date(p.start).getTime();
const stop = new Date(p.stop).getTime();
return nowTime >= start && nowTime < stop;
});
return current ? {
title: current.title,
start: current.start,
stop: current.stop,
desc: current.desc
} : null;
}
/**
* Update filtered channels based on search or group
*/
updateFilteredChannels() {
const searchTerm = this.searchInput ? this.searchInput.value.toLowerCase().trim() : '';
// SEARCH MODE: Filter all channels by name
if (searchTerm) {
this.filteredChannels = this.allMatchedChannels.filter(ch => {
const name = (ch.sourceChannel?.name || '').toLowerCase();
return name.includes(searchTerm);
});
return;
}
// GROUP MODE (Default)
if (this.selectedGroup === 'Favorites') {
this.filteredChannels = this.allMatchedChannels.filter(m =>
this.favorites.has(`${m.sourceChannel.sourceId}:${m.sourceChannel.id}`)
);
return;
}
if (!this.selectedGroup || this.selectedGroup === 'All') {
this.filteredChannels = [...this.allMatchedChannels];
} else {
this.filteredChannels = this.allMatchedChannels.filter(m =>
(m.sourceChannel.groupTitle || 'Uncategorized') === this.selectedGroup
);
}
}
/**
* Render the EPG grid
*/
render() {
// Get channel list instance
const channelList = window.app?.channelList;
if (!channelList) return;
// Get channels and filter out hidden ones (always enforce hidden in EPG)
const playableChannels = (channelList.channels || []).filter(ch => {
const isChannelHidden = channelList.isHidden('channel', ch.sourceId, ch.id);
const isGroupHidden = channelList.isHidden('group', ch.sourceId, ch.groupTitle);
return !isChannelHidden && !isGroupHidden;
});
if (playableChannels.length === 0) {
this.container.innerHTML = `
<div class="empty-state">
<p>No visible channels available</p>
<p class="hint">Check your content settings or add a source</p>
</div>
`;
return;
}
// Match ALL playable channels with optional EPG data
const allChannels = playableChannels.map(sourceChannel => {
// Try to find matching EPG channel by tvgId or name
const epgChannel = this.channels.find(epg =>
epg.id === sourceChannel.tvgId || epg.name === sourceChannel.name
);
return { epgChannel, sourceChannel };
});
// Collect unique groups from ALL playable channels
const groups = [...new Set(allChannels.map(m => m.sourceChannel.groupTitle || 'Uncategorized'))].sort();
// Add Favorites at the top if there are any
const hasFavorites = this.favorites.size > 0;
// Only rebuild dropdown if groups have changed (performance optimization)
const groupsKey = groups.join('|') + (hasFavorites ? '|FAV' : '');
if (this.groupSelect && this._lastGroupsKey !== groupsKey) {
this._lastGroupsKey = groupsKey;
const currentValue = this.selectedGroup;
let optionsHtml = '';
if (hasFavorites) {
optionsHtml += `<option value="Favorites" ${currentValue === 'Favorites' ? 'selected' : ''}>Favorites</option>`;
}
optionsHtml += `<option value="" ${currentValue === '' ? 'selected' : ''}>All Groups</option>`;
optionsHtml += groups.map(g => `<option value="${g}" ${g === currentValue ? 'selected' : ''}>${g}</option>`).join('');
this.groupSelect.innerHTML = optionsHtml;
} else if (this.groupSelect) {
// Just update the selected value without rebuilding
this.groupSelect.value = this.selectedGroup;
}
// Handle case where we defaulted to Favorites but user has none
if (this.selectedGroup === 'Favorites' && !hasFavorites) {
this.selectedGroup = ''; // Fallback to 'All Groups'
if (this.groupSelect) this.groupSelect.value = '';
}
// Store all channels (matched with EPG data) for filtering
this.allMatchedChannels = allChannels;
this.updateFilteredChannels();
// Calculate time range and store for batch rendering
this.startTime = new Date();
this.startTime.setHours(this.startTime.getHours() + this.timeOffset);
this.startTime.setMinutes(0, 0, 0);
this.endTime = new Date(this.startTime);
this.endTime.setHours(this.endTime.getHours() + 24); // Show 24 hours of programming
// Update date display
this.updateDateDisplay(this.startTime);
// Generate time slots
const timeSlots = this.generateTimeSlots(this.startTime, this.endTime);
// Build initial HTML structure
this.container.innerHTML = `
<div class="epg-container" style="position: relative;">
<div class="epg-time-header">
${timeSlots.map(slot => `
<div class="epg-time-slot" style="width: ${30 * this.pixelsPerMinute}px;">
${slot.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
`).join('')}
</div>
<div class="epg-channel-rows"></div>
<div class="epg-loader" style="height: 50px; display: flex; align-items: center; justify-content: center;">
<div class="loading-spinner"></div>
</div>
</div>
`;
// Get references for batch rendering
this.epgContainer = this.container.querySelector('.epg-channel-rows');
this.epgLoader = this.container.querySelector('.epg-loader');
// Reset batch state
this.currentBatch = 0;
// Set up IntersectionObserver for lazy loading
if (this.epgObserver) {
this.epgObserver.disconnect();
}
this.epgObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
this.renderNextEpgBatch();
}
}, { rootMargin: '200px' });
// Render initial batches (render enough to fill viewport)
for (let i = 0; i < 3; i++) {
this.renderNextEpgBatch();
}
// Start observing loader
this.epgObserver.observe(this.epgLoader);
// Add now indicator
this.updateNowIndicator();
}
/**
* Render next batch of EPG channel rows
*/
renderNextEpgBatch() {
const start = this.currentBatch * this.batchSize;
const end = start + this.batchSize;
const batch = this.filteredChannels.slice(start, end);
if (batch.length === 0) {
this.epgLoader.style.display = 'none';
return;
}
let html = '';
for (const { epgChannel, sourceChannel } of batch) {
const isFavorite = this.favorites.has(`${sourceChannel.sourceId}:${sourceChannel.id}`);
// Get programs if EPG data exists
let channelProgrammes = [];
if (epgChannel) {
channelProgrammes = this.programmes
.filter(p => p.channelId === epgChannel.id)
.filter(p => {
const start = new Date(p.start);
const stop = new Date(p.stop);
return start < this.endTime && stop > this.startTime;
})
.sort((a, b) => new Date(a.start) - new Date(b.start));
}
// Fallback values if EPG channel is missing
const logo = sourceChannel.tvgLogo || (epgChannel && epgChannel.icon) || '/img/placeholder.png';
const name = sourceChannel.name || (epgChannel && epgChannel.name);
html += `
<div class="epg-channel-row"
data-channel-id="${sourceChannel.id}"
data-source-id="${sourceChannel.sourceId}">
<div class="epg-channel-info">
<button class="favorite-btn ${isFavorite ? 'active' : ''}" title="${isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}">
${isFavorite ? '❤️' : '♡'}
</button>
<img class="epg-channel-logo" src="${logo}"
alt="" onerror="this.src='/img/placeholder.png'">
<span class="epg-channel-name">${name}</span>
<div class="resize-handle"></div>
</div>
<div class="epg-programs">
${this.renderProgrammes(channelProgrammes, this.startTime, this.endTime)}
</div>
</div>
`;
}
// Append to container
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
while (tempDiv.firstElementChild) {
const row = tempDiv.firstElementChild;
this.attachRowListeners(row);
this.epgContainer.appendChild(row);
}
this.currentBatch++;
// Hide loader if no more batches
if (end >= this.filteredChannels.length) {
this.epgLoader.style.display = 'none';
}
}
/**
* Attach event listeners to an EPG row
*/
attachRowListeners(row) {
// Program click handlers
row.querySelectorAll('.epg-program').forEach(prog => {
prog.addEventListener('click', () => this.showProgramDetails(prog.dataset));
});
const info = row.querySelector('.epg-channel-info');
if (info) {
// Name/Logo click plays channel
info.querySelector('.epg-channel-name')?.addEventListener('click', (e) => {
e.stopPropagation();
this.playChannel(info.querySelector('.epg-channel-name').textContent);
});
info.querySelector('.epg-channel-logo')?.addEventListener('click', (e) => {
e.stopPropagation();
this.playChannel(info.querySelector('.epg-channel-name').textContent);
});
// Favorite click
const favBtn = info.querySelector('.favorite-btn');
if (favBtn) {
favBtn.addEventListener('click', async (e) => {
e.stopPropagation();
const sourceId = parseInt(row.dataset.sourceId);
const channelId = row.dataset.channelId;
await this.toggleFavorite(sourceId, channelId);
});
}
}
}
/**
* Toggle favorite
*/
async toggleFavorite(sourceId, channelId) {
const key = `${sourceId}:${channelId}`;
const wasFavorite = this.favorites.has(key);
const isNowFavorite = !wasFavorite;
// 1. Optimistic Update (EPG)
if (isNowFavorite) {
this.favorites.add(key);
} else {
this.favorites.delete(key);
}
// Update DOM (All matching buttons in EPG)
const rows = this.container.querySelectorAll(`.epg-channel-row[data-channel-id="${channelId}"][data-source-id="${sourceId}"]`);
rows.forEach(row => {
const btn = row.querySelector('.favorite-btn');
if (btn) {
if (isNowFavorite) {
btn.classList.add('active');
btn.innerHTML = '❤️';
btn.title = 'Remove from Favorites';
} else {
btn.classList.remove('active');
btn.innerHTML = '♡';
btn.title = 'Add to Favorites';
}
}
});
// 2. Sync Channel List State (Optimistic)
if (window.app?.channelList) {
window.app.channelList.syncFavorite(sourceId, channelId, isNowFavorite);
}
try {
// 3. API Call
if (wasFavorite) {
await API.favorites.remove(sourceId, channelId, 'channel');
} else {
await API.favorites.add(sourceId, channelId, 'channel');
}
} catch (err) {
console.error('Error toggling favorite in EPG:', err);
// Revert EPG
if (wasFavorite) {
this.favorites.add(key);
} else {
this.favorites.delete(key);
}
rows.forEach(row => {
const btn = row.querySelector('.favorite-btn');
if (btn) {
if (wasFavorite) {
btn.classList.add('active');
btn.innerHTML = '❤️';
btn.title = 'Remove from Favorites';
} else {
btn.classList.remove('active');
btn.innerHTML = '♡';
btn.title = 'Add to Favorites';
}
}
});
// Revert Channel List
if (window.app?.channelList) {
window.app.channelList.syncFavorite(sourceId, channelId, wasFavorite);
}
}
}
/**
* Generate time slots
*/
generateTimeSlots(start, end) {
const slots = [];
const current = new Date(start);
while (current < end) {
slots.push(new Date(current));
current.setMinutes(current.getMinutes() + 30);
}
return slots;
}
/**
* Render programmes for a channel
*/
renderProgrammes(programmes, startTime, endTime) {
if (programmes.length === 0) {
const width = (endTime - startTime) / 60000 * this.pixelsPerMinute;
return `<div class="epg-program" style="width: ${width}px;"><span class="epg-program-title">No data</span></div>`;
}
const now = new Date();
let html = '';
let currentPos = startTime.getTime();
for (const prog of programmes) {
const progStart = Math.max(new Date(prog.start).getTime(), startTime.getTime());
const progEnd = Math.min(new Date(prog.stop).getTime(), endTime.getTime());
// Fill gap if needed
if (progStart > currentPos) {
const gapWidth = (progStart - currentPos) / 60000 * this.pixelsPerMinute;
html += `<div class="epg-program" style="width: ${gapWidth}px;"></div>`;
}
const width = (progEnd - progStart) / 60000 * this.pixelsPerMinute;
const isCurrent = new Date(prog.start) <= now && new Date(prog.stop) > now;
html += `
<div class="epg-program ${isCurrent ? 'current' : ''}"
style="width: ${width}px;"
data-title="${prog.title || ''}"
data-description="${prog.description || ''}"
data-start="${prog.start}"
data-stop="${prog.stop}">
<div class="epg-program-title">${prog.title || 'Unknown'}</div>
<div class="epg-program-time">
${new Date(prog.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
`;
currentPos = progEnd;
}
return html;
}
/**
* Update date display
*/
updateDateDisplay(date) {
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
if (date.toDateString() === today.toDateString()) {
this.dateDisplay.textContent = 'Today';
} else if (date.toDateString() === tomorrow.toDateString()) {
this.dateDisplay.textContent = 'Tomorrow';
} else {
this.dateDisplay.textContent = date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });
}
}
/**
* Debounce utility
*/
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
updateNowIndicator() {
const now = new Date();
const container = this.container.querySelector('.epg-container');
if (!container) return;
// Remove existing indicator
const existing = container.querySelector('.epg-now-line');
if (existing) existing.remove();
// Calculate position
const startTime = new Date();
startTime.setHours(startTime.getHours() + this.timeOffset);
startTime.setMinutes(0, 0, 0);
const minutesFromStart = (now - startTime) / 60000;
if (minutesFromStart < 0 || minutesFromStart > 240) return; // Not in visible range
// Get current sidebar width
const sidebarWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--epg-sidebar-width')) || 150;
const leftPos = sidebarWidth + (minutesFromStart * this.pixelsPerMinute);
const indicator = document.createElement('div');
indicator.className = 'epg-now-line';
indicator.style.left = `${leftPos}px`;
container.appendChild(indicator);
}
/**
* Show program details modal
*/
showProgramDetails(data) {
const modal = document.getElementById('modal');
const title = document.getElementById('modal-title');
const body = document.getElementById('modal-body');
const footer = document.getElementById('modal-footer');
title.textContent = data.title || 'Program Details';
const start = new Date(data.start);
const stop = new Date(data.stop);
body.innerHTML = `
<p><strong>Time:</strong> ${start.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${stop.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</p>
<p><strong>Description:</strong></p>
<p>${data.description || 'No description available'}</p>
`;
footer.innerHTML = '<button class="btn btn-secondary" id="modal-close">Close</button>';
modal.classList.add('active');
document.getElementById('modal-close').onclick = () => modal.classList.remove('active');
modal.querySelector('.modal-close').onclick = () => modal.classList.remove('active');
}
/**
* Play channel from EPG
*/
async playChannel(channelName) {
// Find channel in channel list and play
if (window.app?.channelList) {
const channel = window.app.channelList.channels.find(c =>
c.name === channelName || c.tvgName === channelName
);
if (channel) {
await window.app.channelList.selectChannel({ channelId: channel.id });
// Switch to home page
document.querySelector('[data-page="home"]').click();
}
}
}
}
// Export
window.EpgGuide = EpgGuide;
+759
View File
@@ -0,0 +1,759 @@
/**
* Source Manager Component
* Handles adding, editing, and deleting sources (Xtream, M3U, EPG)
*/
class SourceManager {
constructor() {
this.xtreamList = document.getElementById('xtream-list');
this.m3uList = document.getElementById('m3u-list');
this.epgList = document.getElementById('epg-list');
// Content browser state
this.contentType = 'channels'; // 'channels' or 'movies'
this.init();
}
init() {
// Add source buttons
document.getElementById('add-xtream').addEventListener('click', () => this.showAddModal('xtream'));
document.getElementById('add-m3u').addEventListener('click', () => this.showAddModal('m3u'));
document.getElementById('add-epg').addEventListener('click', () => this.showAddModal('epg'));
// Initialize content browser
this.initContentBrowser();
}
/**
* Load and display all sources
*/
async loadSources() {
try {
const sources = await API.sources.getAll();
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');
} catch (err) {
console.error('Error loading sources:', err);
}
}
/**
* Render source list
*/
renderSourceList(container, sources, type) {
if (sources.length === 0) {
container.innerHTML = `<p class="hint">No ${type.toUpperCase()} sources configured</p>`;
return;
}
const icons = { xtream: '📡', m3u: '📋', epg: '📺' };
container.innerHTML = sources.map(source => `
<div class="source-item ${source.enabled ? '' : 'disabled'}" data-id="${source.id}">
<span class="source-icon">${icons[type]}</span>
<div class="source-info">
<div class="source-name">${source.name}</div>
<div class="source-url">${source.url}</div>
</div>
<div class="source-actions">
<button class="btn btn-sm btn-secondary" data-action="refresh" title="Refresh Data">🔄</button>
<button class="btn btn-sm btn-secondary" data-action="test" title="Test Connection">🔗</button>
<button class="btn btn-sm btn-secondary" data-action="toggle" title="${source.enabled ? 'Disable' : 'Enable'}">
${source.enabled ? '✓' : '○'}
</button>
<button class="btn btn-sm btn-secondary" data-action="edit" title="Edit">✏️</button>
<button class="btn btn-sm btn-danger" data-action="delete" title="Delete">🗑️</button>
</div>
</div>
`).join('');
// Attach event listeners
container.querySelectorAll('.source-item').forEach(item => {
const id = parseInt(item.dataset.id);
item.querySelector('[data-action="refresh"]').addEventListener('click', () => this.refreshSource(id, type));
item.querySelector('[data-action="test"]').addEventListener('click', () => this.testSource(id));
item.querySelector('[data-action="toggle"]').addEventListener('click', () => this.toggleSource(id));
item.querySelector('[data-action="edit"]').addEventListener('click', () => this.showEditModal(id, type));
item.querySelector('[data-action="delete"]').addEventListener('click', () => this.deleteSource(id));
});
}
/**
* Show add source modal
*/
showAddModal(type) {
const modal = document.getElementById('modal');
const title = document.getElementById('modal-title');
const body = document.getElementById('modal-body');
const footer = document.getElementById('modal-footer');
const titles = { xtream: 'Add Xtream Connection', m3u: 'Add M3U Playlist', epg: 'Add EPG Source' };
title.textContent = titles[type];
body.innerHTML = this.getSourceForm(type);
footer.innerHTML = `
<button class="btn btn-secondary" id="modal-cancel">Cancel</button>
<button class="btn btn-primary" id="modal-save">Add Source</button>
`;
modal.classList.add('active');
// Event listeners
modal.querySelector('.modal-close').onclick = () => modal.classList.remove('active');
document.getElementById('modal-cancel').onclick = () => modal.classList.remove('active');
document.getElementById('modal-save').onclick = () => this.saveNewSource(type);
}
/**
* Show edit source modal
*/
async showEditModal(id, type) {
try {
const source = await API.sources.getById(id);
const modal = document.getElementById('modal');
const title = document.getElementById('modal-title');
const body = document.getElementById('modal-body');
const footer = document.getElementById('modal-footer');
title.textContent = `Edit ${type.toUpperCase()} Source`;
body.innerHTML = this.getSourceForm(type, source);
footer.innerHTML = `
<button class="btn btn-secondary" id="modal-cancel">Cancel</button>
<button class="btn btn-primary" id="modal-save">Save Changes</button>
`;
modal.classList.add('active');
modal.querySelector('.modal-close').onclick = () => modal.classList.remove('active');
document.getElementById('modal-cancel').onclick = () => modal.classList.remove('active');
document.getElementById('modal-save').onclick = () => this.updateSource(id, type);
} catch (err) {
console.error('Error loading source:', err);
}
}
/**
* Get source form HTML
*/
getSourceForm(type, source = {}) {
const nameField = `
<div class="form-group">
<label for="source-name">Name</label>
<input type="text" id="source-name" class="form-input" placeholder="My Source" value="${source.name || ''}">
</div>
`;
const urlField = `
<div class="form-group">
<label for="source-url">${type === 'xtream' ? 'Server URL' : 'URL'}</label>
<input type="text" id="source-url" class="form-input"
placeholder="${type === 'xtream' ? 'http://server.com:port' : 'https://example.com/playlist.m3u'}"
value="${source.url || ''}">
</div>
`;
if (type === 'xtream') {
return `
${nameField}
${urlField}
<div class="form-group">
<label for="source-username">Username</label>
<input type="text" id="source-username" class="form-input" value="${source.username || ''}">
</div>
<div class="form-group">
<label for="source-password">Password</label>
<input type="password" id="source-password" class="form-input"
value="${source.password && !source.password.includes('•') ? source.password : ''}">
</div>
`;
}
return nameField + urlField;
}
/**
* Save new source
*/
async saveNewSource(type) {
const name = document.getElementById('source-name').value.trim();
const url = document.getElementById('source-url').value.trim();
const username = document.getElementById('source-username')?.value.trim() || null;
const password = document.getElementById('source-password')?.value.trim() || null;
if (!name || !url) {
alert('Name and URL are required');
return;
}
try {
await API.sources.create({ type, name, url, username, password });
document.getElementById('modal').classList.remove('active');
await this.loadSources();
// Refresh channel list
if (window.app?.channelList) {
await window.app.channelList.loadSources();
await window.app.channelList.loadChannels();
}
} catch (err) {
alert('Error adding source: ' + err.message);
}
}
/**
* Update existing source
*/
async updateSource(id, type) {
const name = document.getElementById('source-name').value.trim();
const url = document.getElementById('source-url').value.trim();
const username = document.getElementById('source-username')?.value.trim();
const password = document.getElementById('source-password')?.value.trim();
if (!name || !url) {
alert('Name and URL are required');
return;
}
try {
const data = { name, url };
if (type === 'xtream') {
data.username = username;
if (password) data.password = password;
}
await API.sources.update(id, data);
document.getElementById('modal').classList.remove('active');
await this.loadSources();
} catch (err) {
alert('Error updating source: ' + err.message);
}
}
/**
* Delete source
*/
async deleteSource(id) {
if (!confirm('Are you sure you want to delete this source?')) return;
try {
await API.sources.delete(id);
await this.loadSources();
if (window.app?.channelList) {
await window.app.channelList.loadSources();
await window.app.channelList.loadChannels();
}
} catch (err) {
alert('Error deleting source: ' + err.message);
}
}
/**
* Toggle source enabled/disabled
*/
async toggleSource(id) {
try {
await API.sources.toggle(id);
await this.loadSources();
} catch (err) {
alert('Error toggling source: ' + err.message);
}
}
/**
* Test source connection
*/
async testSource(id) {
try {
const result = await API.sources.test(id);
if (result.success) {
alert('Connection successful!');
} else {
alert('Connection failed: ' + (result.error || result.message));
}
} catch (err) {
alert('Connection failed: ' + err.message);
}
}
/**
* Refresh source data
*/
async refreshSource(id, type) {
try {
const btn = document.querySelector(`.source-item[data-id="${id}"] [data-action="refresh"]`);
if (btn) {
btn.disabled = true;
btn.textContent = '⏳';
}
if (type === 'epg') {
// Force refresh EPG data (server-side cache will be bypassed)
if (window.app?.epgGuide) {
await window.app.epgGuide.loadEpg(true); // Force refresh
}
alert('EPG data refreshed!');
} else if (type === 'xtream') {
// Re-fetch xtream data by reloading channels
if (window.app?.channelList) {
await window.app.channelList.loadChannels();
}
alert('Xtream data refreshed!');
} else if (type === 'm3u') {
// Re-fetch M3U data by reloading channels
if (window.app?.channelList) {
await window.app.channelList.loadChannels();
}
alert('M3U playlist refreshed!');
}
if (btn) {
btn.disabled = false;
btn.textContent = '🔄';
}
} catch (err) {
console.error('Error refreshing source:', err);
alert('Refresh failed: ' + err.message);
}
}
/**
* Initialize content browser
*/
initContentBrowser() {
this.contentSourceSelect = document.getElementById('content-source-select');
this.contentTree = document.getElementById('content-tree');
this.channelsBtn = document.getElementById('content-type-channels');
this.moviesBtn = document.getElementById('content-type-movies');
this.seriesBtn = document.getElementById('content-type-series');
// Content type toggle
this.channelsBtn?.addEventListener('click', () => {
this.contentType = 'channels';
this.channelsBtn.classList.add('active');
this.moviesBtn?.classList.remove('active');
this.seriesBtn?.classList.remove('active');
this.reloadContentTree();
});
this.moviesBtn?.addEventListener('click', () => {
this.contentType = 'movies';
this.moviesBtn.classList.add('active');
this.channelsBtn?.classList.remove('active');
this.seriesBtn?.classList.remove('active');
this.reloadContentTree();
});
this.seriesBtn?.addEventListener('click', () => {
this.contentType = 'series';
this.seriesBtn.classList.add('active');
this.channelsBtn?.classList.remove('active');
this.moviesBtn?.classList.remove('active');
this.reloadContentTree();
});
// Source selection
this.contentSourceSelect?.addEventListener('change', () => this.reloadContentTree());
// Show All / Hide All buttons
document.getElementById('content-show-all')?.addEventListener('click', () => this.setAllVisibility(true));
document.getElementById('content-hide-all')?.addEventListener('click', () => this.setAllVisibility(false));
}
/**
* Reload content tree based on current type and source
*/
reloadContentTree() {
const sourceId = this.contentSourceSelect?.value;
if (!sourceId) {
const typeLabel = this.contentType === 'movies' ? 'movie categories' :
this.contentType === 'series' ? 'series categories' : 'groups and channels';
this.contentTree.innerHTML = `<p class="hint">Select a source to view ${typeLabel}</p>`;
return;
}
if (this.contentType === 'movies') {
this.loadMovieCategoriesTree(parseInt(sourceId));
} else if (this.contentType === 'series') {
this.loadSeriesCategoriesTree(parseInt(sourceId));
} else {
this.loadContentTree(parseInt(sourceId));
}
}
/**
* Load sources into content browser dropdown
*/
async loadContentSources() {
try {
const sources = await API.sources.getAll();
const select = document.getElementById('content-source-select');
if (!select) return;
// Keep the placeholder option
select.innerHTML = '<option value="">Select a source...</option>';
sources.filter(s => s.type === 'xtream' || s.type === 'm3u').forEach(source => {
select.innerHTML += `<option value="${source.id}">${source.name} (${source.type})</option>`;
});
} catch (err) {
console.error('Error loading content sources:', err);
}
}
/**
* Load content tree for a source
* Checked = Visible, Unchecked = Hidden
*/
async loadContentTree(sourceId) {
this.contentTree.innerHTML = '<p class="hint">Loading...</p>';
try {
const source = await API.sources.getById(sourceId);
let channels = [];
let categoryMap = {};
if (source.type === 'xtream') {
// Run sequentially to avoid overwhelming the provider
const categories = await API.proxy.xtream.liveCategories(sourceId);
const streams = await API.proxy.xtream.liveStreams(sourceId);
channels = streams;
// Create map of category_id -> category_name
categories.forEach(cat => {
categoryMap[cat.category_id] = cat.category_name;
});
} else if (source.type === 'm3u') {
const m3uData = await API.proxy.m3u.get(sourceId);
channels = m3uData.channels || [];
}
// Get currently hidden items
const hiddenItems = await API.channels.getHidden(sourceId);
const hiddenSet = new Set(hiddenItems.map(h => `${h.item_type}:${h.item_id}`));
// Group channels
const groups = {};
channels.forEach(ch => {
let groupName = 'Uncategorized';
if (source.type === 'xtream') {
// Use category map for Xtream
if (ch.category_id && categoryMap[ch.category_id]) {
groupName = categoryMap[ch.category_id];
}
} else {
// Use existing fields for M3U
groupName = ch.category_name || ch.groupTitle || 'Uncategorized';
}
if (!groups[groupName]) {
groups[groupName] = [];
}
groups[groupName].push(ch);
});
// Render tree - checked = visible, unchecked = hidden
let html = '';
Object.keys(groups).sort().forEach(groupName => {
const groupHidden = hiddenSet.has(`group:${groupName}`);
const groupChannels = groups[groupName];
html += `
<div class="content-group collapsed" data-group="${groupName}">
<div class="content-group-header">
<span class="group-expander">▼</span>
<label class="checkbox-label" onclick="event.stopPropagation()">
<input type="checkbox" class="group-checkbox" data-type="group" data-id="${groupName}" data-source-id="${sourceId}" ${groupHidden ? '' : 'checked'}>
<span class="group-name">${groupName} (${groupChannels.length})</span>
</label>
</div>
<div class="content-channels">
${groupChannels.map(ch => {
const channelId = ch.stream_id || ch.id || ch.url;
const channelName = ch.name || ch.tvgName || 'Unknown';
const channelHidden = hiddenSet.has(`channel:${channelId}`);
return `
<label class="checkbox-label channel-item" title="${channelName}">
<input type="checkbox" class="channel-checkbox" data-type="channel" data-id="${channelId}" data-source-id="${sourceId}" ${channelHidden ? '' : 'checked'}>
<span class="channel-name">${channelName}</span>
</label>`;
}).join('')}
</div>
</div>`;
});
this.contentTree.innerHTML = html || '<p class="hint">No channels found</p>';
// Toggle group collapse on header click
this.contentTree.querySelectorAll('.content-group-header').forEach(header => {
header.addEventListener('click', () => {
const group = header.closest('.content-group');
group.classList.toggle('collapsed');
});
});
// Attach change listeners for visibility toggling
this.contentTree.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.addEventListener('change', () => this.toggleVisibility(cb));
});
// Group checkbox toggles all children (BULK)
this.contentTree.querySelectorAll('.group-checkbox').forEach(groupCb => {
groupCb.addEventListener('change', () => this.toggleGroupChildren(groupCb));
});
} catch (err) {
console.error('Error loading content tree:', err);
this.contentTree.innerHTML = '<p class="hint" style="color: var(--color-error);">Error loading content</p>';
}
}
/**
* Load movie categories tree for a source
* Checked = Visible, Unchecked = Hidden
*/
async loadMovieCategoriesTree(sourceId) {
this.contentTree.innerHTML = '<p class="hint">Loading movie categories...</p>';
try {
const source = await API.sources.getById(sourceId);
if (source.type !== 'xtream') {
this.contentTree.innerHTML = '<p class="hint">Movie categories are only available for Xtream sources</p>';
return;
}
// Fetch VOD categories
const categories = await API.proxy.xtream.vodCategories(sourceId);
if (!categories || categories.length === 0) {
this.contentTree.innerHTML = '<p class="hint">No movie categories found</p>';
return;
}
// Get currently hidden items
const hiddenItems = await API.channels.getHidden(sourceId);
const hiddenSet = new Set(hiddenItems.map(h => `${h.item_type}:${h.item_id}`));
// Render categories - checked = visible, unchecked = hidden
let html = '<div class="content-categories">';
categories.sort((a, b) => a.category_name.localeCompare(b.category_name)).forEach(cat => {
const isHidden = hiddenSet.has(`vod_category:${cat.category_id}`);
html += `
<label class="checkbox-label category-item">
<input type="checkbox" class="category-checkbox"
data-type="vod_category"
data-id="${cat.category_id}"
data-source-id="${sourceId}"
${isHidden ? '' : 'checked'}>
<span class="category-name">${cat.category_name}</span>
</label>`;
});
html += '</div>';
this.contentTree.innerHTML = html;
// Attach change listeners for visibility toggling
this.contentTree.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.addEventListener('change', () => this.toggleVisibility(cb));
});
} catch (err) {
console.error('Error loading movie categories:', err);
this.contentTree.innerHTML = '<p class="hint" style="color: var(--color-error);">Error loading movie categories</p>';
}
}
/**
* Load series categories tree for a source
* Checked = Visible, Unchecked = Hidden
*/
async loadSeriesCategoriesTree(sourceId) {
this.contentTree.innerHTML = '<p class="hint">Loading series categories...</p>';
try {
const source = await API.sources.getById(sourceId);
if (source.type !== 'xtream') {
this.contentTree.innerHTML = '<p class="hint">Series categories are only available for Xtream sources</p>';
return;
}
// Fetch series categories
const categories = await API.proxy.xtream.seriesCategories(sourceId);
if (!categories || categories.length === 0) {
this.contentTree.innerHTML = '<p class="hint">No series categories found</p>';
return;
}
// Get currently hidden items
const hiddenItems = await API.channels.getHidden(sourceId);
const hiddenSet = new Set(hiddenItems.map(h => `${h.item_type}:${h.item_id}`));
// Render categories - checked = visible, unchecked = hidden
let html = '<div class="content-categories">';
categories.sort((a, b) => a.category_name.localeCompare(b.category_name)).forEach(cat => {
const isHidden = hiddenSet.has(`series_category:${cat.category_id}`);
html += `
<label class="checkbox-label category-item">
<input type="checkbox" class="category-checkbox"
data-type="series_category"
data-id="${cat.category_id}"
data-source-id="${sourceId}"
${isHidden ? '' : 'checked'}>
<span class="category-name">${cat.category_name}</span>
</label>`;
});
html += '</div>';
this.contentTree.innerHTML = html;
// Attach change listeners for visibility toggling
this.contentTree.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.addEventListener('change', () => this.toggleVisibility(cb));
});
} catch (err) {
console.error('Error loading series categories:', err);
this.contentTree.innerHTML = '<p class="hint" style="color: var(--color-error);">Error loading series categories</p>';
}
}
/**
* Toggle visibility of a single item
* Checked = show (remove from hidden), Unchecked = hide (add to hidden)
*/
async toggleVisibility(checkbox) {
const sourceId = parseInt(checkbox.dataset.sourceId);
const itemType = checkbox.dataset.type;
const itemId = checkbox.dataset.id;
const isVisible = checkbox.checked;
try {
// Fire API call (don't await for faster UI response)
const apiCall = isVisible
? API.channels.show(sourceId, itemType, itemId)
: API.channels.hide(sourceId, itemType, itemId);
// For VOD categories, don't refresh channel list (just let API complete in background)
if (itemType === 'vod_category' || itemType === 'series_category') {
apiCall.catch(err => {
console.error(`Error toggling ${itemType} visibility:`, err);
checkbox.checked = !isVisible; // Revert on error
});
return;
}
// For channels/groups, await and refresh
await apiCall;
// Refresh channel list if visible
if (window.app?.channelList) {
await window.app.channelList.loadHiddenItems();
window.app.channelList.render();
}
} catch (err) {
console.error('Error toggling visibility:', err);
// Revert checkbox on error
checkbox.checked = !isVisible;
}
}
/**
* Toggle all children of a group efficiently
*/
async toggleGroupChildren(groupCb) {
const group = groupCb.closest('.content-group');
const channelCheckboxes = group.querySelectorAll('.channel-checkbox');
const isChecked = groupCb.checked;
const itemsToUpdate = [];
// Identify items that need changing
channelCheckboxes.forEach(chCb => {
if (chCb.checked !== isChecked) {
chCb.checked = isChecked;
itemsToUpdate.push({
sourceId: parseInt(chCb.dataset.sourceId),
itemType: chCb.dataset.type,
itemId: chCb.dataset.id
});
}
});
if (itemsToUpdate.length === 0) return;
try {
if (isChecked) {
await API.channels.bulkShow(itemsToUpdate);
} else {
await API.channels.bulkHide(itemsToUpdate);
}
// Refresh channel list
if (window.app?.channelList) {
await window.app.channelList.loadHiddenItems();
window.app.channelList.render();
}
} catch (err) {
console.error('Error toggling group children:', err);
// We might want to revert UI here, but it's complex for bulk items
}
}
/**
* Set visibility for all items
*/
async setAllVisibility(visible) {
const sourceId = this.contentSourceSelect?.value;
if (!sourceId) return;
const checkboxes = this.contentTree.querySelectorAll('input[type="checkbox"]');
const items = [];
checkboxes.forEach(cb => {
if (cb.checked !== visible) {
items.push({
sourceId: parseInt(cb.dataset.sourceId),
itemType: cb.dataset.type,
itemId: cb.dataset.id
});
cb.checked = visible;
}
});
if (items.length === 0) return;
try {
if (visible) {
// Show all - use bulk API
await API.channels.bulkShow(items);
} else {
// Hide all - use bulk API
await API.channels.bulkHide(items);
}
// Refresh channel list
if (window.app?.channelList) {
await window.app.channelList.loadHiddenItems();
window.app.channelList.render();
}
} catch (err) {
console.error('Error setting all visibility:', err);
alert('Failed to update visibility');
}
}
}
// Export
window.SourceManager = SourceManager;
+505
View File
@@ -0,0 +1,505 @@
/**
* Video Player Component
* Handles HLS video playback with custom controls
*/
class VideoPlayer {
constructor() {
this.video = document.getElementById('video-player');
this.overlay = document.getElementById('player-overlay');
this.nowPlaying = document.getElementById('now-playing');
this.hls = null;
this.currentChannel = null;
this.overlayTimer = null;
this.overlayDuration = 5000; // 5 seconds
this.isUsingProxy = false;
this.currentUrl = null;
// Settings (loaded from localStorage)
this.settings = this.loadSettings();
this.init();
}
/**
* Load settings from localStorage
*/
loadSettings() {
const defaults = {
arrowKeysChangeChannel: true, // Use arrow keys for channel navigation
overlayDuration: 5, // Seconds
defaultVolume: 80, // 0-100
rememberVolume: true, // Persist volume between sessions
lastVolume: 80, // Last used volume (if rememberVolume is true)
autoPlayNextEpisode: false // Auto-play next episode in series
};
try {
const saved = localStorage.getItem('nodecast_tv_player_settings');
if (saved) {
return { ...defaults, ...JSON.parse(saved) };
}
} catch (err) {
console.error('Error loading settings:', err);
}
return defaults;
}
/**
* Save settings to localStorage
*/
saveSettings() {
try {
localStorage.setItem('nodecast_tv_player_settings', JSON.stringify(this.settings));
} catch (err) {
console.error('Error saving settings:', err);
}
}
init() {
// Apply default/remembered volume
const volume = this.settings.rememberVolume ? this.settings.lastVolume : this.settings.defaultVolume;
this.video.volume = volume / 100;
// Save volume changes
this.video.addEventListener('volumechange', () => {
if (this.settings.rememberVolume) {
this.settings.lastVolume = Math.round(this.video.volume * 100);
this.saveSettings();
}
});
// Initialize HLS.js if supported
if (Hls.isSupported()) {
this.hls = new Hls({
enableWorker: true,
lowLatencyMode: true
});
this.hls.on(Hls.Events.ERROR, (event, data) => {
console.error('HLS error:', data);
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
// If it's a network error and we haven't tried the proxy yet, try it
if (this.currentChannel && !this.isUsingProxy) {
console.log('Network error detected, trying via proxy...');
const proxiedUrl = this.getProxiedUrl(this.currentUrl);
this.isUsingProxy = true;
this.hls.loadSource(proxiedUrl);
this.hls.startLoad();
} else {
console.log('Network error, attempting recovery...');
this.hls.startLoad();
}
break;
case Hls.ErrorTypes.MEDIA_ERROR:
console.log('Media error, attempting recovery...');
this.hls.recoverMediaError();
break;
default:
this.stop();
break;
}
}
});
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
this.video.play().catch(e => console.log('Autoplay prevented:', e));
});
}
// Keyboard controls
document.addEventListener('keydown', (e) => this.handleKeyboard(e));
// Click on video shows overlay
this.video.addEventListener('click', () => this.showNowPlayingOverlay());
}
/**
* Show the now playing overlay briefly
*/
showNowPlayingOverlay() {
if (!this.currentChannel) return;
// Clear existing timer
if (this.overlayTimer) {
clearTimeout(this.overlayTimer);
}
// Show overlay
this.nowPlaying.classList.remove('hidden');
// Hide after duration
this.overlayTimer = setTimeout(() => {
this.nowPlaying.classList.add('hidden');
}, this.settings.overlayDuration * 1000);
}
/**
* Hide the now playing overlay
*/
hideNowPlayingOverlay() {
if (this.overlayTimer) {
clearTimeout(this.overlayTimer);
}
this.nowPlaying.classList.add('hidden');
}
/**
* Play a channel
*/
async play(channel, streamUrl) {
this.currentChannel = channel;
try {
// Stop current playback
this.stop();
// Hide "select a channel" overlay
this.overlay.classList.add('hidden');
// Determine if HLS or direct stream
this.currentUrl = streamUrl;
// Proactively use proxy for known CORS-restricted domains (like Pluto TV)
const proxyRequiredDomains = ['pluto.tv'];
const needsProxy = proxyRequiredDomains.some(domain => streamUrl.includes(domain));
this.isUsingProxy = needsProxy;
const finalUrl = needsProxy ? this.getProxiedUrl(streamUrl) : streamUrl;
if (finalUrl.includes('.m3u8') && Hls.isSupported()) {
this.hls = new Hls();
this.hls.loadSource(finalUrl);
this.hls.attachMedia(this.video);
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
this.video.play().catch(e => console.log('Autoplay prevented:', e));
});
// Re-attach error handler for the new Hls instance
this.hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
if (data.type === Hls.ErrorTypes.NETWORK_ERROR && !this.isUsingProxy) {
console.log('CORS/Network error detected, retrying via proxy...');
this.isUsingProxy = true;
this.hls.loadSource(this.getProxiedUrl(this.currentUrl));
this.hls.startLoad();
} else {
console.error('Fatal HLS error:', data);
}
}
});
} else if (this.video.canPlayType('application/vnd.apple.mpegurl')) {
// Native HLS support (Safari)
this.video.src = finalUrl;
this.video.play().catch(e => {
console.log('Autoplay prevented, trying proxy if CORS error:', e);
if (!this.isUsingProxy) {
this.isUsingProxy = true;
this.video.src = this.getProxiedUrl(streamUrl);
this.video.play().catch(err => console.error('Proxy play failed:', err));
}
});
} else {
// Try direct playback
this.video.src = finalUrl;
this.video.play().catch(e => console.log('Autoplay prevented:', e));
}
// Update now playing info
this.updateNowPlaying(channel);
// Show the now playing overlay
this.showNowPlayingOverlay();
// Fetch EPG data for this channel
this.fetchEpgData(channel);
// Dispatch event
window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel }));
} catch (err) {
console.error('Error playing channel:', err);
this.showError('Failed to play channel');
}
}
/**
* Fetch EPG data for current channel
*/
async fetchEpgData(channel) {
try {
// First, try to use the centralized EpgGuide data (already loaded)
if (window.app && window.app.epgGuide && window.app.epgGuide.programmes) {
const epgGuide = window.app.epgGuide;
// Get current program from EpgGuide
const currentProgram = epgGuide.getCurrentProgram(channel.tvgId, channel.name);
if (currentProgram) {
// Find upcoming programs from the guide's data
const epgChannel = epgGuide.channelMap?.get(channel.tvgId) ||
epgGuide.channelMap?.get(channel.name?.toLowerCase());
let upcoming = [];
if (epgChannel) {
const now = Date.now();
upcoming = epgGuide.programmes
.filter(p => p.channelId === epgChannel.id && new Date(p.start).getTime() > now)
.slice(0, 5)
.map(p => ({
title: p.title,
start: new Date(p.start),
stop: new Date(p.stop),
description: p.desc || ''
}));
}
this.updateNowPlaying(channel, {
current: {
title: currentProgram.title,
start: new Date(currentProgram.start),
stop: new Date(currentProgram.stop),
description: currentProgram.desc || ''
},
upcoming
});
return; // Success, exit early
}
}
// Fallback: Try to get EPG from Xtream API if available
if (channel.sourceType === 'xtream' && channel.streamId) {
const epgData = await API.proxy.xtream.shortEpg(channel.sourceId, channel.streamId);
if (epgData && epgData.epg_listings && epgData.epg_listings.length > 0) {
const listings = epgData.epg_listings;
const now = Math.floor(Date.now() / 1000);
// Find current program
const current = listings.find(p => {
const start = parseInt(p.start_timestamp);
const end = parseInt(p.stop_timestamp);
return start <= now && end > now;
});
// Get upcoming programs
const upcoming = listings
.filter(p => parseInt(p.start_timestamp) > now)
.slice(0, 5)
.map(p => ({
title: this.decodeBase64(p.title),
start: new Date(parseInt(p.start_timestamp) * 1000),
stop: new Date(parseInt(p.stop_timestamp) * 1000),
description: this.decodeBase64(p.description)
}));
if (current) {
this.updateNowPlaying(channel, {
current: {
title: this.decodeBase64(current.title),
start: new Date(parseInt(current.start_timestamp) * 1000),
stop: new Date(parseInt(current.stop_timestamp) * 1000),
description: this.decodeBase64(current.description)
},
upcoming
});
}
}
}
} catch (err) {
console.log('EPG data not available:', err.message);
}
}
/**
* Get proxied URL for a stream
*/
getProxiedUrl(url) {
return `/api/proxy/stream?url=${encodeURIComponent(url)}`;
}
/**
* Decode base64 EPG data
*/
decodeBase64(str) {
if (!str) return '';
try {
return decodeURIComponent(escape(atob(str)));
} catch {
return str;
}
}
/**
* Stop playback
*/
stop() {
if (this.hls) {
this.hls.destroy();
this.hls = null;
}
this.video.pause();
this.video.src = '';
this.video.load();
}
/**
* Update now playing display
*/
updateNowPlaying(channel, epgData = null) {
const channelName = this.nowPlaying.querySelector('.channel-name');
const programTitle = this.nowPlaying.querySelector('.program-title');
const programTime = this.nowPlaying.querySelector('.program-time');
const upNextList = document.getElementById('up-next-list');
channelName.textContent = channel.name || channel.tvgName || 'Unknown Channel';
if (epgData && epgData.current) {
programTitle.textContent = epgData.current.title;
const start = new Date(epgData.current.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const end = new Date(epgData.current.stop).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
programTime.textContent = `${start} - ${end}`;
} else {
programTitle.textContent = '';
programTime.textContent = '';
}
// Update up next
upNextList.innerHTML = '';
if (epgData && epgData.upcoming) {
epgData.upcoming.slice(0, 3).forEach(prog => {
const li = document.createElement('li');
const time = new Date(prog.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
li.textContent = `${time} - ${prog.title}`;
upNextList.appendChild(li);
});
}
}
/**
* Show error overlay
*/
showError(message) {
this.overlay.classList.remove('hidden');
this.overlay.querySelector('.overlay-content').innerHTML = `<p style="color: var(--color-error);">${message}</p>`;
}
/**
* Handle keyboard shortcuts
*/
handleKeyboard(e) {
if (document.activeElement.tagName === 'INPUT') return;
switch (e.key) {
case ' ':
case 'k':
e.preventDefault();
this.video.paused ? this.video.play() : this.video.pause();
break;
case 'f':
e.preventDefault();
this.toggleFullscreen();
break;
case 'm':
e.preventDefault();
this.video.muted = !this.video.muted;
break;
case 'ArrowUp':
e.preventDefault();
if (this.settings.arrowKeysChangeChannel) {
this.channelUp();
} else {
this.video.volume = Math.min(1, this.video.volume + 0.1);
}
break;
case 'ArrowDown':
e.preventDefault();
if (this.settings.arrowKeysChangeChannel) {
this.channelDown();
} else {
this.video.volume = Math.max(0, this.video.volume - 0.1);
}
break;
case 'ArrowLeft':
e.preventDefault();
// Volume down when arrow keys are for channels
if (this.settings.arrowKeysChangeChannel) {
this.video.volume = Math.max(0, this.video.volume - 0.1);
}
break;
case 'ArrowRight':
e.preventDefault();
// Volume up when arrow keys are for channels
if (this.settings.arrowKeysChangeChannel) {
this.video.volume = Math.min(1, this.video.volume + 0.1);
}
break;
case 'PageUp':
case 'ChannelUp':
e.preventDefault();
this.channelUp();
break;
case 'PageDown':
case 'ChannelDown':
e.preventDefault();
this.channelDown();
break;
case 'i':
// Show/hide info overlay
e.preventDefault();
if (this.nowPlaying.classList.contains('hidden')) {
this.showNowPlayingOverlay();
} else {
this.hideNowPlayingOverlay();
}
break;
}
}
/**
* Go to previous channel
*/
channelUp() {
if (!window.app?.channelList) return;
const channels = window.app.channelList.getVisibleChannels();
if (channels.length === 0) return;
const currentIdx = this.currentChannel
? channels.findIndex(c => c.id === this.currentChannel.id)
: -1;
const prevIdx = currentIdx <= 0 ? channels.length - 1 : currentIdx - 1;
window.app.channelList.selectChannel({ channelId: channels[prevIdx].id });
}
/**
* Go to next channel
*/
channelDown() {
if (!window.app?.channelList) return;
const channels = window.app.channelList.getVisibleChannels();
if (channels.length === 0) return;
const currentIdx = this.currentChannel
? channels.findIndex(c => c.id === this.currentChannel.id)
: -1;
const nextIdx = currentIdx >= channels.length - 1 ? 0 : currentIdx + 1;
window.app.channelList.selectChannel({ channelId: channels[nextIdx].id });
}
/**
* Toggle fullscreen
*/
toggleFullscreen() {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
this.video.requestFullscreen();
}
}
}
// Export
window.VideoPlayer = VideoPlayer;