feat: add home page with favorites, resume playback, recently added VOD
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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}`);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user