feat: add home page with favorites, resume playback, recently added VOD

This commit is contained in:
Trevor Mears
2026-01-18 14:04:40 -08:00
parent bf111e1fca
commit b890d30253
4 changed files with 211 additions and 9 deletions
+83
View File
@@ -4544,4 +4544,87 @@ kbd {
background: rgba(255, 255, 255, 0.02); background: rgba(255, 255, 255, 0.02);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
border: 1px dashed rgba(255, 255, 255, 0.1); 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;
}
} }
+9
View File
@@ -12,6 +12,15 @@ class GuidePage {
} }
async show() { 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 // Only load EPG data if not already loaded
if (!this.app.epgGuide.programmes || this.app.epgGuide.programmes.length === 0) { if (!this.app.epgGuide.programmes || this.app.epgGuide.programmes.length === 0) {
await this.app.epgGuide.loadEpg(); await this.app.epgGuide.loadEpg();
+117 -7
View File
@@ -31,6 +31,15 @@ class HomePage {
pageHome.innerHTML = ` pageHome.innerHTML = `
<div class="dashboard-content" id="home-content"> <div class="dashboard-content" id="home-content">
<section class="dashboard-section" id="favorite-channels-section">
<div class="section-header">
<h2>Favorite Channels</h2>
</div>
<div class="horizontal-scroll channel-tiles" id="favorite-channels-list">
<div class="loading-state">Loading favorites...</div>
</div>
</section>
<section class="dashboard-section" id="continue-watching-section"> <section class="dashboard-section" id="continue-watching-section">
<div class="section-header"> <div class="section-header">
<h2>Continue Watching</h2> <h2>Continue Watching</h2>
@@ -67,6 +76,9 @@ class HomePage {
this.isLoading = true; this.isLoading = true;
try { try {
// 0. Load Favorite Channels (first section)
await this.renderFavoriteChannels();
// 1. Load Watch History // 1. Load Watch History
const history = await window.API.request('GET', '/history?limit=12'); const history = await window.API.request('GET', '/history?limit=12');
if (history && Array.isArray(history)) { 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 = '<div class="empty-state hint">Add channels to favorites from Live TV</div>';
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 = '<div class="empty-state hint">Add channels to favorites from Live TV</div>';
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 = '<div class="empty-state hint">Error loading favorites</div>';
}
}
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 `
<div class="channel-tile" data-channel-id="${channel.id}" data-source-id="${channel.sourceId}">
<div class="tile-logo">
<img src="${logoUrl}" alt="${name}" loading="lazy" onerror="this.src='/img/placeholder.png'">
</div>
<div class="tile-name" title="${name}">${name}</div>
</div>
`;
}
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) { renderHistory(items) {
const list = document.getElementById('continue-watching-list'); const list = document.getElementById('continue-watching-list');
const section = document.getElementById('continue-watching-section'); const section = document.getElementById('continue-watching-section');
@@ -105,11 +214,12 @@ class HomePage {
const item = items.find(i => i.item_id === id); const item = items.find(i => i.item_id === id);
if (item) { if (item) {
const type = item.item_type || item.type; const type = item.item_type || item.type;
if (type === 'series' && !isResume) {
this.navigateToSeries(item); // IF it's a series, checking details is better than blind resume
} else { // BUT for "Continue Watching", we ideally want to resume
this.playItem(item, true); // true for resume
} // Prioritize playing directly for resume tiles
this.playItem(item, true); // true for resume
} }
}); });
}); });
@@ -245,9 +355,9 @@ class HomePage {
try { try {
const type = item.item_type || item.type; const type = item.item_type || item.type;
const streamType = type === 'movie' ? 'movie' : 'series'; 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 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}`); const result = await window.API.request('GET', `/proxy/xtream/${sourceId}/stream/${streamId}/${streamType}?container=${container}`);
+2 -2
View File
@@ -125,7 +125,7 @@ function parse(input) {
currentObject.description = textBuffer; currentObject.description = textBuffer;
break; break;
case 'category': case 'category':
if (textBuffer) currentObject.category.push(textBuffer); if (textBuffer && currentObject.category) currentObject.category.push(textBuffer);
break; break;
case 'date': case 'date':
currentObject.date = textBuffer; currentObject.date = textBuffer;
@@ -374,7 +374,7 @@ async function* parseStreaming(input, batchSize = 1000) {
currentObject.description = textBuffer; currentObject.description = textBuffer;
break; break;
case 'category': case 'category':
if (textBuffer) currentObject.category.push(textBuffer); if (textBuffer && currentObject.category) currentObject.category.push(textBuffer);
break; break;
case 'date': case 'date':
currentObject.date = textBuffer; currentObject.date = textBuffer;