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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
+117
-7
@@ -31,6 +31,15 @@ class HomePage {
|
||||
|
||||
pageHome.innerHTML = `
|
||||
<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">
|
||||
<div class="section-header">
|
||||
<h2>Continue Watching</h2>
|
||||
@@ -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 = '<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) {
|
||||
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}`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user