From b890d30253a9ad83ce9c06b15140bb3857c76cbe Mon Sep 17 00:00:00 2001 From: Trevor Mears Date: Sun, 18 Jan 2026 14:04:40 -0800 Subject: [PATCH] feat: add home page with favorites, resume playback, recently added VOD --- public/css/main.css | 83 +++++++++++++++++++++++ public/js/pages/Guide.js | 9 +++ public/js/pages/HomePage.js | 124 +++++++++++++++++++++++++++++++++-- server/services/epgParser.js | 4 +- 4 files changed, 211 insertions(+), 9 deletions(-) diff --git a/public/css/main.css b/public/css/main.css index 9f7b784..64b31c3 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -4544,4 +4544,87 @@ kbd { background: rgba(255, 255, 255, 0.02); border-radius: var(--radius-lg); border: 1px dashed rgba(255, 255, 255, 0.1); +} + +/* ===================================================== + Channel Tile Styles (Favorite Channels) + ===================================================== */ + +.channel-tiles { + gap: 16px; +} + +.channel-tile { + flex: 0 0 100px; + display: flex; + flex-direction: column; + align-items: center; + cursor: pointer; + padding: 12px 8px; + border-radius: var(--radius-md); + transition: transform var(--transition-fast), background var(--transition-fast); + scroll-snap-align: start; +} + +.channel-tile:hover { + transform: translateY(-4px); + background: rgba(255, 255, 255, 0.05); +} + +.tile-logo { + width: 64px; + height: 64px; + border-radius: 50%; + overflow: hidden; + background: var(--color-surface); + border: 2px solid rgba(255, 255, 255, 0.1); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 8px; + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); +} + +.channel-tile:hover .tile-logo { + border-color: var(--color-accent); + box-shadow: 0 0 16px rgba(var(--color-accent-rgb), 0.3); +} + +.tile-logo img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.tile-name { + font-size: 0.75rem; + font-weight: 500; + color: var(--color-text-secondary); + text-align: center; + max-width: 90px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.channel-tile:hover .tile-name { + color: var(--color-text-bright); +} + +/* Responsive: smaller tiles on mobile */ +@media (max-width: 480px) { + .channel-tile { + flex: 0 0 80px; + padding: 8px 4px; + } + + .tile-logo { + width: 52px; + height: 52px; + } + + .tile-name { + font-size: 0.7rem; + max-width: 70px; + } } \ No newline at end of file diff --git a/public/js/pages/Guide.js b/public/js/pages/Guide.js index adcf37e..2b0e5af 100644 --- a/public/js/pages/Guide.js +++ b/public/js/pages/Guide.js @@ -12,6 +12,15 @@ class GuidePage { } async show() { + // Ensure channel data is loaded before rendering EPG + // This fixes a race condition where navigating directly to the Guide page + // before visiting Live TV would result in an empty EPG. + const channelList = this.app.channelList; + if (!channelList.channels || channelList.channels.length === 0) { + await channelList.loadSources(); + await channelList.loadChannels(); + } + // Only load EPG data if not already loaded if (!this.app.epgGuide.programmes || this.app.epgGuide.programmes.length === 0) { await this.app.epgGuide.loadEpg(); diff --git a/public/js/pages/HomePage.js b/public/js/pages/HomePage.js index b599351..814b280 100644 --- a/public/js/pages/HomePage.js +++ b/public/js/pages/HomePage.js @@ -31,6 +31,15 @@ class HomePage { pageHome.innerHTML = `
+
+
+

Favorite Channels

+
+
+
Loading favorites...
+
+
+

Continue Watching

@@ -67,6 +76,9 @@ class HomePage { 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)) { @@ -84,6 +96,103 @@ class HomePage { } } + async renderFavoriteChannels() { + const list = document.getElementById('favorite-channels-list'); + const section = document.getElementById('favorite-channels-section'); + if (!list || !section) return; + + try { + // Fetch favorite channels for current user + const favorites = await window.API.request('GET', '/favorites?itemType=channel'); + + if (!favorites || favorites.length === 0) { + list.innerHTML = '
Add channels to favorites from Live TV
'; + return; + } + + // Ensure channel list is loaded to resolve channel details + const channelList = this.app.channelList; + if (!channelList.channels || channelList.channels.length === 0) { + await channelList.loadSources(); + await channelList.loadChannels(); + } + + // Match favorites to channel data + const channels = []; + for (const fav of favorites) { + // Find channel in loaded channel list + const channel = channelList.channels.find(ch => + String(ch.sourceId) === String(fav.source_id) && + (String(ch.id) === String(fav.item_id) || String(ch.streamId) === String(fav.item_id)) + ); + if (channel) { + channels.push({ ...channel, favoriteId: fav.id }); + } + } + + 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('click', () => { + const channelId = tile.dataset.channelId; + const sourceId = tile.dataset.sourceId; + this.playChannel(channelId, sourceId); + }); + }); + + } 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'; + + return ` +
+ +
${name}
+
+ `; + } + + playChannel(channelId, sourceId) { + // Navigate to Live TV and select the channel + this.app.navigateTo('live'); + + // Small delay to ensure page is ready + setTimeout(() => { + const channelList = this.app.channelList; + if (channelList) { + // Find and select the channel + const channel = channelList.channels.find(ch => + String(ch.id) === String(channelId) && String(ch.sourceId) === String(sourceId) + ); + if (channel) { + channelList.selectChannel({ + channelId: channel.id, + sourceId: channel.sourceId, + sourceType: channel.sourceType, + streamId: channel.streamId || '', + url: channel.url || '' + }); + } + } + }, 100); + } + renderHistory(items) { const list = document.getElementById('continue-watching-list'); const section = document.getElementById('continue-watching-section'); @@ -105,11 +214,12 @@ class HomePage { const item = items.find(i => i.item_id === id); if (item) { const type = item.item_type || item.type; - if (type === 'series' && !isResume) { - this.navigateToSeries(item); - } else { - this.playItem(item, true); // true for resume - } + + // 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 } }); }); @@ -245,9 +355,9 @@ class HomePage { try { const type = item.item_type || item.type; const streamType = type === 'movie' ? 'movie' : 'series'; - const sourceId = item.source_id; + const sourceId = item.source_id || (item.data && item.data.sourceId); const streamId = item.item_id; - const container = item.container_extension || 'mp4'; + 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}`); diff --git a/server/services/epgParser.js b/server/services/epgParser.js index 181b022..9b75fff 100644 --- a/server/services/epgParser.js +++ b/server/services/epgParser.js @@ -125,7 +125,7 @@ function parse(input) { currentObject.description = textBuffer; break; case 'category': - if (textBuffer) currentObject.category.push(textBuffer); + if (textBuffer && currentObject.category) currentObject.category.push(textBuffer); break; case 'date': currentObject.date = textBuffer; @@ -374,7 +374,7 @@ async function* parseStreaming(input, batchSize = 1000) { currentObject.description = textBuffer; break; case 'category': - if (textBuffer) currentObject.category.push(textBuffer); + if (textBuffer && currentObject.category) currentObject.category.push(textBuffer); break; case 'date': currentObject.date = textBuffer;