2129 lines
No EOL
99 KiB
HTML
Executable file
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 %} |