kv-tube/templates/watch.html
KV-Tube Deployer 79f69772a0
Some checks failed
Docker Build & Push / build (push) Has been cancelled
v3.1.3: Fix SPA redeclaration errors, update docker-compose for cookies
2026-01-20 07:25:27 +07:00

2129 lines
No EOL
99 KiB
HTML
Executable file

{% extends "layout.html" %}
{% block content %}
<!-- YouTube IFrame Player -->
<!-- Scripts removed -->
<div class="yt-watch-layout">
<!-- Dependencies -->
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<script src="{{ url_for('static', filename='js/artplayer.js') }}?v=debug3"
onload="console.log('ArtPlayer Script Loaded. Global:', window.Artplayer);"></script>
<!-- Player Section -->
<div class="yt-player-section">
<div class="yt-player-container">
<div id="artplayer-app" style="width: 100%; height: 100%;"></div>
<style>
#artplayer-app iframe {
width: 100%;
height: 100%;
border: none;
z-index: 10;
position: relative;
}
</style>
<!-- Loading State (Confined to Player) -->
<div id="loading" class="yt-loader"></div>
</div>
<!-- Info Skeleton -->
<div id="infoSkeleton" style="margin-top:20px;">
<div class="skeleton-line skeleton" style="width:70%; height:28px; margin-bottom:16px;"></div>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
<div style="display:flex; align-items:center; gap:12px;">
<div class="skeleton-avatar skeleton"></div>
<div class="skeleton-line skeleton" style="width:120px; margin:0;"></div>
</div>
<div class="skeleton-line skeleton" style="width:250px; height:36px; border-radius:18px;"></div>
</div>
<div class="skeleton-block skeleton" style="height:100px; border-radius:12px;"></div>
</div>
<!-- Video Info -->
<div class="yt-video-info" id="videoInfo" style="display:none;">
<h1 id="videoTitle">Loading...</h1>
<!-- Actions -->
<div class="yt-video-actions">
<button class="yt-action-btn" id="shareBtn">
<i class="fas fa-share"></i>
Share
</button>
<button class="yt-action-btn" id="downloadBtn" onclick="showDownloadModal(currentVideoData.id)">
<i class="fas fa-download"></i>
Download
</button>
<button class="yt-action-btn" id="saveBtn">
<i class="far fa-bookmark"></i>
Save
</button>
<button class="yt-action-btn" id="loopBtn" onclick="toggleLoop(this)">
<i class="fas fa-redo"></i>
Loop
</button>
<button class="yt-action-btn" id="queueBtn" onclick="addToQueue()" style="position:relative;">
<i class="fas fa-list-ul"></i>
Queue
<span id="queueBadge" class="queue-badge" style="display:none;">0</span>
</button>
<button class="yt-action-btn" id="ccBtn" onclick="toggleCaptions()">
<i class="fas fa-closed-captioning"></i>
CC
</button>
<button class="yt-action-btn" id="summaryBtn" onclick="showSummaryModal()">
<i class="fas fa-magic"></i>
Summarize
</button>
<!-- View Mode Buttons -->
<div class="view-mode-buttons">
<button class="view-mode-btn" id="defaultModeBtn" onclick="setViewMode('default')"
title="Default View">
<i class="fas fa-columns"></i>
</button>
<button class="view-mode-btn active" id="theaterModeBtn" onclick="setViewMode('theater')"
title="Theater Mode">
<i class="fas fa-expand-alt"></i>
</button>
<button class="view-mode-btn" id="pipModeBtn" onclick="togglePiP()" title="Picture-in-Picture">
<i class="fas fa-external-link-square-alt"></i>
</button>
<button class="view-mode-btn" id="fullscreenBtn" onclick="toggleFullscreen()" title="Fullscreen">
<i class="fas fa-expand"></i>
</button>
</div>
</div>
<!-- Summary Box (Hidden by default) -->
<!-- Summary Box removed -->
<!-- Channel Info -->
<div class="yt-channel-info">
<div class="yt-channel-details">
<div class="yt-channel-avatar-lg" id="channelAvatar">
<span id="channelAvatarLetter"></span>
</div>
<div>
<p style="font-weight: 500;" id="channelName">Loading...</p>
<p class="yt-video-stats" id="viewCount">0 views</p>
</div>
</div>
<button class="yt-subscribe-btn" id="subscribeBtn" onclick="toggleSubscribe()">Subscribe</button>
</div>
<!-- AI Summary Box (Enhanced with icons and translation) -->
<div class="yt-summary-box" id="summaryBox"
style="margin-top:16px; padding:20px; background:linear-gradient(135deg, var(--yt-bg-secondary) 0%, rgba(255,0,0,0.05) 100%); border-radius:16px; display:none; border: 1px solid rgba(255,0,0,0.1);">
<!-- Header -->
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
<div style="display:flex; align-items:center; gap:10px;">
<div
style="width:36px; height:36px; background:linear-gradient(135deg, #ff0000, #ff6b6b); border-radius:10px; display:flex; align-items:center; justify-content:center;">
<i class="fas fa-robot" style="color:white; font-size:18px;"></i>
</div>
<div>
<h3 style="margin:0; font-size:16px; font-weight:600; color:var(--yt-text-primary);">AI
Summary</h3>
<span id="summaryLang" style="font-size:11px; color:var(--yt-text-secondary);">🇬🇧
English</span>
</div>
</div>
<div style="display:flex; gap:8px;">
<button id="copySummaryBtn" onclick="copySummaryContent()"
style="padding:6px 12px; background:var(--yt-bg-primary); border:1px solid var(--yt-border); border-radius:20px; color:var(--yt-text-primary); cursor:pointer; font-size:12px; display:flex; align-items:center; gap:4px;"
title="Copy summary to clipboard">
<i class="fas fa-copy"></i> <span>Copy</span>
</button>
<button id="translateBtn" onclick="toggleSummaryTranslation()"
style="padding:6px 12px; background:var(--yt-bg-primary); border:1px solid var(--yt-border); border-radius:20px; color:var(--yt-text-primary); cursor:pointer; font-size:12px; display:flex; align-items:center; gap:4px;">
🇻🇳 <span>Tiếng Việt</span>
</button>
<button onclick="document.getElementById('summaryBox').style.display='none'"
style="width:28px; height:28px; background:var(--yt-bg-primary); border:1px solid var(--yt-border); border-radius:50%; color:var(--yt-text-secondary); cursor:pointer; display:flex; align-items:center; justify-content:center;">
<i class="fas fa-times" style="font-size:12px;"></i>
</button>
</div>
</div>
<!-- Summary Content -->
<div id="summaryContent" style="color:var(--yt-text-secondary); line-height:1.7; font-size:14px;">
<div class="loader" style="margin:20px auto;"></div>
<p style="text-align:center; color:var(--yt-text-secondary);">✨ Generating AI summary...</p>
</div>
<!-- Key Points Section -->
<div id="keyPointsSection"
style="display:none; margin-top:16px; padding-top:16px; border-top:1px solid var(--yt-border);">
<h4
style="margin:0 0 12px 0; font-size:14px; color:var(--yt-text-primary); display:flex; align-items:center; gap:8px;">
<i class="fas fa-lightbulb" style="color:#ffd700;"></i> Key Points
</h4>
<ul id="keyPointsList" style="margin:0; padding-left:20px; color:var(--yt-text-secondary);">
</ul>
</div>
<!-- Footer -->
<div
style="margin-top:16px; padding-top:12px; border-top:1px solid var(--yt-border); display:flex; justify-content:space-between; align-items:center;">
<span style="font-size:11px; color:var(--yt-text-tertiary);">
<i class="fas fa-sparkles" style="margin-right:4px;"></i>Generated by KV-Tube AI
</span>
<button onclick="loadSummaryInline()"
style="font-size:11px; color:var(--yt-text-secondary); background:none; border:none; cursor:pointer;">
<i class="fas fa-sync-alt"></i> Refresh
</button>
</div>
</div>
<!-- Description -->
<div class="yt-description-box" id="descriptionBox" onclick="toggleDescription()">
<p class="yt-description-stats" id="descStats"></p>
<p class="yt-description-text" id="videoDesc">Loading description...</p>
</div>
<!-- Comments Section (Collapsible) -->
<div class="yt-comments-section" id="commentsSection">
<button class="yt-comments-toggle" id="commentsToggle" onclick="toggleComments()">
<div class="yt-comments-preview">
<span id="commentCountDisplay">Comments</span>
<i class="fas fa-chevron-down" id="commentsChevron"></i>
</div>
</button>
<div class="yt-comments-content" id="commentsContent" style="display: none;">
<div class="yt-comments-header">
<h3><span id="commentCount">0</span> Comments</h3>
</div>
<div class="yt-comments-list" id="commentsList">
<!-- Comments loaded here -->
</div>
</div>
</div>
</div>
</div>
<!-- Right Sidebar -->
<!-- Right Sidebar -->
<div class="yt-watch-sidebar">
<!-- Queue Section (Collapsible Dropdown) -->
<div id="queueSection" class="yt-queue-dropdown">
<div class="yt-queue-dropdown-header" onclick="toggleQueueDropdown()">
<span><i class="fas fa-list-ol"></i> Queue (<span id="queueCount">0</span>)</span>
<i class="fas fa-chevron-down" id="queueDropdownChevron"></i>
</div>
<div class="yt-queue-dropdown-content" id="queueDropdownContent">
<div id="queueList">
<!-- Queue items rendered here -->
</div>
</div>
</div>
<!-- Suggested Videos -->
<div class="yt-suggested" id="relatedVideos">
<h3 style="margin-bottom: 16px; font-size: 16px;">Related Videos</h3>
<!-- Skeleton Items for Related -->
<div class="skeleton-related-item" style="display:flex; gap:8px; margin-bottom:12px;">
<div class="skeleton" style="width:140px; aspect-ratio:16/9; border-radius:8px; flex-shrink:0;"></div>
<div style="flex:1;">
<div class="skeleton-line skeleton" style="width:90%; height:14px; margin-bottom:6px;"></div>
<div class="skeleton-line skeleton" style="width:60%; height:12px;"></div>
</div>
</div>
<div class="skeleton-related-item" style="display:flex; gap:8px; margin-bottom:12px;">
<div class="skeleton" style="width:168px; height:94px; border-radius:8px; flex-shrink:0;"></div>
<div style="flex:1;">
<div class="skeleton-line skeleton" style="width:90%; height:14px; margin-bottom:6px;"></div>
<div class="skeleton-line skeleton" style="width:60%; height:12px;"></div>
</div>
</div>
<div class="skeleton-related-item" style="display:flex; gap:8px; margin-bottom:12px;">
<div class="skeleton" style="width:168px; height:94px; border-radius:8px; flex-shrink:0;"></div>
<div style="flex:1;">
<div class="skeleton-line skeleton" style="width:90%; height:14px; margin-bottom:6px;"></div>
<div class="skeleton-line skeleton" style="width:60%; height:12px;"></div>
</div>
</div>
<div class="skeleton-related-item" style="display:flex; gap:8px; margin-bottom:12px;">
<div class="skeleton" style="width:168px; height:94px; border-radius:8px; flex-shrink:0;"></div>
<div style="flex:1;">
<div class="skeleton-line skeleton" style="width:90%; height:14px; margin-bottom:6px;"></div>
<div class="skeleton-line skeleton" style="width:60%; height:12px;"></div>
</div>
</div>
<div class="skeleton-related-item" style="display:flex; gap:8px; margin-bottom:12px;">
<div class="skeleton" style="width:140px; aspect-ratio:16/9; border-radius:8px; flex-shrink:0;"></div>
<div style="flex:1;">
<div class="skeleton-line skeleton" style="width:90%; height:14px; margin-bottom:6px;"></div>
<div class="skeleton-line skeleton" style="width:60%; height:12px;"></div>
</div>
</div>
<!-- Sentinel for Infinite Scroll -->
<div id="relatedSentinel" style="height: 20px; margin-top: 10px;"></div>
</div>
</div>
<!-- Summary Modal -->
<div id="summaryModal" class="yt-modal"
style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.7); z-index:10000; justify-content:center; align-items:center;">
<div class="yt-modal-content"
style="background:var(--yt-bg-primary); border-radius:12px; max-width:600px; width:90%; max-height:80vh; overflow:auto; padding:24px; position:relative;">
<button onclick="closeSummaryModal()"
style="position:absolute; top:12px; right:12px; background:transparent; border:none; color:var(--yt-text-primary); font-size:20px; cursor:pointer;">
<i class="fas fa-times"></i>
</button>
<h2 style="margin-top:0; margin-bottom:16px; color:var(--yt-text-primary);">AI Summary</h2>
<div id="summaryModalContent" style="color:var(--yt-text-secondary); line-height:1.6;">
<!-- Summary content loads here -->
</div>
</div>
</div>
<!-- Watch page styles extracted to external file for better caching -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/modules/watch.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modules/downloads.css') }}">
<!-- WebLLM Styles -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/modules/webllm.css') }}">
<!-- WebLLM Loading Overlay -->
<div id="webllmLoadingOverlay" class="webllm-loading-overlay hidden">
<div class="webllm-header">
<div class="webllm-icon">
<i class="fas fa-robot"></i>
</div>
<div>
<p class="webllm-title">Loading AI Model</p>
<p class="webllm-subtitle">Qwen2-0.5B · Vietnamese Support</p>
</div>
</div>
<div class="webllm-progress-container">
<div class="webllm-progress-bar" id="webllmProgressBar" style="width: 0%"></div>
</div>
<div class="webllm-status">
<span id="webllmStatusText">Initializing...</span>
<span class="webllm-percent" id="webllmPercent">0%</span>
</div>
</div>
<!-- WebLLM Service -->
<script src="{{ url_for('static', filename='js/webllm-service.js') }}"></script>
<script>
var commentsLoaded = false;
// Current video data for Queue/History/Saved - Populated by JS or Server
var currentVideoData = {
id: "{{ request.args.get('v') }}",
title: document.title.replace(' - KV-Tube', ''), // Initial fallback
thumbnail: "", // Will be updated by Artplayer or API
uploader: ""
};
// Initialize immediately from URL params for instant feedback
(function hydrateFromUrl() {
const urlParams = new URLSearchParams(window.location.search);
const title = urlParams.get('title');
const uploader = urlParams.get('uploader');
const thumbnail = urlParams.get('thumbnail');
if (title) {
currentVideoData.title = title;
document.title = title + ' - KV-Tube';
const titleEl = document.getElementById('videoTitle');
if (titleEl) {
titleEl.innerText = title;
document.getElementById('videoInfo').style.display = 'block'; // Show info immediately
document.getElementById('infoSkeleton').style.display = 'none'; // Hide skeleton
}
}
if (uploader) {
currentVideoData.uploader = uploader;
const channelEl = document.getElementById('channelName');
if (channelEl) channelEl.innerText = uploader;
// Set avatar letter
const avatarLetter = document.getElementById('channelAvatarLetter');
if (avatarLetter) avatarLetter.innerText = uploader.charAt(0).toUpperCase();
}
if (thumbnail) {
currentVideoData.thumbnail = thumbnail;
// Show thumbnail as placeholder in player area
const playerContainer = document.getElementById('artplayer-app');
if (playerContainer) {
playerContainer.style.backgroundImage = `url('${thumbnail}')`;
playerContainer.style.backgroundSize = 'cover';
playerContainer.style.backgroundPosition = 'center';
}
}
})();
// Rotation function removed
// End of hydration script
</script>
<script>
// Start of initialization script
// ... (existing hydrating logic) ...
// --- ARTPLAYER IMPLEMENTATION ---
function initRealArtplayer(url, poster, subtitleUrl) {
console.log("Initializing ArtPlayer with URL:", url);
const container = document.getElementById('artplayer-app');
if (!container) return;
// Clean container
container.innerHTML = '';
container.style = "width: 100%; height: 100%;"; // Reset styles
// HLS Integration
function playM3u8(video, url, art) {
if (Hls.isSupported()) {
if (art.hls) art.hls.destroy();
const hls = new Hls();
hls.loadSource(url);
hls.attachMedia(video);
art.hls = hls;
art.on('destroy', () => hls.destroy());
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url;
} else {
art.notice.show = 'Unsupported playback format: m3u8';
}
}
const art = new Artplayer({
container: container,
url: url,
poster: poster,
title: currentVideoData.title || document.title,
volume: 0.5,
isLive: false,
muted: false,
autoplay: true,
pip: true,
autoSize: true,
autoMini: true,
screenshot: true,
setting: true,
loop: true,
flip: true,
playbackRate: true,
aspectRatio: true,
fullscreen: true,
fullscreenWeb: true,
subtitleOffset: true,
miniProgressBar: true,
mutex: true,
backdrop: true,
playsInline: true,
autoPlayback: true,
airplay: true,
theme: '#ff0000',
lang: 'en',
whitelist: ['*'],
moreVideoAttr: {
crossOrigin: 'anonymous',
playsInline: true,
'webkit-playsinline': true
},
customType: {
m3u8: playM3u8
},
plugins: [
// Quality plugin if qualities are available
],
// Quality selector will be added after initialization
});
// Subtitles
if (subtitleUrl) {
art.subtitle.url = subtitleUrl;
art.subtitle.show = true;
}
// Bind global logic
art.on('video:ended', () => {
playNextVideo();
});
// Expose to window
window.player = art;
// --- Quality Selector ---
// Fetch available qualities and add to settings
const videoId = currentVideoData.id;
if (videoId) {
fetch(`/api/stream/qualities?v=${videoId}`)
.then(res => res.json())
.then(data => {
if (data.success && data.qualities && data.qualities.length > 0) {
const qualities = data.qualities;
// Add quality selector to settings
art.setting.add({
html: 'Quality',
icon: '<svg width="22" height="22" viewBox="0 0 24 24"><path fill="currentColor" d="M14.5 4C10.93 4 8.05 6.89 8.05 10.47H5.05L8.55 13.97L12.05 10.47H9.06C9.06 7.44 11.48 5.01 14.5 5.01S19.94 7.44 19.94 10.47C19.94 13.5 17.52 15.93 14.5 15.93H12V16.94H14.5C18.07 16.94 20.95 14.05 20.95 10.47C20.95 6.89 18.07 4 14.5 4Z"/></svg>',
tooltip: 'Auto',
selector: qualities.map((q, index) => ({
html: q.label,
url: q.url,
default: q.default || false,
})),
onSelect: function (item) {
console.log('Quality selected:', item.html);
// Save current position
const currentTime = art.currentTime;
const wasPlaying = art.playing;
// Switch to new quality
art.switchUrl(item.url).then(() => {
// Restore position
art.currentTime = currentTime;
if (wasPlaying) art.play();
art.notice.show = `Quality: ${item.html}`;
});
return item.html;
},
});
console.log('Quality selector added with', qualities.length, 'options');
}
})
.catch(err => {
console.log('Could not load quality options:', err);
});
}
return art;
}
// --- YOUTUBE IFRAME PLAYER ---
function initYouTubePlayer(videoId) {
console.log("Initializing YouTube IFrame Player for:", videoId);
// Check if already initialized for this video
if (window.player && window.player.videoId === videoId) {
console.log("Player already initialized for this video. Skipping.");
return;
}
const container = document.getElementById('artplayer-app');
if (!container) return;
// Clear previous properly
if (window.player && window.player.destroy) {
window.player.destroy();
}
container.innerHTML = '';
// Apply styles
container.style.backgroundColor = '#000';
container.style.display = 'flex';
container.style.alignItems = 'center';
container.style.justifyContent = 'center';
container.style.width = '100%';
container.style.height = '100%';
// Ensure absolute positioning overrides
container.style.position = 'absolute';
container.style.top = '0';
container.style.left = '0';
// Create IFrame
const iframe = document.createElement('iframe');
iframe.width = "100%";
iframe.height = "100%";
// Force display block with !important to override any external CSS
iframe.style.setProperty('display', 'block', 'important');
iframe.style.setProperty('visibility', 'visible', 'important');
iframe.style.setProperty('opacity', '1', 'important');
iframe.style.setProperty('z-index', '20', 'important');
iframe.style.position = 'relative';
iframe.src = `https://www.youtube.com/embed/${videoId}?autoplay=1&modestbranding=1&rel=0&playsinline=1&enablejsapi=1&origin=${window.location.origin}`;
iframe.frameBorder = "0";
iframe.allow = "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture";
iframe.allowFullscreen = true;
container.appendChild(iframe);
// Mock player object
window.player = {
isNative: true,
videoId: videoId,
destroy: () => {
container.innerHTML = '';
window.player = null;
},
loop: false,
play: () => iframe.contentWindow.postMessage('{"event":"command","func":"playVideo","args":""}', '*'),
pause: () => iframe.contentWindow.postMessage('{"event":"command","func":"pauseVideo","args":""}', '*'),
};
// Auto-save history
currentVideoData.thumbnail = `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`;
setTimeout(() => {
const titleEl = document.getElementById('videoTitle');
if (titleEl) currentVideoData.title = titleEl.innerText;
const uploaderEl = document.getElementById('channelName');
if (uploaderEl) currentVideoData.uploader = uploaderEl.innerText;
saveToLibrary('history', currentVideoData);
}, 2000);
return window.player;
}
// --- MASTER INIT FUNCTION ---
// This replaces the old redirected initArtplayer
function initArtplayer(url, poster, subtitleUrl) {
// Check User Preference
const pref = localStorage.getItem('kv_player_pref') || 'artplayer'; // Default to ArtPlayer
// Should we force use of proxy URL for ArtPlayer?
// The 'url' passed here is usually the proxy url from the API
if (pref === 'native') {
// Use YouTube IFrame
const videoId = "{{ request.args.get('v') }}" || new URLSearchParams(window.location.search).get('v');
if (videoId) {
return initYouTubePlayer(videoId);
}
}
// Default: Use ArtPlayer
return initRealArtplayer(url, poster, subtitleUrl);
}
// Redirect native calls (if any still exist)
function initNativePlayer(url, poster) {
const videoId = "{{ request.args.get('v') }}" || new URLSearchParams(window.location.search).get('v');
return initYouTubePlayer(videoId);
}
// Mini player removed per user request
// --- Loop Logic ---
function toggleLoop(btn) {
if (!window.player) {
showToast("Player not ready yet", "error");
return;
}
const isLoop = !window.player.loop;
window.player.loop = isLoop;
// Force style update
if (isLoop) {
btn.classList.add('active');
btn.style.background = '#ff0000';
btn.style.color = '#fff';
btn.innerHTML = '<i class="fas fa-redo"></i> Loop On';
showToast("Loop Mode is ON", "success");
} else {
btn.classList.remove('active');
btn.style.background = '';
btn.style.color = '';
btn.innerHTML = '<i class="fas fa-redo"></i> Loop';
showToast("Loop Mode is OFF");
}
}
// --- Auto Play Next Logic ---
// --- Auto Play Next Logic ---
async function playNextVideo() {
console.log("Video ended. Looking for next video...");
const relatedContainer = document.getElementById('relatedVideos');
if (!relatedContainer) return;
// Helper to get first card
const getNextCard = () => relatedContainer.querySelector('.yt-video-card-horizontal');
let nextCard = getNextCard();
if (!nextCard) {
console.log("No related videos loaded yet. Fetching...");
showToast("Loading next video...", "info");
// Trigger fetch if not already loading
if (!relatedIsLoading) {
await loadMoreRelated();
} else {
// Wait a bit if already loading
await new Promise(r => setTimeout(r, 1500));
}
// Retry getting card
nextCard = getNextCard();
}
if (nextCard) {
console.log("Playing next video...");
showToast("Playing next video...", "info");
nextCard.click();
} else {
console.log("No related videos found even after fetch.");
showToast("No next video found.", "info");
}
}
// --- Queue Logic (Toggle) ---
function addToQueue() {
const btn = document.getElementById('queueBtn');
if (!currentVideoData.id) {
showToast("Video data not ready", "error");
return;
}
let queue = JSON.parse(localStorage.getItem('kv_queue') || '[]');
const existingIndex = queue.findIndex(v => v.id === currentVideoData.id);
if (existingIndex !== -1) {
// Remove from queue (toggle off)
queue.splice(existingIndex, 1);
localStorage.setItem('kv_queue', JSON.stringify(queue));
showToast("Removed from Queue", "info");
// Reset button
if (btn) {
btn.classList.remove('active');
btn.style.background = '';
btn.style.color = '';
btn.innerHTML = '<i class="fas fa-list-ul"></i> Queue';
}
} else {
// Add to queue
queue.push({
id: currentVideoData.id,
title: currentVideoData.title,
thumbnail: currentVideoData.thumbnail,
uploader: currentVideoData.uploader,
duration: currentVideoData.duration
});
localStorage.setItem('kv_queue', JSON.stringify(queue));
showToast("Added to Queue", "success");
// Active button
if (btn) {
btn.classList.add('active');
btn.style.background = '#ff0000';
btn.style.color = '#fff';
btn.innerHTML = '<i class="fas fa-check"></i> In Queue';
}
updateQueueCount(); // Update count in dropdown
updateQueueBadge(); // Update button badge
}
}
// Check if current video is already in queue on load
function updateQueueButtonState() {
const btn = document.getElementById('queueBtn');
const badge = document.getElementById('queueBadge');
if (!btn) return;
const queue = JSON.parse(localStorage.getItem('kv_queue') || '[]');
const isInQueue = currentVideoData.id && queue.some(v => v.id === currentVideoData.id);
// Update badge with total queue count
if (badge) {
if (queue.length > 0) {
badge.style.display = 'flex';
badge.innerText = queue.length;
} else {
badge.style.display = 'none';
}
}
if (isInQueue) {
btn.classList.add('active');
btn.style.background = '#ff0000';
btn.style.color = '#fff';
btn.innerHTML = '<i class="fas fa-check"></i> In Queue <span id="queueBadge" class="queue-badge" style="display:flex;">' + queue.length + '</span>';
} else {
btn.classList.remove('active');
btn.style.background = '';
btn.style.color = '';
btn.innerHTML = '<i class="fas fa-list-ul"></i> Queue' + (queue.length > 0 ? ' <span id="queueBadge" class="queue-badge" style="display:flex;">' + queue.length + '</span>' : '');
}
}
// --- Save to Library (Local Storage) ---
// Note: Named toggleSaveToLibrary to avoid shadowing global saveToLibrary(type, item) in main.js
function toggleSaveToLibrary() {
const btn = document.getElementById('saveBtn');
if (!currentVideoData.id) {
showToast("Video data not ready", "error");
return;
}
let saved = JSON.parse(localStorage.getItem('kv_saved') || '[]');
const existingIndex = saved.findIndex(v => v.id === currentVideoData.id);
if (existingIndex !== -1) {
// Remove (toggle off)
saved.splice(existingIndex, 1);
localStorage.setItem('kv_saved', JSON.stringify(saved));
showToast("Removed from Library", "info");
if (btn) {
btn.classList.remove('active');
btn.style.background = '';
btn.style.color = '';
btn.innerHTML = '<i class="far fa-bookmark"></i> Save';
}
} else {
// Add
saved.push({
id: currentVideoData.id,
title: currentVideoData.title,
thumbnail: currentVideoData.thumbnail,
uploader: currentVideoData.uploader,
savedAt: new Date().toISOString()
});
localStorage.setItem('kv_saved', JSON.stringify(saved));
showToast("Saved to Library", "success");
if (btn) {
btn.classList.add('active');
btn.style.background = '#ff0000';
btn.style.color = '#fff';
btn.innerHTML = '<i class="fas fa-bookmark"></i> Saved';
}
}
}
function updateSaveButtonState() {
const btn = document.getElementById('saveBtn');
if (!btn || !currentVideoData.id) return;
const saved = JSON.parse(localStorage.getItem('kv_saved') || '[]');
const isSaved = saved.some(v => v.id === currentVideoData.id);
if (isSaved) {
btn.classList.add('active');
btn.style.background = '#ff0000';
btn.style.color = '#fff';
btn.innerHTML = '<i class="fas fa-bookmark"></i> Saved';
}
}
// --- Subscribe Logic ---
function toggleSubscribe() {
const btn = document.getElementById('subscribeBtn');
// Get channel info from current video data
const channelName = document.getElementById('channelName')?.innerText || currentVideoData.uploader;
if (!channelName || channelName === 'Loading...') {
showToast("Channel info not ready yet", "error");
return;
}
// Try to get channel ID from the avatar link or construct from name
const avatarEl = document.getElementById('channelAvatar');
let channelId = avatarEl?.onclick ? avatarEl.getAttribute('data-channel-id') : null;
// If no channel ID stored, use channel name as ID (fallback)
if (!channelId) {
channelId = channelName.replace(/\s+/g, '');
}
let subscriptions = JSON.parse(localStorage.getItem('kv_subscriptions') || '[]');
const existingIndex = subscriptions.findIndex(s => s.id === channelId || s.title === channelName);
if (existingIndex !== -1) {
// Unsubscribe (toggle off)
subscriptions.splice(existingIndex, 1);
localStorage.setItem('kv_subscriptions', JSON.stringify(subscriptions));
showToast("Unsubscribed from " + channelName);
if (btn) {
btn.classList.remove('subscribed');
btn.style.background = '';
btn.style.color = '';
btn.innerHTML = 'Subscribe';
}
} else {
// Subscribe
const avatarLetter = document.getElementById('channelAvatarLetter')?.innerText || channelName.charAt(0).toUpperCase();
subscriptions.push({
id: channelId,
title: channelName,
thumbnail: null, // Could be fetched from API if available
letter: avatarLetter
});
localStorage.setItem('kv_subscriptions', JSON.stringify(subscriptions));
showToast("Subscribed to " + channelName, "success");
if (btn) {
btn.classList.add('subscribed');
btn.style.background = 'var(--yt-bg-secondary)';
btn.style.color = 'var(--yt-text-secondary)';
btn.innerHTML = '<i class="fas fa-bell"></i> Subscribed';
}
}
}
function updateSubscribeButtonState() {
const btn = document.getElementById('subscribeBtn');
if (!btn) return;
const channelName = document.getElementById('channelName')?.innerText;
if (!channelName || channelName === 'Loading...') return;
const subscriptions = JSON.parse(localStorage.getItem('kv_subscriptions') || '[]');
const isSubscribed = subscriptions.some(s => s.title === channelName);
if (isSubscribed) {
btn.classList.add('subscribed');
btn.style.background = 'var(--yt-bg-secondary)';
btn.style.color = 'var(--yt-text-secondary)';
btn.innerHTML = '<i class="fas fa-bell"></i> Subscribed';
} else {
btn.classList.remove('subscribed');
btn.style.background = '';
btn.style.color = '';
btn.innerHTML = 'Subscribe';
}
}
// --- View Mode Functions ---
function setViewMode(mode) {
const layout = document.querySelector('.yt-watch-layout');
const defaultBtn = document.getElementById('defaultModeBtn');
const theaterBtn = document.getElementById('theaterModeBtn');
if (mode === 'default') {
layout.classList.add('default-mode');
defaultBtn.classList.add('active');
theaterBtn.classList.remove('active');
} else {
layout.classList.remove('default-mode');
defaultBtn.classList.remove('active');
theaterBtn.classList.add('active');
}
// Save preference
localStorage.setItem('kv_view_mode', mode);
}
function togglePiP() {
const video = document.querySelector('video');
if (!video) {
showToast('Video not ready', 'error');
return;
}
if (document.pictureInPictureElement) {
document.exitPictureInPicture();
document.getElementById('pipModeBtn').classList.remove('active');
} else if (document.pictureInPictureEnabled) {
video.requestPictureInPicture();
document.getElementById('pipModeBtn').classList.add('active');
} else {
showToast('PiP not supported', 'info');
}
}
function toggleFullscreen() {
const container = document.querySelector('.yt-player-container');
const btn = document.getElementById('fullscreenBtn');
if (document.fullscreenElement) {
document.exitFullscreen();
btn.classList.remove('active');
} else {
container.requestFullscreen();
btn.classList.add('active');
}
}
// Initialize view mode from localStorage
document.addEventListener('DOMContentLoaded', () => {
// On mobile, always use theater mode for better viewing
const isMobile = window.innerWidth <= 1024;
const savedMode = isMobile ? 'theater' : (localStorage.getItem('kv_view_mode') || 'theater');
setViewMode(savedMode);
// Listen for PiP exit
document.addEventListener('leavepictureinpicture', () => {
document.getElementById('pipModeBtn').classList.remove('active');
});
// Listen for fullscreen exit
document.addEventListener('fullscreenchange', () => {
if (!document.fullscreenElement) {
document.getElementById('fullscreenBtn').classList.remove('active');
}
});
});
// --- Download Video ---
async function downloadVideo() {
const videoId = "{{ video_id }}";
const btn = document.getElementById('downloadBtn');
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Preparing...';
btn.disabled = true;
try {
const response = await fetch(`/api/download?v=${videoId}`);
const data = await response.json();
if (data.url) {
// Open direct MP4 URL
window.open(data.url, '_blank');
showToast("Download started!", "success");
} else {
showToast(data.error || "Could not get download link", "error");
}
} catch (e) {
showToast("Download failed", "error");
} finally {
btn.innerHTML = '<i class="fas fa-download"></i> Download';
btn.disabled = false;
}
}
// --- Queue Dropdown ---
function toggleQueueDropdown() {
const content = document.getElementById('queueDropdownContent');
const chevron = document.getElementById('queueDropdownChevron');
if (content.classList.contains('expanded')) {
content.classList.remove('expanded');
chevron.classList.remove('rotated');
} else {
content.classList.add('expanded');
chevron.classList.add('rotated');
renderQueueList();
}
}
function renderQueueList() {
const list = document.getElementById('queueList');
const queue = JSON.parse(localStorage.getItem('kv_queue') || '[]');
if (!list) return;
if (queue.length === 0) {
list.innerHTML = '<p class="yt-queue-empty">Queue is empty. Add videos using the Queue button.</p>';
return;
}
list.innerHTML = queue.map((item, index) => `
<div class="yt-queue-item" onclick="window.location.href='/watch?v=${item.id}'">
<img src="${item.thumbnail}" loading="lazy">
<div class="yt-queue-item-info">
<div class="yt-queue-item-title">${escapeHtml(item.title)}</div>
<div class="yt-queue-item-uploader">${escapeHtml(item.uploader || 'Unknown')}</div>
</div>
<button class="yt-queue-remove-btn" onclick="event.stopPropagation(); removeFromQueueDropdown(${index})">
<i class="fas fa-trash-alt"></i>
</button>
</div>
`).join('');
}
function removeFromQueueDropdown(index) {
let queue = JSON.parse(localStorage.getItem('kv_queue') || '[]');
const removedItem = queue[index];
queue.splice(index, 1);
localStorage.setItem('kv_queue', JSON.stringify(queue));
renderQueueList();
updateQueueCount();
updateQueueBadge();
// Update Add/Remove Queue button state if looking at the removed video
if (currentVideoData && removedItem && currentVideoData.id === removedItem.id) {
updateQueueButtonState();
}
showToast("Removed from Queue", "info");
}
function updateQueueCount() {
const countEl = document.getElementById('queueCount');
if (countEl) {
const queue = JSON.parse(localStorage.getItem('kv_queue') || '[]');
countEl.innerText = queue.length;
}
}
function updateQueueBadge() {
const badge = document.getElementById('queueBadge');
if (badge) {
const queue = JSON.parse(localStorage.getItem('kv_queue') || '[]');
if (queue.length > 0) {
badge.style.display = 'flex';
badge.innerText = queue.length;
} else {
badge.style.display = 'none';
}
}
}
function toggleDescription() {
const desc = document.getElementById('videoDesc');
desc.style.webkitLineClamp = desc.style.webkitLineClamp === 'unset' ? '3' : 'unset';
}
function toggleComments() {
const content = document.getElementById('commentsContent');
const chevron = document.getElementById('commentsChevron');
const videoId = "{{ video_id }}";
if (content.style.display === 'none') {
content.style.display = 'block';
chevron.classList.add('rotated');
// Load comments only once
if (!commentsLoaded) {
loadComments(videoId);
commentsLoaded = true;
}
} else {
content.style.display = 'none';
chevron.classList.remove('rotated');
}
}
function saveToLocalHistory(item) {
let history = JSON.parse(localStorage.getItem('kv_history') || '[]');
// Remove existing if present (to move to top)
history = history.filter(v => v.id !== item.id);
// Add to front
history.unshift(item);
// Limit to 50
if (history.length > 50) history.pop();
localStorage.setItem('kv_history', JSON.stringify(history));
}
async function loadComments(videoId) {
const commentsList = document.getElementById('commentsList');
commentsList.innerHTML = Array(3).fill(0).map(() => `
<div class="skeleton-comment">
<div class="skeleton-comment-avatar skeleton"></div>
<div class="skeleton-comment-body">
<div class="skeleton-line skeleton" style="width: 30%;"></div>
<div class="skeleton-line skeleton" style="width: 80%;"></div>
<div class="skeleton-line skeleton" style="width: 60%;"></div>
</div>
</div>
`).join('');
try {
const response = await fetch(`/api/comments?v=${videoId}`);
const data = await response.json();
document.getElementById('commentCount').innerText = formatViews(data.count);
document.getElementById('commentCountDisplay').innerText = `${formatViews(data.count)} Comments`;
commentsList.innerHTML = '';
if (data.comments && data.comments.length > 0) {
data.comments.forEach(comment => {
const commentEl = document.createElement('div');
commentEl.className = 'yt-comment';
commentEl.innerHTML = `
<div class="yt-comment-avatar">
${comment.author_thumbnail
? `<img src="${comment.author_thumbnail}" alt="">`
: escapeHtml(comment.author.charAt(0).toUpperCase())
}
</div>
<div class="yt-comment-content">
<div class="yt-comment-header">
${comment.is_pinned ? '<span class="yt-pinned-badge">📌 Pinned</span>' : ''}
<span class="yt-comment-author">${escapeHtml(comment.author)}</span>
<span class="yt-comment-time">${comment.time || ''}</span>
</div>
<p class="yt-comment-text">${escapeHtml(comment.text)}</p>
<div class="yt-comment-actions">
<button class="yt-comment-action">
<i class="fas fa-thumbs-up"></i>
${comment.likes > 0 ? formatViews(comment.likes) : ''}
</button>
<button class="yt-comment-action">
<i class="fas fa-thumbs-down"></i>
</button>
</div>
</div>
`;
commentsList.appendChild(commentEl);
});
} else {
commentsList.innerHTML = `<p class="yt-no-comments">Comments are disabled or unavailable for this video.</p>`;
}
} catch (e) {
console.error('Error loading comments:', e);
commentsList.innerHTML = `<p class="yt-no-comments">Could not load comments.</p>`;
}
}
// Initialize Watch Page Logic
(async function initWatchPage() {
const videoType = "{{ video_type }}";
const loading = document.getElementById('loading');
const infoSkeleton = document.getElementById('infoSkeleton');
const videoInfo = document.getElementById('videoInfo');
const videoId = "{{ video_id }}";
// If local video, init immediately
if (videoType === 'local') {
const player = initArtplayer("{{ src }}", "");
document.getElementById('videoTitle').innerText = "{{ title }}";
document.getElementById('channelName').innerText = "Local Video";
document.getElementById('channelAvatarLetter').innerText = 'L';
loading.style.display = 'none';
if (infoSkeleton) infoSkeleton.style.display = 'none';
videoInfo.style.display = 'block';
document.getElementById('commentsSection').style.display = 'none';
return;
}
// --- PLAYER INITIALIZATION LOGIC ---
const pref = localStorage.getItem('kv_player_pref') || 'artplayer';
// Helper to render metadata once fetched
function renderMetadata(data) {
// Update Metadata
document.getElementById('videoTitle').innerText = data.title || 'Untitled';
currentVideoTitle = data.title; // Set this EARLY for related videos fetch
// Set window title
document.title = (data.title || 'Video') + ' - KV-Tube';
const channelLink = data.uploader_id ? `/channel/${data.uploader_id}` : (data.channel_id ? `/channel/${data.channel_id}` : `/channel/${data.uploader || 'unknown'}`);
const channelNameEl = document.getElementById('channelName');
channelNameEl.innerHTML = `<a href="${channelLink}" style="color:inherit; text-decoration:none;">${data.uploader || 'Unknown'}</a>`;
const avatarEl = document.getElementById('channelAvatar');
avatarEl.style.cursor = 'pointer';
avatarEl.onclick = () => window.location.href = channelLink;
document.getElementById('viewCount').innerText = formatViews(data.view_count) + ' views';
document.getElementById('videoDesc').innerText = data.description || 'No description';
document.getElementById('descStats').innerText = `${formatViews(data.view_count)} views • ${formatDate(data.upload_date)}`;
// Update global data (merging with existing ID)
currentVideoData = {
...currentVideoData,
title: data.title,
thumbnail: `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`,
uploader: data.uploader || 'Unknown',
channel_id: data.channel_id || data.uploader_id || '',
duration: data.duration,
stream_url: data.stream_url // Store for download/reference
};
// Update download button if exists
const dlBtn = document.getElementById('downloadBtn');
if (dlBtn && data.stream_url) dlBtn.href = data.stream_url;
document.getElementById('channelAvatarLetter').innerText = (data.uploader || '?').charAt(0).toUpperCase();
// Update states
updateSaveButtonState();
updateQueueButtonState();
updateSubscribeButtonState();
// Related
const relatedContainer = document.getElementById('relatedVideos');
// Ensure we don't wipe if we already rendered (unlikely but safe)
if (relatedContainer.querySelectorAll('.yt-video-card-horizontal').length === 0) {
relatedContainer.innerHTML = '<h3 style="margin-bottom: 16px; font-size: 16px;">Related Videos</h3>';
if (data.related && data.related.length > 0) {
renderRelated(data.related);
} else {
relatedPage = 1;
loadMoreRelated();
}
// Add Sentinel
const sentinel = document.createElement('div');
sentinel.id = 'relatedSentinel';
sentinel.style.height = '20px';
relatedContainer.appendChild(sentinel);
setupRelatedObserver();
}
if (infoSkeleton) infoSkeleton.style.display = 'none';
videoInfo.style.display = 'block';
}
// Start Data Fetch
const dataPromise = fetch(`/api/get_stream_info?v=${videoId}`)
.then(r => {
if (!r.ok) throw new Error("Metadata fetch failed");
return r.json();
});
if (pref === 'native') {
// --- FAST PATH: Native Player ---
console.log("Preference: Native. Init IFrame immediately.");
initYouTubePlayer(videoId);
loading.style.display = 'none';
// Render metadata when ready
if (infoSkeleton) infoSkeleton.style.display = 'block';
dataPromise
.then(data => {
if (data.error) throw new Error(data.error);
renderMetadata(data);
})
.catch(e => {
console.warn("Metadata load failed for Native player:", e);
if (infoSkeleton) infoSkeleton.style.display = 'none';
// Fallbacks are handled by hydrateFromUrl mostly
});
} else {
// --- ARTPLAYER PATH ---
// Must wait for stream URL
console.log("Preference: ArtPlayer. Fetching stream info...");
if (infoSkeleton) infoSkeleton.style.display = 'block';
dataPromise
.then(data => {
if (data.error) throw new Error(data.error);
// Initialize ArtPlayer
if (data.stream_url) {
// Ensure poster is a valid string (fallback to generated thumbnail or empty string)
const posterUrl = data.thumbnail || `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg` || "";
initArtplayer(data.stream_url, posterUrl, data.subtitleUrl);
} else {
throw new Error("No stream URL in metadata");
}
loading.style.display = 'none';
renderMetadata(data);
})
.catch(e => {
console.error("ArtPlayer init failed:", e);
showToast("Failed to load video: " + e.message, "error");
loading.style.display = 'none';
if (infoSkeleton) infoSkeleton.style.display = 'none';
videoInfo.style.display = 'block';
// Show error message in player area
const container = document.getElementById('artplayer-app');
if (container) {
container.innerHTML = `
<div style="display: flex; align-items: center; justify-content: center; height: 100%; background: #000; color: #fff; flex-direction: column; padding: 20px; text-align: center;">
<i class="fas fa-exclamation-triangle" style="font-size: 48px; margin-bottom: 16px; color: #ff0000;"></i>
<h3 style="margin-bottom: 8px;">Failed to load video</h3>
<p style="color: #aaa;">${e.message || 'Unknown error'}</p>
<button onclick="location.reload()" style="margin-top: 16px; padding: 8px 16px; background: #ff0000; color: #fff; border: none; border-radius: 4px; cursor: pointer;">Retry</button>
</div>
`;
}
// Use hydrated data from URL params for metadata
const urlParams = new URLSearchParams(window.location.search);
const fallbackTitle = urlParams.get('title') || currentVideoData.title || 'Video';
const fallbackUploader = urlParams.get('uploader') || currentVideoData.uploader || 'Unknown';
document.getElementById('videoDesc').innerText = 'Description not available (API error)';
document.getElementById('descStats').innerText = '';
document.getElementById('videoTitle').innerText = fallbackTitle;
document.getElementById('channelName').innerText = fallbackUploader;
document.getElementById('channelAvatarLetter').innerText = fallbackUploader.charAt(0).toUpperCase();
updateSaveButtonState();
updateQueueButtonState();
currentVideoData.title = fallbackTitle;
currentVideoData.uploader = fallbackUploader;
currentVideoData.thumbnail = `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`;
// Load related videos
const relatedContainer = document.getElementById('relatedVideos');
relatedContainer.innerHTML = '<h3 style="margin-bottom: 16px; font-size: 16px;">Related Videos</h3>';
relatedPage = 1;
currentVideoTitle = fallbackTitle;
loadMoreRelated();
const sentinel = document.createElement('div');
sentinel.id = 'relatedSentinel';
sentinel.style.height = '20px';
relatedContainer.appendChild(sentinel);
setupRelatedObserver();
});
}
// OLD LOGIC BELOW IS SKIPPED
/*
// Retry Configuration
const MAX_RETRIES = 2; // Try 3 times total
let retryCount = 0;
while (retryCount <= MAX_RETRIES) {
...
const channelLink = data.uploader_id ? `/channel/${data.uploader_id}` : (data.channel_id ? `/channel/${data.channel_id}` : `/channel/${data.uploader || 'unknown'}`);
document.getElementById('videoTitle').innerText = data.title || 'Untitled';
const channelNameEl = document.getElementById('channelName');
channelNameEl.innerHTML = `<a href="${channelLink}" style="color:inherit; text-decoration:none;">${data.uploader || 'Unknown'}</a>`;
// Make avatar clickable too
const avatarEl = document.getElementById('channelAvatar');
avatarEl.style.cursor = 'pointer';
avatarEl.onclick = () => window.location.href = channelLink;
document.getElementById('viewCount').innerText = formatViews(data.view_count) + ' views';
document.getElementById('videoDesc').innerText = data.description || 'No description';
document.getElementById('descStats').innerText = `${formatViews(data.view_count)} views • ${data.upload_date || 'Recently'}`;
document.getElementById('downloadBtn').href = data.stream_url;
const uploaderName = data.uploader || 'Unknown';
document.getElementById('channelAvatarLetter').innerText = uploaderName.charAt(0).toUpperCase();
// Update subscribe button state based on stored subscriptions
updateSubscribeButtonState();
// Save to History (Local & Server)
const historyItem = {
id: videoId,
title: data.title,
thumbnail: `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`,
timestamp: new Date().toISOString(),
uploader: data.uploader
};
// 1. Save Local
saveToLocalHistory(historyItem);
// 2. Save Server (if logged in - fail silently if 401/403)
fetch('/api/save_video', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: videoId,
title: data.title,
thumbnail: `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`,
type: 'history'
})
}).catch(() => { }); // Ignore errors for anon users
// Save Button - Local Storage based
// Save Button handler is setup in DOMContentLoaded below
// Just update state here
// document.getElementById('saveBtn').onclick setup moved to line ~1600
// Check if already saved
updateSaveButtonState();
document.getElementById('shareBtn').onclick = () => {
navigator.clipboard.writeText(window.location.href);
showToast('Link copied to clipboard!', 'success');
};
// Related Videos
// Related Videos
const relatedContainer = document.getElementById('relatedVideos');
// Clear but keep header
relatedContainer.innerHTML = '<h3 style="margin-bottom: 16px; font-size: 16px;">Related Videos</h3>';
if (data.related && data.related.length > 0) {
renderRelated(data.related);
} else {
// No initial related videos, trigger load immediately on mobile
relatedPage = 1; // Start from page 1
setTimeout(() => loadMoreRelated(), 500);
}
// Add Sentinel for infinite scroll
const sentinel = document.createElement('div');
sentinel.id = 'relatedSentinel';
sentinel.style.height = '20px';
relatedContainer.appendChild(sentinel);
// Setup Observer
setupRelatedObserver();
// Set global title for pagination
currentVideoTitle = data.title;
if (data.related && data.related.length > 0) {
relatedPage = 2; // Next page is 2 if we had initial data
}
*/
// End OLD LOGIC
})(); // End initWatchPage IIFE
function formatViews(views) {
if (!views) return '0';
const num = parseInt(views);
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(0) + 'K';
return num.toLocaleString();
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async function summarizeVideo() {
const videoId = "{{ video_id }}";
const btn = document.getElementById('summarizeBtn');
const box = document.getElementById('summaryBox');
const text = document.getElementById('summaryText');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Generating...';
box.style.display = 'block';
text.innerText = 'Analyzing transcript and extracting key insights...';
try {
const response = await fetch(`/api/summarize?v=${videoId}`);
const data = await response.json();
if (data.success) {
text.innerText = data.summary;
} else {
text.innerText = data.message || 'Could not generate summary.';
}
} catch (e) {
text.innerText = 'Network error during summarization.';
}
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-magic"></i> Summarize with AI';
}
// --- Save Button Logic ---
document.addEventListener('DOMContentLoaded', () => {
const saveBtn = document.getElementById('saveBtn');
if (saveBtn) {
// Check initial state
if (isInLibrary('saved', currentVideoData.id)) {
saveBtn.innerHTML = '<i class="fas fa-bookmark"></i> Saved';
saveBtn.classList.add('active');
}
saveBtn.onclick = () => {
console.log("[Debug] Save Clicked. Current Data:", currentVideoData);
if (!currentVideoData || !currentVideoData.id) {
showToast("Error: Video data not ready yet", "error");
return;
}
// Ensure data is up to date
const titleEl = document.getElementById('videoTitle');
if (titleEl) currentVideoData.title = titleEl.innerText;
const uploaderEl = document.getElementById('channelName');
if (uploaderEl) currentVideoData.uploader = uploaderEl.innerText;
console.log("[Debug] Saving:", currentVideoData);
if (isInLibrary('saved', currentVideoData.id)) {
removeFromLibrary('saved', currentVideoData.id);
saveBtn.innerHTML = '<i class="far fa-bookmark"></i> Save';
saveBtn.classList.remove('active');
} else {
saveToLibrary('saved', currentVideoData);
saveBtn.innerHTML = '<i class="fas fa-bookmark"></i> Saved';
saveBtn.classList.add('active');
}
};
}
// --- Subscribe Button Logic ---
const subBtn = document.getElementById('subscribeBtn');
if (subBtn) {
const updateSubState = () => {
// Check against ID or Uploader name (fallback)
const key = currentVideoData.channel_id || currentVideoData.uploader;
if (!key) return;
if (isInLibrary('subscriptions', key)) {
subBtn.innerHTML = '<i class="fas fa-check-circle"></i> Subscribed';
subBtn.classList.add('subscribed');
} else {
subBtn.innerHTML = '<i class="fas fa-user-plus"></i> Subscribe';
subBtn.classList.remove('subscribed');
}
};
// Polling/Delay check as data populates
setTimeout(updateSubState, 1000);
setTimeout(updateSubState, 3000);
subBtn.onclick = () => {
if (!currentVideoData.uploader) {
showToast("Channel data not ready", "error");
return;
}
// Capture avatar image or fallback to letter content
const avatarImg = document.querySelector('#channelAvatar img');
const avatarLetter = document.getElementById('channelAvatarLetter');
const channelItem = {
id: currentVideoData.channel_id || currentVideoData.uploader,
title: currentVideoData.uploader,
thumbnail: avatarImg ? avatarImg.src : '',
letter: avatarLetter ? avatarLetter.innerText : (currentVideoData.uploader[0] || '?'),
type: 'channel'
};
if (isInLibrary('subscriptions', channelItem.id)) {
removeFromLibrary('subscriptions', channelItem.id);
subBtn.innerHTML = '<i class="fas fa-user-plus"></i> Subscribe';
subBtn.classList.remove('subscribed');
} else {
saveToLibrary('subscriptions', channelItem);
subBtn.innerHTML = '<i class="fas fa-check-circle"></i> Subscribed';
subBtn.classList.add('subscribed');
}
};
}
});
// --- Related Videos Infinite Scroll Functions ---
var currentVideoTitle = '';
var relatedPage = 1;
var relatedIsLoading = false;
function setupRelatedObserver() {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !relatedIsLoading && currentVideoTitle) {
loadMoreRelated();
}
}, { rootMargin: '200px' });
const sentinel = document.getElementById('relatedSentinel');
if (sentinel) observer.observe(sentinel);
}
async function loadMoreRelated() {
if (relatedIsLoading) return;
relatedIsLoading = true;
try {
// Show mini loader
const sentinel = document.getElementById('relatedSentinel');
if (sentinel) sentinel.innerHTML = '<div class="loader" style="margin:0 auto; width:20px; height:20px;"></div>';
const channel = currentVideoData?.channel_id || '';
const uploader = currentVideoData?.uploader || '';
const response = await fetch(`/api/related?title=${encodeURIComponent(currentVideoTitle)}&page=${relatedPage}&channel=${encodeURIComponent(channel)}&uploader=${encodeURIComponent(uploader)}`);
const videos = await response.json();
if (videos && videos.length > 0) {
renderRelated(videos);
relatedPage++;
if (sentinel) sentinel.innerHTML = ''; // Clear loader
} else {
if (sentinel) sentinel.remove(); // Stop observing if no more data
}
} catch (e) {
console.error("Failed to load related:", e);
} finally {
relatedIsLoading = false;
}
}
function renderRelated(videos) {
const container = document.getElementById('relatedVideos');
const sentinel = document.getElementById('relatedSentinel'); // Insert before sentinel
const fragment = document.createDocumentFragment();
videos.forEach(video => {
const card = document.createElement('div');
card.className = 'yt-video-card-horizontal';
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
card.innerHTML = `
<div class="yt-thumb-container-h">
<img src="${video.thumbnail || 'https://i.ytimg.com/vi/' + video.id + '/mqdefault.jpg'}" loading="lazy" onerror="this.onerror=null; this.src='https://i.ytimg.com/vi/${video.id}/mqdefault.jpg'">
${video.duration ? `<div class="yt-duration">${video.duration}</div>` : ''}
</div>
<div class="yt-details-h">
<div class="yt-title-h">${escapeHtml(video.title)}</div>
<div class="yt-meta-h">${escapeHtml(video.uploader || 'Unknown')}</div>
<div class="yt-meta-h">${formatViews(video.view_count)} views • ${formatDate(video.upload_date)}</div>
</div>
`;
fragment.appendChild(card);
});
if (sentinel) {
container.insertBefore(fragment, sentinel);
} else {
container.appendChild(fragment); // Fallback
}
}
</script>
<!-- Download Modal -->
<div id="downloadModal" class="download-modal" onclick="if(event.target===this) closeDownloadModal()">
<div class="download-modal-content">
<button class="download-close" onclick="closeDownloadModal()">
<i class="fas fa-times"></i>
</button>
<div id="downloadModalContent">
<!-- Content loaded dynamically -->
</div>
</div>
</div>
<!-- Summary Modal -->
<div id="summaryModal" class="download-modal" onclick="if(event.target===this) closeSummaryModal()">
<div class="download-modal-content">
<button class="download-close" onclick="closeSummaryModal()">
<i class="fas fa-times"></i>
</button>
<h3>Video Summary</h3>
<div id="summaryModalContent"
style="margin-top: 15px; line-height: 1.6; max-height: 60vh; overflow-y: auto;">
<div class="loader" style="margin: 20px auto;"></div>
</div>
</div>
</div>
<script>
// Track current language state and WebLLM status
// Use var and guards to prevent redeclaration errors on SPA navigation
if (typeof currentSummaryLang === 'undefined') var currentSummaryLang = 'en';
if (typeof webllmInitialized === 'undefined') var webllmInitialized = false;
if (typeof currentTranscriptText === 'undefined') var currentTranscriptText = null;
if (typeof currentSummaryData === 'undefined') var currentSummaryData = { en: null, vi: null };
// WebLLM Progress Update Handler - Silent loading (just log to console)
function updateWebLLMProgress(percent, status) {
// Silent loading - only log to console for debugging
console.log(`WebLLM: ${percent}% - ${status}`);
}
// Initialize WebLLM in background (silent, no UI)
async function initWebLLMIfNeeded() {
if (webllmInitialized || !window.webLLMService) return false;
if (!WebLLMService.isSupported()) {
console.log('WebGPU not supported, using server-side AI');
return false;
}
try {
// Silent init - no UI callback
webllmInitialized = await window.webLLMService.init(null, updateWebLLMProgress);
console.log('WebLLM initialized successfully');
return webllmInitialized;
} catch (e) {
console.error('WebLLM init failed:', e);
return false;
}
}
// Fetch transcript text (for WebLLM local processing)
async function fetchTranscript(videoId) {
if (currentTranscriptText) return currentTranscriptText;
try {
const response = await fetch(`/api/transcript?v=${videoId}`);
if (response.ok) {
const text = await response.text();
// Parse VTT to plain text
currentTranscriptText = text
.split('\n')
.filter(line => !line.includes('-->') && !line.startsWith('WEBVTT') && line.trim())
.join(' ')
.replace(/<[^>]+>/g, '')
.substring(0, 8000);
return currentTranscriptText;
}
} catch (e) {
console.error('Transcript fetch error:', e);
}
return null;
}
// Auto-load summary on page load (inline display)
async function loadSummaryInline(lang = 'en') {
if (!currentVideoData.id) return;
const summaryBox = document.getElementById('summaryBox');
const content = document.getElementById('summaryContent');
const keyPointsSection = document.getElementById('keyPointsSection');
const keyPointsList = document.getElementById('keyPointsList');
const summaryLang = document.getElementById('summaryLang');
const translateBtn = document.getElementById('translateBtn');
if (!summaryBox || !content) return;
// Show the box with loading state
summaryBox.style.display = 'block';
content.innerHTML = '<div class="loader" style="margin:20px auto;"></div><p style="text-align:center; color:var(--yt-text-secondary);">✨ Generating AI summary...</p>';
if (keyPointsSection) keyPointsSection.style.display = 'none';
// Check if WebLLM is ready and try to use it
let useLocalAI = false;
if (window.webLLMService && window.webLLMService.isReady()) {
useLocalAI = true;
}
if (useLocalAI) {
// Use Local WebLLM AI
try {
const transcript = await fetchTranscript(currentVideoData.id);
if (!transcript) {
throw new Error('No transcript available');
}
// Generate summary with WebLLM
const summary = await window.webLLMService.summarize(transcript, lang);
currentSummaryData[lang] = summary;
currentSummaryLang = lang;
// Update UI
displaySummary(summary, null, lang, true);
// Extract key points
if (keyPointsSection && keyPointsList) {
try {
const keyPoints = await window.webLLMService.extractKeyPoints(transcript, lang);
if (keyPoints && keyPoints.length > 0) {
keyPointsList.innerHTML = keyPoints.map(point =>
`<li style="margin-bottom:6px;">${point}</li>`
).join('');
keyPointsSection.style.display = 'block';
}
} catch (e) {
console.error('Key points extraction failed:', e);
}
}
return;
} catch (e) {
console.error('WebLLM summarization failed, falling back to server:', e);
// Fall through to server API
}
}
// Fallback to Server API
let url = `/api/summarize?v=${currentVideoData.id}`;
if (lang === 'vi') url += '&lang=vi';
try {
const response = await fetch(url);
const data = await response.json();
if (data.success && data.summary) {
currentSummaryLang = data.lang || 'en';
displaySummary(data.summary, data.translated_summary, currentSummaryLang, false);
// Display key points
if (data.key_points && data.key_points.length > 0 && keyPointsSection && keyPointsList) {
keyPointsList.innerHTML = data.key_points.map(point =>
`<li style="margin-bottom:6px;">${point}</li>`
).join('');
keyPointsSection.style.display = 'block';
}
} else {
content.innerHTML = `<p style="color:var(--yt-text-tertiary); text-align:center;">
<i class="fas fa-info-circle"></i> ${data.error || 'No summary available for this video.'}
</p>`;
// Hide key points and disable translate when no summary available
if (keyPointsSection) keyPointsSection.style.display = 'none';
// Disable translate button (no content to translate)
const translateBtn = document.getElementById('translateBtn');
if (translateBtn) {
translateBtn.disabled = true;
translateBtn.style.opacity = '0.5';
translateBtn.style.cursor = 'not-allowed';
translateBtn.classList.remove('loading');
translateBtn.innerHTML = '🇻🇳 <span>Tiếng Việt</span>';
}
}
} catch (err) {
console.error('Summary error:', err);
summaryBox.style.display = 'none';
}
}
// Display summary with proper formatting
// summary = English content, translatedSummary = Vietnamese content (if available)
function displaySummary(summary, translatedSummary, lang, isLocal) {
const content = document.getElementById('summaryContent');
const summaryLang = document.getElementById('summaryLang');
const translateBtn = document.getElementById('translateBtn');
if (!content) return;
// Store original English summary for later use
if (lang === 'en' || !currentSummaryData.en) {
currentSummaryData.en = summary;
}
if (translatedSummary) {
currentSummaryData.vi = translatedSummary;
}
// Update language indicator
if (summaryLang) {
const langLabel = translatedSummary ? '🇬🇧 + 🇻🇳' : (lang === 'vi' ? '🇻🇳 Tiếng Việt' : '🇬🇧 English');
const aiLabel = isLocal ? '<span class="ai-source-indicator local"><i class="fas fa-microchip"></i> Local AI</span>' : '';
summaryLang.innerHTML = langLabel + ' ' + aiLabel;
}
if (translateBtn) {
translateBtn.innerHTML = translatedSummary
? '🇬🇧 <span>English Only</span>'
: (lang === 'vi' ? '🇬🇧 <span>English</span>' : '🇻🇳 <span>Tiếng Việt</span>');
}
// Display summary - show dual language if we have translation
if (translatedSummary && summary !== translatedSummary) {
// Dual display: English + Vietnamese (from API with both)
content.innerHTML = `
<div style="margin-bottom:12px;">
<div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">
<span style="font-size:12px; background:#e3f2fd; color:#1976d2; padding:2px 8px; border-radius:4px;">🇬🇧 English</span>
</div>
<p style="margin:0; padding:10px; background:var(--yt-bg-primary); border-radius:8px; border-left:3px solid #1976d2;">${summary}</p>
</div>
<div>
<div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">
<span style="font-size:12px; background:#fff3e0; color:#e65100; padding:2px 8px; border-radius:4px;">🇻🇳 Tiếng Việt</span>
</div>
<p style="margin:0; padding:10px; background:var(--yt-bg-primary); border-radius:8px; border-left:3px solid #e65100;">${translatedSummary}</p>
</div>
`;
currentSummaryData.en = summary;
currentSummaryData.vi = translatedSummary;
} else if (currentSummaryData.en && currentSummaryData.vi && currentSummaryData.en !== currentSummaryData.vi) {
// We have cached both versions - validate and show dual display
// Verify which is actually English vs Vietnamese using character detection
const hasVietnameseInEn = /[àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ]/i.test(currentSummaryData.en);
const hasVietnameseInVi = /[àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ]/i.test(currentSummaryData.vi);
// Correct if labels are swapped (en has Vietnamese chars, vi doesn't)
let actualEn = currentSummaryData.en;
let actualVi = currentSummaryData.vi;
if (hasVietnameseInEn && !hasVietnameseInVi) {
// Labels were swapped - fix them
actualEn = currentSummaryData.vi;
actualVi = currentSummaryData.en;
currentSummaryData.en = actualEn;
currentSummaryData.vi = actualVi;
}
content.innerHTML = `
<div style="margin-bottom:12px;">
<div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">
<span style="font-size:12px; background:#e3f2fd; color:#1976d2; padding:2px 8px; border-radius:4px;">🇬🇧 English</span>
</div>
<p style="margin:0; padding:10px; background:var(--yt-bg-primary); border-radius:8px; border-left:3px solid #1976d2;">${actualEn}</p>
</div>
<div>
<div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">
<span style="font-size:12px; background:#fff3e0; color:#e65100; padding:2px 8px; border-radius:4px;">🇻🇳 Tiếng Việt</span>
</div>
<p style="margin:0; padding:10px; background:var(--yt-bg-primary); border-radius:8px; border-left:3px solid #e65100;">${actualVi}</p>
</div>
`;
} else {
// Single language display - detect language from content
// Simple heuristic: Vietnamese has characters like ạ, ế, ư, ơ, etc.
const hasVietnamese = /[àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ]/i.test(summary);
const actualLang = hasVietnamese ? 'vi' : 'en';
// Store in cache
if (actualLang === 'en') {
currentSummaryData.en = summary;
} else {
currentSummaryData.vi = summary;
}
const labelFlag = actualLang === 'vi' ? '🇻🇳' : '🇬🇧';
const labelText = actualLang === 'vi' ? 'Tiếng Việt' : 'English';
const labelBg = actualLang === 'vi' ? '#fff3e0' : '#e3f2fd';
const labelColor = actualLang === 'vi' ? '#e65100' : '#1976d2';
content.innerHTML = `
<div>
<div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">
<span style="font-size:12px; background:${labelBg}; color:${labelColor}; padding:2px 8px; border-radius:4px;">${labelFlag} ${labelText}</span>
</div>
<p style="margin:0; padding:10px; background:var(--yt-bg-primary); border-radius:8px; border-left:3px solid ${labelColor};">${summary}</p>
</div>
`;
}
}
// Toggle between English and Vietnamese translation
async function toggleSummaryTranslation() {
const newLang = currentSummaryLang === 'vi' ? 'en' : 'vi';
const translateBtn = document.getElementById('translateBtn');
// Show loading state
if (translateBtn) {
translateBtn.innerHTML = '<div class="spinner"></div> Translating...';
translateBtn.classList.add('loading');
}
// Try WebLLM first for instant translation
if (window.webLLMService && window.webLLMService.isReady() && currentSummaryData[currentSummaryLang]) {
try {
if (currentSummaryData[newLang]) {
// Already have translation cached
displaySummary(currentSummaryData[newLang], null, newLang, true);
currentSummaryLang = newLang;
} else {
// Translate with WebLLM
const translated = await window.webLLMService.translate(
currentSummaryData[currentSummaryLang],
currentSummaryLang,
newLang
);
currentSummaryData[newLang] = translated;
displaySummary(translated, null, newLang, true);
currentSummaryLang = newLang;
}
if (translateBtn) translateBtn.classList.remove('loading');
return;
} catch (e) {
console.error('WebLLM translation failed:', e);
}
}
// Fallback to server
loadSummaryInline(newLang);
if (translateBtn) translateBtn.classList.remove('loading');
}
// Copy summary content to clipboard
function copySummaryContent() {
const content = document.getElementById('summaryContent');
const copyBtn = document.getElementById('copySummaryBtn');
if (!content) return;
// Get text content (strip HTML)
const text = content.innerText || content.textContent;
// Copy to clipboard
navigator.clipboard.writeText(text).then(() => {
// Show success feedback
if (copyBtn) {
const originalHTML = copyBtn.innerHTML;
copyBtn.innerHTML = '<i class="fas fa-check"></i> <span>Copied!</span>';
copyBtn.style.background = 'linear-gradient(135deg, #10b981, #059669)';
copyBtn.style.color = 'white';
copyBtn.style.borderColor = 'transparent';
setTimeout(() => {
copyBtn.innerHTML = originalHTML;
copyBtn.style.background = '';
copyBtn.style.color = '';
copyBtn.style.borderColor = '';
}, 2000);
}
if (typeof showToast === 'function') {
showToast('Summary copied to clipboard!', 'success');
}
}).catch(err => {
console.error('Copy failed:', err);
if (typeof showToast === 'function') {
showToast('Failed to copy', 'error');
}
});
}
function closeSummaryModal() {
const modal = document.getElementById('summaryModal');
if (modal) modal.style.display = 'none';
}
// Keep the button click function for manual trigger
function showSummaryModal() {
// Start loading WebLLM in background
initWebLLMIfNeeded();
loadSummaryInline();
}
// Auto-load summary when page loads (after a short delay to ensure video ID is set)
document.addEventListener('DOMContentLoaded', function () {
setTimeout(function () {
if (currentVideoData.id) {
// Pre-fetch transcript for WebLLM
fetchTranscript(currentVideoData.id);
loadSummaryInline();
}
}, 2000);
});
function toggleCaptions() {
if (window.player) {
// If using Native Player (YouTube IFrame)
const pref = localStorage.getItem('kv_player_pref') || 'artplayer';
if (pref === 'native') {
showToast("Please use the 'CC' button in the YouTube player.", "info");
return;
}
// If using ArtPlayer
const art = window.player;
if (!art.subtitle) {
showToast("Captions not supported for this player mode.", "error");
return;
}
if (art.subtitle.url) {
art.subtitle.show = !art.subtitle.show;
if (art.subtitle.show) {
art.notice.show = 'Captions On';
} else {
art.notice.show = 'Captions Off';
}
} else {
// Try to fetch transcript if not loaded
art.notice.show = 'Fetching captions...';
fetch(`/api/transcript?v=${currentVideoData.id}`)
.then(res => res.text()) // Assuming it returns VTT or text
.then(data => {
if (data && data.length > 0) {
// Create a blob URL for the subtitle
const blob = new Blob([data], { type: 'text/vtt' });
const url = URL.createObjectURL(blob);
art.subtitle.url = url;
art.subtitle.show = true;
art.notice.show = 'Captions Loaded';
} else {
art.notice.show = 'No captions available';
}
})
.catch(err => {
console.error(err);
art.notice.show = 'Error loading captions';
});
}
} else {
showToast("Player not ready", "error");
}
}
</script>
{% endblock %}