/** * Series Page Controller * Handles TV series browsing and playback */ class SeriesPage { constructor(app) { this.app = app; this.container = document.getElementById('series-grid'); this.sourceSelect = document.getElementById('series-source-select'); this.categorySelect = document.getElementById('series-category-select'); this.searchInput = document.getElementById('series-search'); this.detailsPanel = document.getElementById('series-details'); this.seasonsContainer = document.getElementById('series-seasons'); this.seriesList = []; this.categories = []; this.sources = []; this.currentBatch = 0; this.batchSize = 24; this.filteredSeries = []; this.isLoading = false; this.observer = null; this.hiddenCategoryIds = new Set(); this.currentSeries = null; this.favoriteIds = new Set(); // Track favorite series IDs this.showFavoritesOnly = false; this.init(); } init() { // Source change handler this.sourceSelect?.addEventListener('change', async () => { await this.loadCategories(); await this.loadSeries(); }); // Category change handler this.categorySelect?.addEventListener('change', () => { this.loadSeries(); }); // Search with debounce let searchTimeout; this.searchInput?.addEventListener('input', () => { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => this.filterAndRender(), 300); }); // Back button document.querySelector('.series-back-btn')?.addEventListener('click', () => { this.hideDetails(); }); // Set up IntersectionObserver for lazy loading this.observer = new IntersectionObserver((entries) => { if (entries[0].isIntersecting && !this.isLoading) { this.renderNextBatch(); } }, { rootMargin: '200px' }); // Favorites filter toggle const favBtn = document.getElementById('series-favorites-btn'); favBtn?.addEventListener('click', () => { this.showFavoritesOnly = !this.showFavoritesOnly; favBtn.classList.toggle('active', this.showFavoritesOnly); this.filterAndRender(); }); } async show() { // Hide details panel when showing page this.hideDetails(); // Load sources if not loaded // Load sources if not loaded if (this.sources.length === 0) { await this.loadSources(); } // Load favorites await this.loadFavorites(); // Load series if empty if (this.seriesList.length === 0) { await this.loadCategories(); await this.loadSeries(); } } hide() { // Page is hidden } async loadFavorites() { try { const favs = await API.favorites.getAll(null, 'series'); this.favoriteIds = new Set(favs.map(f => `${f.source_id}:${f.item_id}`)); } catch (err) { console.error('Error loading favorites:', err); } } async loadSources() { try { const allSources = await API.sources.getAll(); this.sources = allSources.filter(s => s.type === 'xtream' && s.enabled); this.sourceSelect.innerHTML = ''; this.sources.forEach(s => { const option = document.createElement('option'); option.value = s.id; option.textContent = s.name; this.sourceSelect.appendChild(option); }); } catch (err) { console.error('Error loading sources:', err); } } async loadCategories() { try { this.categories = []; this.hiddenCategoryIds = new Set(); this.categorySelect.innerHTML = ''; const sourceId = this.sourceSelect.value; const sourcesToLoad = sourceId ? this.sources.filter(s => s.id === parseInt(sourceId)) : this.sources; // Fetch hidden items for each source for (const source of sourcesToLoad) { try { const hiddenItems = await API.channels.getHidden(source.id); hiddenItems.forEach(h => { if (h.item_type === 'series_category') { this.hiddenCategoryIds.add(`${source.id}:${h.item_id}`); } }); } catch (err) { console.warn(`Failed to load hidden items from source ${source.id}`); } } for (const source of sourcesToLoad) { try { const cats = await API.proxy.xtream.seriesCategories(source.id); if (cats && Array.isArray(cats)) { cats.forEach(c => { // Skip hidden categories if (!this.hiddenCategoryIds.has(`${source.id}:${c.category_id}`)) { this.categories.push({ ...c, sourceId: source.id }); } }); } } catch (err) { console.warn(`Failed to load series categories from source ${source.id}:`, err.message); } } // Populate dropdown this.categories.forEach(c => { const option = document.createElement('option'); option.value = `${c.sourceId}:${c.category_id}`; option.textContent = c.category_name; this.categorySelect.appendChild(option); }); } catch (err) { console.error('Error loading categories:', err); } } async loadSeries() { this.isLoading = true; this.container.innerHTML = '
'; try { this.seriesList = []; const sourceId = this.sourceSelect.value; const categoryValue = this.categorySelect.value; const sourcesToLoad = sourceId ? this.sources.filter(s => s.id === parseInt(sourceId)) : this.sources; for (const source of sourcesToLoad) { try { // Parse category if selected let catId = null; if (categoryValue) { const [catSourceId, categoryId] = categoryValue.split(':'); if (parseInt(catSourceId) === source.id) { catId = categoryId; } else if (sourceId) { continue; } } const series = await API.proxy.xtream.series(source.id, catId); console.log(`[Series] Source ${source.id}, Category ${catId || 'ALL'}: Got ${series?.length || 0} series`); if (series && Array.isArray(series)) { series.forEach(s => { // Skip series from hidden categories if (this.hiddenCategoryIds.has(`${source.id}:${s.category_id}`)) { return; } this.seriesList.push({ ...s, sourceId: source.id, id: `${source.id}:${s.series_id}` }); }); } } catch (err) { console.warn(`Failed to load series from source ${source.id}:`, err.message); } } console.log(`[Series] Total loaded: ${this.seriesList.length} series`); this.filterAndRender(); } catch (err) { console.error('Error loading series:', err); this.container.innerHTML = '

Error loading series

'; } finally { this.isLoading = false; } } filterAndRender() { const searchTerm = this.searchInput?.value?.toLowerCase() || ''; this.filteredSeries = this.seriesList.filter(s => { // Filter by favorites if enabled if (this.showFavoritesOnly) { const favKey = `${s.sourceId}:${s.series_id}`; if (!this.favoriteIds.has(favKey)) return false; } if (searchTerm && !s.name?.toLowerCase().includes(searchTerm)) { return false; } return true; }); console.log(`[Series] Displaying ${this.filteredSeries.length} of ${this.seriesList.length} series`); this.currentBatch = 0; this.container.innerHTML = ''; if (this.filteredSeries.length === 0) { this.container.innerHTML = '

No series found

'; return; } // Create loader element const loader = document.createElement('div'); loader.className = 'series-loader'; loader.innerHTML = '
'; this.container.appendChild(loader); // Render initial batches for (let i = 0; i < 5; i++) { this.renderNextBatch(); } // Start observing loader this.observer.observe(loader); } renderNextBatch() { const start = this.currentBatch * this.batchSize; const end = start + this.batchSize; const batch = this.filteredSeries.slice(start, end); if (batch.length === 0) { const loader = this.container.querySelector('.series-loader'); if (loader) loader.style.display = 'none'; return; } const fragment = document.createDocumentFragment(); batch.forEach(series => { const card = document.createElement('div'); card.className = 'series-card'; card.dataset.seriesId = series.series_id; card.dataset.sourceId = series.sourceId; const poster = series.cover || '/img/placeholder.png'; const year = series.year || series.releaseDate?.substring(0, 4) || ''; const rating = series.rating ? `${Icons.star} ${series.rating}` : ''; const isFav = this.favoriteIds.has(`${series.sourceId}:${series.series_id}`); card.innerHTML = `
${series.name}
${Icons.play}
${series.name}
${year ? `${year}` : ''} ${rating ? `${rating}` : ''}
`; card.addEventListener('click', (e) => { if (e.target.closest('.favorite-btn')) { const btn = e.target.closest('.favorite-btn'); this.toggleFavorite(series, btn); e.stopPropagation(); } else { this.showSeriesDetails(series); } }); fragment.appendChild(card); }); // Insert before loader const loader = this.container.querySelector('.series-loader'); if (loader) { this.container.insertBefore(fragment, loader); } else { this.container.appendChild(fragment); } this.currentBatch++; // Hide loader if done if (end >= this.filteredSeries.length && loader) { loader.style.display = 'none'; } } async showSeriesDetails(series) { this.currentSeries = series; // Show details panel this.container.classList.add('hidden'); this.detailsPanel.classList.remove('hidden'); // Set header info document.getElementById('series-poster').src = series.cover || '/img/placeholder.png'; document.getElementById('series-title').textContent = series.name; document.getElementById('series-plot').textContent = series.plot || ''; // Show loading this.seasonsContainer.innerHTML = '
'; try { // Fetch series info (seasons/episodes) const info = await API.proxy.xtream.seriesInfo(series.sourceId, series.series_id); if (!info || !info.episodes) { this.seasonsContainer.innerHTML = '

No episodes found

'; return; } // Store series info for WatchPage this.currentSeriesInfo = info; // Render seasons and episodes let html = ''; const seasons = Object.keys(info.episodes).sort((a, b) => parseInt(a) - parseInt(b)); seasons.forEach(seasonNum => { const episodes = info.episodes[seasonNum]; html += `
${Icons.chevronDown} Season ${seasonNum} (${episodes.length} episodes)
${episodes.map(ep => `
E${ep.episode_num} ${ep.title || `Episode ${ep.episode_num}`} ${ep.duration || ''}
`).join('')}
`; }); this.seasonsContainer.innerHTML = html; // Add click handlers this.seasonsContainer.querySelectorAll('.season-header').forEach(header => { header.addEventListener('click', () => { header.closest('.season-group').classList.toggle('collapsed'); }); }); this.seasonsContainer.querySelectorAll('.episode-item').forEach(ep => { ep.addEventListener('click', () => this.playEpisode(ep)); }); } catch (err) { console.error('Error loading series info:', err); this.seasonsContainer.innerHTML = '

Error loading episodes

'; } } hideDetails() { this.detailsPanel.classList.add('hidden'); this.container.classList.remove('hidden'); this.currentSeries = null; } async playEpisode(episodeEl) { const episodeId = episodeEl.dataset.episodeId; const sourceId = parseInt(episodeEl.dataset.sourceId); const container = episodeEl.dataset.container || 'mp4'; // Get season and episode number from the episode element context const seasonGroup = episodeEl.closest('.season-group'); const seasonHeader = seasonGroup?.querySelector('.season-name')?.textContent || ''; const seasonMatch = seasonHeader.match(/Season (\d+)/); const seasonNum = seasonMatch ? seasonMatch[1] : '1'; const episodeNum = episodeEl.querySelector('.episode-number')?.textContent?.replace('E', '') || '1'; try { // Get stream URL for episode (use 'series' type) const result = await API.proxy.xtream.getStreamUrl(sourceId, episodeId, 'series', container); if (result && result.url) { // Play in dedicated Watch page if (this.app.pages.watch) { const episodeTitle = episodeEl.querySelector('.episode-title')?.textContent || `Episode ${episodeNum}`; this.app.pages.watch.play({ type: 'series', id: episodeId, title: this.currentSeries?.name || 'Series', subtitle: `S${seasonNum} E${episodeNum} - ${episodeTitle}`, poster: this.currentSeries?.cover, description: this.currentSeries?.plot || '', year: this.currentSeries?.year, rating: this.currentSeries?.rating, sourceId: sourceId, seriesId: this.currentSeries?.series_id, seriesInfo: this.currentSeriesInfo, currentSeason: seasonNum, currentEpisode: episodeNum, containerExtension: container }, result.url); } } } catch (err) { console.error('Error playing episode:', err); } } async toggleFavorite(series, btn) { const favKey = `${series.sourceId}:${series.series_id}`; const isFav = this.favoriteIds.has(favKey); const iconSpan = btn.querySelector('.fav-icon'); try { // Optimistic update if (isFav) { this.favoriteIds.delete(favKey); btn.classList.remove('active'); btn.title = 'Add to Favorites'; if (iconSpan) iconSpan.innerHTML = Icons.favoriteOutline; await API.favorites.remove(series.sourceId, series.series_id, 'series'); } else { this.favoriteIds.add(favKey); btn.classList.add('active'); btn.title = 'Remove from Favorites'; if (iconSpan) iconSpan.innerHTML = Icons.favorite; await API.favorites.add(series.sourceId, series.series_id, 'series'); } } catch (err) { console.error('Error toggling favorite:', err); // Revert on error if (isFav) { this.favoriteIds.add(favKey); btn.classList.add('active'); if (iconSpan) iconSpan.innerHTML = Icons.favorite; } else { this.favoriteIds.delete(favKey); btn.classList.remove('active'); if (iconSpan) iconSpan.innerHTML = Icons.favoriteOutline; } } } } window.SeriesPage = SeriesPage;