/** * Home Dashboard Page * Features "Continue Watching" and "Recently Added" content */ class HomePage { constructor(app) { this.app = app; this.container = null; // Will be set in renderLayout this.isLoading = false; } async init() { // Initialization if needed } async show() { this.renderLayout(); await this.loadDashboardData(); } hide() { // Cleanup if needed if (this.container) { this.container.innerHTML = ''; } } renderLayout() { const pageHome = document.getElementById('page-home'); if (!pageHome) return; pageHome.innerHTML = `

Favorite Channels

Loading favorites...

Continue Watching

Loading history...

Recently Added Movies

Loading recently added...

Recently Added Series

Loading recently added...
`; this.container = document.getElementById('home-content'); // Attach scroll arrow handlers this.initScrollArrows(); } initScrollArrows() { this.container.querySelectorAll('.scroll-wrapper').forEach(wrapper => { const scrollContainer = wrapper.querySelector('.horizontal-scroll'); const leftBtn = wrapper.querySelector('.scroll-left'); const rightBtn = wrapper.querySelector('.scroll-right'); if (!scrollContainer || !leftBtn || !rightBtn) return; const scrollAmount = 300; // pixels to scroll per click leftBtn.addEventListener('click', () => { scrollContainer.scrollBy({ left: -scrollAmount, behavior: 'smooth' }); }); rightBtn.addEventListener('click', () => { scrollContainer.scrollBy({ left: scrollAmount, behavior: 'smooth' }); }); // Update arrow visibility based on scroll position const updateArrows = () => { const { scrollLeft, scrollWidth, clientWidth } = scrollContainer; leftBtn.classList.toggle('hidden', scrollLeft <= 0); rightBtn.classList.toggle('hidden', scrollLeft + clientWidth >= scrollWidth - 5); }; // Store reference for later updates wrapper._updateArrows = updateArrows; scrollContainer.addEventListener('scroll', updateArrows); // Initial check after content loads setTimeout(updateArrows, 100); }); } /** * Re-check scroll arrow visibility for all sections * Call this after dynamically loading content */ updateScrollArrows() { this.container?.querySelectorAll('.scroll-wrapper').forEach(wrapper => { if (wrapper._updateArrows) { wrapper._updateArrows(); } }); } async loadDashboardData() { if (this.isLoading) return; this.isLoading = true; try { // 0. Load Favorite Channels (first section) await this.renderFavoriteChannels(); // 1. Load Watch History const history = await window.API.request('GET', '/history?limit=12'); if (history && Array.isArray(history)) { this.renderHistory(history); } // 2. Load Recent Items this.renderRecentMovies(); this.renderRecentSeries(); } catch (err) { console.error('[Dashboard] Error loading data:', err); } finally { this.isLoading = false; } } async renderFavoriteChannels() { const list = document.getElementById('favorite-channels-list'); const section = document.getElementById('favorite-channels-section'); if (!list || !section) return; const version = this.favoriteCatalogVersion = (this.favoriteCatalogVersion || 0) + 1; try { const channelList = this.app.channelList; const result = await API.channels.liveCatalog({ favoriteOnly: true, limit: 200 }); if (version !== this.favoriteCatalogVersion) return; const channels = result.items; channelList.favoriteCatalog = channels; if (channels.length === 0) { list.innerHTML = '
Add channels to favorites from Live TV
'; return; } // Render channel tiles list.innerHTML = channels.map(ch => this.createChannelTile(ch)).join(''); // Attach click handlers list.querySelectorAll('.channel-tile').forEach(tile => { tile.addEventListener('keydown', event => this.activateFavoriteTile(event, tile.dataset.channelId, tile.dataset.sourceId)); tile.addEventListener('click', () => { const channelId = tile.dataset.channelId; const sourceId = tile.dataset.sourceId; this.playChannel(channelId, sourceId); }); }); let nextCursor = result.page.nextCursor; if (result.page.hasMore) { const more = document.createElement('button'); more.type = 'button'; more.className = 'channel-tile channel-tile-action'; more.textContent = 'Load more'; list.appendChild(more); more.addEventListener('click', async event => { const keyboard = event.detail === 0; const scrollLeft = list.scrollLeft; more.disabled = true; more.textContent = 'Loading…'; let firstNewTile; try { const page = await API.channels.liveCatalog({ favoriteOnly: true, limit: 200, cursor: nextCursor }); if (version !== this.favoriteCatalogVersion) return; channelList.favoriteCatalog.push(...page.items); for (const channel of page.items) { const wrapper = document.createElement('div'); wrapper.innerHTML = this.createChannelTile(channel); const tile = wrapper.firstElementChild; tile.addEventListener('click', () => this.playChannel(channel.id, channel.sourceId)); tile.addEventListener('keydown', event => this.activateFavoriteTile(event, channel.id, channel.sourceId)); firstNewTile ||= tile; list.insertBefore(tile, more); } nextCursor = page.page.nextCursor; if (keyboard && firstNewTile) { firstNewTile.focus({ preventScroll: true }); firstNewTile.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } else { list.scrollLeft = scrollLeft; } more.hidden = !page.page.hasMore; more.textContent = 'Load more'; this.updateScrollArrows(); } catch (err) { more.textContent = 'Retry'; } finally { more.disabled = false; } }); } // Update scroll arrows after content renders this.updateScrollArrows(); } catch (err) { console.error('[Dashboard] Error loading favorite channels:', err); list.innerHTML = '
Error loading favorites
'; } } createChannelTile(channel) { const logo = channel.tvgLogo || '/img/placeholder.png'; const logoUrl = logo.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(logo)}` : logo; const name = channel.name || 'Unknown'; const channelId = this.app.channelList.escapeHtml(String(channel.id)); const sourceId = this.app.channelList.escapeHtml(String(channel.sourceId)); return `
${name}
`; } activateFavoriteTile(event, channelId, sourceId) { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); this.playChannel(channelId, sourceId); } } playChannel(channelId, sourceId) { const channelList = this.app.channelList; const channel = channelList.favoriteCatalog?.find(ch => String(ch.id) === String(channelId) && String(ch.sourceId) === String(sourceId)); if (!channel) return; this.app.navigateTo('live'); // selectChannel resolves Home DTOs independently of the active Live filters. channelList.selectChannel({ channelId: channel.id, sourceId: channel.sourceId }); } renderHistory(items) { const list = document.getElementById('continue-watching-list'); const section = document.getElementById('continue-watching-section'); if (!list || !section) return; if (items.length === 0) { section.classList.add('hidden'); return; } section.classList.remove('hidden'); list.innerHTML = items.map(item => this.createCard(item)).join(''); // Attach click listeners list.querySelectorAll('.dashboard-card').forEach(card => { card.addEventListener('click', () => { const id = card.dataset.id; const item = items.find(i => i.item_id === id); if (item) { const type = item.item_type || item.type; // IF it's a series, checking details is better than blind resume // BUT for "Continue Watching", we ideally want to resume // Prioritize playing directly for resume tiles this.playItem(item, true); // true for resume } }); }); // Update scroll arrows after content renders this.updateScrollArrows(); } navigateToSeries(item) { if (!this.app.pages.series) return; // Prepare the series object as expected by SeriesPage.showSeriesDetails const series = { series_id: item.item_id, sourceId: item.source_id, name: item.name || (item.data ? item.data.title : 'Series'), cover: item.stream_icon || (item.data ? item.data.poster : null), plot: item.data ? item.data.description : '', year: item.data ? item.data.year : '' }; // Switch page this.app.navigateTo('series'); // Show details (delay slightly to ensure page is visible) setTimeout(() => { this.app.pages.series.showSeriesDetails(series); }, 100); } async renderRecentMovies() { const list = document.getElementById('recent-movies-list'); if (!list) return; try { const movies = await window.API.request('GET', '/channels/recent?type=movie&limit=12'); if (!movies || movies.length === 0) { list.innerHTML = '
No recently added movies found
'; return; } list.innerHTML = movies.map(item => this.createRecentCard(item)).join(''); // Attach listeners list.querySelectorAll('.dashboard-card').forEach(card => { card.addEventListener('click', () => { const id = card.dataset.id; const item = movies.find(m => m.item_id === id); if (item) this.playItem(item); }); }); // Update scroll arrows after content renders this.updateScrollArrows(); } catch (err) { console.error('[Dashboard] Error loading recent movies:', err); } } async renderRecentSeries() { const list = document.getElementById('recent-series-list'); if (!list) return; try { const series = await window.API.request('GET', '/channels/recent?type=series&limit=12'); if (!series || series.length === 0) { list.innerHTML = '
No recently added series found
'; return; } list.innerHTML = series.map(item => this.createRecentCard(item)).join(''); // Attach listeners list.querySelectorAll('.dashboard-card').forEach(card => { card.addEventListener('click', () => { const id = card.dataset.id; const item = series.find(s => s.item_id === id); if (item) this.navigateToSeries(item); }); }); // Update scroll arrows after content renders this.updateScrollArrows(); } catch (err) { console.error('[Dashboard] Error loading recent series:', err); } } createCard(item) { const { data, progress, duration, item_id } = item; const type = item.item_type || item.type; const percent = Math.min(100, Math.round((progress / duration) * 100)); // Proxy the poster if it's an external URL const poster = data.poster || '/img/poster-placeholder.jpg'; const posterUrl = poster.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(poster)}` : poster; return `
${data.title || item.name}
${item.name || data.title || 'Unknown Title'}
${data.subtitle || (type === 'movie' ? 'Movie' : 'Series')}
`; } createRecentCard(item) { const { data, item_id } = item; const type = item.type || item.item_type; const poster = item.stream_icon || data.poster || '/img/poster-placeholder.jpg'; const posterUrl = poster.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(poster)}` : poster; return `
${item.name}
${item.name || (data && data.title) || 'Unknown Title'}
${(data && data.subtitle) || (type === 'movie' ? 'Movie' : 'Series')}
`; } async playItem(item, isResume = false) { if (!this.app.pages.watch) return; try { const type = item.item_type || item.type; const streamType = type === 'movie' ? 'movie' : 'series'; const sourceId = item.source_id || (item.data && item.data.sourceId); const streamId = item.item_id; const container = item.container_extension || (item.data && item.data.containerExtension) || 'mp4'; const result = await window.API.request('GET', `/proxy/xtream/${sourceId}/stream/${streamId}/${streamType}?container=${container}`); if (result && result.url) { const content = { id: item.item_id, type: type, title: item.name || item.data.title, subtitle: item.data.subtitle || (type === 'movie' ? 'Movie' : 'Series'), poster: item.stream_icon || item.data.poster, sourceId: sourceId, resumeTime: isResume ? item.progress : 0, containerExtension: container }; // For episodes, try to restore series data for next episode functionality if (type === 'episode' && item.data) { content.seriesId = item.data.seriesId || null; content.currentSeason = item.data.currentSeason || null; content.currentEpisode = item.data.currentEpisode || null; // Fetch seriesInfo if we have a seriesId if (content.seriesId && sourceId) { try { const seriesInfo = await window.API.request('GET', `/proxy/xtream/${sourceId}/series_info?series_id=${content.seriesId}`); if (seriesInfo) { content.seriesInfo = seriesInfo; } } catch (e) { console.warn('[Dashboard] Could not fetch seriesInfo for next episode:', e); } } } // Switch to watch page this.app.navigateTo('watch'); this.app.pages.watch.play(content, result.url); } } catch (err) { console.error('[Dashboard] Playback failed:', err); } } } window.HomePage = HomePage;