1040 lines
39 KiB
JavaScript
1040 lines
39 KiB
JavaScript
// KV-Tube Main JavaScript - YouTube Clone
|
|
|
|
// Re-usable init function for SPA
|
|
window.initApp = function () {
|
|
const searchInput = document.getElementById('searchInput');
|
|
const resultsArea = document.getElementById('resultsArea');
|
|
|
|
// cleanup previous observers if any
|
|
if (window.currentObserver) {
|
|
window.currentObserver.disconnect();
|
|
}
|
|
|
|
// Check APP_CONFIG if available (set in index.html)
|
|
const socketConfig = window.APP_CONFIG || {};
|
|
const pageType = socketConfig.page || 'home';
|
|
|
|
if (searchInput) {
|
|
// Clear previous event listeners to avoid duplicates (optional, but safer to just re-attach if we are careful)
|
|
// Actually, searchInput is in the header, which is NOT replaced.
|
|
// So we should NOT re-attach listener to searchInput every time.
|
|
// We need to check if we already attached it.
|
|
if (!searchInput.dataset.listenerAttached) {
|
|
searchInput.addEventListener('keypress', async (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
const query = searchInput.value.trim();
|
|
if (query) {
|
|
// Use navigation manager if available
|
|
if (window.navigationManager) {
|
|
window.navigationManager.navigateTo(`/results?search_query=${encodeURIComponent(query)}`);
|
|
} else {
|
|
window.location.href = `/results?search_query=${encodeURIComponent(query)}`;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
searchInput.dataset.listenerAttached = 'true';
|
|
}
|
|
|
|
// Handle Page Initialization - only if resultsArea exists (not on channel.html)
|
|
if (resultsArea) {
|
|
if (pageType === 'channel' && socketConfig.channelId) {
|
|
console.log("Loading Channel:", socketConfig.channelId);
|
|
loadChannelVideos(socketConfig.channelId);
|
|
} else if (pageType === 'results' || socketConfig.query) {
|
|
const q = socketConfig.query || new URLSearchParams(window.location.search).get('search_query');
|
|
if (q) {
|
|
if (searchInput) searchInput.value = q;
|
|
searchYouTube(q);
|
|
}
|
|
} else {
|
|
// Default Home
|
|
// Check if we are actually on home page based on URL or Config
|
|
if (pageType === 'home') {
|
|
loadTrending();
|
|
}
|
|
}
|
|
|
|
// Init Infinite Scroll
|
|
initInfiniteScroll();
|
|
}
|
|
}
|
|
|
|
// Init Theme (check if already init)
|
|
initTheme();
|
|
|
|
// Restore sidebar state from localStorage to prevent layout shift
|
|
initSidebarState();
|
|
|
|
// Check for category in URL if we are on home and need to switch
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const category = urlParams.get('category');
|
|
if (category && typeof switchCategory === 'function' && pageType === 'home') {
|
|
// We might have already loaded trending above, but switchCategory handles UI state
|
|
// It also triggers a load, so maybe we want to avoid double loading.
|
|
// But switchCategory also sets the active pill.
|
|
// Let's just set the active pill visually for now if we already loaded trending.
|
|
const pill = document.querySelector(`.yt-chip[onclick*="'${category}'"]`);
|
|
if (pill) {
|
|
document.querySelectorAll('.yt-category-pill, .yt-chip').forEach(b => b.classList.remove('active'));
|
|
pill.classList.add('active');
|
|
}
|
|
// If switchCategory is called it will re-fetch.
|
|
}
|
|
};
|
|
|
|
document.addEventListener('DOMContentLoaded', window.initApp);
|
|
|
|
// Note: Global variables like currentCategory are defined below
|
|
let currentCategory = 'all';
|
|
let currentPage = 1;
|
|
let isLoading = false;
|
|
let hasMore = true; // Track if there are more videos to load
|
|
|
|
// --- Lazy Loading ---
|
|
const imageObserver = new IntersectionObserver((entries, observer) => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
const img = entry.target;
|
|
const src = img.getAttribute('data-src');
|
|
if (src) {
|
|
img.src = src;
|
|
img.onload = () => img.classList.add('loaded');
|
|
img.removeAttribute('data-src');
|
|
}
|
|
observer.unobserve(img);
|
|
}
|
|
});
|
|
}, {
|
|
rootMargin: '50px 0px',
|
|
threshold: 0.1
|
|
});
|
|
|
|
window.observeImages = function () {
|
|
document.querySelectorAll('img[data-src]').forEach(img => {
|
|
imageObserver.observe(img);
|
|
});
|
|
};
|
|
|
|
// --- Infinite Scroll ---
|
|
function initInfiniteScroll() {
|
|
const observer = new IntersectionObserver((entries) => {
|
|
if (entries[0].isIntersecting && !isLoading && hasMore) {
|
|
loadMore();
|
|
}
|
|
}, { rootMargin: '200px' });
|
|
|
|
// Create sentinel logic or observe existing footer/element
|
|
// We'll observe a sentinel element at the bottom of the grid
|
|
// Create sentinel logic or observe existing footer/element
|
|
// We'll observe a sentinel element at the bottom of the grid
|
|
const resultsArea = document.getElementById('resultsArea');
|
|
if (!resultsArea) return; // Exit if not on home page
|
|
|
|
const sentinel = document.createElement('div');
|
|
sentinel.id = 'scroll-sentinel';
|
|
sentinel.style.width = '100%';
|
|
sentinel.style.height = '20px';
|
|
resultsArea.parentNode.appendChild(sentinel);
|
|
observer.observe(sentinel);
|
|
}
|
|
|
|
// --- UI Helpers ---
|
|
function renderSkeleton() {
|
|
// Generate 8 skeleton cards
|
|
return Array(8).fill(0).map(() => `
|
|
<div class="yt-video-card skeleton-card">
|
|
<div class="skeleton-thumb skeleton"></div>
|
|
<div class="skeleton-details">
|
|
<div class="skeleton-avatar skeleton"></div>
|
|
<div class="skeleton-text">
|
|
<div class="skeleton-title skeleton"></div>
|
|
<div class="skeleton-meta skeleton"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function renderNoContent(message = 'Try searching for something else', title = 'No videos found') {
|
|
return `
|
|
<div class="yt-empty-state">
|
|
<div class="yt-empty-icon"><i class="fas fa-film"></i></div>
|
|
<div class="yt-empty-title">${title}</div>
|
|
<div class="yt-empty-desc">${message}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Render homepage with personalized sections
|
|
function renderHomepageSections(sections, container, localHistory = []) {
|
|
// Create a map for quick history lookup
|
|
const historyMap = {};
|
|
localHistory.forEach(v => {
|
|
if (v && v.id) historyMap[v.id] = v;
|
|
});
|
|
|
|
sections.forEach(section => {
|
|
if (!section.videos || section.videos.length === 0) return;
|
|
|
|
// Create section wrapper
|
|
const sectionEl = document.createElement('div');
|
|
sectionEl.className = 'yt-homepage-section';
|
|
sectionEl.id = `section-${section.id}`;
|
|
|
|
// Section header
|
|
const header = document.createElement('div');
|
|
header.className = 'yt-section-header';
|
|
header.innerHTML = `
|
|
<h2>${escapeHtml(section.title)}</h2>
|
|
`;
|
|
sectionEl.appendChild(header);
|
|
|
|
// Video grid for this section
|
|
const grid = document.createElement('div');
|
|
grid.className = 'yt-video-grid';
|
|
|
|
// LIMIT VISIBLE VIDEOS TO 8 (2 rows of 4 on desktop)
|
|
const INITIAL_LIMIT = 8;
|
|
const hasMore = section.videos.length > INITIAL_LIMIT;
|
|
|
|
section.videos.forEach((video, index) => {
|
|
// For continue watching
|
|
if (video._from_history && historyMap[video.id]) {
|
|
const hist = historyMap[video.id];
|
|
video.title = hist.title || video.title;
|
|
video.uploader = hist.uploader || video.uploader;
|
|
video.thumbnail = hist.thumbnail || video.thumbnail;
|
|
}
|
|
|
|
const card = document.createElement('div');
|
|
card.className = 'yt-video-card';
|
|
// Hide videos beyond limit initially
|
|
if (index >= INITIAL_LIMIT) {
|
|
card.classList.add('yt-hidden-video');
|
|
card.style.display = 'none';
|
|
}
|
|
|
|
card.innerHTML = `
|
|
<div class="yt-thumbnail-container">
|
|
<img class="yt-thumbnail" src="${video.thumbnail}" alt="${escapeHtml(video.title || 'Video')}" loading="lazy" onload="this.classList.add('loaded')">
|
|
${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''}
|
|
</div>
|
|
<div class="yt-video-details">
|
|
<div class="yt-channel-avatar">
|
|
${video.uploader ? video.uploader.charAt(0).toUpperCase() : 'Y'}
|
|
</div>
|
|
<div class="yt-video-meta">
|
|
<h3 class="yt-video-title">${escapeHtml(video.title || 'Unknown')}</h3>
|
|
<p class="yt-channel-name">${escapeHtml(video.uploader || 'Unknown')}</p>
|
|
<p class="yt-video-stats">${formatViews(video.view_count)} views${video.upload_date ? ' • ' + formatDate(video.upload_date) : ''}</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
card.addEventListener('click', () => {
|
|
const params = new URLSearchParams({
|
|
v: video.id,
|
|
title: video.title || '',
|
|
uploader: video.uploader || '',
|
|
thumbnail: video.thumbnail || ''
|
|
});
|
|
const dest = `/watch?${params.toString()}`;
|
|
|
|
if (window.navigationManager) {
|
|
window.navigationManager.navigateTo(dest);
|
|
} else {
|
|
window.location.href = dest;
|
|
}
|
|
});
|
|
|
|
grid.appendChild(card);
|
|
});
|
|
|
|
sectionEl.appendChild(grid);
|
|
|
|
// ADD LOAD MORE BUTTON IF NEEDED
|
|
if (hasMore) {
|
|
const btnContainer = document.createElement('div');
|
|
btnContainer.className = 'yt-section-footer';
|
|
btnContainer.style.textAlign = 'center';
|
|
btnContainer.style.padding = '10px';
|
|
|
|
const btn = document.createElement('button');
|
|
btn.className = 'yt-action-btn'; // Re-use existing or generic class
|
|
btn.style.padding = '8px 24px';
|
|
btn.style.borderRadius = '18px';
|
|
btn.style.border = '1px solid var(--yt-border)';
|
|
btn.style.background = 'var(--yt-bg-secondary)';
|
|
btn.style.color = 'var(--yt-text-primary)';
|
|
btn.style.cursor = 'pointer';
|
|
btn.style.fontWeight = '500';
|
|
btn.innerText = 'Show more';
|
|
|
|
btn.onmouseover = () => btn.style.background = 'var(--yt-bg-hover)';
|
|
btn.onmouseout = () => btn.style.background = 'var(--yt-bg-secondary)';
|
|
|
|
btn.onclick = function () {
|
|
// Reveal hidden videos
|
|
const hidden = grid.querySelectorAll('.yt-hidden-video');
|
|
hidden.forEach(el => el.style.display = 'flex'); // Restore display
|
|
btnContainer.remove(); // Remove button
|
|
};
|
|
|
|
btnContainer.appendChild(btn);
|
|
sectionEl.appendChild(btnContainer);
|
|
}
|
|
|
|
container.appendChild(sectionEl);
|
|
});
|
|
|
|
if (window.observeImages) window.observeImages();
|
|
}
|
|
|
|
|
|
async function searchYouTube(query) {
|
|
if (isLoading) return;
|
|
|
|
const resultsArea = document.getElementById('resultsArea');
|
|
const loadMoreArea = document.getElementById('loadMoreArea');
|
|
|
|
isLoading = true;
|
|
resultsArea.innerHTML = renderSkeleton();
|
|
|
|
try {
|
|
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
|
const data = await response.json();
|
|
|
|
if (data.error) {
|
|
resultsArea.innerHTML = `<div class="yt-loader" style="grid-column: 1/-1;"><p style="color:#f00;">Error: ${data.error}</p></div>`;
|
|
return;
|
|
}
|
|
|
|
displayResults(data, false);
|
|
if (loadMoreArea) loadMoreArea.style.display = 'none';
|
|
} catch (error) {
|
|
console.error('Search error:', error);
|
|
resultsArea.innerHTML = `<div class="yt-loader" style="grid-column: 1/-1;"><p style="color:#f00;">Failed to fetch results</p></div>`;
|
|
} finally {
|
|
isLoading = false;
|
|
}
|
|
}
|
|
|
|
// Switch category
|
|
async function switchCategory(category, btn) {
|
|
if (isLoading) return;
|
|
|
|
// Update UI (Pills)
|
|
document.querySelectorAll('.yt-category-pill').forEach(b => b.classList.remove('active'));
|
|
if (btn && btn.classList) btn.classList.add('active');
|
|
|
|
// Update UI (Sidebar)
|
|
document.querySelectorAll('.yt-sidebar-item').forEach(item => {
|
|
item.classList.remove('active');
|
|
if (item.getAttribute('data-category') === category) {
|
|
item.classList.add('active');
|
|
}
|
|
});
|
|
|
|
// Reset state
|
|
currentCategory = category;
|
|
currentPage = 1;
|
|
window.currentPage = 1;
|
|
hasMore = true; // Reset infinite scroll
|
|
|
|
const resultsArea = document.getElementById('resultsArea');
|
|
const videosSection = document.getElementById('videosSection');
|
|
|
|
// Show resultsArea (may have been hidden by homepage sections)
|
|
resultsArea.style.display = '';
|
|
// Remove any homepage sections
|
|
if (videosSection) {
|
|
videosSection.querySelectorAll('.yt-homepage-section').forEach(el => el.remove());
|
|
}
|
|
|
|
resultsArea.innerHTML = renderSkeleton();
|
|
|
|
// Hide pagination while loading
|
|
const paginationArea = document.getElementById('paginationArea');
|
|
if (paginationArea) paginationArea.style.display = 'none';
|
|
|
|
// Handle Shorts Layout
|
|
const shortsSection = document.getElementById('shortsSection');
|
|
// videosSection already declared above
|
|
|
|
if (shortsSection) {
|
|
if (category === 'shorts') {
|
|
shortsSection.style.display = 'none'; // Hide carousel, show grid in results
|
|
if (videosSection) videosSection.querySelector('h2').style.display = 'none'; // Optional: hide "Videos" header
|
|
} else {
|
|
shortsSection.style.display = 'block';
|
|
if (videosSection) videosSection.querySelector('h2').style.display = 'flex';
|
|
}
|
|
}
|
|
|
|
// Handle Special Categories
|
|
if (category === 'history') {
|
|
const response = await fetch('/api/history');
|
|
const data = await response.json();
|
|
displayResults(data, false);
|
|
isLoading = false;
|
|
return;
|
|
}
|
|
if (category === 'suggested') {
|
|
// Build query params from localStorage history
|
|
const history = JSON.parse(localStorage.getItem('kv_history') || '[]');
|
|
const titles = history.slice(0, 5).map(v => v.title).filter(Boolean).join(',');
|
|
const channels = history.slice(0, 3).map(v => v.uploader).filter(Boolean).join(',');
|
|
|
|
let url = '/api/suggested';
|
|
const params = new URLSearchParams();
|
|
if (titles) params.append('titles', titles);
|
|
if (channels) params.append('channels', channels);
|
|
if (params.toString()) url += '?' + params.toString();
|
|
|
|
const response = await fetch(url);
|
|
const data = await response.json();
|
|
displayResults(data, false);
|
|
isLoading = false;
|
|
return;
|
|
}
|
|
|
|
// Load both videos and shorts with current category, sort, and region
|
|
await loadTrending(true);
|
|
|
|
|
|
// Also reload shorts to match category
|
|
if (typeof loadShorts === 'function') {
|
|
loadShorts();
|
|
}
|
|
|
|
// Render pagination
|
|
if (typeof renderPagination === 'function') {
|
|
renderPagination();
|
|
}
|
|
}
|
|
|
|
// Load more videos
|
|
async function loadMore() {
|
|
currentPage++;
|
|
await loadTrending(false);
|
|
}
|
|
|
|
// Load trending videos
|
|
async function loadTrending(reset = true) {
|
|
if (isLoading && reset) isLoading = false;
|
|
|
|
const resultsArea = document.getElementById('resultsArea');
|
|
const loadMoreArea = document.getElementById('loadMoreArea');
|
|
const loadMoreBtn = document.getElementById('loadMoreBtn');
|
|
|
|
if (!resultsArea) return; // Exit if not on home page
|
|
|
|
isLoading = true;
|
|
if (!reset && loadMoreBtn) {
|
|
loadMoreBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading...';
|
|
} else if (reset) {
|
|
resultsArea.innerHTML = renderSkeleton();
|
|
}
|
|
|
|
try {
|
|
const regionValue = window.currentRegion || 'vietnam';
|
|
|
|
// For 'all' category, use new homepage API with personalization
|
|
if (currentCategory === 'all') {
|
|
// Build personalization params from localStorage
|
|
const history = JSON.parse(localStorage.getItem('kv_history') || '[]');
|
|
const subscriptions = JSON.parse(localStorage.getItem('kv_subscriptions') || '[]');
|
|
|
|
const params = new URLSearchParams();
|
|
params.append('region', regionValue);
|
|
params.append('page', currentPage); // Add Pagination
|
|
params.append('_', Date.now()); // Cache buster
|
|
|
|
if (history.length > 0 && reset) { // Only send history on first page for relevance
|
|
const historyIds = history.slice(0, 10).map(v => v.id).filter(Boolean);
|
|
const historyTitles = history.slice(0, 5).map(v => v.title).filter(Boolean);
|
|
const historyChannels = history.slice(0, 5).map(v => v.uploader).filter(Boolean);
|
|
|
|
if (historyIds.length) params.append('history', historyIds.join(','));
|
|
if (historyTitles.length) params.append('titles', historyTitles.join(','));
|
|
if (historyChannels.length) params.append('channels', historyChannels.join(','));
|
|
}
|
|
|
|
if (subscriptions.length > 0 && reset) {
|
|
const subIds = subscriptions.slice(0, 10).map(s => s.id).filter(Boolean);
|
|
if (subIds.length) params.append('subs', subIds.join(','));
|
|
}
|
|
|
|
// Show skeleton for infinite scroll
|
|
if (!reset) {
|
|
const videosSection = document.getElementById('videosSection');
|
|
// Avoid duplicates
|
|
if (!document.getElementById('infinite-scroll-skeleton')) {
|
|
const skelDiv = document.createElement('div');
|
|
skelDiv.id = 'infinite-scroll-skeleton';
|
|
skelDiv.className = 'yt-video-grid';
|
|
skelDiv.style.marginTop = '20px';
|
|
skelDiv.innerHTML = renderSkeleton(); // Reuse existing skeleton generator
|
|
videosSection.appendChild(skelDiv);
|
|
}
|
|
}
|
|
|
|
const response = await fetch(`/api/homepage?${params.toString()}`);
|
|
const data = await response.json();
|
|
|
|
if (data.mode === 'sections' && data.data) {
|
|
// Hide the grid-based resultsArea and render sections to parent
|
|
resultsArea.style.display = 'none';
|
|
const videosSection = document.getElementById('videosSection');
|
|
|
|
if (reset) {
|
|
// Remove previous sections if reset
|
|
videosSection.querySelectorAll('.yt-homepage-section').forEach(el => el.remove());
|
|
}
|
|
|
|
// Remove infinite scroll skeleton if it exists
|
|
const existingSkeleton = document.getElementById('infinite-scroll-skeleton');
|
|
if (existingSkeleton) existingSkeleton.remove();
|
|
|
|
// Append new sections (for Infinite Scroll)
|
|
renderHomepageSections(data.data, videosSection, history);
|
|
isLoading = false;
|
|
hasMore = data.data.length > 0; // Continue if we got sections
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Fallback: Original trending logic for category pages
|
|
const sortValue = window.currentSort || 'month';
|
|
const cb = reset ? `&_=${Date.now()}` : '';
|
|
|
|
const response = await fetch(`/api/trending?category=${currentCategory}&page=${currentPage}&sort=${sortValue}®ion=${regionValue}${cb}`);
|
|
const data = await response.json();
|
|
|
|
if (data.error) {
|
|
console.error('Trending error:', data.error);
|
|
if (reset) {
|
|
resultsArea.innerHTML = renderNoContent(`Error: ${data.error}`, 'Something went wrong');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (data.mode === 'sections') {
|
|
if (reset) resultsArea.innerHTML = '';
|
|
|
|
// Flatten all section videos into a single unified grid
|
|
// User requested single vertical scroll instead of per-section carousels
|
|
let allVideos = [];
|
|
|
|
data.data.forEach(section => {
|
|
const videos = section.videos || [];
|
|
// Add section info to each video for potential category display
|
|
videos.forEach(video => {
|
|
video._sectionId = section.id;
|
|
video._sectionTitle = section.title;
|
|
});
|
|
allVideos = allVideos.concat(videos);
|
|
});
|
|
|
|
// Render all videos in a unified grid
|
|
allVideos.forEach(video => {
|
|
const card = document.createElement('div');
|
|
card.className = 'yt-video-card';
|
|
|
|
card.innerHTML = `
|
|
<div class="yt-thumbnail-container">
|
|
<img class="yt-thumbnail" src="${video.thumbnail}" loading="lazy" onload="this.classList.add('loaded')" alt="${escapeHtml(video.title)}">
|
|
${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''}
|
|
</div>
|
|
<div class="yt-video-details">
|
|
<div class="yt-channel-avatar">
|
|
${video.uploader ? video.uploader.charAt(0).toUpperCase() : 'Y'}
|
|
</div>
|
|
<div class="yt-video-meta">
|
|
<h3 class="yt-video-title">${escapeHtml(video.title)}</h3>
|
|
<p class="yt-channel-name">${escapeHtml(video.uploader || 'Unknown')}</p>
|
|
<p class="yt-video-stats">${formatViews(video.view_count)} views</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
card.onclick = () => {
|
|
const params = new URLSearchParams({
|
|
v: video.id,
|
|
title: video.title || '',
|
|
uploader: video.uploader || '',
|
|
thumbnail: video.thumbnail || ''
|
|
});
|
|
const dest = `/watch?${params.toString()}`;
|
|
|
|
if (window.navigationManager) {
|
|
window.navigationManager.navigateTo(dest);
|
|
} else {
|
|
window.location.href = dest;
|
|
}
|
|
};
|
|
resultsArea.appendChild(card);
|
|
});
|
|
|
|
if (window.observeImages) window.observeImages();
|
|
return;
|
|
}
|
|
|
|
if (reset) resultsArea.innerHTML = '';
|
|
|
|
if (data.length === 0) {
|
|
if (reset) {
|
|
resultsArea.innerHTML = renderNoContent();
|
|
}
|
|
hasMore = false; // Only stop if we get no results at all
|
|
} else {
|
|
displayResults(data, !reset);
|
|
// Keep loading unless we got 0 videos
|
|
// Multi-category API returns variable amounts, so don't limit by 20
|
|
hasMore = data.length > 0;
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load trending:', e);
|
|
if (reset) {
|
|
resultsArea.innerHTML = `<div class="yt-loader" style="grid-column: 1/-1;"><p style="color:#f00;">Connection error</p></div>`;
|
|
}
|
|
} finally {
|
|
isLoading = false;
|
|
}
|
|
}
|
|
|
|
// Display results with YouTube-style cards
|
|
function displayResults(videos, append = false) {
|
|
const resultsArea = document.getElementById('resultsArea');
|
|
if (!append) resultsArea.innerHTML = '';
|
|
|
|
if (videos.length === 0 && !append) {
|
|
resultsArea.innerHTML = renderNoContent();
|
|
return;
|
|
}
|
|
|
|
videos.forEach(video => {
|
|
const card = document.createElement('div');
|
|
|
|
if (currentCategory === 'shorts') {
|
|
// Render as Short Card (Vertical)
|
|
card.className = 'yt-short-card';
|
|
// Adjust styling for grid view if needed
|
|
card.style.width = '100%';
|
|
card.style.maxWidth = '200px';
|
|
card.innerHTML = `
|
|
<img data-src="${video.thumbnail}" class="yt-short-thumb" style="width:100%; aspect-ratio:9/16; height:auto;">
|
|
<p class="yt-short-title">${escapeHtml(video.title)}</p>
|
|
<p class="yt-short-views">${formatViews(video.view_count)} views</p>
|
|
`;
|
|
} else {
|
|
// Render as Standard Video Card
|
|
card.className = 'yt-video-card';
|
|
card.innerHTML = `
|
|
<div class="yt-thumbnail-container">
|
|
<img class="yt-thumbnail" data-src="${video.thumbnail}" alt="${escapeHtml(video.title)}">
|
|
${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''}
|
|
</div>
|
|
<div class="yt-video-details">
|
|
<div class="yt-channel-avatar">
|
|
${video.uploader ? video.uploader.charAt(0).toUpperCase() : 'Y'}
|
|
</div>
|
|
<div class="yt-video-meta">
|
|
<h3 class="yt-video-title">${escapeHtml(video.title)}</h3>
|
|
<p class="yt-channel-name">
|
|
<a href="/channel/${video.channel_id || video.uploader_id || video.uploader || 'unknown'}"
|
|
class="yt-channel-link"
|
|
style="color:inherit; text-decoration:none;">
|
|
${escapeHtml(video.uploader || 'Unknown')}
|
|
</a>
|
|
</p>
|
|
<p class="yt-video-stats">${formatViews(video.view_count)} views • ${formatDate(video.upload_date)}</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
card.addEventListener('click', (e) => {
|
|
// Prevent navigation if clicking on channel link
|
|
if (e.target.closest('.yt-channel-link')) return;
|
|
|
|
const params = new URLSearchParams({
|
|
v: video.id,
|
|
title: video.title || '',
|
|
uploader: video.uploader || '',
|
|
thumbnail: video.thumbnail || ''
|
|
});
|
|
const dest = `/watch?${params.toString()}`;
|
|
|
|
if (window.navigationManager) {
|
|
window.navigationManager.navigateTo(dest);
|
|
} else {
|
|
window.location.href = dest;
|
|
}
|
|
});
|
|
resultsArea.appendChild(card);
|
|
});
|
|
|
|
if (window.observeImages) window.observeImages();
|
|
}
|
|
|
|
// Format view count (YouTube style)
|
|
function formatViews(views) {
|
|
if (!views) return '0';
|
|
const num = parseInt(views);
|
|
if (num >= 1000000000) return (num / 1000000000).toFixed(1) + 'B';
|
|
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
|
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
|
|
return num.toLocaleString();
|
|
}
|
|
|
|
// Format date (YouTube style: "2 hours ago", "3 days ago", etc.)
|
|
function formatDate(dateStr) {
|
|
if (!dateStr) return 'Recently';
|
|
|
|
// Ensure string
|
|
dateStr = String(dateStr);
|
|
console.log('[Debug] formatDate input:', dateStr);
|
|
|
|
// Handle YYYYMMDD format
|
|
if (/^\d{8}$/.test(dateStr)) {
|
|
const year = dateStr.substring(0, 4);
|
|
const month = dateStr.substring(4, 6);
|
|
const day = dateStr.substring(6, 8);
|
|
dateStr = `${year}-${month}-${day}`;
|
|
}
|
|
|
|
const date = new Date(dateStr);
|
|
console.log('[Debug] Date Logic:', { input: dateStr, parsed: date, valid: !isNaN(date.getTime()) });
|
|
|
|
if (isNaN(date.getTime())) return 'Invalid Date';
|
|
|
|
const now = new Date();
|
|
const diffMs = now - date;
|
|
const diffSec = Math.floor(diffMs / 1000);
|
|
const diffMin = Math.floor(diffSec / 60);
|
|
const diffHour = Math.floor(diffMin / 60);
|
|
const diffDay = Math.floor(diffHour / 24);
|
|
const diffWeek = Math.floor(diffDay / 7);
|
|
const diffMonth = Math.floor(diffDay / 30);
|
|
const diffYear = Math.floor(diffDay / 365);
|
|
|
|
if (diffYear > 0) return `${diffYear} year${diffYear > 1 ? 's' : ''} ago`;
|
|
if (diffMonth > 0) return `${diffMonth} month${diffMonth > 1 ? 's' : ''} ago`;
|
|
if (diffWeek > 0) return `${diffWeek} week${diffWeek > 1 ? 's' : ''} ago`;
|
|
if (diffDay > 0) return `${diffDay} day${diffDay > 1 ? 's' : ''} ago`;
|
|
if (diffHour > 0) return `${diffHour} hour${diffHour > 1 ? 's' : ''} ago`;
|
|
if (diffMin > 0) return `${diffMin} minute${diffMin > 1 ? 's' : ''} ago`;
|
|
return 'Just now';
|
|
}
|
|
|
|
// Escape HTML to prevent XSS
|
|
function escapeHtml(text) {
|
|
if (!text) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// Sidebar toggle (for mobile and desktop)
|
|
function toggleSidebar() {
|
|
const sidebar = document.getElementById('sidebar');
|
|
const main = document.getElementById('mainContent');
|
|
const overlay = document.querySelector('.yt-sidebar-overlay');
|
|
|
|
if (window.innerWidth <= 1024) {
|
|
// Mobile: slide-in sidebar with overlay
|
|
sidebar.classList.toggle('open');
|
|
if (overlay) {
|
|
overlay.classList.toggle('active', sidebar.classList.contains('open'));
|
|
}
|
|
} else {
|
|
// Desktop: collapse/expand sidebar
|
|
sidebar.classList.toggle('collapsed');
|
|
main.classList.toggle('sidebar-collapsed');
|
|
localStorage.setItem('sidebarCollapsed', sidebar.classList.contains('collapsed'));
|
|
}
|
|
}
|
|
|
|
// Close sidebar when clicking outside (mobile)
|
|
document.addEventListener('click', (e) => {
|
|
const sidebar = document.getElementById('sidebar');
|
|
const menuBtn = document.querySelector('.yt-menu-btn');
|
|
const overlay = document.querySelector('.yt-sidebar-overlay');
|
|
|
|
if (window.innerWidth <= 1024 &&
|
|
sidebar &&
|
|
sidebar.classList.contains('open') &&
|
|
!sidebar.contains(e.target) &&
|
|
menuBtn && !menuBtn.contains(e.target)) {
|
|
sidebar.classList.remove('open');
|
|
if (overlay) {
|
|
overlay.classList.remove('active');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Initialize sidebar state from localStorage to prevent layout shift
|
|
function initSidebarState() {
|
|
const sidebar = document.getElementById('sidebar');
|
|
const main = document.getElementById('mainContent');
|
|
const overlay = document.querySelector('.yt-sidebar-overlay');
|
|
|
|
if (!sidebar || !main) return;
|
|
|
|
// Mobile: always hide sidebar (it will slide in when toggled)
|
|
if (window.innerWidth <= 1024) {
|
|
sidebar.classList.remove('open', 'collapsed');
|
|
main.classList.remove('sidebar-collapsed');
|
|
if (overlay) overlay.classList.remove('active');
|
|
return;
|
|
}
|
|
|
|
// Desktop: Check if we're on watch page
|
|
const isWatchPage = document.querySelector('.yt-watch-layout') !== null;
|
|
|
|
// On watch page, always use mini sidebar for more video space
|
|
if (isWatchPage) {
|
|
sidebar.classList.add('collapsed');
|
|
main.classList.add('sidebar-collapsed');
|
|
return;
|
|
}
|
|
|
|
// On other pages, restore from localStorage for consistent experience
|
|
const savedState = localStorage.getItem('sidebarCollapsed');
|
|
if (savedState === 'true') {
|
|
sidebar.classList.add('collapsed');
|
|
main.classList.add('sidebar-collapsed');
|
|
} else {
|
|
sidebar.classList.remove('collapsed');
|
|
main.classList.remove('sidebar-collapsed');
|
|
}
|
|
}
|
|
|
|
// Re-initialize sidebar state on window resize
|
|
let resizeTimer;
|
|
window.addEventListener('resize', () => {
|
|
clearTimeout(resizeTimer);
|
|
resizeTimer = setTimeout(() => {
|
|
initSidebarState();
|
|
}, 150);
|
|
});
|
|
|
|
// --- Theme Logic ---
|
|
function initTheme() {
|
|
// Check for saved preference
|
|
let savedTheme = localStorage.getItem('theme');
|
|
|
|
// If no saved preference, default to dark theme
|
|
if (!savedTheme) {
|
|
savedTheme = 'dark';
|
|
}
|
|
|
|
setTheme(savedTheme, false); // Initial set without saving (already saved or computed)
|
|
}
|
|
|
|
function setTheme(theme, save = true) {
|
|
document.documentElement.setAttribute('data-theme', theme);
|
|
if (save) {
|
|
localStorage.setItem('theme', theme);
|
|
}
|
|
|
|
// Update UI Buttons (if on settings page)
|
|
const btnLight = document.getElementById('themeBtnLight');
|
|
const btnDark = document.getElementById('themeBtnDark');
|
|
|
|
if (btnLight && btnDark) {
|
|
btnLight.classList.remove('active');
|
|
btnDark.classList.remove('active');
|
|
|
|
if (theme === 'light') btnLight.classList.add('active');
|
|
else btnDark.classList.add('active');
|
|
}
|
|
}
|
|
|
|
// Ensure theme persists on back navigation (BFCache)
|
|
window.addEventListener('pageshow', (event) => {
|
|
// Re-apply theme from storage to ensure it matches user preference
|
|
// even if page was restored from cache with old state
|
|
const savedTheme = localStorage.getItem('theme');
|
|
if (savedTheme) {
|
|
setTheme(savedTheme, false);
|
|
} else {
|
|
initTheme();
|
|
}
|
|
});
|
|
|
|
// Sync across tabs
|
|
window.addEventListener('storage', (event) => {
|
|
if (event.key === 'theme') {
|
|
setTheme(event.newValue, false);
|
|
}
|
|
});
|
|
|
|
// --- Profile Logic ---
|
|
async function updateProfile(e) {
|
|
if (e) e.preventDefault();
|
|
|
|
const displayName = document.getElementById('displayName').value;
|
|
const btn = e.target.querySelector('button');
|
|
const originalText = btn.innerHTML;
|
|
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Saving...';
|
|
|
|
try {
|
|
const response = await fetch('/api/update_profile', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ username: displayName })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
showToast('Profile updated successfully!', 'success');
|
|
// Update UI immediately
|
|
const avatarName = document.querySelector('.yt-avatar');
|
|
if (avatarName) avatarName.title = displayName;
|
|
} else {
|
|
showToast(data.message || 'Update failed', 'error');
|
|
}
|
|
} catch (err) {
|
|
showToast('Network error', 'error');
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.innerHTML = originalText;
|
|
}
|
|
}
|
|
|
|
// --- Local Storage Helpers ---
|
|
function getLibrary(type) {
|
|
return JSON.parse(localStorage.getItem(`kv_${type}`) || '[]');
|
|
}
|
|
|
|
function saveToLibrary(type, item) {
|
|
let lib = getLibrary(type);
|
|
// Filter out nulls/invalid items to self-heal storage
|
|
lib = lib.filter(i => i && i.id);
|
|
|
|
// Avoid duplicates
|
|
if (!lib.some(i => i.id === item.id)) {
|
|
lib.unshift(item); // Add to top
|
|
localStorage.setItem(`kv_${type}`, JSON.stringify(lib));
|
|
if (type !== 'history') {
|
|
showToast(`Saved to ${type}`, 'success');
|
|
}
|
|
}
|
|
}
|
|
|
|
function removeFromLibrary(type, id) {
|
|
let lib = getLibrary(type);
|
|
lib = lib.filter(i => i && i.id !== id);
|
|
localStorage.setItem(`kv_${type}`, JSON.stringify(lib));
|
|
showToast(`Removed from ${type}`, 'info');
|
|
// Refresh if on library page
|
|
if (window.location.pathname === '/my-videos') {
|
|
location.reload();
|
|
}
|
|
}
|
|
|
|
function isInLibrary(type, id) {
|
|
const lib = getLibrary(type);
|
|
return lib.some(i => i && i.id === id);
|
|
}
|
|
|
|
// --- Subscription Logic ---
|
|
function toggleSubscribe(channelId, channelName, avatarUrl, btnElement) {
|
|
event.stopPropagation(); // Prevent card clicks
|
|
|
|
if (isInLibrary('subscriptions', channelId)) {
|
|
removeFromLibrary('subscriptions', channelId);
|
|
if (btnElement) {
|
|
btnElement.classList.remove('subscribed');
|
|
btnElement.innerHTML = 'Subscribe';
|
|
}
|
|
} else {
|
|
saveToLibrary('subscriptions', {
|
|
id: channelId,
|
|
title: channelName,
|
|
thumbnail: avatarUrl,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
if (btnElement) {
|
|
btnElement.classList.add('subscribed');
|
|
btnElement.innerHTML = 'Subscribed';
|
|
}
|
|
}
|
|
}
|
|
|
|
function checkSubscriptionStatus(channelId, btnElement) {
|
|
if (isInLibrary('subscriptions', channelId)) {
|
|
btnElement.classList.add('subscribed');
|
|
btnElement.innerHTML = 'Subscribed';
|
|
}
|
|
}
|
|
|
|
// Load Channel Videos
|
|
async function loadChannelVideos(channelId) {
|
|
const resultsArea = document.getElementById('resultsArea');
|
|
if (!resultsArea) return; // Guard: only works on pages with resultsArea
|
|
|
|
isLoading = true;
|
|
resultsArea.innerHTML = renderSkeleton();
|
|
|
|
try {
|
|
const response = await fetch(`/api/channel?id=${encodeURIComponent(channelId)}`);
|
|
const data = await response.json();
|
|
|
|
if (data.error) {
|
|
resultsArea.innerHTML = renderNoContent(`Error: ${data.error}`, "Could not load channel.");
|
|
return;
|
|
}
|
|
|
|
// Render header
|
|
const headerHtml = `
|
|
<div class="yt-channel-header" style="padding: 24px 0; border-bottom: 1px solid var(--yt-border); margin-bottom: 24px; display: flex; align-items: center; gap: 20px;">
|
|
<div class="yt-channel-avatar-xl" style="width: 80px; height: 80px; border-radius: 50%; background: var(--yt-accent-blue); display: flex; align-items: center; justify-content: center; font-size: 32px; color: white; font-weight: bold;">
|
|
${channelId.startsWith('UC') ? channelId[0] : (data[0]?.uploader?.[0] || 'C')}
|
|
</div>
|
|
<div>
|
|
<h1 style="font-size: 24px; margin: 0 0 8px 0;">${data[0]?.uploader || 'Channel Content'}</h1>
|
|
<p style="color: var(--yt-text-secondary); margin: 0;">${data.length} Videos</p>
|
|
</div>
|
|
</div>
|
|
<div class="yt-video-grid">
|
|
`;
|
|
|
|
// Videos
|
|
const videosHtml = data.map(video => `
|
|
<div class="yt-video-card" onclick="window.navigationManager ? window.navigationManager.navigateTo('/watch?v=${video.id}') : window.location.href='/watch?v=${video.id}'">
|
|
<div class="yt-thumbnail-container">
|
|
<img class="yt-thumbnail" src="${video.thumbnail}" loading="lazy" onload="this.classList.add('loaded')" alt="${escapeHtml(video.title)}">
|
|
${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''}
|
|
</div>
|
|
<div class="yt-video-details">
|
|
<div class="yt-video-meta">
|
|
<h3 class="yt-video-title">${escapeHtml(video.title)}</h3>
|
|
<div class="yt-video-info">
|
|
<span>${formatViews(video.view_count)} views</span>
|
|
<span>• ${formatDate(video.upload_date)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
resultsArea.innerHTML = headerHtml + videosHtml + '</div>';
|
|
|
|
if (window.observeImages) window.observeImages();
|
|
|
|
} catch (e) {
|
|
console.error("Channel Load Error:", e);
|
|
resultsArea.innerHTML = renderNoContent("Failed to load channel", "Please try again later.");
|
|
} finally {
|
|
isLoading = false;
|
|
}
|
|
}
|