`;
+ }
+}
+
+function toggleAdvancedFormats(btn) {
+ const advanced = btn.nextElementSibling;
+ const isHidden = advanced.style.display === 'none';
+ advanced.style.display = isHidden ? 'block' : 'none';
+ btn.innerHTML = isHidden ?
+ ' Less options' :
+ ' More options';
+}
+
+function closeDownloadModal() {
+ const modal = document.getElementById('downloadModal');
+ if (modal) {
+ modal.classList.remove('visible');
+ }
+}
+
+async function startDownloadFromModal(videoId, format, title) {
+ closeDownloadModal();
+ showToast(`Starting download: ${format.quality}...`, 'info');
+
+ try {
+ await window.downloadManager.startDownload(videoId, format, title);
+ showToast('Download started!', 'success');
+ } catch (error) {
+ showToast(`Download failed: ${error.message}`, 'error');
+ }
+}
+
+function escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+}
+
+function formatDuration(seconds) {
+ if (!seconds) return '';
+ const m = Math.floor(seconds / 60);
+ const s = seconds % 60;
+ return `${m}:${s.toString().padStart(2, '0')}`;
+}
diff --git a/static/js/main.js b/static/js/main.js
index 5c54c16..371bdc9 100644
--- a/static/js/main.js
+++ b/static/js/main.js
@@ -1,23 +1,41 @@
// KV-Tube Main JavaScript - YouTube Clone
-document.addEventListener('DOMContentLoaded', () => {
+// Re-usable init function for SPA
+window.initApp = function () {
const searchInput = document.getElementById('searchInput');
const resultsArea = document.getElementById('resultsArea');
+ // cleanup previous observers if any
+ if (window.currentObserver) {
+ window.currentObserver.disconnect();
+ }
+
// Check APP_CONFIG if available (set in index.html)
const socketConfig = window.APP_CONFIG || {};
const pageType = socketConfig.page || 'home';
if (searchInput) {
- searchInput.addEventListener('keypress', async (e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- const query = searchInput.value.trim();
- if (query) {
- window.location.href = `/results?search_query=${encodeURIComponent(query)}`;
+ // Clear previous event listeners to avoid duplicates (optional, but safer to just re-attach if we are careful)
+ // Actually, searchInput is in the header, which is NOT replaced.
+ // So we should NOT re-attach listener to searchInput every time.
+ // We need to check if we already attached it.
+ if (!searchInput.dataset.listenerAttached) {
+ searchInput.addEventListener('keypress', async (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ const query = searchInput.value.trim();
+ if (query) {
+ // Use navigation manager if available
+ if (window.navigationManager) {
+ window.navigationManager.navigateTo(`/results?search_query=${encodeURIComponent(query)}`);
+ } else {
+ window.location.href = `/results?search_query=${encodeURIComponent(query)}`;
+ }
+ }
}
- }
- });
+ });
+ searchInput.dataset.listenerAttached = 'true';
+ }
// Handle Page Initialization - only if resultsArea exists (not on channel.html)
if (resultsArea) {
@@ -32,7 +50,10 @@ document.addEventListener('DOMContentLoaded', () => {
}
} else {
// Default Home
- loadTrending();
+ // Check if we are actually on home page based on URL or Config
+ if (pageType === 'home') {
+ loadTrending();
+ }
}
// Init Infinite Scroll
@@ -40,9 +61,27 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
- // Init Theme
+ // Init Theme (check if already init)
initTheme();
-});
+
+ // Check for category in URL if we are on home and need to switch
+ const urlParams = new URLSearchParams(window.location.search);
+ const category = urlParams.get('category');
+ if (category && typeof switchCategory === 'function' && pageType === 'home') {
+ // We might have already loaded trending above, but switchCategory handles UI state
+ // It also triggers a load, so maybe we want to avoid double loading.
+ // But switchCategory also sets the active pill.
+ // Let's just set the active pill visually for now if we already loaded trending.
+ const pill = document.querySelector(`.yt-chip[onclick*="'${category}'"]`);
+ if (pill) {
+ document.querySelectorAll('.yt-category-pill, .yt-chip').forEach(b => b.classList.remove('active'));
+ pill.classList.add('active');
+ }
+ // If switchCategory is called it will re-fetch.
+ }
+};
+
+document.addEventListener('DOMContentLoaded', window.initApp);
// Note: Global variables like currentCategory are defined below
let currentCategory = 'all';
@@ -348,7 +387,21 @@ async function loadTrending(reset = true) {
`;
- card.onclick = () => window.location.href = `/watch?v=${video.id}`;
+ card.onclick = () => {
+ const params = new URLSearchParams({
+ v: video.id,
+ title: video.title || '',
+ uploader: video.uploader || '',
+ thumbnail: video.thumbnail || ''
+ });
+ const dest = `/watch?${params.toString()}`;
+
+ if (window.navigationManager) {
+ window.navigationManager.navigateTo(dest);
+ } else {
+ window.location.href = dest;
+ }
+ };
scrollContainer.appendChild(card);
});
@@ -436,7 +489,20 @@ function displayResults(videos, append = false) {
card.addEventListener('click', (e) => {
// Prevent navigation if clicking on channel link
if (e.target.closest('.yt-channel-link')) return;
- window.location.href = `/watch?v=${video.id}`;
+
+ const params = new URLSearchParams({
+ v: video.id,
+ title: video.title || '',
+ uploader: video.uploader || '',
+ thumbnail: video.thumbnail || ''
+ });
+ const dest = `/watch?${params.toString()}`;
+
+ if (window.navigationManager) {
+ window.navigationManager.navigateTo(dest);
+ } else {
+ window.location.href = dest;
+ }
});
resultsArea.appendChild(card);
});
@@ -712,7 +778,7 @@ async function loadChannelVideos(channelId) {
// Videos
const videosHtml = data.map(video => `
-
+
${video.duration ? `${video.duration}` : ''}
diff --git a/static/js/navigation-manager.js b/static/js/navigation-manager.js
new file mode 100644
index 0000000..57da5c6
--- /dev/null
+++ b/static/js/navigation-manager.js
@@ -0,0 +1,204 @@
+/**
+ * KV-Tube Navigation Manager
+ * Handles SPA-style navigation to persist state (like downloads) across pages.
+ */
+
+class NavigationManager {
+ constructor() {
+ this.mainContentId = 'mainContent';
+ this.pageCache = new Map();
+ this.maxCacheSize = 20;
+ this.init();
+ }
+
+ init() {
+ // Handle browser back/forward buttons
+ window.addEventListener('popstate', (e) => {
+ if (e.state && e.state.url) {
+ this.loadPage(e.state.url, false);
+ } else {
+ // Fallback for initial state or external navigation
+ this.loadPage(window.location.href, false);
+ }
+ });
+
+ // Intercept clicks
+ document.addEventListener('click', (e) => {
+ // Find closest anchor tag
+ const link = e.target.closest('a');
+
+ // Check if it's an internal link and not a download/special link
+ if (link &&
+ link.href &&
+ link.href.startsWith(window.location.origin) &&
+ !link.getAttribute('download') &&
+ !link.getAttribute('target') &&
+ !link.classList.contains('no-spa') &&
+ !e.ctrlKey && !e.metaKey && !e.shiftKey // Allow new tab clicks
+ ) {
+ e.preventDefault();
+ const url = link.href;
+ this.navigateTo(url);
+
+ // Update active state in sidebar
+ this.updateSidebarActiveState(link);
+ }
+ });
+
+ // Save initial state
+ const currentUrl = window.location.href;
+ if (!this.pageCache.has(currentUrl)) {
+ // We don't have the raw HTML, so we can't fully cache the initial page accurately
+ // without fetching it or serializing current DOM.
+ // For now, we will cache it upon *leaving* securely or just let the first visit be uncached.
+ // Better: Cache the current DOM state as the "initial" state.
+ this.saveCurrentState(currentUrl);
+ }
+ }
+
+ saveCurrentState(url) {
+ const mainContent = document.getElementById(this.mainContentId);
+ if (mainContent) {
+ this.pageCache.set(url, {
+ html: mainContent.innerHTML,
+ title: document.title,
+ scrollY: window.scrollY,
+ className: mainContent.className
+ });
+
+ // Prune cache
+ if (this.pageCache.size > this.maxCacheSize) {
+ const firstKey = this.pageCache.keys().next().value;
+ this.pageCache.delete(firstKey);
+ }
+ }
+ }
+
+ async navigateTo(url) {
+ // Start Progress Bar
+ const bar = document.getElementById('nprogress-bar');
+ if (bar) {
+ bar.style.opacity = '1';
+ bar.style.width = '30%';
+ }
+
+ // Save state of current page before leaving
+ this.saveCurrentState(window.location.href);
+
+ // Update history
+ history.pushState({ url: url }, '', url);
+ await this.loadPage(url);
+ }
+
+ async loadPage(url, pushState = true) {
+ const bar = document.getElementById('nprogress-bar');
+ if (bar) bar.style.width = '60%';
+
+ const mainContent = document.getElementById(this.mainContentId);
+ if (!mainContent) return;
+
+ // Check cache
+ if (this.pageCache.has(url)) {
+ const cached = this.pageCache.get(url);
+
+ // Restore content
+ document.title = cached.title;
+ mainContent.innerHTML = cached.html;
+ mainContent.className = cached.className;
+
+ // Re-execute scripts
+ this.executeScripts(mainContent);
+
+ // Re-initialize App
+ if (typeof window.initApp === 'function') {
+ window.initApp();
+ }
+
+ // Restore scroll
+ window.scrollTo(0, cached.scrollY);
+ return;
+ }
+
+ // Show loading state if needed
+ mainContent.style.opacity = '0.5';
+
+ try {
+ const response = await fetch(url);
+ const html = await response.text();
+
+ // Parse HTML
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(html, 'text/html');
+
+ // Extract new content
+ const newContent = doc.getElementById(this.mainContentId);
+ if (!newContent) {
+ // Check if it's a full page not extending layout properly or error
+ console.error('Could not find mainContent in response');
+ window.location.href = url; // Fallback to full reload
+ return;
+ }
+
+ // Update title
+ document.title = doc.title;
+
+ // Replace content
+ mainContent.innerHTML = newContent.innerHTML;
+ mainContent.className = newContent.className; // Maintain classes
+
+ // Execute scripts found in the new content (critical for APP_CONFIG)
+ this.executeScripts(mainContent);
+
+ // Re-initialize App logic
+ if (typeof window.initApp === 'function') {
+ window.initApp();
+ }
+
+ // Scroll to top for new pages
+ window.scrollTo(0, 0);
+
+ // Save to cache (initial state of this page)
+ this.pageCache.set(url, {
+ html: newContent.innerHTML,
+ title: doc.title,
+ scrollY: 0,
+ className: newContent.className
+ });
+
+ } catch (error) {
+ console.error('Navigation error:', error);
+ // Fallback
+ window.location.href = url;
+ } finally {
+ mainContent.style.opacity = '1';
+ }
+ }
+
+ executeScripts(element) {
+ const scripts = element.querySelectorAll('script');
+ scripts.forEach(oldScript => {
+ const newScript = document.createElement('script');
+ Array.from(oldScript.attributes).forEach(attr => newScript.setAttribute(attr.name, attr.value));
+ newScript.textContent = oldScript.textContent;
+ oldScript.parentNode.replaceChild(newScript, oldScript);
+ });
+ }
+
+ updateSidebarActiveState(clickedLink) {
+ // Remove active class from all items
+ document.querySelectorAll('.yt-sidebar-item').forEach(item => item.classList.remove('active'));
+
+ // Add to clicked if it is a sidebar item
+ if (clickedLink.classList.contains('yt-sidebar-item')) {
+ clickedLink.classList.add('active');
+ } else {
+ // Try to find matching sidebar item
+ const path = new URL(clickedLink.href).pathname;
+ const match = document.querySelector(`.yt-sidebar-item[href="${path}"]`);
+ if (match) match.classList.add('active');
+ }
+ }
+}
+
+// Initialize
+window.navigationManager = new NavigationManager();
diff --git a/static/js/webai.js b/static/js/webai.js
new file mode 100644
index 0000000..4d00228
--- /dev/null
+++ b/static/js/webai.js
@@ -0,0 +1,144 @@
+/**
+ * KV-Tube WebAI Service
+ * Local AI chatbot for transcript Q&A using WebLLM
+ *
+ * Runs entirely in-browser, no server required after model download
+ */
+
+// WebLLM CDN import (lazy loaded)
+var WEBLLM_CDN = 'https://esm.run/@mlc-ai/web-llm';
+
+// Model options - using verified WebLLM model IDs
+var AI_MODELS = {
+ small: { id: 'Qwen2-0.5B-Instruct-q4f16_1-MLC', name: 'Qwen2 (0.5B)', size: '350MB' },
+ medium: { id: 'Qwen2-1.5B-Instruct-q4f16_1-MLC', name: 'Qwen2 (1.5B)', size: '1GB' },
+};
+
+// Default to small model
+var DEFAULT_MODEL = AI_MODELS.small;
+
+if (typeof TranscriptAI === 'undefined') {
+ window.TranscriptAI = class TranscriptAI {
+ constructor() {
+ this.engine = null;
+ this.isLoading = false;
+ this.isReady = false;
+ this.transcript = '';
+ this.onProgressCallback = null;
+ this.onReadyCallback = null;
+ }
+
+ setTranscript(text) {
+ this.transcript = text.slice(0, 8000); // Limit context size
+ }
+
+ setCallbacks({ onProgress, onReady }) {
+ this.onProgressCallback = onProgress;
+ this.onReadyCallback = onReady;
+ }
+
+ async init() {
+ if (this.isReady || this.isLoading) return;
+
+ this.isLoading = true;
+
+ try {
+ // Dynamic import WebLLM
+ const { CreateMLCEngine } = await import(WEBLLM_CDN);
+
+ // Initialize engine with progress callback
+ this.engine = await CreateMLCEngine(DEFAULT_MODEL.id, {
+ initProgressCallback: (report) => {
+ if (this.onProgressCallback) {
+ this.onProgressCallback(report);
+ }
+ console.log('AI Load Progress:', report.text);
+ }
+ });
+
+ this.isReady = true;
+ this.isLoading = false;
+
+ if (this.onReadyCallback) {
+ this.onReadyCallback();
+ }
+
+ console.log('TranscriptAI ready with model:', DEFAULT_MODEL.name);
+
+ } catch (err) {
+ this.isLoading = false;
+ console.error('Failed to load AI model:', err);
+ throw err;
+ }
+ }
+
+ async ask(question) {
+ if (!this.isReady) {
+ throw new Error('AI not initialized');
+ }
+
+ const systemPrompt = this.transcript
+ ? `You are a helpful AI assistant analyzing a video transcript. Answer the user's question based ONLY on the transcript content below. Be concise and direct. If the answer is not in the transcript, say so.\n\nTRANSCRIPT:\n${this.transcript}`
+ : `You are a helpful AI assistant for KV-Tube, a lightweight YouTube client. You can help the user with general questions, explain features of the app, or chat casually. Be concise and helpful.`;
+
+ try {
+ const response = await this.engine.chat.completions.create({
+ messages: [
+ { role: 'system', content: systemPrompt },
+ { role: 'user', content: question }
+ ],
+ max_tokens: 256,
+ temperature: 0.7,
+ });
+
+ return response.choices[0].message.content;
+
+ } catch (err) {
+ console.error('AI response error:', err);
+ throw err;
+ }
+ }
+
+ async *askStreaming(question) {
+ if (!this.isReady) {
+ throw new Error('AI not initialized');
+ }
+
+ const systemPrompt = this.transcript
+ ? `You are a helpful AI assistant analyzing a video transcript. Answer the user's question based ONLY on the transcript content below. Be concise and direct. If the answer is not in the transcript, say so.\n\nTRANSCRIPT:\n${this.transcript}`
+ : `You are a helpful AI assistant for KV-Tube, a lightweight YouTube client. You can help the user with general questions, explain features of the app, or chat casually. Be concise and helpful.`;
+
+ const chunks = await this.engine.chat.completions.create({
+ messages: [
+ { role: 'system', content: systemPrompt },
+ { role: 'user', content: question }
+ ],
+ max_tokens: 256,
+ temperature: 0.7,
+ stream: true,
+ });
+
+ for await (const chunk of chunks) {
+ const delta = chunk.choices[0]?.delta?.content;
+ if (delta) {
+ yield delta;
+ }
+ }
+ }
+
+ getModelInfo() {
+ return DEFAULT_MODEL;
+ }
+
+ isModelReady() {
+ return this.isReady;
+ }
+
+ isModelLoading() {
+ return this.isLoading;
+ }
+ }
+
+ // Global instance
+ window.transcriptAI = new TranscriptAI();
+}
diff --git a/templates/downloads.html b/templates/downloads.html
new file mode 100644
index 0000000..731b22d
--- /dev/null
+++ b/templates/downloads.html
@@ -0,0 +1,205 @@
+{% extends "layout.html" %}
+
+{% block content %}
+
+
+
+
+
Downloads
+
+
+
+
+
+
+
+
+
+
No downloads yet
+
Videos you download will appear here
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/templates/index.html b/templates/index.html
index 652114f..cd37e05 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -151,21 +151,27 @@
+
+
+
+
+
+
@@ -63,6 +172,9 @@
+
@@ -163,7 +281,9 @@
+
+
+
+
+
+
+
AI Assistant
+
Click to load AI model
+
+
+
+
+
Downloading AI Model...
+
+
+
+
Preparing...
+
+
+
Ask me anything about this video!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/templates/settings.html b/templates/settings.html
index 5435f65..da151bd 100644
--- a/templates/settings.html
+++ b/templates/settings.html
@@ -16,6 +16,20 @@
+
+
Playback
+
Choose your preferred video player.
+
+ Default Player
+
+
+
+
+
+
+
{% if session.get('user_id') %}
Profile
@@ -205,4 +219,46 @@
}
}
+
+
+
{% endblock %}
\ No newline at end of file
diff --git a/templates/watch.html b/templates/watch.html
index a5a46ca..7cc53aa 100644
--- a/templates/watch.html
+++ b/templates/watch.html
@@ -12,6 +12,10 @@
+
+