/**
* 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 = `
`;
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 `
`;
}
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 `
${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 || (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;