Fix subscribe button and library tab navigation
This commit is contained in:
parent
38449d23d6
commit
60d3bc3a5e
11 changed files with 1034 additions and 329 deletions
12
.dockerignore
Normal file
12
.dockerignore
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
__pycache__
|
||||||
|
.venv
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
*.mp4
|
||||||
|
*.webm
|
||||||
|
*.mp3
|
||||||
|
videos/
|
||||||
|
data/
|
||||||
|
temp/
|
||||||
|
deployment_package/
|
||||||
|
kvtube.db
|
||||||
27
deploy_v2.ps1
Normal file
27
deploy_v2.ps1
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# deploy_v2.ps1 - Deploy KV-Tube v2.0
|
||||||
|
|
||||||
|
Write-Host "--- KV-Tube v2.0 Deployment ---" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# 1. Check Git Remote
|
||||||
|
Write-Host "1. Pushing to Git..." -ForegroundColor Yellow
|
||||||
|
# Note: Ensure 'origin' is the correct writable remote, not a mirror.
|
||||||
|
git push -u origin main --tags
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "Error: Git push failed. Verify that 'origin' is not a read-only mirror." -ForegroundColor Red
|
||||||
|
# Continue anyway to try Docker?
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Build Docker Image
|
||||||
|
Write-Host "2. Building Docker Image (linux/amd64)..." -ForegroundColor Yellow
|
||||||
|
# Requires Docker Desktop to be running
|
||||||
|
docker build --platform linux/amd64 -t kv-tube:v2.0 .
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
Write-Host "Success! Docker image 'kv-tube:v2.0' built." -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "Error: Docker build failed. Is Docker Desktop running?" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Done." -ForegroundColor Cyan
|
||||||
|
pause
|
||||||
|
|
@ -120,16 +120,37 @@
|
||||||
|
|
||||||
.yt-sidebar.collapsed .yt-sidebar-item {
|
.yt-sidebar.collapsed .yt-sidebar-item {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 0;
|
||||||
padding: 16px 0;
|
padding: 16px 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide text labels in collapsed mode - icons only */
|
||||||
.yt-sidebar.collapsed .yt-sidebar-item span {
|
.yt-sidebar.collapsed .yt-sidebar-item span {
|
||||||
font-size: 10px;
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Center icons in collapsed mode */
|
||||||
|
.yt-sidebar.collapsed .yt-sidebar-item i {
|
||||||
|
font-size: 20px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide Saved, Subscriptions, dividers and titles in collapsed mode */
|
||||||
|
.yt-sidebar.collapsed .yt-sidebar-title,
|
||||||
|
.yt-sidebar.collapsed .yt-sidebar-divider {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide Saved and Subscriptions globally (both full and collapsed sidebar) */
|
||||||
|
.yt-sidebar a[data-category="saved"],
|
||||||
|
.yt-sidebar a[data-category="subscriptions"] {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-sidebar-divider {
|
.yt-sidebar-divider {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
/* ===== Watch Page ===== */
|
/* ===== Watch Page ===== */
|
||||||
|
/* Layout rules moved to watch.css - this is kept for compatibility */
|
||||||
.yt-watch-layout {
|
.yt-watch-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 400px;
|
grid-template-columns: 1fr 400px;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
max-width: 1800px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-player-section {
|
.yt-player-section {
|
||||||
|
|
|
||||||
|
|
@ -90,33 +90,118 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== Watch Page Layout ========== */
|
/* ========== Watch Page Layout ========== */
|
||||||
.yt-main {
|
/* Only apply these overrides when the watch layout is present */
|
||||||
|
.yt-main:has(.yt-watch-layout) {
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
margin-left: 240px;
|
/* Auto-collapse main content margin on watch page to match collapsed sidebar */
|
||||||
|
margin-left: var(--yt-sidebar-mini) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Auto-collapse sidebar on watch page */
|
||||||
|
.yt-sidebar:has(~ .yt-sidebar-overlay ~ .yt-main .yt-watch-layout),
|
||||||
|
body:has(.yt-watch-layout) .yt-sidebar {
|
||||||
|
width: var(--yt-sidebar-mini);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar item styling for mini mode on watch page */
|
||||||
|
body:has(.yt-watch-layout) .yt-sidebar .yt-sidebar-item {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
padding: 16px 0;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide text labels in mini mode - icons only */
|
||||||
|
body:has(.yt-watch-layout) .yt-sidebar .yt-sidebar-item span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Center the icons */
|
||||||
|
body:has(.yt-watch-layout) .yt-sidebar .yt-sidebar-item i {
|
||||||
|
font-size: 20px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide Saved, Subscriptions, and dividers/titles on watch page */
|
||||||
|
body:has(.yt-watch-layout) .yt-sidebar .yt-sidebar-title,
|
||||||
|
body:has(.yt-watch-layout) .yt-sidebar .yt-sidebar-divider,
|
||||||
|
body:has(.yt-watch-layout) .yt-sidebar a[data-category="saved"],
|
||||||
|
body:has(.yt-watch-layout) .yt-sidebar a[data-category="subscriptions"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theater Mode (Default) - Full width video with sidebar below */
|
||||||
.yt-watch-layout {
|
.yt-watch-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 24px 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Default Mode - 2 column layout */
|
||||||
|
.yt-watch-layout.default-mode {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 400px;
|
grid-template-columns: 1fr 400px;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
width: 100%;
|
}
|
||||||
padding: 24px;
|
|
||||||
margin: 0;
|
.yt-watch-layout.default-mode .yt-watch-sidebar {
|
||||||
box-sizing: border-box;
|
position: sticky;
|
||||||
|
top: 80px;
|
||||||
|
align-self: start;
|
||||||
|
max-height: calc(100vh - 100px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theater mode sidebar moves below */
|
||||||
|
.yt-watch-layout:not(.default-mode) .yt-watch-sidebar {
|
||||||
|
margin-top: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-watch-sidebar {
|
.yt-watch-sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
position: sticky;
|
|
||||||
top: 80px;
|
|
||||||
align-self: start;
|
|
||||||
max-height: calc(100vh - 100px);
|
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* View Mode Button Styles */
|
||||||
|
.view-mode-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-mode-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: var(--yt-bg-secondary);
|
||||||
|
color: var(--yt-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-mode-btn:hover {
|
||||||
|
background: var(--yt-bg-hover);
|
||||||
|
color: var(--yt-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-mode-btn.active {
|
||||||
|
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
.yt-channel-avatar-lg {
|
.yt-channel-avatar-lg {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
|
|
|
||||||
|
|
@ -245,7 +245,18 @@ async function switchCategory(category, btn) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (category === 'suggested') {
|
if (category === 'suggested') {
|
||||||
const response = await fetch('/api/suggested');
|
// Build query params from localStorage history
|
||||||
|
const history = JSON.parse(localStorage.getItem('kv_history') || '[]');
|
||||||
|
const titles = history.slice(0, 5).map(v => v.title).filter(Boolean).join(',');
|
||||||
|
const channels = history.slice(0, 3).map(v => v.uploader).filter(Boolean).join(',');
|
||||||
|
|
||||||
|
let url = '/api/suggested';
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (titles) params.append('titles', titles);
|
||||||
|
if (channels) params.append('channels', channels);
|
||||||
|
if (params.toString()) url += '?' + params.toString();
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
displayResults(data, false);
|
displayResults(data, false);
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
|
|
@ -296,7 +307,20 @@ async function loadTrending(reset = true) {
|
||||||
const regionValue = window.currentRegion || 'vietnam';
|
const regionValue = window.currentRegion || 'vietnam';
|
||||||
// Add cache-buster for home page to ensure fresh content
|
// Add cache-buster for home page to ensure fresh content
|
||||||
const cb = reset && currentCategory === 'all' ? `&_=${Date.now()}` : '';
|
const cb = reset && currentCategory === 'all' ? `&_=${Date.now()}` : '';
|
||||||
const response = await fetch(`/api/trending?category=${currentCategory}&page=${currentPage}&sort=${sortValue}®ion=${regionValue}${cb}`);
|
|
||||||
|
// Include localStorage history for personalized suggestions on home page
|
||||||
|
let historyParams = '';
|
||||||
|
if (currentCategory === 'all') {
|
||||||
|
const history = JSON.parse(localStorage.getItem('kv_history') || '[]');
|
||||||
|
if (history.length > 0) {
|
||||||
|
const titles = history.slice(0, 5).map(v => v.title).filter(Boolean).join(',');
|
||||||
|
const channels = history.slice(0, 3).map(v => v.uploader).filter(Boolean).join(',');
|
||||||
|
if (titles) historyParams += `&history_titles=${encodeURIComponent(titles)}`;
|
||||||
|
if (channels) historyParams += `&history_channels=${encodeURIComponent(channels)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/trending?category=${currentCategory}&page=${currentPage}&sort=${sortValue}®ion=${regionValue}${historyParams}${cb}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,7 @@
|
||||||
%}@Loading...{% endif %}
|
%}@Loading...{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<div class="yt-channel-stats">
|
<div class="yt-channel-stats">
|
||||||
<span id="channelStats">Subscribe for more</span>
|
<span id="channelStats"></span>
|
||||||
</div>
|
|
||||||
<div class="yt-channel-actions">
|
|
||||||
<button class="yt-subscribe-btn-lg" id="subscribeChannelBtn">Subscribe</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -35,14 +32,29 @@
|
||||||
<div class="yt-section">
|
<div class="yt-section">
|
||||||
<div class="yt-section-header">
|
<div class="yt-section-header">
|
||||||
<div class="yt-tabs">
|
<div class="yt-tabs">
|
||||||
<a href="#" onclick="changeChannelTab('video', this); return false;" class="active">Videos</a>
|
<a href="#" onclick="changeChannelTab('video', this); return false;" class="active">
|
||||||
<a href="#" onclick="changeChannelTab('shorts', this); return false;">Shorts</a>
|
<i class="fas fa-video"></i>
|
||||||
|
<span>Videos</span>
|
||||||
|
</a>
|
||||||
|
<a href="#" onclick="changeChannelTab('shorts', this); return false;">
|
||||||
|
<i class="fas fa-bolt"></i>
|
||||||
|
<span>Shorts</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="yt-sort-options">
|
<div class="yt-sort-options">
|
||||||
<a href="#" onclick="changeChannelSort('latest', this); return false;" class="active">Latest</a>
|
<a href="#" onclick="changeChannelSort('latest', this); return false;" class="active">
|
||||||
<a href="#" onclick="changeChannelSort('popular', this); return false;">Popular</a>
|
<i class="fas fa-clock"></i>
|
||||||
<a href="#" onclick="changeChannelSort('oldest', this); return false;">Oldest</a>
|
<span>Latest</span>
|
||||||
|
</a>
|
||||||
|
<a href="#" onclick="changeChannelSort('popular', this); return false;">
|
||||||
|
<i class="fas fa-fire"></i>
|
||||||
|
<span>Popular</span>
|
||||||
|
</a>
|
||||||
|
<a href="#" onclick="changeChannelSort('oldest', this); return false;">
|
||||||
|
<i class="fas fa-history"></i>
|
||||||
|
<span>Oldest</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -129,53 +141,58 @@
|
||||||
|
|
||||||
.yt-tabs {
|
.yt-tabs {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 0;
|
gap: 8px;
|
||||||
background: var(--yt-bg-secondary);
|
background: var(--yt-bg-secondary);
|
||||||
padding: 4px;
|
padding: 6px;
|
||||||
border-radius: 24px;
|
border-radius: 16px;
|
||||||
position: relative;
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-tabs a {
|
.yt-tabs a {
|
||||||
font-size: 14px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.95rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding: 8px 24px;
|
transition: all 0.25s ease;
|
||||||
border-radius: 20px;
|
border: none;
|
||||||
border-bottom: none;
|
background: transparent;
|
||||||
z-index: 1;
|
|
||||||
transition: color 0.2s;
|
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-tabs a:hover {
|
.yt-tabs a:hover {
|
||||||
color: var(--yt-text-primary);
|
color: var(--yt-text-primary);
|
||||||
|
background: var(--yt-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-tabs a.active {
|
.yt-tabs a.active {
|
||||||
color: var(--yt-bg-primary);
|
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
|
||||||
background: var(--yt-text-primary);
|
color: white;
|
||||||
/* The "slider" is actually the active pill moving */
|
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-sort-options {
|
.yt-sort-options {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
justify-content: center;
|
background: var(--yt-bg-secondary);
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-sort-options a {
|
.yt-sort-options a {
|
||||||
padding: 6px 16px;
|
padding: 10px 20px;
|
||||||
border-radius: 16px;
|
border-radius: 12px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--yt-border);
|
border: none;
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 13px;
|
font-size: 0.9rem;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
transition: all 0.2s;
|
transition: all 0.25s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-sort-options a:hover {
|
.yt-sort-options a:hover {
|
||||||
|
|
@ -184,9 +201,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-sort-options a.active {
|
.yt-sort-options a.active {
|
||||||
background: var(--yt-bg-secondary);
|
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
|
||||||
color: var(--yt-text-primary);
|
color: white;
|
||||||
border-color: var(--yt-text-primary);
|
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shorts Card Styling override for Channel Page grid */
|
/* Shorts Card Styling override for Channel Page grid */
|
||||||
|
|
@ -245,133 +262,134 @@
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
console.log("Channel.html script loaded, channelId will be:", "{{ channel.id }}");
|
(function () {
|
||||||
|
// IIFE to prevent variable redeclaration errors on SPA navigation
|
||||||
|
console.log("Channel.html script loaded, channelId will be:", "{{ channel.id }}");
|
||||||
|
|
||||||
let currentChannelSort = 'latest';
|
var currentChannelSort = 'latest';
|
||||||
let currentChannelPage = 1;
|
var currentChannelPage = 1;
|
||||||
let isChannelLoading = false;
|
var isChannelLoading = false;
|
||||||
let hasMoreChannelVideos = true;
|
var hasMoreChannelVideos = true;
|
||||||
let currentFilterType = 'video';
|
var currentFilterType = 'video';
|
||||||
const channelId = "{{ channel.id }}";
|
var channelId = "{{ channel.id }}";
|
||||||
|
// Store initial channel title from server template (don't overwrite with empty API data)
|
||||||
|
var initialChannelTitle = "{{ channel.title }}";
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
function init() {
|
||||||
console.log("DOMContentLoaded fired, calling fetchChannelContent...");
|
console.log("Channel init called, fetching content...");
|
||||||
console.log("typeof fetchChannelContent:", typeof fetchChannelContent);
|
|
||||||
if (typeof fetchChannelContent === 'function') {
|
|
||||||
fetchChannelContent();
|
fetchChannelContent();
|
||||||
|
setupInfiniteScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle both initial page load and SPA navigation
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
} else {
|
} else {
|
||||||
console.error("fetchChannelContent is NOT a function!");
|
// DOM is already ready (SPA navigation)
|
||||||
}
|
init();
|
||||||
setupInfiniteScroll();
|
|
||||||
});
|
|
||||||
|
|
||||||
function changeChannelTab(type, btn) {
|
|
||||||
if (type === currentFilterType || isChannelLoading) return;
|
|
||||||
currentFilterType = type;
|
|
||||||
currentChannelPage = 1;
|
|
||||||
hasMoreChannelVideos = true;
|
|
||||||
document.getElementById('channelVideosGrid').innerHTML = '';
|
|
||||||
|
|
||||||
// Update Tabs UI
|
|
||||||
document.querySelectorAll('.yt-tabs a').forEach(a => a.classList.remove('active'));
|
|
||||||
btn.classList.add('active');
|
|
||||||
|
|
||||||
// Adjust Grid layout for Shorts vs Videos
|
|
||||||
const grid = document.getElementById('channelVideosGrid');
|
|
||||||
if (type === 'shorts') {
|
|
||||||
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(200px, 1fr))';
|
|
||||||
} else {
|
|
||||||
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(320px, 1fr))';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchChannelContent();
|
function changeChannelTab(type, btn) {
|
||||||
}
|
if (type === currentFilterType || isChannelLoading) return;
|
||||||
|
currentFilterType = type;
|
||||||
|
currentChannelPage = 1;
|
||||||
|
hasMoreChannelVideos = true;
|
||||||
|
document.getElementById('channelVideosGrid').innerHTML = '';
|
||||||
|
|
||||||
function changeChannelSort(sort, btn) {
|
// Update Tabs UI
|
||||||
if (isChannelLoading) return;
|
document.querySelectorAll('.yt-tabs a').forEach(a => a.classList.remove('active'));
|
||||||
currentChannelSort = sort;
|
btn.classList.add('active');
|
||||||
currentChannelPage = 1;
|
|
||||||
hasMoreChannelVideos = true;
|
|
||||||
document.getElementById('channelVideosGrid').innerHTML = ''; // Clear
|
|
||||||
|
|
||||||
// Update tabs
|
// Adjust Grid layout for Shorts vs Videos
|
||||||
document.querySelectorAll('.yt-sort-options a').forEach(a => a.classList.remove('active'));
|
const grid = document.getElementById('channelVideosGrid');
|
||||||
btn.classList.add('active');
|
if (type === 'shorts') {
|
||||||
|
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(200px, 1fr))';
|
||||||
fetchChannelContent();
|
} else {
|
||||||
}
|
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(320px, 1fr))';
|
||||||
|
|
||||||
async function fetchChannelContent() {
|
|
||||||
console.log("fetchChannelContent() called");
|
|
||||||
if (isChannelLoading || !hasMoreChannelVideos) {
|
|
||||||
console.log("Early return:", { isChannelLoading, hasMoreChannelVideos });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
isChannelLoading = true;
|
|
||||||
|
|
||||||
const grid = document.getElementById('channelVideosGrid');
|
|
||||||
|
|
||||||
// Append Loading indicator
|
|
||||||
if (typeof renderSkeleton === 'function') {
|
|
||||||
grid.insertAdjacentHTML('beforeend', renderSkeleton(4));
|
|
||||||
} else {
|
|
||||||
grid.insertAdjacentHTML('beforeend', '<div class="loading-text" style="color:var(--yt-text-secondary); padding: 20px;">Loading videos...</div>');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log(`Fetching: /api/channel/videos?id=${channelId}&page=${currentChannelPage}`);
|
|
||||||
const response = await fetch(`/api/channel/videos?id=${channelId}&page=${currentChannelPage}&sort=${currentChannelSort}&filter_type=${currentFilterType}`);
|
|
||||||
const videos = await response.json();
|
|
||||||
console.log("Channel Videos Response:", videos);
|
|
||||||
|
|
||||||
// Remove skeletons (simple way: remove last N children or just clear all if page 1?
|
|
||||||
// Better: mark skeletons with class and remove)
|
|
||||||
// For simplicity in this v1: We just clear skeletons by removing elements with 'skeleton-card' class
|
|
||||||
document.querySelectorAll('#channelVideosGrid .skeleton-card').forEach(el => el.remove());
|
|
||||||
|
|
||||||
// Check if response is an error
|
|
||||||
if (videos.error) {
|
|
||||||
hasMoreChannelVideos = false;
|
|
||||||
grid.innerHTML = `<p style="padding:20px; color:var(--yt-text-secondary);">Error: ${videos.error}</p>`;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(videos) || videos.length === 0) {
|
fetchChannelContent();
|
||||||
hasMoreChannelVideos = false;
|
}
|
||||||
if (currentChannelPage === 1) grid.innerHTML = '<p style="padding:20px; color:var(--yt-text-secondary);">No videos found.</p>';
|
|
||||||
|
function changeChannelSort(sort, btn) {
|
||||||
|
if (isChannelLoading) return;
|
||||||
|
currentChannelSort = sort;
|
||||||
|
currentChannelPage = 1;
|
||||||
|
hasMoreChannelVideos = true;
|
||||||
|
document.getElementById('channelVideosGrid').innerHTML = ''; // Clear
|
||||||
|
|
||||||
|
// Update tabs
|
||||||
|
document.querySelectorAll('.yt-sort-options a').forEach(a => a.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
|
||||||
|
fetchChannelContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchChannelContent() {
|
||||||
|
console.log("fetchChannelContent() called");
|
||||||
|
if (isChannelLoading || !hasMoreChannelVideos) {
|
||||||
|
console.log("Early return:", { isChannelLoading, hasMoreChannelVideos });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isChannelLoading = true;
|
||||||
|
|
||||||
|
const grid = document.getElementById('channelVideosGrid');
|
||||||
|
|
||||||
|
// Append Loading indicator
|
||||||
|
if (typeof renderSkeleton === 'function') {
|
||||||
|
grid.insertAdjacentHTML('beforeend', renderSkeleton(4));
|
||||||
} else {
|
} else {
|
||||||
// Update channel header with uploader info from first video (on first page only)
|
grid.insertAdjacentHTML('beforeend', '<div class="loading-text" style="color:var(--yt-text-secondary); padding: 20px;">Loading videos...</div>');
|
||||||
if (currentChannelPage === 1 && videos[0]) {
|
}
|
||||||
// Try multiple sources for channel name
|
|
||||||
let channelName = videos[0].uploader || videos[0].channel || '';
|
|
||||||
|
|
||||||
// If still empty, try to get from video title (sometimes includes " - ChannelName")
|
try {
|
||||||
if (!channelName && videos[0].title) {
|
console.log(`Fetching: /api/channel/videos?id=${channelId}&page=${currentChannelPage}`);
|
||||||
const parts = videos[0].title.split(' - ');
|
const response = await fetch(`/api/channel/videos?id=${channelId}&page=${currentChannelPage}&sort=${currentChannelSort}&filter_type=${currentFilterType}`);
|
||||||
if (parts.length > 1) channelName = parts[parts.length - 1];
|
const videos = await response.json();
|
||||||
}
|
console.log("Channel Videos Response:", videos);
|
||||||
|
|
||||||
// Final fallback: use channel ID
|
// Remove skeletons (simple way: remove last N children or just clear all if page 1?
|
||||||
if (!channelName) channelName = channelId;
|
// Better: mark skeletons with class and remove)
|
||||||
|
// For simplicity in this v1: We just clear skeletons by removing elements with 'skeleton-card' class
|
||||||
|
document.querySelectorAll('#channelVideosGrid .skeleton-card').forEach(el => el.remove());
|
||||||
|
|
||||||
document.getElementById('channelTitle').textContent = channelName;
|
// Check if response is an error
|
||||||
document.getElementById('channelHandle').textContent = '@' + channelName.replace(/\s+/g, '');
|
if (videos.error) {
|
||||||
const avatarLetter = document.getElementById('channelAvatarLetter');
|
hasMoreChannelVideos = false;
|
||||||
if (avatarLetter) avatarLetter.textContent = channelName.charAt(0).toUpperCase();
|
grid.innerHTML = `<p style="padding:20px; color:var(--yt-text-secondary);">Error: ${videos.error}</p>`;
|
||||||
|
return;
|
||||||
// Update browser URL to show friendly name
|
|
||||||
const friendlyUrl = `/channel/@${encodeURIComponent(channelName.replace(/\s+/g, ''))}`;
|
|
||||||
window.history.replaceState({ channelId: channelId }, '', friendlyUrl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
videos.forEach(video => {
|
if (!Array.isArray(videos) || videos.length === 0) {
|
||||||
const card = document.createElement('div');
|
hasMoreChannelVideos = false;
|
||||||
|
if (currentChannelPage === 1) grid.innerHTML = '<p style="padding:20px; color:var(--yt-text-secondary);">No videos found.</p>';
|
||||||
|
} else {
|
||||||
|
// Update channel header with uploader info from first video (on first page only)
|
||||||
|
if (currentChannelPage === 1 && videos[0]) {
|
||||||
|
// Use only proper channel/uploader fields - do NOT parse from title
|
||||||
|
let channelName = videos[0].channel || videos[0].uploader || '';
|
||||||
|
|
||||||
if (currentFilterType === 'shorts') {
|
// Only update header if API returned a meaningful name
|
||||||
// Render Vertical Short Card
|
// (not empty, not just the channel ID, and not "Loading...")
|
||||||
card.className = 'yt-channel-short-card';
|
if (channelName && channelName !== channelId &&
|
||||||
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
|
!channelName.startsWith('UC') && channelName !== 'Loading...') {
|
||||||
card.innerHTML = `
|
|
||||||
|
document.getElementById('channelTitle').textContent = channelName;
|
||||||
|
document.getElementById('channelHandle').textContent = '@' + channelName.replace(/\s+/g, '');
|
||||||
|
const avatarLetter = document.getElementById('channelAvatarLetter');
|
||||||
|
if (avatarLetter) avatarLetter.textContent = channelName.charAt(0).toUpperCase();
|
||||||
|
}
|
||||||
|
// If no meaningful name from API, keep the initial template-rendered title
|
||||||
|
}
|
||||||
|
|
||||||
|
videos.forEach(video => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
|
||||||
|
if (currentFilterType === 'shorts') {
|
||||||
|
// Render Vertical Short Card
|
||||||
|
card.className = 'yt-channel-short-card';
|
||||||
|
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
|
||||||
|
card.innerHTML = `
|
||||||
<div class="yt-short-thumb-container">
|
<div class="yt-short-thumb-container">
|
||||||
<img src="${video.thumbnail}" class="yt-short-thumb loaded" loading="lazy" onload="this.classList.add('loaded')">
|
<img src="${video.thumbnail}" class="yt-short-thumb loaded" loading="lazy" onload="this.classList.add('loaded')">
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -380,11 +398,11 @@
|
||||||
<p class="yt-video-stats">${formatViews(video.view_count)} views</p>
|
<p class="yt-video-stats">${formatViews(video.view_count)} views</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
// Render Standard Video Card (Match Home)
|
// Render Standard Video Card (Match Home)
|
||||||
card.className = 'yt-video-card';
|
card.className = 'yt-video-card';
|
||||||
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
|
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="yt-thumbnail-container">
|
<div class="yt-thumbnail-container">
|
||||||
<img class="yt-thumbnail loaded" src="${video.thumbnail}" loading="lazy" onload="this.classList.add('loaded')">
|
<img class="yt-thumbnail loaded" src="${video.thumbnail}" loading="lazy" onload="this.classList.add('loaded')">
|
||||||
${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''}
|
${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''}
|
||||||
|
|
@ -398,64 +416,69 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
grid.appendChild(card);
|
grid.appendChild(card);
|
||||||
});
|
});
|
||||||
currentChannelPage++;
|
currentChannelPage++;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
isChannelLoading = false;
|
||||||
|
document.querySelectorAll('#channelVideosGrid .skeleton-card').forEach(el => el.remove());
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
} finally {
|
|
||||||
isChannelLoading = false;
|
|
||||||
document.querySelectorAll('#channelVideosGrid .skeleton-card').forEach(el => el.remove());
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function setupInfiniteScroll() {
|
function setupInfiniteScroll() {
|
||||||
const trigger = document.getElementById('channelLoadingTrigger');
|
const trigger = document.getElementById('channelLoadingTrigger');
|
||||||
const observer = new IntersectionObserver((entries) => {
|
const observer = new IntersectionObserver((entries) => {
|
||||||
if (entries[0].isIntersecting) {
|
if (entries[0].isIntersecting) {
|
||||||
fetchChannelContent();
|
fetchChannelContent();
|
||||||
|
}
|
||||||
|
}, { threshold: 0.1 });
|
||||||
|
observer.observe(trigger);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers - Define locally to ensure availability
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 formatDate(dateStr) {
|
||||||
|
if (!dateStr) return 'Recently';
|
||||||
|
try {
|
||||||
|
// Format: YYYYMMDD
|
||||||
|
const year = dateStr.substring(0, 4);
|
||||||
|
const month = dateStr.substring(4, 6);
|
||||||
|
const day = dateStr.substring(6, 8);
|
||||||
|
const date = new Date(year, month - 1, day);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now - date;
|
||||||
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
|
if (days < 1) return 'Today';
|
||||||
|
if (days < 7) return `${days} days ago`;
|
||||||
|
if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
|
||||||
|
if (days < 365) return `${Math.floor(days / 30)} months ago`;
|
||||||
|
return `${Math.floor(days / 365)} years ago`;
|
||||||
|
} catch (e) {
|
||||||
|
return 'Recently';
|
||||||
}
|
}
|
||||||
}, { threshold: 0.1 });
|
|
||||||
observer.observe(trigger);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helpers - Define locally to ensure availability
|
|
||||||
function escapeHtml(text) {
|
|
||||||
if (!text) return '';
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 formatDate(dateStr) {
|
|
||||||
if (!dateStr) return 'Recently';
|
|
||||||
try {
|
|
||||||
// Format: YYYYMMDD
|
|
||||||
const year = dateStr.substring(0, 4);
|
|
||||||
const month = dateStr.substring(4, 6);
|
|
||||||
const day = dateStr.substring(6, 8);
|
|
||||||
const date = new Date(year, month - 1, day);
|
|
||||||
const now = new Date();
|
|
||||||
const diff = now - date;
|
|
||||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
|
||||||
if (days < 1) return 'Today';
|
|
||||||
if (days < 7) return `${days} days ago`;
|
|
||||||
if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
|
|
||||||
if (days < 365) return `${Math.floor(days / 30)} months ago`;
|
|
||||||
return `${Math.floor(days / 365)} years ago`;
|
|
||||||
} catch (e) {
|
|
||||||
return 'Recently';
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Expose functions globally for onclick handlers
|
||||||
|
window.changeChannelTab = changeChannelTab;
|
||||||
|
window.changeChannelSort = changeChannelSort;
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -141,12 +141,6 @@
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<!-- Global Progress Bar -->
|
|
||||||
<div id="nprogress-container"
|
|
||||||
style="position:fixed; top:0; left:0; width:100%; height:3px; z-index:9999; pointer-events:none;">
|
|
||||||
<div id="nprogress-bar" style="width:0%; height:100%; background:red; transition: width 0.2s ease; opacity: 0;">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="app-wrapper">
|
<div class="app-wrapper">
|
||||||
<!-- YouTube-style Header -->
|
<!-- YouTube-style Header -->
|
||||||
<header class="yt-header">
|
<header class="yt-header">
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,200 @@
|
||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="yt-container" style="padding-top: 20px;">
|
<style>
|
||||||
<div class="library-header"
|
/* Library Page Premium Styles */
|
||||||
style="margin-bottom: 3rem; display: flex; flex-direction: column; align-items: center; gap: 1.5rem;">
|
.library-container {
|
||||||
<h1 style="font-size: 2rem; font-weight: 700;">My Library</h1>
|
padding: 24px;
|
||||||
<div class="tabs"
|
max-width: 1600px;
|
||||||
style="display: flex; gap: 0.5rem; background: var(--yt-bg-secondary); padding: 0.4rem; border-radius: 100px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); align-items: center;">
|
margin: 0 auto;
|
||||||
<a href="/my-videos?type=history" class="yt-btn" id="tab-history"
|
}
|
||||||
style="border-radius: 100px; font-size: 0.95rem; padding: 0.6rem 2rem; font-weight:500; transition: all 0.2s;">History</a>
|
|
||||||
<a href="/my-videos?type=saved" class="yt-btn" id="tab-saved"
|
.library-header {
|
||||||
style="border-radius: 100px; font-size: 0.95rem; padding: 0.6rem 2rem; font-weight:500; transition: all 0.2s;">Saved</a>
|
margin-bottom: 2rem;
|
||||||
<a href="/my-videos?type=subscriptions" class="yt-btn" id="tab-subscriptions"
|
text-align: center;
|
||||||
style="border-radius: 100px; font-size: 0.95rem; padding: 0.6rem 2rem; font-weight:500; transition: all 0.2s;">Subscriptions</a>
|
}
|
||||||
|
|
||||||
|
.library-title {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
background: linear-gradient(135deg, var(--yt-text-primary) 0%, var(--yt-text-secondary) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-tabs {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--yt-bg-secondary);
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--yt-text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-tab:hover {
|
||||||
|
color: var(--yt-text-primary);
|
||||||
|
background: var(--yt-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-tab.active {
|
||||||
|
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-tab i {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--yt-border);
|
||||||
|
border-radius: 24px;
|
||||||
|
color: var(--yt-text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn:hover {
|
||||||
|
background: rgba(204, 0, 0, 0.1);
|
||||||
|
border-color: #cc0000;
|
||||||
|
color: #cc0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-stats {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 24px;
|
||||||
|
margin-top: 12px;
|
||||||
|
color: var(--yt-text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-stat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-stat i {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State Enhancement */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
color: var(--yt-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
margin: 0 auto 1.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--yt-bg-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 2rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h3 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--yt-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browse-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.browse-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px rgba(204, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="library-container">
|
||||||
|
<div class="library-header">
|
||||||
|
<h1 class="library-title">My Library</h1>
|
||||||
|
|
||||||
|
<div class="library-tabs">
|
||||||
|
<a href="/my-videos?type=history" class="library-tab" id="tab-history">
|
||||||
|
<i class="fas fa-history"></i>
|
||||||
|
<span>History</span>
|
||||||
|
</a>
|
||||||
|
<a href="/my-videos?type=saved" class="library-tab" id="tab-saved">
|
||||||
|
<i class="fas fa-bookmark"></i>
|
||||||
|
<span>Saved</span>
|
||||||
|
</a>
|
||||||
|
<a href="/my-videos?type=subscriptions" class="library-tab" id="tab-subscriptions">
|
||||||
|
<i class="fas fa-users"></i>
|
||||||
|
<span>Subscriptions</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Clear Button (Hidden by default) -->
|
<div class="library-stats" id="libraryStats" style="display: none;">
|
||||||
<button id="clearBtn" onclick="clearLibrary()" class="yt-btn"
|
<div class="library-stat">
|
||||||
style="display:none; color: var(--yt-text-secondary); background: transparent; border: 1px solid var(--yt-border); margin-top: 10px; font-size: 0.9rem;">
|
<i class="fas fa-video"></i>
|
||||||
<i class="fas fa-trash-alt"></i> Clear <span id="clearType">All</span>
|
<span id="videoCount">0 videos</span>
|
||||||
</button>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="library-actions">
|
||||||
|
<button id="clearBtn" onclick="clearLibrary()" class="clear-btn">
|
||||||
|
<i class="fas fa-trash-alt"></i>
|
||||||
|
<span>Clear <span id="clearType">All</span></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Video Grid -->
|
<!-- Video Grid -->
|
||||||
|
|
@ -28,32 +203,44 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div id="emptyState" style="text-align: center; padding: 4rem; color: var(--yt-text-secondary); display: none;">
|
<div id="emptyState" class="empty-state" style="display: none;">
|
||||||
<i class="fas fa-folder-open fa-3x" style="margin-bottom: 1rem; opacity: 0.5;"></i>
|
<div class="empty-state-icon">
|
||||||
|
<i class="fas fa-folder-open"></i>
|
||||||
|
</div>
|
||||||
<h3>Nothing here yet</h3>
|
<h3>Nothing here yet</h3>
|
||||||
<p id="emptyMsg">Go watch some videos to fill this up!</p>
|
<p id="emptyMsg">Go watch some videos to fill this up!</p>
|
||||||
<a href="/" class="yt-btn"
|
<a href="/" class="browse-btn">
|
||||||
style="margin-top: 1rem; background: var(--yt-text-primary); color: var(--yt-bg-primary);">Browse
|
<i class="fas fa-play"></i>
|
||||||
Content</a>
|
Browse Content
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
// Load library content - extracted to function for reuse on pageshow
|
||||||
|
function loadLibraryContent() {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
// Default to history if no type or invalid type
|
// Default to history if no type or invalid type
|
||||||
const type = urlParams.get('type') || 'history';
|
const type = urlParams.get('type') || 'history';
|
||||||
|
|
||||||
// Update Active Tab UI
|
// Reset all tabs first, then activate the correct one
|
||||||
|
document.querySelectorAll('.library-tab').forEach(tab => tab.classList.remove('active'));
|
||||||
const activeTab = document.getElementById(`tab-${type}`);
|
const activeTab = document.getElementById(`tab-${type}`);
|
||||||
if (activeTab) {
|
if (activeTab) {
|
||||||
activeTab.style.background = 'var(--yt-text-primary)';
|
activeTab.classList.add('active');
|
||||||
activeTab.style.color = 'var(--yt-bg-primary)';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const grid = document.getElementById('libraryGrid');
|
const grid = document.getElementById('libraryGrid');
|
||||||
const empty = document.getElementById('emptyState');
|
const empty = document.getElementById('emptyState');
|
||||||
const emptyMsg = document.getElementById('emptyMsg');
|
const emptyMsg = document.getElementById('emptyMsg');
|
||||||
|
const statsDiv = document.getElementById('libraryStats');
|
||||||
|
const clearBtn = document.getElementById('clearBtn');
|
||||||
|
|
||||||
|
// Reset UI before loading
|
||||||
|
grid.innerHTML = '';
|
||||||
|
empty.style.display = 'none';
|
||||||
|
if (statsDiv) statsDiv.style.display = 'none';
|
||||||
|
if (clearBtn) clearBtn.style.display = 'none';
|
||||||
|
|
||||||
// Mapping URL type to localStorage key suffix
|
// Mapping URL type to localStorage key suffix
|
||||||
// saved -> kv_saved
|
// saved -> kv_saved
|
||||||
|
|
@ -62,16 +249,24 @@
|
||||||
const storageKey = `kv_${type}`;
|
const storageKey = `kv_${type}`;
|
||||||
const data = JSON.parse(localStorage.getItem(storageKey) || '[]').filter(i => i && i.id);
|
const data = JSON.parse(localStorage.getItem(storageKey) || '[]').filter(i => i && i.id);
|
||||||
|
|
||||||
// Show Clear Button if there is data
|
// Show stats and Clear Button if there is data
|
||||||
if (data.length > 0) {
|
if (data.length > 0) {
|
||||||
empty.style.display = 'none';
|
empty.style.display = 'none';
|
||||||
const clearBtn = document.getElementById('clearBtn');
|
|
||||||
|
// Update stats
|
||||||
|
const videoCount = document.getElementById('videoCount');
|
||||||
|
if (statsDiv && videoCount) {
|
||||||
|
statsDiv.style.display = 'flex';
|
||||||
|
const countText = type === 'subscriptions'
|
||||||
|
? `${data.length} channel${data.length !== 1 ? 's' : ''}`
|
||||||
|
: `${data.length} video${data.length !== 1 ? 's' : ''}`;
|
||||||
|
videoCount.innerText = countText;
|
||||||
|
}
|
||||||
|
|
||||||
const clearTypeSpan = document.getElementById('clearType');
|
const clearTypeSpan = document.getElementById('clearType');
|
||||||
|
|
||||||
if (clearBtn) {
|
if (clearBtn) {
|
||||||
clearBtn.style.display = 'inline-flex';
|
clearBtn.style.display = 'inline-flex';
|
||||||
clearBtn.style.alignItems = 'center';
|
|
||||||
clearBtn.style.gap = '8px';
|
|
||||||
|
|
||||||
// Format type name for display
|
// Format type name for display
|
||||||
const typeName = type.charAt(0).toUpperCase() + type.slice(1);
|
const typeName = type.charAt(0).toUpperCase() + type.slice(1);
|
||||||
|
|
@ -155,6 +350,28 @@
|
||||||
emptyMsg.innerText = "No saved videos yet.";
|
emptyMsg.innerText = "No saved videos yet.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run on initial page load
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadLibraryContent();
|
||||||
|
|
||||||
|
// Intercept tab clicks for client-side navigation
|
||||||
|
document.querySelectorAll('.library-tab').forEach(tab => {
|
||||||
|
tab.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const newUrl = tab.getAttribute('href');
|
||||||
|
// Update URL without reloading
|
||||||
|
history.pushState(null, '', newUrl);
|
||||||
|
// Immediately load the new content
|
||||||
|
loadLibraryContent();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle browser back/forward buttons
|
||||||
|
window.addEventListener('popstate', () => {
|
||||||
|
loadLibraryContent();
|
||||||
});
|
});
|
||||||
|
|
||||||
function clearLibrary() {
|
function clearLibrary() {
|
||||||
|
|
|
||||||
|
|
@ -67,9 +67,24 @@
|
||||||
Queue
|
Queue
|
||||||
<span id="queueBadge" class="queue-badge" style="display:none;">0</span>
|
<span id="queueBadge" class="queue-badge" style="display:none;">0</span>
|
||||||
</button>
|
</button>
|
||||||
<!-- Summarize button removed -->
|
|
||||||
<!-- Transcribe button removed -->
|
<!-- View Mode Buttons -->
|
||||||
<!-- Rotation controls removed -->
|
<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>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -87,7 +102,7 @@
|
||||||
<p class="yt-video-stats" id="viewCount">0 views</p>
|
<p class="yt-video-stats" id="viewCount">0 views</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="yt-subscribe-btn" id="subscribeBtn">Subscribe</button>
|
<button class="yt-subscribe-btn" id="subscribeBtn" onclick="toggleSubscribe()">Subscribe</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
|
|
@ -730,7 +745,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Save to Library (Local Storage) ---
|
// --- Save to Library (Local Storage) ---
|
||||||
function saveToLibrary() {
|
// Note: Named toggleSaveToLibrary to avoid shadowing global saveToLibrary(type, item) in main.js
|
||||||
|
function toggleSaveToLibrary() {
|
||||||
const btn = document.getElementById('saveBtn');
|
const btn = document.getElementById('saveBtn');
|
||||||
if (!currentVideoData.id) {
|
if (!currentVideoData.id) {
|
||||||
showToast("Video data not ready", "error");
|
showToast("Video data not ready", "error");
|
||||||
|
|
@ -788,6 +804,154 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 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', () => {
|
||||||
|
const savedMode = 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 ---
|
// --- Download Video ---
|
||||||
async function downloadVideo() {
|
async function downloadVideo() {
|
||||||
const videoId = "{{ video_id }}";
|
const videoId = "{{ video_id }}";
|
||||||
|
|
@ -1132,6 +1296,9 @@
|
||||||
const uploaderName = data.uploader || 'Unknown';
|
const uploaderName = data.uploader || 'Unknown';
|
||||||
document.getElementById('channelAvatarLetter').innerText = uploaderName.charAt(0).toUpperCase();
|
document.getElementById('channelAvatarLetter').innerText = uploaderName.charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
// Update subscribe button state based on stored subscriptions
|
||||||
|
updateSubscribeButtonState();
|
||||||
|
|
||||||
// Save to History (Local & Server)
|
// Save to History (Local & Server)
|
||||||
const historyItem = {
|
const historyItem = {
|
||||||
id: videoId,
|
id: videoId,
|
||||||
|
|
|
||||||
269
wsgi.py
269
wsgi.py
|
|
@ -215,27 +215,66 @@ def get_history():
|
||||||
|
|
||||||
@app.route("/api/suggested")
|
@app.route("/api/suggested")
|
||||||
def get_suggested():
|
def get_suggested():
|
||||||
# Simple recommendation based on history: search for "trending" related to the last 3 viewed channels/titles
|
"""
|
||||||
conn = get_db_connection()
|
Get suggested videos based on watch history.
|
||||||
history = conn.execute(
|
Accepts both server-side DB history and client-side localStorage history.
|
||||||
'SELECT title FROM user_videos WHERE type = "history" ORDER BY timestamp DESC LIMIT 3'
|
Query params:
|
||||||
).fetchall()
|
- titles: comma-separated list of watched video titles (from localStorage)
|
||||||
conn.close()
|
- channels: comma-separated list of watched channel names (from localStorage)
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
|
||||||
if not history:
|
# Get client-side history from query params
|
||||||
|
client_titles = request.args.get("titles", "")
|
||||||
|
client_channels = request.args.get("channels", "")
|
||||||
|
|
||||||
|
history_titles = []
|
||||||
|
history_channels = []
|
||||||
|
|
||||||
|
# Parse client-side history
|
||||||
|
if client_titles:
|
||||||
|
history_titles = [t.strip() for t in client_titles.split(",") if t.strip()][:5]
|
||||||
|
if client_channels:
|
||||||
|
history_channels = [c.strip() for c in client_channels.split(",") if c.strip()][:3]
|
||||||
|
|
||||||
|
# Also get server-side history as fallback
|
||||||
|
if not history_titles:
|
||||||
|
try:
|
||||||
|
conn = get_db_connection()
|
||||||
|
rows = conn.execute(
|
||||||
|
'SELECT title FROM user_videos WHERE type = "history" ORDER BY timestamp DESC LIMIT 5'
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
history_titles = [row['title'] for row in rows]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# If still no history, return trending
|
||||||
|
if not history_titles:
|
||||||
return jsonify(fetch_videos("trending", limit=20))
|
return jsonify(fetch_videos("trending", limit=20))
|
||||||
|
|
||||||
all_suggestions = []
|
all_suggestions = []
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
|
|
||||||
queries = [f"{row['title']} related" for row in history]
|
# Build queries from history titles
|
||||||
results = list(executor.map(lambda q: fetch_videos(q, limit=10), queries))
|
queries = []
|
||||||
|
for title in history_titles[:3]:
|
||||||
|
# Extract key words from title (first 3-4 words usually capture the topic)
|
||||||
|
words = title.split()[:4]
|
||||||
|
query_base = " ".join(words)
|
||||||
|
queries.append(f"{query_base} related -shorts")
|
||||||
|
|
||||||
|
# Add channel-based queries
|
||||||
|
for channel in history_channels[:2]:
|
||||||
|
queries.append(f"{channel} latest videos -shorts")
|
||||||
|
|
||||||
|
# Fetch in parallel
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
|
||||||
|
results = list(executor.map(lambda q: fetch_videos(q, limit=8, filter_type="video"), queries))
|
||||||
for res in results:
|
for res in results:
|
||||||
all_suggestions.extend(res)
|
all_suggestions.extend(res)
|
||||||
|
|
||||||
# Remove duplicates and shuffle
|
# Remove duplicates
|
||||||
unique_vids = {v["id"]: v for v in all_suggestions}.values()
|
unique_vids = {v["id"]: v for v in all_suggestions}.values()
|
||||||
import random
|
|
||||||
|
|
||||||
final_list = list(unique_vids)
|
final_list = list(unique_vids)
|
||||||
random.shuffle(final_list)
|
random.shuffle(final_list)
|
||||||
|
|
||||||
|
|
@ -365,7 +404,11 @@ def channel(channel_id):
|
||||||
real_id_or_url = channel_id
|
real_id_or_url = channel_id
|
||||||
is_search_fallback = False
|
is_search_fallback = False
|
||||||
|
|
||||||
if not channel_id.startswith("UC") and not channel_id.startswith("@"):
|
# If channel_id is @UCN... format, strip the @ to get the proper UC ID
|
||||||
|
if channel_id.startswith("@UC"):
|
||||||
|
real_id_or_url = channel_id[1:] # Remove the @ prefix
|
||||||
|
|
||||||
|
if not real_id_or_url.startswith("UC") and not real_id_or_url.startswith("@"):
|
||||||
# Simple resolve logic - reusing similar block from before but optimized for metadata
|
# Simple resolve logic - reusing similar block from before but optimized for metadata
|
||||||
search_cmd = [
|
search_cmd = [
|
||||||
sys.executable,
|
sys.executable,
|
||||||
|
|
@ -434,6 +477,24 @@ def channel(channel_id):
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# If title is still just the ID, try to get channel name with --print
|
||||||
|
if channel_info["title"].startswith("UC") or channel_info["title"].startswith("@"):
|
||||||
|
try:
|
||||||
|
name_cmd = [
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"yt_dlp",
|
||||||
|
target_url,
|
||||||
|
"--print", "channel",
|
||||||
|
"--playlist-items", "1",
|
||||||
|
"--no-warnings",
|
||||||
|
]
|
||||||
|
name_proc = subprocess.run(name_cmd, capture_output=True, text=True, timeout=15)
|
||||||
|
if name_proc.returncode == 0 and name_proc.stdout.strip():
|
||||||
|
channel_info["title"] = name_proc.stdout.strip()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
# Render shell - videos fetched via JS
|
# Render shell - videos fetched via JS
|
||||||
return render_template("channel.html", channel=channel_info)
|
return render_template("channel.html", channel=channel_info)
|
||||||
|
|
||||||
|
|
@ -625,47 +686,67 @@ def get_download_formats():
|
||||||
|
|
||||||
# Categorize by type
|
# Categorize by type
|
||||||
if f_ext == "mp4" or f_ext == "webm":
|
if f_ext == "mp4" or f_ext == "webm":
|
||||||
# Check if it's video or audio
|
vcodec = f.get("vcodec", "none")
|
||||||
if (
|
acodec = f.get("acodec", "none")
|
||||||
f.get("vcodec", "none") != "none"
|
|
||||||
and f.get("acodec", "none") == "none"
|
# Combined video+audio format (best for downloads with sound!)
|
||||||
):
|
if vcodec != "none" and acodec != "none":
|
||||||
# Video only - include detailed specs
|
width = f.get("width", 0)
|
||||||
|
height = f.get("height", 0)
|
||||||
|
resolution = f"{width}x{height}" if width and height else None
|
||||||
|
fps = f.get("fps", 0)
|
||||||
|
vbr = f.get("vbr", 0) or f.get("tbr", 0)
|
||||||
|
|
||||||
|
video_formats.append({
|
||||||
|
"quality": f"{quality} (with audio)",
|
||||||
|
"ext": f_ext,
|
||||||
|
"size": size_str,
|
||||||
|
"size_bytes": f_filesize,
|
||||||
|
"url": f_url,
|
||||||
|
"type": "combined", # Has both video and audio!
|
||||||
|
"resolution": resolution,
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
"fps": fps,
|
||||||
|
"vcodec": vcodec.split(".")[0],
|
||||||
|
"acodec": acodec.split(".")[0],
|
||||||
|
"bitrate": int(vbr) if vbr else None,
|
||||||
|
"has_audio": True,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Video only - include detailed specs
|
||||||
|
elif vcodec != "none" and acodec == "none":
|
||||||
|
# Get resolution
|
||||||
|
width = f.get("width", 0)
|
||||||
|
height = f.get("height", 0)
|
||||||
|
resolution = f"{width}x{height}" if width and height else None
|
||||||
|
|
||||||
|
# Get codec (simplified name)
|
||||||
|
codec_display = vcodec.split(".")[0] if vcodec else ""
|
||||||
|
|
||||||
|
# Get fps and bitrate
|
||||||
|
fps = f.get("fps", 0)
|
||||||
|
vbr = f.get("vbr", 0) or f.get("tbr", 0)
|
||||||
|
|
||||||
if quality not in ["audio only", "unknown"]:
|
if quality not in ["audio only", "unknown"]:
|
||||||
# Get resolution
|
video_formats.append({
|
||||||
width = f.get("width", 0)
|
"quality": quality,
|
||||||
height = f.get("height", 0)
|
"ext": f_ext,
|
||||||
resolution = f"{width}x{height}" if width and height else None
|
"size": size_str,
|
||||||
|
"size_bytes": f_filesize,
|
||||||
|
"url": f_url,
|
||||||
|
"type": "video",
|
||||||
|
"resolution": resolution,
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
"fps": fps,
|
||||||
|
"vcodec": codec_display,
|
||||||
|
"bitrate": int(vbr) if vbr else None,
|
||||||
|
"has_audio": False,
|
||||||
|
})
|
||||||
|
|
||||||
# Get codec (simplified name)
|
# Audio only
|
||||||
vcodec = f.get("vcodec", "")
|
elif acodec != "none" and vcodec == "none":
|
||||||
codec_display = vcodec.split(".")[0] if vcodec else "" # e.g., "avc1" from "avc1.4d401f"
|
|
||||||
|
|
||||||
# Get fps and bitrate
|
|
||||||
fps = f.get("fps", 0)
|
|
||||||
vbr = f.get("vbr", 0) or f.get("tbr", 0) # video bitrate in kbps
|
|
||||||
|
|
||||||
video_formats.append(
|
|
||||||
{
|
|
||||||
"quality": quality,
|
|
||||||
"ext": f_ext,
|
|
||||||
"size": size_str,
|
|
||||||
"size_bytes": f_filesize,
|
|
||||||
"url": f_url,
|
|
||||||
"type": "video",
|
|
||||||
"resolution": resolution,
|
|
||||||
"width": width,
|
|
||||||
"height": height,
|
|
||||||
"fps": fps,
|
|
||||||
"vcodec": codec_display,
|
|
||||||
"bitrate": int(vbr) if vbr else None,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
elif (
|
|
||||||
f.get("acodec", "none") != "none"
|
|
||||||
and f.get("vcodec", "none") == "none"
|
|
||||||
):
|
|
||||||
# Audio only - include detailed specs
|
|
||||||
acodec = f.get("acodec", "")
|
acodec = f.get("acodec", "")
|
||||||
codec_display = acodec.split(".")[0] if acodec else ""
|
codec_display = acodec.split(".")[0] if acodec else ""
|
||||||
|
|
||||||
|
|
@ -1562,22 +1643,37 @@ def trending():
|
||||||
# === 1. Suggested For You (History Based) ===
|
# === 1. Suggested For You (History Based) ===
|
||||||
suggested_videos = []
|
suggested_videos = []
|
||||||
try:
|
try:
|
||||||
conn = get_db_connection()
|
import random
|
||||||
# Get last 5 videos for context
|
|
||||||
history = conn.execute(
|
|
||||||
'SELECT title, video_id, type FROM user_videos WHERE type = "history" ORDER BY timestamp DESC LIMIT 5'
|
|
||||||
).fetchall()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
if history:
|
# Get client-side history from query params (from localStorage)
|
||||||
# Create a composite query from history
|
client_titles = request.args.get("history_titles", "")
|
||||||
import random
|
client_channels = request.args.get("history_channels", "")
|
||||||
|
|
||||||
|
history_titles = []
|
||||||
|
history_channels = []
|
||||||
|
|
||||||
|
if client_titles:
|
||||||
|
history_titles = [t.strip() for t in client_titles.split(",") if t.strip()][:5]
|
||||||
|
if client_channels:
|
||||||
|
history_channels = [c.strip() for c in client_channels.split(",") if c.strip()][:3]
|
||||||
|
|
||||||
|
# Fallback to server-side history if no client history
|
||||||
|
if not history_titles:
|
||||||
|
try:
|
||||||
|
conn = get_db_connection()
|
||||||
|
rows = conn.execute(
|
||||||
|
'SELECT title, video_id FROM user_videos WHERE type = "history" ORDER BY timestamp DESC LIMIT 5'
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
history_titles = [row["title"] for row in rows]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if history_titles:
|
||||||
# Pick 1-2 random items from recent history to diversify
|
# Pick 1-2 random items from recent history to diversify
|
||||||
bases = random.sample(history, min(len(history), 2))
|
bases = random.sample(history_titles, min(len(history_titles), 2))
|
||||||
query_parts = [row["title"] for row in bases]
|
query_parts = [" ".join(title.split()[:4]) for title in bases] # First 4 words
|
||||||
# Add "related" to find similar content, not exact same
|
suggestion_query = " ".join(query_parts) + " related -shorts"
|
||||||
suggestion_query = " ".join(query_parts) + " related"
|
|
||||||
suggested_videos = fetch_videos(
|
suggested_videos = fetch_videos(
|
||||||
suggestion_query, limit=16, filter_type="video"
|
suggestion_query, limit=16, filter_type="video"
|
||||||
)
|
)
|
||||||
|
|
@ -1609,6 +1705,36 @@ def trending():
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# === 3. More From Your Channels (Same-Channel Recommendations) ===
|
||||||
|
channel_videos = []
|
||||||
|
channel_name = "Channels"
|
||||||
|
try:
|
||||||
|
conn = get_db_connection()
|
||||||
|
# Get unique channels from recent history
|
||||||
|
channels = conn.execute(
|
||||||
|
'''SELECT DISTINCT channel_id, uploader FROM user_videos
|
||||||
|
WHERE type = "history" AND channel_id IS NOT NULL AND channel_id != ""
|
||||||
|
ORDER BY timestamp DESC LIMIT 3'''
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if channels:
|
||||||
|
# Pick a random channel from recent history
|
||||||
|
import random
|
||||||
|
selected_channel = random.choice(channels)
|
||||||
|
channel_id = selected_channel["channel_id"]
|
||||||
|
channel_name = selected_channel["uploader"] or "Channel"
|
||||||
|
|
||||||
|
# Fetch videos from this channel
|
||||||
|
if channel_id:
|
||||||
|
channel_videos = fetch_videos(
|
||||||
|
f"channel:{channel_id}",
|
||||||
|
limit=8,
|
||||||
|
filter_type="video"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Channel recommendation error: {e}")
|
||||||
|
|
||||||
# === New Progressive Loading Strategy ===
|
# === New Progressive Loading Strategy ===
|
||||||
feed_type = request.args.get('feed_type', 'all') # 'primary', 'secondary', or 'all'
|
feed_type = request.args.get('feed_type', 'all') # 'primary', 'secondary', or 'all'
|
||||||
final_sections = []
|
final_sections = []
|
||||||
|
|
@ -1633,7 +1759,16 @@ def trending():
|
||||||
"videos": discovery_videos[:8], # Limit to 8
|
"videos": discovery_videos[:8], # Limit to 8
|
||||||
})
|
})
|
||||||
|
|
||||||
# 3. Trending (Standard)
|
# 3. More From Your Channels (Same-Channel Recommendations)
|
||||||
|
if channel_videos:
|
||||||
|
final_sections.append({
|
||||||
|
"id": "channel_rec",
|
||||||
|
"title": f"More from {channel_name}",
|
||||||
|
"icon": "user-circle",
|
||||||
|
"videos": channel_videos[:8],
|
||||||
|
})
|
||||||
|
|
||||||
|
# 4. Trending (Standard)
|
||||||
# Limit reduced to 8 (2 rows) for speed
|
# Limit reduced to 8 (2 rows) for speed
|
||||||
trending_videos = fetch_videos(get_query("trending", region, "relevance"), limit=8, filter_type="video")
|
trending_videos = fetch_videos(get_query("trending", region, "relevance"), limit=8, filter_type="video")
|
||||||
if trending_videos:
|
if trending_videos:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue