feat: enforce horizontal videos, personalize categories, remove shorts

This commit is contained in:
KV-Tube Deployer 2026-01-02 12:35:33 +07:00
parent 0ebea200b0
commit 7dee541640
19 changed files with 2601 additions and 2581 deletions

View file

@ -1,17 +1,22 @@
# KV-Tube #
**KV-Tube** is a distraction-free, privacy-focused YouTube frontend designed for a premium viewing experience.
A modern, ad-free YouTube web client and video proxy designed for **Synology NAS** and personal home servers. ### 🚀 **New Features (v2.0 Updates)**
* **Horizontal-First Experience**: Strictly enforces horizontal videos across all categories. "Shorts" and vertical content are aggressively filtered out for a cleaner, cinematic feed.
* **Personalized Discovery**:
* **Suggested for You**: Dynamic recommendations based on your local watch history.
* **You Might Like**: curated discovery topics to help you find new interests.
* **Refined Tech Feed**: Specialized "Tech & AI" section focusing on gadget reviews, unboxings, and deep dives (no spammy vertical clips).
* **Performance**: Optimized fetching limits to ensure rich, full grids of content despite strict filtering.
## ✨ Features ## Features
* **No Ads**: Watch videos without interruptions.
- **Ad-Free Watching**: Clean interface without distractions. * **Privacy Focused**: No Google account required. Watch history is stored locally (managed by SQLite).
- **Smart Search**: Directly search YouTube content.
- **Trending**: Browse trending videos by category (Tech, Music, Gaming, etc.). - **Trending**: Browse trending videos by category (Tech, Music, Gaming, etc.).
- **Auto-Captions**: English subtitles automatically enabled if available. - **Auto-Captions**: English subtitles automatically enabled if available.
- **AI Summary**: (Optional) Extractive summarization of video content running locally. - **AI Summary**: (Optional) Extractive summarization of video content running locally.
- **PWA Ready**: Installable on mobile devices with a responsive drawer layout. - **PWA Ready**: Installable on mobile devices with a responsive drawer layout.
- **Dark/Light Mode**: User preference persisted in settings. - **Dark/Light Mode**: User preference persisted in settings.
- **Privacy Focused**: Everything runs on your server.
## 🚀 Deployment ## 🚀 Deployment

137
app.py
View file

@ -916,6 +916,11 @@ def summarize_video():
# Helper function to fetch videos (not a route) # Helper function to fetch videos (not a route)
def fetch_videos(query, limit=20, filter_type=None, playlist_start=1, playlist_end=None): def fetch_videos(query, limit=20, filter_type=None, playlist_start=1, playlist_end=None):
try: try:
# Source-Level Filter: Exclude Shorts for standard video requests
# REMOVED: Causing 0 results with complex queries. Rely on Python filtering.
# if filter_type == 'video':
# query = f"{query} -shorts -#shorts"
# If no end specified, default to start + limit - 1 # If no end specified, default to start + limit - 1
if not playlist_end: if not playlist_end:
playlist_end = playlist_start + limit - 1 playlist_end = playlist_start + limit - 1
@ -944,8 +949,19 @@ def fetch_videos(query, limit=20, filter_type=None, playlist_start=1, playlist_e
duration_secs = data.get('duration') duration_secs = data.get('duration')
# Filter Logic # Filter Logic
if filter_type == 'video' and duration_secs and int(duration_secs) <= 60: title_lower = data.get('title', '').lower()
continue if filter_type == 'video':
# STRICT: If duration is missing, DO NOT SKIP. Just trust the query exclusion.
# if not duration_secs:
# continue
# Exclude explicit Shorts
if '#shorts' in title_lower:
continue
# Exclude short duration (buffer to 70s to avoid vertical clutter) ONLY IF WE KNOW IT
if duration_secs and int(duration_secs) <= 70:
continue
if filter_type == 'short' and duration_secs and int(duration_secs) > 60: if filter_type == 'short' and duration_secs and int(duration_secs) > 60:
continue continue
@ -985,37 +1001,36 @@ def trending():
region = request.args.get('region', 'vietnam') region = request.args.get('region', 'vietnam')
limit = 120 if category != 'all' else 20 # 120 for grid, 20 for sections limit = 120 if category != 'all' else 20 # 120 for grid, 20 for sections
# Helper to build query
def get_query(cat, reg, s_sort): def get_query(cat, reg, s_sort):
if reg == 'vietnam': if reg == 'vietnam':
queries = { queries = {
'general': 'trending vietnam', 'general': 'trending vietnam -shorts',
'tech': 'AI tools software tech review IT việt nam', 'tech': 'review công nghệ điện thoại laptop',
'all': 'trending vietnam', 'all': 'trending vietnam -shorts',
'music': 'nhạc việt trending', 'music': 'nhạc việt trending -shorts',
'gaming': 'gaming việt nam', 'gaming': 'gaming việt nam -shorts',
'movies': 'phim việt nam', 'movies': 'phim việt nam -shorts',
'news': 'tin tức việt nam hôm nay', 'news': 'tin tức việt nam hôm nay -shorts',
'sports': 'thể thao việt nam', 'sports': 'thể thao việt nam -shorts',
'shorts': 'trending việt nam', 'shorts': 'trending việt nam',
'trending': 'trending việt nam', 'trending': 'trending việt nam -shorts',
'podcasts': 'podcast việt nam', 'podcasts': 'podcast việt nam -shorts',
'live': 'live stream việt nam' 'live': 'live stream việt nam -shorts'
} }
else: else:
queries = { queries = {
'general': 'trending', 'general': 'trending -shorts',
'tech': 'AI tools software tech review IT', 'tech': 'tech gadget review smartphone',
'all': 'trending', 'all': 'trending -shorts',
'music': 'music trending', 'music': 'music trending -shorts',
'gaming': 'gaming trending', 'gaming': 'gaming trending -shorts',
'movies': 'movies trending', 'movies': 'movies trending -shorts',
'news': 'news today', 'news': 'news today -shorts',
'sports': 'sports highlights', 'sports': 'sports highlights -shorts',
'shorts': 'trending', 'shorts': 'trending',
'trending': 'trending now', 'trending': 'trending now -shorts',
'podcasts': 'podcast trending', 'podcasts': 'podcast trending -shorts',
'live': 'live stream' 'live': 'live stream -shorts'
} }
base = queries.get(cat, 'trending') base = queries.get(cat, 'trending')
@ -1039,14 +1054,46 @@ def trending():
# === Parallel Fetching for Home Feed === # === Parallel Fetching for Home Feed ===
if category == 'all': if category == 'all':
# === 1. Suggested For You (History Based) ===
suggested_videos = []
try:
conn = get_db_connection()
# 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:
# Create a composite query from history
import random
# Pick 1-2 random items from recent history to diversify
bases = random.sample(history, min(len(history), 2))
query_parts = [row['title'] for row in bases]
# Add "related" to find similar content, not exact same
suggestion_query = " ".join(query_parts) + " related"
suggested_videos = fetch_videos(suggestion_query, limit=16, filter_type='video')
except Exception as e:
print(f"Suggestion Error: {e}")
# === 2. You Might Like (Discovery) ===
discovery_videos = []
try:
# curated list of interesting topics to rotate
topics = ['amazing inventions', 'primitive technology', 'street food around the world',
'documentary 2024', 'space exploration', 'wildlife 4k', 'satisfying restoration',
'travel vlog 4k', 'tech gadgets review', 'coding tutorial']
import random
topic = random.choice(topics)
discovery_videos = fetch_videos(f"{topic} best", limit=16, filter_type='video')
except: pass
# === Define Standard Sections ===
sections_to_fetch = [ sections_to_fetch = [
{'id': 'trending', 'title': 'Trending Now', 'icon': 'fire'}, {'id': 'trending', 'title': 'Trending Now', 'icon': 'fire'},
{'id': 'all', 'title': 'New Releases', 'icon': 'clock'}, # 'all' results in trending, but we'll sort by newest
{'id': 'tech', 'title': 'AI & Tech', 'icon': 'microchip'},
{'id': 'music', 'title': 'Music', 'icon': 'music'}, {'id': 'music', 'title': 'Music', 'icon': 'music'},
{'id': 'tech', 'title': 'Tech & AI', 'icon': 'microchip'},
{'id': 'movies', 'title': 'Movies', 'icon': 'film'}, {'id': 'movies', 'title': 'Movies', 'icon': 'film'},
{'id': 'news', 'title': 'News', 'icon': 'newspaper'},
{'id': 'gaming', 'title': 'Gaming', 'icon': 'gamepad'}, {'id': 'gaming', 'title': 'Gaming', 'icon': 'gamepad'},
{'id': 'news', 'title': 'News', 'icon': 'newspaper'},
{'id': 'sports', 'title': 'Sports', 'icon': 'football-ball'} {'id': 'sports', 'title': 'Sports', 'icon': 'football-ball'}
] ]
@ -1055,7 +1102,9 @@ def trending():
q = get_query(section['id'], region, target_sort) q = get_query(section['id'], region, target_sort)
# Add a unique component to query for freshness # Add a unique component to query for freshness
q_fresh = f"{q} {int(time.time())}" if section['id'] == 'all' else q q_fresh = f"{q} {int(time.time())}" if section['id'] == 'all' else q
vids = fetch_videos(q_fresh, limit=20, filter_type='video', playlist_start=1)
# Increase fetch limit to 150 (was 100) to compensate for strict filtering (dropping shorts/no-duration)
vids = fetch_videos(q_fresh, limit=150, filter_type='video', playlist_start=1)
return { return {
'id': section['id'], 'id': section['id'],
'title': section['title'], 'title': section['title'],
@ -1064,9 +1113,33 @@ def trending():
} }
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor: with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor:
results = list(executor.map(fetch_section, sections_to_fetch)) standard_results = list(executor.map(fetch_section, sections_to_fetch))
return jsonify({'mode': 'sections', 'data': results}) # === Assemble Final Feed ===
final_sections = []
# Add Suggested if we have them
if suggested_videos:
final_sections.append({
'id': 'suggested',
'title': 'Suggested for You',
'icon': 'sparkles',
'videos': suggested_videos
})
# Add Discovery
if discovery_videos:
final_sections.append({
'id': 'discovery',
'title': 'You Might Like',
'icon': 'compass',
'videos': discovery_videos
})
# Add Standard Sections
final_sections.extend(standard_results)
return jsonify({'mode': 'sections', 'data': final_sections})
# === Standard Single Category Fetch === # === Standard Single Category Fetch ===
query = get_query(category, region, sort) query = get_query(category, region, sort)

View file

@ -1,66 +1,66 @@
@echo off @echo off
REM deploy-docker.bat - Build and push KV-Tube to Docker Hub REM deploy-docker.bat - Build and push KV-Tube to Docker Hub
set DOCKER_USER=vndangkhoa set DOCKER_USER=vndangkhoa
set IMAGE_NAME=kvtube set IMAGE_NAME=kvtube
set TAG=latest set TAG=latest
set FULL_IMAGE=%DOCKER_USER%/%IMAGE_NAME%:%TAG% set FULL_IMAGE=%DOCKER_USER%/%IMAGE_NAME%:%TAG%
echo ======================================== echo ========================================
echo KV-Tube Docker Deployment Script echo KV-Tube Docker Deployment Script
echo ======================================== echo ========================================
echo. echo.
REM Step 1: Check Docker REM Step 1: Check Docker
echo [1/4] Checking Docker... echo [1/4] Checking Docker...
docker info >nul 2>&1 docker info >nul 2>&1
if %errorlevel% neq 0 ( if %errorlevel% neq 0 (
echo X Docker is not running. Please start Docker Desktop. echo X Docker is not running. Please start Docker Desktop.
pause pause
exit /b 1 exit /b 1
) )
echo OK Docker is running echo OK Docker is running
REM Step 2: Build Image REM Step 2: Build Image
echo. echo.
echo [2/4] Building Docker image: %FULL_IMAGE% echo [2/4] Building Docker image: %FULL_IMAGE%
docker build --no-cache -t %FULL_IMAGE% . docker build --no-cache -t %FULL_IMAGE% .
if %errorlevel% neq 0 ( if %errorlevel% neq 0 (
echo X Build failed! echo X Build failed!
pause pause
exit /b 1 exit /b 1
) )
echo OK Build successful echo OK Build successful
REM Step 3: Login to Docker Hub REM Step 3: Login to Docker Hub
echo. echo.
echo [3/4] Logging into Docker Hub... echo [3/4] Logging into Docker Hub...
docker login docker login
if %errorlevel% neq 0 ( if %errorlevel% neq 0 (
echo X Login failed! echo X Login failed!
pause pause
exit /b 1 exit /b 1
) )
echo OK Login successful echo OK Login successful
REM Step 4: Push Image REM Step 4: Push Image
echo. echo.
echo [4/4] Pushing to Docker Hub... echo [4/4] Pushing to Docker Hub...
docker push %FULL_IMAGE% docker push %FULL_IMAGE%
if %errorlevel% neq 0 ( if %errorlevel% neq 0 (
echo X Push failed! echo X Push failed!
pause pause
exit /b 1 exit /b 1
) )
echo OK Push successful echo OK Push successful
echo. echo.
echo ======================================== echo ========================================
echo Deployment Complete! echo Deployment Complete!
echo Image: %FULL_IMAGE% echo Image: %FULL_IMAGE%
echo URL: https://hub.docker.com/r/%DOCKER_USER%/%IMAGE_NAME% echo URL: https://hub.docker.com/r/%DOCKER_USER%/%IMAGE_NAME%
echo ======================================== echo ========================================
echo. echo.
echo To run: docker run -p 5001:5001 %FULL_IMAGE% echo To run: docker run -p 5001:5001 %FULL_IMAGE%
echo. echo.
pause pause

View file

@ -1,63 +1,63 @@
#!/usr/bin/env pwsh #!/usr/bin/env pwsh
# deploy-docker.ps1 - Build and push KV-Tube to Docker Hub # deploy-docker.ps1 - Build and push KV-Tube to Docker Hub
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$DOCKER_USER = "vndangkhoa" $DOCKER_USER = "vndangkhoa"
$IMAGE_NAME = "kvtube" $IMAGE_NAME = "kvtube"
$TAG = "latest" $TAG = "latest"
$FULL_IMAGE = "${DOCKER_USER}/${IMAGE_NAME}:${TAG}" $FULL_IMAGE = "${DOCKER_USER}/${IMAGE_NAME}:${TAG}"
Write-Host "========================================" -ForegroundColor Cyan Write-Host "========================================" -ForegroundColor Cyan
Write-Host " KV-Tube Docker Deployment Script" -ForegroundColor Cyan Write-Host " KV-Tube Docker Deployment Script" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan Write-Host "========================================" -ForegroundColor Cyan
Write-Host "" Write-Host ""
# Step 1: Check Docker # Step 1: Check Docker
Write-Host "[1/4] Checking Docker..." -ForegroundColor Yellow Write-Host "[1/4] Checking Docker..." -ForegroundColor Yellow
try { try {
docker info | Out-Null docker info | Out-Null
Write-Host " ✓ Docker is running" -ForegroundColor Green Write-Host " ✓ Docker is running" -ForegroundColor Green
} catch { } catch {
Write-Host " ✗ Docker is not running. Please start Docker Desktop." -ForegroundColor Red Write-Host " ✗ Docker is not running. Please start Docker Desktop." -ForegroundColor Red
exit 1 exit 1
} }
# Step 2: Build Image # Step 2: Build Image
Write-Host "" Write-Host ""
Write-Host "[2/4] Building Docker image: $FULL_IMAGE" -ForegroundColor Yellow Write-Host "[2/4] Building Docker image: $FULL_IMAGE" -ForegroundColor Yellow
docker build --no-cache -t $FULL_IMAGE . docker build --no-cache -t $FULL_IMAGE .
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
Write-Host " ✗ Build failed!" -ForegroundColor Red Write-Host " ✗ Build failed!" -ForegroundColor Red
exit 1 exit 1
} }
Write-Host " ✓ Build successful" -ForegroundColor Green Write-Host " ✓ Build successful" -ForegroundColor Green
# Step 3: Login to Docker Hub # Step 3: Login to Docker Hub
Write-Host "" Write-Host ""
Write-Host "[3/4] Logging into Docker Hub..." -ForegroundColor Yellow Write-Host "[3/4] Logging into Docker Hub..." -ForegroundColor Yellow
docker login docker login
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
Write-Host " ✗ Login failed!" -ForegroundColor Red Write-Host " ✗ Login failed!" -ForegroundColor Red
exit 1 exit 1
} }
Write-Host " ✓ Login successful" -ForegroundColor Green Write-Host " ✓ Login successful" -ForegroundColor Green
# Step 4: Push Image # Step 4: Push Image
Write-Host "" Write-Host ""
Write-Host "[4/4] Pushing to Docker Hub..." -ForegroundColor Yellow Write-Host "[4/4] Pushing to Docker Hub..." -ForegroundColor Yellow
docker push $FULL_IMAGE docker push $FULL_IMAGE
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
Write-Host " ✗ Push failed!" -ForegroundColor Red Write-Host " ✗ Push failed!" -ForegroundColor Red
exit 1 exit 1
} }
Write-Host " ✓ Push successful" -ForegroundColor Green Write-Host " ✓ Push successful" -ForegroundColor Green
Write-Host "" Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Deployment Complete!" -ForegroundColor Cyan Write-Host " Deployment Complete!" -ForegroundColor Cyan
Write-Host " Image: $FULL_IMAGE" -ForegroundColor Cyan Write-Host " Image: $FULL_IMAGE" -ForegroundColor Cyan
Write-Host " URL: https://hub.docker.com/r/${DOCKER_USER}/${IMAGE_NAME}" -ForegroundColor Cyan Write-Host " URL: https://hub.docker.com/r/${DOCKER_USER}/${IMAGE_NAME}" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan Write-Host "========================================" -ForegroundColor Cyan
Write-Host "" Write-Host ""
Write-Host "To run: docker run -p 5001:5001 $FULL_IMAGE" -ForegroundColor White Write-Host "To run: docker run -p 5001:5001 $FULL_IMAGE" -ForegroundColor White

View file

@ -1,46 +1,46 @@
Product Requirements Document (PRD) - KV-Tube Product Requirements Document (PRD) - KV-Tube
1. Product Overview 1. Product Overview
Product Name: KV-Tube Version: 1.0 (In Development) Description: KV-Tube is a comprehensive media center web application designed to provide an ad-free YouTube experience, a curated movie streaming service, and a local video management system. It emphasizes privacy, absence of advertisements, and utility features like AI summarization and language learning tools. Product Name: KV-Tube Version: 1.0 (In Development) Description: KV-Tube is a comprehensive media center web application designed to provide an ad-free YouTube experience, a curated movie streaming service, and a local video management system. It emphasizes privacy, absence of advertisements, and utility features like AI summarization and language learning tools.
2. User Personas 2. User Personas
The Binge Watcher: Wants uninterrupted access to YouTube content and movies without ads. The Binge Watcher: Wants uninterrupted access to YouTube content and movies without ads.
The Archivist: Maintains a local collection of videos and wants a clean interface to organize and watch them securely. The Archivist: Maintains a local collection of videos and wants a clean interface to organize and watch them securely.
The Learner: Uses video content for educational purposes, specifically English learning. The Learner: Uses video content for educational purposes, specifically English learning.
The Tech Enthusiast: Appreciates PWA support, torrent integration, and customizable settings. The Tech Enthusiast: Appreciates PWA support, torrent integration, and customizable settings.
3. Core Features 3. Core Features
3.1. YouTube Viewer (Home) 3.1. YouTube Viewer (Home)
Ad-Free Experience: Plays YouTube videos without third-party advertisements. Ad-Free Experience: Plays YouTube videos without third-party advertisements.
Search: Integrated search bar powered by yt-dlp to find videos, channels, and playlists. Search: Integrated search bar powered by yt-dlp to find videos, channels, and playlists.
Playback: Custom video player with support for quality selection and playback speed. Playback: Custom video player with support for quality selection and playback speed.
AI Summarization: Feature to summarize video content using Google Gemini API (Optional). AI Summarization: Feature to summarize video content using Google Gemini API (Optional).
3.2. local Video Manager ("My Videos") 3.2. local Video Manager ("My Videos")
Secure Access: Password-protected section for personal video collections. Secure Access: Password-protected section for personal video collections.
File Management: Scans local directories for video files. File Management: Scans local directories for video files.
Metadata: Extracts metadata (duration, format) and generates thumbnails using FFmpeg/MoviePy. Metadata: Extracts metadata (duration, format) and generates thumbnails using FFmpeg/MoviePy.
Playback: Native HTML5 player for local files. Playback: Native HTML5 player for local files.
3.3. Utilities 3.3. Utilities
Torrent Player: Interface for streaming/playing video content via torrents. Torrent Player: Interface for streaming/playing video content via torrents.
Playlist Manager: Create and manage custom playlists of YouTube videos. Playlist Manager: Create and manage custom playlists of YouTube videos.
Camera/Photo: ("Chụp ảnh") Feature to capture or manage photos (Webcam integration). Camera/Photo: ("Chụp ảnh") Feature to capture or manage photos (Webcam integration).
Configuration: Web-based settings to manage application behavior (e.g., password, storage paths). Configuration: Web-based settings to manage application behavior (e.g., password, storage paths).
4. Technical Architecture 4. Technical Architecture
Backend: Python / Flask Backend: Python / Flask
Frontend: HTML5, CSS3, JavaScript (Vanilla) Frontend: HTML5, CSS3, JavaScript (Vanilla)
Database/Storage: JSON-based local storage and file system. Database/Storage: JSON-based local storage and file system.
Video Processing: yt-dlp (YouTube), FFmpeg (Conversion/Thumbnail), MoviePy (Optional). Video Processing: yt-dlp (YouTube), FFmpeg (Conversion/Thumbnail), MoviePy (Optional).
AI Service: Google Gemini API (for summarization). AI Service: Google Gemini API (for summarization).
Deployment: Docker container support (xehopnet/kctube). Deployment: Docker container support (xehopnet/kctube).
5. Non-Functional Requirements 5. Non-Functional Requirements
Performance: Fast load times and responsive UI. Performance: Fast load times and responsive UI.
Compatibility: PWA-ready for installation on desktop and mobile. Compatibility: PWA-ready for installation on desktop and mobile.
Reliability: graceful degradation if optional dependencies (MoviePy, Gemini) are missing. Reliability: graceful degradation if optional dependencies (MoviePy, Gemini) are missing.
Privacy: No user tracking or external analytics. Privacy: No user tracking or external analytics.
6. Known Limitations 6. Known Limitations
Search Reliability: Dependent on yt-dlp stability and YouTube's anti-bot measures. Search Reliability: Dependent on yt-dlp stability and YouTube's anti-bot measures.
External APIs: Movie features rely on third-party APIs which may have downtime. External APIs: Movie features rely on third-party APIs which may have downtime.
Dependency Management: Some Python libraries (MoviePy, Numpy) require compilation tools. Dependency Management: Some Python libraries (MoviePy, Numpy) require compilation tools.
7. Future Roadmap 7. Future Roadmap
Database: Migrate from JSON to SQLite for better performance with large libraries. Database: Migrate from JSON to SQLite for better performance with large libraries.
User Accounts: Individual user profiles and history. User Accounts: Individual user profiles and history.
Offline Mode: Enhanced offline capabilities for PWA. Offline Mode: Enhanced offline capabilities for PWA.
Casting: Support for Chromecast/AirPlay. Casting: Support for Chromecast/AirPlay.

View file

@ -0,0 +1,56 @@
/* ===== Reset & Base ===== */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--yt-bg-primary);
/* Fix white bar issue */
}
body {
font-family: 'Roboto', 'Arial', sans-serif;
background-color: var(--yt-bg-primary);
color: var(--yt-text-primary);
line-height: 1.4;
overflow-x: hidden;
min-height: 100vh;
}
a {
color: inherit;
text-decoration: none;
}
button {
font-family: inherit;
cursor: pointer;
border: none;
background: none;
}
/* Hide scrollbar globally but allow scroll */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--yt-bg-secondary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--yt-bg-hover);
}

View file

@ -0,0 +1,325 @@
/* ===== Video Card (Standard) ===== */
.yt-video-card {
cursor: pointer;
border-radius: var(--yt-radius-lg);
overflow: hidden;
transition: transform 0.1s;
animation: fadeIn 0.3s ease forwards;
/* Animation from style.css */
}
/* Stagger animation */
.yt-video-card:nth-child(1) {
animation-delay: 0.05s;
}
.yt-video-card:nth-child(2) {
animation-delay: 0.1s;
}
.yt-video-card:nth-child(3) {
animation-delay: 0.15s;
}
.yt-video-card:nth-child(4) {
animation-delay: 0.2s;
}
.yt-video-card:nth-child(5) {
animation-delay: 0.25s;
}
.yt-video-card:nth-child(6) {
animation-delay: 0.3s;
}
.yt-video-card:hover {
transform: scale(1.02);
}
.yt-thumbnail-container {
position: relative;
width: 100%;
aspect-ratio: 16/9;
border-radius: var(--yt-radius-lg);
overflow: hidden;
background: var(--yt-bg-secondary);
}
.yt-thumbnail {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.5s ease, transform 0.3s ease;
}
.yt-thumbnail.loaded {
opacity: 1;
}
.yt-video-card:hover .yt-thumbnail {
transform: scale(1.05);
}
.yt-duration {
position: absolute;
bottom: 4px;
right: 4px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 2px 4px;
border-radius: var(--yt-radius-sm);
font-size: 12px;
font-weight: 500;
}
.yt-video-details {
display: flex;
gap: 12px;
padding: 12px 0;
}
.yt-video-meta {
flex: 1;
min-width: 0;
}
.yt-video-title {
font-size: 14px;
font-weight: 500;
line-height: 1.4;
color: var(--yt-text-primary);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 4px;
}
.yt-channel-name {
font-size: 12px;
color: var(--yt-text-secondary);
margin-bottom: 2px;
}
.yt-channel-name:hover {
color: var(--yt-text-primary);
}
.yt-video-stats {
font-size: 12px;
color: var(--yt-text-secondary);
}
/* ===== Shorts Card & Container ===== */
.yt-section {
margin-bottom: 32px;
padding: 0 16px;
}
.yt-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.yt-section-header h2 {
font-size: 20px;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
}
.yt-section-header h2 i {
color: var(--yt-accent-red);
}
.yt-section-title-link:hover {
color: var(--yt-text-primary);
opacity: 0.8;
}
.yt-shorts-container {
position: relative;
display: flex;
align-items: center;
}
.yt-shorts-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--yt-bg-primary);
border: 1px solid var(--yt-border);
color: var(--yt-text-primary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10;
transition: all 0.2s;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.yt-shorts-arrow:hover {
background: var(--yt-bg-secondary);
transform: translateY(-50%) scale(1.1);
}
.yt-shorts-left {
left: -20px;
}
.yt-shorts-right {
right: -20px;
}
.yt-shorts-grid {
display: flex;
gap: 12px;
overflow-x: auto;
padding: 8px 0;
scroll-behavior: smooth;
scrollbar-width: none;
flex: 1;
}
.yt-shorts-grid::-webkit-scrollbar {
display: none;
}
.yt-short-card {
flex-shrink: 0;
width: 180px;
cursor: pointer;
transition: transform 0.2s;
}
.yt-short-card:hover {
transform: scale(1.02);
}
.yt-short-thumb {
width: 180px;
height: 320px;
border-radius: 12px;
object-fit: cover;
background: var(--yt-bg-secondary);
opacity: 0;
transition: opacity 0.5s ease;
}
.yt-short-thumb.loaded {
opacity: 1;
}
.yt-short-title {
font-size: 14px;
font-weight: 500;
margin-top: 8px;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.yt-short-views {
font-size: 12px;
color: var(--yt-text-secondary);
margin-top: 4px;
}
/* ===== Horizontal Video Card ===== */
.yt-video-card-horizontal {
display: flex;
gap: 8px;
margin-bottom: 8px;
cursor: pointer;
border-radius: var(--yt-radius-md);
transition: background 0.2s;
padding: 6px;
}
.yt-video-card-horizontal:hover {
background: var(--yt-bg-hover);
}
.yt-thumb-container-h {
position: relative;
width: 140px;
aspect-ratio: 16/9;
border-radius: var(--yt-radius-md);
overflow: hidden;
flex-shrink: 0;
background: var(--yt-bg-secondary);
}
.yt-thumb-container-h img {
width: 100%;
height: 100%;
object-fit: cover;
}
.yt-details-h {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.yt-title-h {
font-size: 14px;
font-weight: 500;
line-height: 1.3;
color: var(--yt-text-primary);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
}
.yt-meta-h {
font-size: 12px;
color: var(--yt-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 768px) {
.yt-video-card {
border-radius: 0;
padding: 4px !important;
margin-bottom: 4px !important;
}
.yt-thumbnail-container {
border-radius: 6px !important;
/* V4 Override */
}
.yt-video-details {
padding: 6px 8px 12px !important;
}
.yt-video-title {
font-size: 13px !important;
line-height: 1.2 !important;
}
.yt-shorts-arrow {
display: none;
}
}

View file

@ -0,0 +1,512 @@
/* ===== Components ===== */
/* --- Buttons --- */
.yt-menu-btn,
.yt-icon-btn {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--yt-text-primary);
transition: background 0.2s;
font-size: 20px;
}
.yt-menu-btn:hover,
.yt-icon-btn:hover {
background: var(--yt-bg-hover);
}
.yt-back-btn {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--yt-text-primary);
}
/* Search Button */
.yt-search-btn {
width: 64px;
height: 40px;
background: var(--yt-bg-secondary);
border: 1px solid var(--yt-border);
border-radius: 0 20px 20px 0;
color: var(--yt-text-primary);
display: flex;
align-items: center;
justify-content: center;
}
.yt-search-btn:hover {
background: var(--yt-bg-hover);
}
/* Sign In Button */
.yt-signin-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px solid var(--yt-border);
border-radius: var(--yt-radius-pill);
color: var(--yt-accent-blue);
font-size: 14px;
font-weight: 500;
transition: background 0.2s;
}
.yt-signin-btn:hover {
background: rgba(62, 166, 255, 0.1);
}
/* Primary Button */
.yt-btn-primary {
width: 100%;
padding: 12px 24px;
background: var(--yt-accent-blue);
color: var(--yt-bg-primary);
border-radius: var(--yt-radius-md);
font-size: 16px;
font-weight: 500;
transition: opacity 0.2s;
}
.yt-btn-primary:hover {
opacity: 0.9;
}
/* Floating Back Button */
.yt-floating-back {
position: fixed;
bottom: 24px;
right: 24px;
width: 56px;
height: 56px;
background: var(--yt-accent-blue);
color: white;
border-radius: 50%;
display: none;
/* Hidden on desktop */
align-items: center;
justify-content: center;
font-size: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
z-index: 2000;
cursor: pointer;
transition: transform 0.2s, background 0.2s;
border: none;
}
.yt-floating-back:active {
transform: scale(0.95);
background: #2c95dd;
}
@media (max-width: 768px) {
.yt-floating-back {
display: flex;
/* Show only on mobile */
}
.yt-floating-back {
background: var(--yt-accent-red) !important;
}
.yt-floating-back:active {
background: #cc0000 !important;
}
}
/* --- Inputs --- */
.yt-search-form {
display: flex;
flex: 1;
max-width: 600px;
}
.yt-search-input {
flex: 1;
height: 40px;
background: var(--yt-bg-secondary);
border: 1px solid var(--yt-border);
border-right: none;
border-radius: 20px 0 0 20px;
padding: 0 16px;
font-size: 16px;
color: var(--yt-text-primary);
outline: none;
}
.yt-search-input:focus {
border-color: var(--yt-accent-blue);
}
.yt-search-input::placeholder {
color: var(--yt-text-disabled);
}
.yt-form-group {
margin-bottom: 16px;
text-align: left;
}
.yt-form-group label {
display: block;
font-size: 14px;
margin-bottom: 8px;
color: var(--yt-text-secondary);
}
.yt-form-input {
width: 100%;
padding: 12px 16px;
background: var(--yt-bg-primary);
border: 1px solid var(--yt-border);
border-radius: var(--yt-radius-md);
font-size: 16px;
color: var(--yt-text-primary);
outline: none;
transition: border-color 0.2s;
}
.yt-form-input:focus {
border-color: var(--yt-accent-blue);
}
@media (max-width: 768px) {
.yt-search-input {
padding: 0 12px;
font-size: 14px;
border-radius: 18px 0 0 18px;
}
.yt-search-btn {
width: 48px;
border-radius: 0 18px 18px 0;
}
}
/* Mobile Search Bar */
.yt-mobile-search-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: var(--yt-header-height);
background: var(--yt-bg-primary);
display: none;
align-items: center;
gap: 12px;
padding: 0 12px;
z-index: 1100;
}
.yt-mobile-search-bar.active {
display: flex;
}
.yt-mobile-search-bar input {
flex: 1;
height: 40px;
background: var(--yt-bg-secondary);
border: none;
border-radius: 20px;
padding: 0 16px;
font-size: 16px;
color: var(--yt-text-primary);
outline: none;
}
.yt-mobile-search {
display: none;
}
/* --- Avatars --- */
.yt-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--yt-accent-blue);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.yt-channel-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--yt-bg-secondary);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: var(--yt-text-primary);
}
.yt-channel-avatar-lg {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--yt-bg-secondary);
}
/* --- Categories / Pills --- */
.yt-categories {
display: flex;
gap: 12px;
padding: 12px 0 24px;
overflow-x: auto;
scrollbar-width: none;
flex-wrap: nowrap;
-ms-overflow-style: none;
/* IE/Edge */
}
.yt-categories::-webkit-scrollbar {
display: none;
}
.yt-chip,
.yt-category-pill {
padding: 0.5rem 1rem;
border-radius: 8px;
background: var(--yt-bg-secondary);
color: var(--yt-text-primary);
border: none;
white-space: nowrap;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background 0.2s;
}
.yt-category-pill {
padding: 8px 12px;
/* style.css match */
border-radius: var(--yt-radius-pill);
}
.yt-chip:hover,
.yt-category-pill:hover {
background: var(--yt-bg-hover);
}
.yt-chip-active,
.yt-category-pill.active {
background: var(--yt-text-primary);
color: var(--yt-bg-primary);
}
.yt-chip-active:hover {
background: var(--yt-text-primary);
opacity: 0.9;
}
@media (max-width: 768px) {
.yt-categories {
padding: 8px 0 8px 8px !important;
gap: 8px;
display: flex !important;
flex-wrap: nowrap !important;
width: 100% !important;
mask-image: linear-gradient(to right, black 95%, transparent 100%);
-webkit-mask-image: linear-gradient(to right, black 95%, transparent 100%);
}
.yt-chip,
.yt-category-pill {
font-size: 12px !important;
padding: 6px 12px !important;
height: 30px !important;
border-radius: 6px !important;
}
}
/* --- Dropdowns --- */
.yt-filter-actions {
flex-shrink: 0;
position: relative;
}
.yt-dropdown-menu {
display: none;
position: absolute;
top: 100%;
right: 0;
width: 200px;
background: var(--yt-bg-secondary);
border-radius: 12px;
padding: 1rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
margin-top: 0.5rem;
z-index: 100;
border: 1px solid var(--yt-border);
}
.yt-dropdown-menu.show {
display: block;
}
.yt-menu-section {
margin-bottom: 1rem;
}
.yt-menu-section:last-child {
margin-bottom: 0;
}
.yt-menu-section h4 {
font-size: 0.8rem;
color: var(--yt-text-secondary);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.yt-menu-section button {
display: block;
width: 100%;
text-align: left;
padding: 0.5rem;
background: none;
border: none;
color: var(--yt-text-primary);
cursor: pointer;
border-radius: 6px;
}
.yt-menu-section button:hover {
background: var(--yt-bg-hover);
}
/* --- Queue Drawer --- */
.yt-queue-drawer {
position: fixed;
top: 0;
right: -350px;
width: 350px;
height: 100vh;
background: var(--yt-bg-secondary);
z-index: 10000;
transition: right 0.3s ease;
display: flex;
flex-direction: column;
box-shadow: none;
}
.yt-queue-drawer.open {
right: 0;
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.5);
}
.yt-queue-header {
padding: 16px;
border-bottom: 1px solid var(--yt-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.yt-queue-header h3 {
font-size: 18px;
font-weight: 600;
}
.yt-queue-list {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.yt-queue-footer {
padding: 16px;
border-top: 1px solid var(--yt-border);
text-align: center;
}
.yt-queue-clear-btn {
background: transparent;
border: 1px solid var(--yt-border);
color: var(--yt-text-primary);
padding: 8px 16px;
border-radius: 18px;
cursor: pointer;
}
.yt-queue-clear-btn:hover {
background: var(--yt-bg-hover);
}
.yt-queue-item {
display: flex;
gap: 12px;
margin-bottom: 12px;
align-items: center;
}
.yt-queue-thumb {
width: 100px;
height: 56px;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
flex-shrink: 0;
}
.yt-queue-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.yt-queue-info {
flex: 1;
overflow: hidden;
}
.yt-queue-title {
font-size: 14px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.yt-queue-title:hover {
text-decoration: underline;
}
.yt-queue-uploader {
font-size: 12px;
color: var(--yt-text-secondary);
}
.yt-queue-remove {
background: none;
border: none;
color: var(--yt-text-secondary);
cursor: pointer;
padding: 4px;
}
.yt-queue-remove:hover {
color: #ff4e45;
}
@media (max-width: 480px) {
.yt-queue-drawer {
width: 85%;
right: -85%;
}
}

View file

@ -0,0 +1,96 @@
/* ===== Video Grid ===== */
.yt-video-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
@media (max-width: 1400px) {
.yt-video-grid,
.yt-section-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 1100px) {
.yt-video-grid,
.yt-section-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 600px) {
.yt-video-grid,
.yt-section-grid {
grid-template-columns: 1fr;
}
}
/* Grid Layout for Sections (4 rows x 4 columns = 16 videos) */
.yt-section-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
padding-bottom: 24px;
}
.yt-section-grid .yt-video-card {
width: 100%;
min-width: 0;
}
/* Scrollbar hiding */
.yt-section-grid::-webkit-scrollbar {
display: none;
}
/* Mobile Grid Overrides */
@media (max-width: 768px) {
/* Main Grid */
.yt-video-grid {
grid-template-columns: repeat(2, 1fr) !important;
gap: 8px !important;
padding: 0 4px !important;
background: var(--yt-bg-primary);
gap: 1px !important;
/* Minimal gap from V4 override */
padding: 0 !important;
/* V4 override */
}
/* Section Grid (Horizontal Carousel) */
.yt-section-grid {
display: grid;
grid-auto-flow: column;
grid-template-columns: none;
grid-template-rows: 1fr;
grid-auto-columns: 85%;
gap: 12px;
overflow-x: auto;
padding-bottom: 12px;
scroll-snap-type: x mandatory;
scrollbar-width: none;
}
.yt-section-grid::-webkit-scrollbar {
display: none;
}
/* Adjust video card size for single row */
.yt-section-grid .yt-video-card {
width: 100%;
scroll-snap-align: start;
margin: 0;
}
}
/* Tablet Grid */
@media (min-width: 769px) and (max-width: 1024px) {
.yt-video-grid {
grid-template-columns: repeat(2, 1fr);
}
}

View file

@ -0,0 +1,251 @@
/* ===== App Layout ===== */
.app-wrapper {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* ===== Header (YouTube Style) ===== */
.yt-header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: var(--yt-header-height);
background: var(--yt-bg-primary);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
z-index: 1000;
border-bottom: 1px solid var(--yt-border);
}
.yt-header-start {
display: flex;
align-items: center;
gap: 16px;
min-width: 200px;
}
.yt-header-center {
flex: 1;
display: flex;
justify-content: center;
max-width: 728px;
margin: 0 40px;
}
.yt-header-end {
display: flex;
align-items: center;
gap: 8px;
min-width: 200px;
justify-content: flex-end;
}
/* Logo */
.yt-logo {
display: flex;
align-items: center;
gap: 4px;
font-size: 20px;
font-weight: 600;
color: var(--yt-text-primary);
text-decoration: none;
}
.yt-logo-icon {
width: 90px;
height: 20px;
background: var(--yt-accent-red);
border-radius: var(--yt-radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
letter-spacing: -0.5px;
}
/* ===== Sidebar (YouTube Style) ===== */
.yt-sidebar {
position: fixed;
top: var(--yt-header-height);
left: 0;
bottom: 0;
width: var(--yt-sidebar-width);
background: var(--yt-bg-primary);
overflow-y: auto;
overflow-x: hidden;
padding: 12px 0;
z-index: 900;
transition: width 0.2s, transform 0.2s;
}
.yt-sidebar.collapsed {
width: var(--yt-sidebar-mini);
}
.yt-sidebar-item {
display: flex;
align-items: center;
gap: 24px;
padding: 10px 12px 10px 24px;
color: var(--yt-text-primary);
font-size: 14px;
border-radius: var(--yt-radius-lg);
margin: 0 12px;
transition: background 0.2s;
}
.yt-sidebar-item:hover {
background: var(--yt-bg-hover);
}
.yt-sidebar-item.active {
background: var(--yt-bg-secondary);
font-weight: 500;
}
.yt-sidebar-item i {
font-size: 18px;
width: 22px;
text-align: center;
}
.yt-sidebar-item span {
white-space: nowrap;
}
.yt-sidebar.collapsed .yt-sidebar-item {
flex-direction: column;
gap: 6px;
padding: 16px 0;
margin: 0;
border-radius: 0;
justify-content: center;
text-align: center;
}
.yt-sidebar.collapsed .yt-sidebar-item span {
font-size: 10px;
}
.yt-sidebar-divider {
height: 1px;
background: var(--yt-border);
margin: 12px 0;
}
.yt-sidebar-title {
padding: 8px 24px;
font-size: 14px;
color: var(--yt-text-secondary);
font-weight: 500;
}
/* Sidebar Overlay (Mobile) */
.yt-sidebar-overlay {
position: fixed;
top: var(--yt-header-height);
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 899;
display: none;
}
.yt-sidebar-overlay.active {
display: block;
}
/* ===== Main Content ===== */
.yt-main {
margin-top: var(--yt-header-height);
margin-left: var(--yt-sidebar-width);
padding: 24px;
min-height: calc(100vh - var(--yt-header-height));
transition: margin-left 0.2s;
}
.yt-main.sidebar-collapsed {
margin-left: var(--yt-sidebar-mini);
}
/* ===== Filter Bar ===== */
/* From index.html originally */
.yt-filter-bar {
display: flex;
align-items: center;
gap: 1rem;
padding: 0 1rem;
margin-bottom: 1rem;
position: sticky;
top: 56px;
/* Adjust based on header height */
z-index: 99;
background: var(--yt-bg-primary);
border-bottom: 1px solid var(--yt-border);
}
/* ===== Responsive Layout Overrides ===== */
@media (max-width: 1024px) {
.yt-sidebar {
transform: translateX(-100%);
}
.yt-sidebar.open {
transform: translateX(0);
}
.yt-main {
margin-left: 0;
}
.yt-header-center {
margin: 0 20px;
}
}
@media (max-width: 768px) {
.yt-header-center {
display: flex;
/* Show search on mobile */
margin: 0 8px;
max-width: none;
flex: 1;
justify-content: center;
}
.yt-header-start,
.yt-header-end {
min-width: auto;
}
.yt-logo span:last-child {
display: none;
}
.yt-main {
padding: 12px;
}
/* Reduce header padding to match */
.yt-header {
padding: 0 12px !important;
}
/* Filter bar spacing */
.yt-filter-bar {
padding-left: 0 !important;
padding-right: 0 !important;
}
}
@media (min-width: 769px) and (max-width: 1024px) {
.yt-main {
padding: 16px;
}
}

View file

@ -0,0 +1,249 @@
/* ===== Watch Page ===== */
.yt-watch-layout {
display: grid;
grid-template-columns: 1fr 400px;
gap: 24px;
max-width: 1800px;
}
.yt-player-section {
width: 100%;
}
.yt-player-container {
width: 100%;
aspect-ratio: 16/9;
background: #000;
border-radius: var(--yt-radius-lg);
overflow: hidden;
}
.yt-video-info {
padding: 16px 0;
}
.yt-video-info h1 {
font-size: 20px;
font-weight: 600;
line-height: 1.4;
margin-bottom: 12px;
}
.yt-video-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
flex-wrap: wrap;
}
.yt-action-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: var(--yt-bg-secondary);
border-radius: var(--yt-radius-pill);
font-size: 14px;
font-weight: 500;
color: var(--yt-text-primary);
transition: background 0.2s;
}
.yt-action-btn:hover {
background: var(--yt-bg-hover);
}
.yt-action-btn.active {
background: var(--yt-text-primary);
color: var(--yt-bg-primary);
}
.yt-channel-info {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
border-bottom: 1px solid var(--yt-border);
}
.yt-channel-details {
display: flex;
align-items: center;
gap: 12px;
}
.yt-subscribe-btn {
padding: 10px 16px;
background: var(--yt-text-primary);
color: var(--yt-bg-primary);
border-radius: var(--yt-radius-pill);
font-size: 14px;
font-weight: 500;
transition: opacity 0.2s;
}
.yt-subscribe-btn:hover {
opacity: 0.9;
}
.yt-subscribe-btn.subscribed {
background: var(--yt-bg-secondary);
color: var(--yt-text-primary);
}
.yt-description-box {
background: var(--yt-bg-secondary);
border-radius: var(--yt-radius-lg);
padding: 12px;
margin-top: 16px;
cursor: pointer;
}
.yt-description-box:hover {
background: var(--yt-bg-hover);
}
.yt-description-stats {
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
}
.yt-description-text {
font-size: 14px;
color: var(--yt-text-primary);
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Suggested Videos */
.yt-suggested {
display: flex;
flex-direction: column;
gap: 8px;
max-height: calc(100vh - 100px);
overflow-y: auto;
position: sticky;
top: 80px;
padding-right: 8px;
}
/* Custom scrollbar for suggested videos */
.yt-suggested::-webkit-scrollbar {
width: 6px;
}
.yt-suggested::-webkit-scrollbar-track {
background: transparent;
}
.yt-suggested::-webkit-scrollbar-thumb {
background: var(--yt-border);
border-radius: 3px;
}
.yt-suggested::-webkit-scrollbar-thumb:hover {
background: var(--yt-text-secondary);
}
.yt-suggested-card {
display: flex;
gap: 8px;
cursor: pointer;
padding: 4px;
border-radius: var(--yt-radius-md);
transition: background 0.2s;
}
.yt-suggested-card:hover {
background: var(--yt-bg-secondary);
}
.yt-suggested-thumb {
width: 168px;
aspect-ratio: 16/9;
border-radius: var(--yt-radius-md);
object-fit: cover;
flex-shrink: 0;
}
.yt-suggested-info {
flex: 1;
min-width: 0;
}
.yt-suggested-title {
font-size: 14px;
font-weight: 500;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 4px;
}
.yt-suggested-channel {
font-size: 12px;
color: var(--yt-text-secondary);
}
.yt-suggested-stats {
font-size: 12px;
color: var(--yt-text-secondary);
}
@media (max-width: 1200px) {
.yt-watch-layout {
grid-template-columns: 1fr;
}
.yt-suggested {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
max-height: none;
/* Allow full height on mobile/tablet */
position: static;
overflow-y: visible;
}
.yt-suggested-card {
flex-direction: column;
}
.yt-suggested-thumb {
width: 100%;
}
}
/* ===== Auth Pages ===== */
.yt-auth-container {
display: flex;
justify-content: center;
align-items: center;
min-height: calc(100vh - var(--yt-header-height) - 100px);
}
.yt-auth-card {
background: var(--yt-bg-secondary);
border-radius: var(--yt-radius-lg);
padding: 48px;
width: 100%;
max-width: 400px;
text-align: center;
}
.yt-auth-card h2 {
font-size: 24px;
margin-bottom: 8px;
}
.yt-auth-card p {
color: var(--yt-text-secondary);
margin-bottom: 24px;
}

View file

@ -0,0 +1,212 @@
/* ===== Animations ===== */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ===== Skeleton Loader (Shimmer) ===== */
.skeleton {
background: var(--yt-bg-secondary);
background: linear-gradient(90deg,
var(--yt-bg-secondary) 25%,
var(--yt-bg-hover) 50%,
var(--yt-bg-secondary) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.skeleton-card {
display: flex;
flex-direction: column;
gap: 12px;
}
.skeleton-thumb {
width: 100%;
aspect-ratio: 16/9;
border-radius: var(--yt-radius-lg);
}
.skeleton-details {
display: flex;
gap: 12px;
}
.skeleton-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
flex-shrink: 0;
}
.skeleton-text {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.skeleton-title {
height: 14px;
width: 90%;
}
.skeleton-meta {
height: 12px;
width: 60%;
}
.skeleton-short {
width: 180px;
height: 320px;
border-radius: 12px;
background: var(--yt-bg-secondary);
background: linear-gradient(90deg,
var(--yt-bg-secondary) 25%,
var(--yt-bg-hover) 50%,
var(--yt-bg-secondary) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
flex-shrink: 0;
}
.skeleton-comment {
display: flex;
gap: 16px;
margin-bottom: 20px;
}
.skeleton-comment-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--yt-bg-secondary);
}
.skeleton-comment-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.skeleton-line {
height: 12px;
border-radius: 4px;
background: var(--yt-bg-secondary);
background: linear-gradient(90deg,
var(--yt-bg-secondary) 25%,
var(--yt-bg-hover) 50%,
var(--yt-bg-secondary) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
/* ===== Loader ===== */
.yt-loader {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-height: 300px;
color: var(--yt-text-secondary);
background: transparent;
}
/* ===== Friendly Empty State ===== */
.yt-empty-state {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
color: var(--yt-text-secondary);
}
.yt-empty-icon {
font-size: 48px;
margin-bottom: 24px;
opacity: 0.5;
}
.yt-empty-title {
font-size: 18px;
font-weight: 500;
margin-bottom: 8px;
color: var(--yt-text-primary);
}
.yt-empty-desc {
font-size: 14px;
margin-bottom: 24px;
}
/* ===== Toasts ===== */
.yt-toast-container {
position: fixed;
bottom: 24px;
left: 24px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 12px;
pointer-events: none;
}
.yt-toast {
background: #1f1f1f;
color: #fff;
padding: 12px 24px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
font-size: 14px;
animation: slideUp 0.3s ease;
pointer-events: auto;
display: flex;
align-items: center;
gap: 12px;
min-width: 280px;
border-left: 4px solid #3ea6ff;
}
.yt-toast.error {
border-left-color: #ff4e45;
}
.yt-toast.success {
border-left-color: #2ba640;
}
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}

View file

@ -0,0 +1,43 @@
/* ===== YouTube Dark Theme Colors ===== */
:root {
--yt-bg-primary: #0f0f0f;
--yt-bg-secondary: #333333;
--yt-bg-elevated: #282828;
--yt-bg-hover: #444444;
--yt-bg-active: #3ea6ff;
--yt-text-primary: #f1f1f1;
--yt-text-secondary: #aaaaaa;
--yt-text-disabled: #717171;
--yt-static-white: #ffffff;
--yt-accent-red: #ff0000;
--yt-accent-blue: #3ea6ff;
--yt-border: rgba(255, 255, 255, 0.1);
--yt-divider: rgba(255, 255, 255, 0.2);
--yt-header-height: 56px;
--yt-sidebar-width: 240px;
--yt-sidebar-mini: 72px;
--yt-radius-sm: 4px;
--yt-radius-md: 8px;
--yt-radius-lg: 12px;
--yt-radius-xl: 16px;
--yt-radius-pill: 9999px;
}
[data-theme="light"] {
--yt-bg-primary: #ffffff;
--yt-bg-secondary: #f2f2f2;
--yt-bg-elevated: #e5e5e5;
--yt-bg-hover: #e5e5e5;
--yt-text-primary: #0f0f0f;
--yt-text-secondary: #606060;
--yt-text-disabled: #909090;
--yt-border: rgba(0, 0, 0, 0.1);
--yt-divider: rgba(0, 0, 0, 0.1);
}

File diff suppressed because it is too large Load diff

View file

@ -282,11 +282,30 @@ async function loadTrending(reset = true) {
sectionDiv.style.marginBottom = '24px'; sectionDiv.style.marginBottom = '24px';
// Header // Header
// Make title clickable - user request
const categoryLink = section.id === 'suggested' || section.id === 'discovery'
? '#'
: `/?category=${section.id}`;
// If it is suggested or discovery, maybe we don't link or link to something generic?
// User asked for "categories name has a hyperlink".
// Standard categories link to their pages. Suggested/Discovery link to # (no-op) or trending?
// Let's link standard ones. For Suggested/Discovery, we can just not link or link to home.
// Actually, if we link to /?category=tech it works.
// Use a conditional logic for href.
const titleHtml = (section.id !== 'suggested' && section.id !== 'discovery')
? `<a href="/?category=${section.id}" class="yt-section-title-link" style="text-decoration:none; color:inherit; display:flex; align-items:center; gap:10px;">
<i class="fas fa-${section.icon}"></i> ${section.title}
<i class="fas fa-chevron-right" style="font-size: 14px; opacity: 0.7;"></i>
</a>`
: `<span style="display:flex; align-items:center; gap:10px;"><i class="fas fa-${section.icon}"></i> ${section.title}</span>`;
sectionDiv.innerHTML = ` sectionDiv.innerHTML = `
<div class="yt-section-header" style="margin-bottom:12px;"> <div class="yt-section-header" style="margin-bottom:12px;">
<h2><i class="fas fa-${section.icon}"></i> ${section.title}</h2> <h2>${titleHtml}</h2>
</div> </div>
`; `;
const videos = section.videos || []; const videos = section.videos || [];
let chunks = []; let chunks = [];

View file

@ -1,461 +1,461 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block content %} {% block content %}
<div class="yt-container yt-channel-page"> <div class="yt-container yt-channel-page">
<!-- Channel Header (No Banner) --> <!-- Channel Header (No Banner) -->
<div class="yt-channel-header"> <div class="yt-channel-header">
<div class="yt-channel-info-row"> <div class="yt-channel-info-row">
<div class="yt-channel-avatar-xl" id="channelAvatarLarge"> <div class="yt-channel-avatar-xl" id="channelAvatarLarge">
{% if channel.avatar %} {% if channel.avatar %}
<img src="{{ channel.avatar }}"> <img src="{{ channel.avatar }}">
{% else %} {% else %}
<span id="channelAvatarLetter">{{ channel.title[0] | upper if channel.title and channel.title != <span id="channelAvatarLetter">{{ channel.title[0] | upper if channel.title and channel.title !=
'Loading...' else 'C' }}</span> 'Loading...' else 'C' }}</span>
{% endif %} {% endif %}
</div> </div>
<div class="yt-channel-meta"> <div class="yt-channel-meta">
<h1 id="channelTitle">{{ channel.title if channel.title and channel.title != 'Loading...' else <h1 id="channelTitle">{{ channel.title if channel.title and channel.title != 'Loading...' else
'Loading...' }}</h1> 'Loading...' }}</h1>
<p class="yt-channel-handle" id="channelHandle"> <p class="yt-channel-handle" id="channelHandle">
{% if channel.title and channel.title != 'Loading...' %}@{{ channel.title|replace(' ', '') }}{% else {% if channel.title and channel.title != 'Loading...' %}@{{ channel.title|replace(' ', '') }}{% else
%}@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">Subscribe for more</span>
</div> </div>
<div class="yt-channel-actions"> <div class="yt-channel-actions">
<button class="yt-subscribe-btn-lg" id="subscribeChannelBtn">Subscribe</button> <button class="yt-subscribe-btn-lg" id="subscribeChannelBtn">Subscribe</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Video Grid --> <!-- Video Grid -->
<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">Videos</a>
<a href="#" onclick="changeChannelTab('shorts', this); return false;">Shorts</a> <a href="#" onclick="changeChannelTab('shorts', this); return false;">Shorts</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">Latest</a>
<a href="#" onclick="changeChannelSort('popular', this); return false;">Popular</a> <a href="#" onclick="changeChannelSort('popular', this); return false;">Popular</a>
<a href="#" onclick="changeChannelSort('oldest', this); return false;">Oldest</a> <a href="#" onclick="changeChannelSort('oldest', this); return false;">Oldest</a>
</div> </div>
</div> </div>
<div class="yt-video-grid" id="channelVideosGrid"> <div class="yt-video-grid" id="channelVideosGrid">
<!-- Videos loaded via JS --> <!-- Videos loaded via JS -->
</div> </div>
<div id="channelLoadingTrigger" style="height: 20px; margin: 20px 0;"></div> <div id="channelLoadingTrigger" style="height: 20px; margin: 20px 0;"></div>
</div> </div>
</div> </div>
<style> <style>
.yt-channel-page { .yt-channel-page {
padding-top: 40px; padding-top: 40px;
padding-bottom: 40px; padding-bottom: 40px;
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
} }
/* Removed .yt-channel-banner */ /* Removed .yt-channel-banner */
.yt-channel-info-row { .yt-channel-info-row {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 32px; gap: 32px;
margin-bottom: 32px; margin-bottom: 32px;
padding: 0 16px; padding: 0 16px;
} }
.yt-channel-avatar-xl { .yt-channel-avatar-xl {
width: 160px; width: 160px;
height: 160px; height: 160px;
border-radius: 50%; border-radius: 50%;
overflow: hidden; overflow: hidden;
background: linear-gradient(135deg, #FF6B6B 0%, #d62d2d 100%); background: linear-gradient(135deg, #FF6B6B 0%, #d62d2d 100%);
/* Simpler color for no-banner look */ /* Simpler color for no-banner look */
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 64px; font-size: 64px;
font-weight: bold; font-weight: bold;
color: white; color: white;
flex-shrink: 0; flex-shrink: 0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
} }
.yt-channel-avatar-xl img { .yt-channel-avatar-xl img {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
} }
.yt-channel-meta { .yt-channel-meta {
padding-top: 12px; padding-top: 12px;
} }
.yt-channel-meta h1 { .yt-channel-meta h1 {
font-size: 32px; font-size: 32px;
margin-bottom: 8px; margin-bottom: 8px;
font-weight: 700; font-weight: 700;
} }
.yt-channel-handle { .yt-channel-handle {
color: var(--yt-text-secondary); color: var(--yt-text-secondary);
font-size: 16px; font-size: 16px;
margin-bottom: 12px; margin-bottom: 12px;
} }
.yt-channel-stats { .yt-channel-stats {
color: var(--yt-text-secondary); color: var(--yt-text-secondary);
font-size: 14px; font-size: 14px;
margin-bottom: 24px; margin-bottom: 24px;
} }
.yt-section-header { .yt-section-header {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 20px; gap: 20px;
margin-bottom: 24px; margin-bottom: 24px;
padding: 0 16px; padding: 0 16px;
border-bottom: none; border-bottom: none;
} }
.yt-tabs { .yt-tabs {
display: inline-flex; display: inline-flex;
gap: 0; gap: 0;
background: var(--yt-bg-secondary); background: var(--yt-bg-secondary);
padding: 4px; padding: 4px;
border-radius: 24px; border-radius: 24px;
position: relative; position: relative;
} }
.yt-tabs a { .yt-tabs a {
font-size: 14px; font-size: 14px;
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; padding: 8px 24px;
border-radius: 20px; border-radius: 20px;
border-bottom: none; border-bottom: none;
z-index: 1; z-index: 1;
transition: color 0.2s; transition: color 0.2s;
position: relative; position: relative;
} }
.yt-tabs a:hover { .yt-tabs a:hover {
color: var(--yt-text-primary); color: var(--yt-text-primary);
} }
.yt-tabs a.active { .yt-tabs a.active {
color: var(--yt-bg-primary); color: var(--yt-bg-primary);
background: var(--yt-text-primary); background: var(--yt-text-primary);
/* The "slider" is actually the active pill moving */ /* The "slider" is actually the active pill moving */
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
} }
.yt-sort-options { .yt-sort-options {
display: flex; display: flex;
gap: 8px; gap: 8px;
justify-content: center; justify-content: center;
} }
.yt-sort-options a { .yt-sort-options a {
padding: 6px 16px; padding: 6px 16px;
border-radius: 16px; border-radius: 16px;
background: transparent; background: transparent;
border: 1px solid var(--yt-border); border: 1px solid var(--yt-border);
color: var(--yt-text-secondary); color: var(--yt-text-secondary);
text-decoration: none; text-decoration: none;
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
transition: all 0.2s; transition: all 0.2s;
} }
.yt-sort-options a:hover { .yt-sort-options a:hover {
background: var(--yt-bg-hover); background: var(--yt-bg-hover);
color: var(--yt-text-primary); color: var(--yt-text-primary);
} }
.yt-sort-options a.active { .yt-sort-options a.active {
background: var(--yt-bg-secondary); background: var(--yt-bg-secondary);
color: var(--yt-text-primary); color: var(--yt-text-primary);
border-color: var(--yt-text-primary); border-color: var(--yt-text-primary);
} }
/* Shorts Card Styling override for Channel Page grid */ /* Shorts Card Styling override for Channel Page grid */
.yt-channel-short-card { .yt-channel-short-card {
border-radius: var(--yt-radius-lg); border-radius: var(--yt-radius-lg);
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
transition: transform 0.2s; transition: transform 0.2s;
} }
.yt-channel-short-card:hover { .yt-channel-short-card:hover {
transform: scale(1.02); transform: scale(1.02);
} }
.yt-short-thumb-container { .yt-short-thumb-container {
aspect-ratio: 9/16; aspect-ratio: 9/16;
width: 100%; width: 100%;
position: relative; position: relative;
} }
.yt-short-thumb { .yt-short-thumb {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.yt-channel-info-row { .yt-channel-info-row {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
text-align: center; text-align: center;
gap: 16px; gap: 16px;
} }
.yt-channel-avatar-xl { .yt-channel-avatar-xl {
width: 100px; width: 100px;
height: 100px; height: 100px;
font-size: 40px; font-size: 40px;
} }
.yt-channel-meta h1 { .yt-channel-meta h1 {
font-size: 24px; font-size: 24px;
} }
.yt-section-header { .yt-section-header {
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
align-items: flex-start; align-items: flex-start;
} }
.yt-tabs { .yt-tabs {
width: 100%; width: 100%;
justify-content: center; justify-content: center;
} }
} }
</style> </style>
<script> <script>
console.log("Channel.html script loaded, channelId will be:", "{{ channel.id }}"); console.log("Channel.html script loaded, channelId will be:", "{{ channel.id }}");
let currentChannelSort = 'latest'; let currentChannelSort = 'latest';
let currentChannelPage = 1; let currentChannelPage = 1;
let isChannelLoading = false; let isChannelLoading = false;
let hasMoreChannelVideos = true; let hasMoreChannelVideos = true;
let currentFilterType = 'video'; let currentFilterType = 'video';
const channelId = "{{ channel.id }}"; const channelId = "{{ channel.id }}";
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
console.log("DOMContentLoaded fired, calling fetchChannelContent..."); console.log("DOMContentLoaded fired, calling fetchChannelContent...");
console.log("typeof fetchChannelContent:", typeof fetchChannelContent); console.log("typeof fetchChannelContent:", typeof fetchChannelContent);
if (typeof fetchChannelContent === 'function') { if (typeof fetchChannelContent === 'function') {
fetchChannelContent(); fetchChannelContent();
} else { } else {
console.error("fetchChannelContent is NOT a function!"); console.error("fetchChannelContent is NOT a function!");
} }
setupInfiniteScroll(); setupInfiniteScroll();
}); });
function changeChannelTab(type, btn) { function changeChannelTab(type, btn) {
if (type === currentFilterType || isChannelLoading) return; if (type === currentFilterType || isChannelLoading) return;
currentFilterType = type; currentFilterType = type;
currentChannelPage = 1; currentChannelPage = 1;
hasMoreChannelVideos = true; hasMoreChannelVideos = true;
document.getElementById('channelVideosGrid').innerHTML = ''; document.getElementById('channelVideosGrid').innerHTML = '';
// Update Tabs UI // Update Tabs UI
document.querySelectorAll('.yt-tabs a').forEach(a => a.classList.remove('active')); document.querySelectorAll('.yt-tabs a').forEach(a => a.classList.remove('active'));
btn.classList.add('active'); btn.classList.add('active');
// Adjust Grid layout for Shorts vs Videos // Adjust Grid layout for Shorts vs Videos
const grid = document.getElementById('channelVideosGrid'); const grid = document.getElementById('channelVideosGrid');
if (type === 'shorts') { if (type === 'shorts') {
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(200px, 1fr))'; grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(200px, 1fr))';
} else { } else {
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(320px, 1fr))'; grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(320px, 1fr))';
} }
fetchChannelContent(); fetchChannelContent();
} }
function changeChannelSort(sort, btn) { function changeChannelSort(sort, btn) {
if (isChannelLoading) return; if (isChannelLoading) return;
currentChannelSort = sort; currentChannelSort = sort;
currentChannelPage = 1; currentChannelPage = 1;
hasMoreChannelVideos = true; hasMoreChannelVideos = true;
document.getElementById('channelVideosGrid').innerHTML = ''; // Clear document.getElementById('channelVideosGrid').innerHTML = ''; // Clear
// Update tabs // Update tabs
document.querySelectorAll('.yt-sort-options a').forEach(a => a.classList.remove('active')); document.querySelectorAll('.yt-sort-options a').forEach(a => a.classList.remove('active'));
btn.classList.add('active'); btn.classList.add('active');
fetchChannelContent(); fetchChannelContent();
} }
async function fetchChannelContent() { async function fetchChannelContent() {
console.log("fetchChannelContent() called"); console.log("fetchChannelContent() called");
if (isChannelLoading || !hasMoreChannelVideos) { if (isChannelLoading || !hasMoreChannelVideos) {
console.log("Early return:", { isChannelLoading, hasMoreChannelVideos }); console.log("Early return:", { isChannelLoading, hasMoreChannelVideos });
return; return;
} }
isChannelLoading = true; isChannelLoading = true;
const grid = document.getElementById('channelVideosGrid'); const grid = document.getElementById('channelVideosGrid');
// Append Loading indicator // Append Loading indicator
if (typeof renderSkeleton === 'function') { if (typeof renderSkeleton === 'function') {
grid.insertAdjacentHTML('beforeend', renderSkeleton(4)); grid.insertAdjacentHTML('beforeend', renderSkeleton(4));
} else { } else {
grid.insertAdjacentHTML('beforeend', '<div class="loading-text" style="color:var(--yt-text-secondary); padding: 20px;">Loading videos...</div>'); grid.insertAdjacentHTML('beforeend', '<div class="loading-text" style="color:var(--yt-text-secondary); padding: 20px;">Loading videos...</div>');
} }
try { try {
console.log(`Fetching: /api/channel/videos?id=${channelId}&page=${currentChannelPage}`); 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 response = await fetch(`/api/channel/videos?id=${channelId}&page=${currentChannelPage}&sort=${currentChannelSort}&filter_type=${currentFilterType}`);
const videos = await response.json(); const videos = await response.json();
console.log("Channel Videos Response:", videos); console.log("Channel Videos Response:", videos);
// Remove skeletons (simple way: remove last N children or just clear all if page 1? // Remove skeletons (simple way: remove last N children or just clear all if page 1?
// Better: mark skeletons with class and remove) // Better: mark skeletons with class and remove)
// For simplicity in this v1: We just clear skeletons by removing elements with 'skeleton-card' class // 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.querySelectorAll('#channelVideosGrid .skeleton-card').forEach(el => el.remove());
// Check if response is an error // Check if response is an error
if (videos.error) { if (videos.error) {
hasMoreChannelVideos = false; hasMoreChannelVideos = false;
grid.innerHTML = `<p style="padding:20px; color:var(--yt-text-secondary);">Error: ${videos.error}</p>`; grid.innerHTML = `<p style="padding:20px; color:var(--yt-text-secondary);">Error: ${videos.error}</p>`;
return; return;
} }
if (!Array.isArray(videos) || videos.length === 0) { if (!Array.isArray(videos) || videos.length === 0) {
hasMoreChannelVideos = false; hasMoreChannelVideos = false;
if (currentChannelPage === 1) grid.innerHTML = '<p style="padding:20px; color:var(--yt-text-secondary);">No videos found.</p>'; if (currentChannelPage === 1) grid.innerHTML = '<p style="padding:20px; color:var(--yt-text-secondary);">No videos found.</p>';
} else { } else {
// Update channel header with uploader info from first video (on first page only) // Update channel header with uploader info from first video (on first page only)
if (currentChannelPage === 1 && videos[0]) { if (currentChannelPage === 1 && videos[0]) {
// Try multiple sources for channel name // Try multiple sources for channel name
let channelName = videos[0].uploader || videos[0].channel || ''; let channelName = videos[0].uploader || videos[0].channel || '';
// If still empty, try to get from video title (sometimes includes " - ChannelName") // If still empty, try to get from video title (sometimes includes " - ChannelName")
if (!channelName && videos[0].title) { if (!channelName && videos[0].title) {
const parts = videos[0].title.split(' - '); const parts = videos[0].title.split(' - ');
if (parts.length > 1) channelName = parts[parts.length - 1]; if (parts.length > 1) channelName = parts[parts.length - 1];
} }
// Final fallback: use channel ID // Final fallback: use channel ID
if (!channelName) channelName = channelId; if (!channelName) channelName = channelId;
document.getElementById('channelTitle').textContent = channelName; document.getElementById('channelTitle').textContent = channelName;
document.getElementById('channelHandle').textContent = '@' + channelName.replace(/\s+/g, ''); document.getElementById('channelHandle').textContent = '@' + channelName.replace(/\s+/g, '');
const avatarLetter = document.getElementById('channelAvatarLetter'); const avatarLetter = document.getElementById('channelAvatarLetter');
if (avatarLetter) avatarLetter.textContent = channelName.charAt(0).toUpperCase(); if (avatarLetter) avatarLetter.textContent = channelName.charAt(0).toUpperCase();
// Update browser URL to show friendly name // Update browser URL to show friendly name
const friendlyUrl = `/channel/@${encodeURIComponent(channelName.replace(/\s+/g, ''))}`; const friendlyUrl = `/channel/@${encodeURIComponent(channelName.replace(/\s+/g, ''))}`;
window.history.replaceState({ channelId: channelId }, '', friendlyUrl); window.history.replaceState({ channelId: channelId }, '', friendlyUrl);
} }
videos.forEach(video => { videos.forEach(video => {
const card = document.createElement('div'); const card = document.createElement('div');
if (currentFilterType === 'shorts') { if (currentFilterType === 'shorts') {
// Render Vertical Short Card // Render Vertical Short Card
card.className = 'yt-channel-short-card'; card.className = 'yt-channel-short-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-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>
<div class="yt-details" style="padding: 8px;"> <div class="yt-details" style="padding: 8px;">
<h3 class="yt-video-title" style="font-size: 14px; margin-bottom: 4px;">${escapeHtml(video.title)}</h3> <h3 class="yt-video-title" style="font-size: 14px; margin-bottom: 4px;">${escapeHtml(video.title)}</h3>
<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>` : ''}
</div> </div>
<div class="yt-video-details"> <div class="yt-video-details">
<div class="yt-video-meta"> <div class="yt-video-meta">
<h3 class="yt-video-title">${escapeHtml(video.title)}</h3> <h3 class="yt-video-title">${escapeHtml(video.title)}</h3>
<p class="yt-video-stats"> <p class="yt-video-stats">
${formatViews(video.view_count)} views • ${formatDate(video.upload_date)} ${formatViews(video.view_count)} views • ${formatDate(video.upload_date)}
</p> </p>
</div> </div>
</div> </div>
`; `;
} }
grid.appendChild(card); grid.appendChild(card);
}); });
currentChannelPage++; currentChannelPage++;
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} finally { } finally {
isChannelLoading = false; isChannelLoading = false;
document.querySelectorAll('#channelVideosGrid .skeleton-card').forEach(el => el.remove()); 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 }); }, { threshold: 0.1 });
observer.observe(trigger); observer.observe(trigger);
} }
// Helpers - Define locally to ensure availability // Helpers - Define locally to ensure availability
function escapeHtml(text) { function escapeHtml(text) {
if (!text) return ''; if (!text) return '';
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = text; div.textContent = text;
return div.innerHTML; return div.innerHTML;
} }
function formatViews(views) { function formatViews(views) {
if (!views) return '0'; if (!views) return '0';
const num = parseInt(views); const num = parseInt(views);
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'; if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(0) + 'K'; if (num >= 1000) return (num / 1000).toFixed(0) + 'K';
return num.toLocaleString(); return num.toLocaleString();
} }
function formatDate(dateStr) { function formatDate(dateStr) {
if (!dateStr) return 'Recently'; if (!dateStr) return 'Recently';
try { try {
// Format: YYYYMMDD // Format: YYYYMMDD
const year = dateStr.substring(0, 4); const year = dateStr.substring(0, 4);
const month = dateStr.substring(4, 6); const month = dateStr.substring(4, 6);
const day = dateStr.substring(6, 8); const day = dateStr.substring(6, 8);
const date = new Date(year, month - 1, day); const date = new Date(year, month - 1, day);
const now = new Date(); const now = new Date();
const diff = now - date; const diff = now - date;
const days = Math.floor(diff / (1000 * 60 * 60 * 24)); const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days < 1) return 'Today'; if (days < 1) return 'Today';
if (days < 7) return `${days} days ago`; if (days < 7) return `${days} days ago`;
if (days < 30) return `${Math.floor(days / 7)} weeks ago`; if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
if (days < 365) return `${Math.floor(days / 30)} months ago`; if (days < 365) return `${Math.floor(days / 30)} months ago`;
return `${Math.floor(days / 365)} years ago`; return `${Math.floor(days / 365)} years ago`;
} catch (e) { } catch (e) {
return 'Recently'; return 'Recently';
} }
} }
</script> </script>
{% endblock %} {% endblock %}

View file

@ -12,8 +12,10 @@
<div class="yt-filter-bar"> <div class="yt-filter-bar">
<div class="yt-categories" id="categoryList"> <div class="yt-categories" id="categoryList">
<!-- Pinned Categories --> <!-- Pinned Categories -->
<button class="yt-chip" onclick="switchCategory('history', this)"><i class="fas fa-history"></i> Watched</button> <button class="yt-chip" onclick="switchCategory('history', this)"><i class="fas fa-history"></i>
<button class="yt-chip" onclick="switchCategory('suggested', this)"><i class="fas fa-magic"></i> Suggested</button> Watched</button>
<button class="yt-chip" onclick="switchCategory('suggested', this)"><i class="fas fa-magic"></i>
Suggested</button>
<!-- Standard Categories --> <!-- Standard Categories -->
<button class="yt-chip" onclick="switchCategory('tech', this)">Tech</button> <button class="yt-chip" onclick="switchCategory('tech', this)">Tech</button>
<button class="yt-chip" onclick="switchCategory('music', this)">Music</button> <button class="yt-chip" onclick="switchCategory('music', this)">Music</button>
@ -145,251 +147,7 @@
</div> </div>
</div> </div>
<style> <!-- Styles moved to CSS modules -->
/* Filter Bar Styles */
.yt-filter-bar {
display: flex;
align-items: center;
gap: 1rem;
padding: 0 1rem;
margin-bottom: 1rem;
position: sticky;
top: 56px;
/* Adjust based on header height */
z-index: 99;
background: var(--yt-bg-primary);
border-bottom: 1px solid var(--yt-border);
}
.yt-categories {
display: flex;
overflow-x: auto;
gap: 0.8rem;
padding: 0.5rem 0;
flex: 1;
scrollbar-width: none;
/* Firefox */
}
.yt-categories::-webkit-scrollbar {
display: none;
}
.yt-chip {
padding: 0.5rem 1rem;
border-radius: 8px;
background: var(--yt-bg-secondary);
color: var(--yt-text-primary);
border: none;
white-space: nowrap;
cursor: pointer;
font-size: 0.9rem;
transition: background 0.2s;
}
.yt-chip:hover {
background: var(--yt-bg-hover);
}
.yt-chip-active {
background: var(--yt-text-primary);
color: var(--yt-bg-primary);
}
.yt-chip-active:hover {
background: var(--yt-text-primary);
opacity: 0.9;
}
.yt-filter-actions {
flex-shrink: 0;
position: relative;
}
.yt-dropdown-menu {
display: none;
position: absolute;
top: 100%;
right: 0;
width: 200px;
background: var(--yt-bg-secondary);
border-radius: 12px;
padding: 1rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
margin-top: 0.5rem;
z-index: 100;
border: 1px solid var(--yt-border);
}
.yt-dropdown-menu.show {
display: block;
}
.yt-menu-section {
margin-bottom: 1rem;
}
.yt-menu-section:last-child {
margin-bottom: 0;
}
.yt-menu-section h4 {
font-size: 0.8rem;
color: var(--yt-text-secondary);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.yt-menu-section button {
display: block;
width: 100%;
text-align: left;
padding: 0.5rem;
background: none;
border: none;
color: var(--yt-text-primary);
cursor: pointer;
border-radius: 6px;
}
.yt-menu-section button:hover {
background: var(--yt-bg-hover);
}
/* Shorts Section Styles */
.yt-section {
margin-bottom: 32px;
padding: 0 16px;
}
.yt-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.yt-section-header h2 {
font-size: 20px;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
}
.yt-section-header h2 i {
color: var(--yt-accent-red);
}
.yt-shorts-container {
position: relative;
display: flex;
align-items: center;
}
.yt-shorts-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--yt-bg-primary);
border: 1px solid var(--yt-border);
color: var(--yt-text-primary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10;
transition: all 0.2s;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.yt-shorts-arrow:hover {
background: var(--yt-bg-secondary);
transform: translateY(-50%) scale(1.1);
}
.yt-shorts-left {
left: -20px;
}
.yt-shorts-right {
right: -20px;
}
.yt-shorts-grid {
display: flex;
gap: 12px;
overflow-x: auto;
padding: 8px 0;
scroll-behavior: smooth;
scrollbar-width: none;
flex: 1;
}
.yt-shorts-grid::-webkit-scrollbar {
display: none;
}
.yt-short-card {
flex-shrink: 0;
width: 180px;
cursor: pointer;
transition: transform 0.2s;
}
.yt-short-card:hover {
transform: scale(1.02);
}
.yt-short-thumb {
width: 180px;
height: 320px;
border-radius: 12px;
object-fit: cover;
background: var(--yt-bg-secondary);
opacity: 0;
transition: opacity 0.5s ease;
}
.yt-short-thumb.loaded {
opacity: 1;
.yt-short-title {
font-size: 14px;
font-weight: 500;
margin-top: 8px;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.yt-short-views {
font-size: 12px;
color: var(--yt-text-secondary);
margin-top: 4px;
}
@media (max-width: 768px) {
.yt-shorts-arrow {
display: none;
}
.yt-filter-bar {
padding: 0 10px;
top: 56px;
}
.yt-sort-container {
/* Legacy override if needed */
}
}
</style>
<script> <script>
// Global filter state // Global filter state
@ -413,71 +171,15 @@
window.currentSort = sort; window.currentSort = sort;
// Global loadTrending from main.js will use this // Global loadTrending from main.js will use this
loadTrending(true); loadTrending(true);
loadShorts(); // Also reload shorts with new sort
toggleFilterMenu(); toggleFilterMenu();
} }
function changeRegion(region) { function changeRegion(region) {
window.currentRegion = region; window.currentRegion = region;
loadTrending(true); loadTrending(true);
loadShorts(); // Also reload shorts with new region
toggleFilterMenu(); toggleFilterMenu();
} }
// Scroll shorts logic
function scrollShorts(direction) {
const grid = document.getElementById('shortsGrid');
const scrollAmount = 400;
if (direction === 'left') {
grid.scrollBy({ left: -scrollAmount, behavior: 'smooth' });
} else {
grid.scrollBy({ left: scrollAmount, behavior: 'smooth' });
}
}
// Load Shorts Logic
// Load Shorts Logic
async function loadShorts() {
const shortsGrid = document.getElementById('shortsGrid');
// Skeleton loader
shortsGrid.innerHTML = Array(10).fill(0).map(() => `
<div class="skeleton-short"></div>
`).join('');
try {
const page = 1;
const category = window.currentCategory || 'general';
const shortsCategory = category === 'all' || category === 'general' ? 'shorts' : category;
const response = await fetch(`/api/trending?category=${shortsCategory}&page=${page}&sort=${window.currentSort}&region=${window.currentRegion}&shorts=1`);
const data = await response.json();
shortsGrid.innerHTML = '';
if (data && data.length > 0) {
// Show up to 20 shorts
data.slice(0, 20).forEach(video => {
const card = document.createElement('div');
card.className = 'yt-short-card';
card.innerHTML = `
<img data-src="${video.thumbnail}" class="yt-short-thumb">
<p class="yt-short-title">${escapeHtml(video.title)}</p>
<p class="yt-short-views">${formatViews(video.view_count)} views</p>
`;
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
shortsGrid.appendChild(card);
});
if (window.observeImages) window.observeImages();
} else {
shortsGrid.innerHTML = '<p style="color:var(--yt-text-secondary);padding:20px;">No shorts found</p>';
}
} catch (e) {
console.error('Error loading shorts:', e);
shortsGrid.innerHTML = '<p style="color:var(--yt-text-secondary);padding:20px;">Could not load shorts</p>';
}
}
// Helpers (if main.js not loaded yet or for standalone usage) // Helpers (if main.js not loaded yet or for standalone usage)
function escapeHtml(text) { function escapeHtml(text) {
if (!text) return ''; if (!text) return '';
@ -496,7 +198,6 @@
// Init Logic // Init Logic
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadShorts();
// Pagination logic removed for infinite scroll // Pagination logic removed for infinite scroll
// Check URL params for category // Check URL params for category

View file

@ -86,11 +86,6 @@
<i class="fas fa-home"></i> <i class="fas fa-home"></i>
<span>Home</span> <span>Home</span>
</a> </a>
<a href="javascript:void(0)" class="yt-sidebar-item" data-category="shorts"
onclick="navigateCategory('shorts')">
<i class="fas fa-bolt"></i>
<span>Shorts</span>
</a>
<div class="yt-sidebar-divider"></div> <div class="yt-sidebar-divider"></div>
@ -300,54 +295,7 @@
</div> </div>
<div class="yt-queue-overlay" id="queueOverlay" onclick="toggleQueue()"></div> <div class="yt-queue-overlay" id="queueOverlay" onclick="toggleQueue()"></div>
<style> <!-- Toast Styles Moved to static/css/modules/utils.css -->
.yt-toast-container {
position: fixed;
bottom: 24px;
left: 24px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 12px;
pointer-events: none;
}
.yt-toast {
background: #1f1f1f;
color: #fff;
padding: 12px 24px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
font-size: 14px;
animation: slideUp 0.3s ease;
pointer-events: auto;
display: flex;
align-items: center;
gap: 12px;
min-width: 280px;
border-left: 4px solid #3ea6ff;
}
.yt-toast.error {
border-left-color: #ff4e45;
}
.yt-toast.success {
border-left-color: #2ba640;
}
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
</style>
<script> <script>
function showToast(message, type = 'info') { function showToast(message, type = 'info') {
@ -424,148 +372,6 @@
// --- Back Button Logic --- // --- Back Button Logic ---
// Back Button Logic Removed (Handled Server-Side) // Back Button Logic Removed (Handled Server-Side)
</script> </script>
<style> <!-- Queue Drawer Styles Moved to static/css/modules/components.css -->
/* Queue Drawer Styles */
.yt-queue-drawer {
position: fixed;
top: 0;
right: -350px;
width: 350px;
height: 100vh;
background: var(--yt-bg-secondary);
z-index: 10000;
transition: right 0.3s ease;
display: flex;
flex-direction: column;
/* Shadow removed here to prevent bleeding when closed */
box-shadow: none;
}
.yt-queue-drawer.open {
right: 0;
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.5);
/* Apply shadow only when open */
}
.yt-queue-header {
padding: 16px;
border-bottom: 1px solid var(--yt-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.yt-queue-header h3 {
font-size: 18px;
font-weight: 600;
}
.yt-queue-list {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.yt-queue-footer {
padding: 16px;
border-top: 1px solid var(--yt-border);
text-align: center;
}
.yt-queue-clear-btn {
background: transparent;
border: 1px solid var(--yt-border);
color: var(--yt-text-primary);
padding: 8px 16px;
border-radius: 18px;
cursor: pointer;
}
.yt-queue-clear-btn:hover {
background: var(--yt-bg-hover);
}
.yt-queue-item {
display: flex;
gap: 12px;
margin-bottom: 12px;
align-items: center;
}
.yt-queue-thumb {
width: 100px;
height: 56px;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
flex-shrink: 0;
}
.yt-queue-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.yt-queue-info {
flex: 1;
overflow: hidden;
}
.yt-queue-title {
font-size: 14px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.yt-queue-title:hover {
text-decoration: underline;
}
.yt-queue-uploader {
font-size: 12px;
color: var(--yt-text-secondary);
}
.yt-queue-remove {
background: none;
border: none;
color: var(--yt-text-secondary);
cursor: pointer;
padding: 4px;
}
.yt-queue-remove:hover {
color: #ff4e45;
}
.yt-queue-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
}
.yt-queue-overlay.active {
opacity: 1;
pointer-events: auto;
}
@media (max-width: 480px) {
.yt-queue-drawer {
width: 85%;
right: -85%;
}
}
</style>
</html> </html>

66
test_crawl.py Normal file
View file

@ -0,0 +1,66 @@
import sys
import subprocess
import json
import time
def test_fetch(query, label):
print(f"--- Testing Query: '{query}' ({label}) ---")
limit = 150
cmd = [
sys.executable, '-m', 'yt_dlp',
f'ytsearch{limit}:{query}',
'--dump-json',
'--default-search', 'ytsearch',
'--no-playlist',
'--flat-playlist',
'--playlist-start', '1',
'--playlist-end', str(limit)
]
start_time = time.time()
try:
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
stdout, stderr = process.communicate()
except Exception as e:
print(f"Error running command: {e}")
return
raw_count = 0
valid_count = 0
for line in stdout.splitlines():
try:
data = json.loads(line)
raw_count += 1
# Simulate our strict filter filters
video_id = data.get('id')
title = data.get('title', '').lower()
duration_secs = data.get('duration')
if not video_id: continue
# Title Filter
if '#shorts' in title:
continue
# Duration Filter (Loose: allow missing, strict if present)
if duration_secs and int(duration_secs) <= 70:
continue
# If we are here, it's valid
valid_count += 1
except:
continue
elapsed = time.time() - start_time
print(f"Raw Results: {raw_count}")
print(f"Valid Horizontal Videos (filtered): {valid_count}")
print(f"Time Taken: {elapsed:.2f}s")
print("-" * 30)
if __name__ == "__main__":
# Test queries from app.py
test_fetch("review công nghệ điện thoại laptop", "Vietnam Tech")
test_fetch("tech gadget review smartphone", "Global Tech")