Fix layout, queue, infinite scroll, and mobile responsiveness

This commit is contained in:
Khoa.vo 2025-12-17 18:30:04 +07:00
parent bf47ec62ea
commit 665a8209e4
8 changed files with 2942 additions and 1144 deletions

497
app.py
View file

@ -138,6 +138,49 @@ def logout():
session.clear()
return redirect(url_for('index')) # Changed from 'home' to 'index'
@app.template_filter('format_views')
def format_views(views):
if not views: return '0'
try:
num = int(views)
if num >= 1000000: return f"{num / 1000000:.1f}M"
if num >= 1000: return f"{num / 1000:.0f}K"
return f"{num:,}"
except:
return str(views)
@app.template_filter('format_date')
def format_date(value):
if not value: return 'Recently'
from datetime import datetime, timedelta
try:
# Handle YYYYMMDD
if len(str(value)) == 8 and str(value).isdigit():
dt = datetime.strptime(str(value), '%Y%m%d')
# Handle Timestamp
elif isinstance(value, (int, float)):
dt = datetime.fromtimestamp(value)
# Handle already formatted (YYYY-MM-DD)
else:
# Try common formats
try: dt = datetime.strptime(str(value), '%Y-%m-%d')
except: return str(value)
now = datetime.now()
diff = now - dt
if diff.days > 365:
return f"{diff.days // 365} years ago"
if diff.days > 30:
return f"{diff.days // 30} months ago"
if diff.days > 0:
return f"{diff.days} days ago"
if diff.seconds > 3600:
return f"{diff.seconds // 3600} hours ago"
return "Just now"
except:
return str(value)
# Configuration for local video path - configurable via env var
VIDEO_DIR = os.environ.get('KVTUBE_VIDEO_DIR', './videos')
@ -146,19 +189,22 @@ def index():
return render_template('index.html', page='home')
@app.route('/my-videos')
@login_required
def my_videos():
filter_type = request.args.get('type', 'saved') # 'saved' or 'history'
filter_type = request.args.get('type', 'history') # 'saved' or 'history'
conn = get_db_connection()
videos = conn.execute('''
SELECT * FROM user_videos
WHERE user_id = ? AND type = ?
ORDER BY timestamp DESC
''', (session['user_id'], filter_type)).fetchall()
conn.close()
videos = []
logged_in = 'user_id' in session
return render_template('my_videos.html', videos=videos, filter_type=filter_type)
if logged_in:
conn = get_db_connection()
videos = conn.execute('''
SELECT * FROM user_videos
WHERE user_id = ? AND type = ?
ORDER BY timestamp DESC
''', (session['user_id'], filter_type)).fetchall()
conn.close()
return render_template('my_videos.html', videos=videos, filter_type=filter_type, logged_in=logged_in)
@app.route('/api/save_video', methods=['POST'])
@login_required
@ -269,6 +315,249 @@ def watch():
return "No video ID provided", 400
return render_template('watch.html', video_type='youtube', video_id=video_id)
@app.route('/channel/<channel_id>')
def channel(channel_id):
if not channel_id:
return redirect(url_for('index'))
try:
# Robustness: Resolve name to ID if needed (Metadata only fetch)
real_id_or_url = channel_id
is_search_fallback = False
if not channel_id.startswith('UC') and not channel_id.startswith('@'):
# Simple resolve logic - reusing similar block from before but optimized for metadata
search_cmd = [
sys.executable, '-m', 'yt_dlp',
f'ytsearch1:{channel_id}',
'--dump-json',
'--default-search', 'ytsearch',
'--no-playlist'
]
try:
proc_search = subprocess.run(search_cmd, capture_output=True, text=True)
if proc_search.returncode == 0:
first_result = json.loads(proc_search.stdout.splitlines()[0])
if first_result.get('channel_id'):
real_id_or_url = first_result.get('channel_id')
is_search_fallback = True
except: pass
# Fetch basic channel info (Avatar/Banner)
# We use a very short playlist fetch just to get the channel dict
channel_info = {
'id': real_id_or_url, # Use resolved ID for API calls
'title': channel_id if not is_search_fallback else 'Loading...',
'avatar': None,
'banner': None,
'subscribers': None
}
# Determine target URL for metadata fetch
target_url = real_id_or_url
if target_url.startswith('UC'): target_url = f'https://www.youtube.com/channel/{target_url}'
elif target_url.startswith('@'): target_url = f'https://www.youtube.com/{target_url}'
cmd = [
sys.executable, '-m', 'yt_dlp',
target_url,
'--dump-json',
'--flat-playlist',
'--playlist-end', '1', # Fetch just 1 to get metadata
'--no-warnings'
]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
stdout, stderr = proc.communicate()
if stdout:
try:
first = json.loads(stdout.splitlines()[0])
channel_info['title'] = first.get('channel') or first.get('uploader') or channel_info['title']
channel_info['id'] = first.get('channel_id') or channel_info['id']
# Try to get avatar/banner if available in flat dump (often NOT, but title/id are key)
except: pass
# Render shell - videos fetched via JS
return render_template('channel.html', channel=channel_info)
except Exception as e:
return f"Error loading channel: {str(e)}", 500
@app.route('/api/related')
def get_related_videos():
video_id = request.args.get('v')
title = request.args.get('title')
page = int(request.args.get('page', 1))
limit = int(request.args.get('limit', 10))
if not title and not video_id:
return jsonify({'error': 'Video ID or Title required'}), 400
try:
query = f"{title} related" if title else f"{video_id} related"
# Calculate pagination
# Page 1: 0-10 (but usually fetched by get_stream_info)
# Page 2: 10-20
start = (page - 1) * limit
end = start + limit
videos = fetch_videos(query, limit=limit, playlist_start=start+1, playlist_end=end)
return jsonify(videos)
except Exception as e:
print(f"Error fetching related: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/download')
def get_download_url():
"""Get a direct MP4 download URL for a video"""
video_id = request.args.get('v')
if not video_id:
return jsonify({'error': 'No video ID'}), 400
try:
url = f"https://www.youtube.com/watch?v={video_id}"
# Use format that avoids HLS/DASH manifests (m3u8)
# Prefer progressive download formats
ydl_opts = {
'format': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best[protocol!*=m3u8]/best',
'noplaylist': True,
'quiet': True,
'no_warnings': True,
'skip_download': True,
'youtube_include_dash_manifest': False, # Avoid DASH
'youtube_include_hls_manifest': False, # Avoid HLS
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
# Try to get URL that's NOT an m3u8
download_url = info.get('url', '')
# If still m3u8, try getting from formats directly
if '.m3u8' in download_url or not download_url:
formats = info.get('formats', [])
# Find best non-HLS format
for f in reversed(formats):
f_url = f.get('url', '')
f_ext = f.get('ext', '')
f_protocol = f.get('protocol', '')
if f_url and 'm3u8' not in f_url and f_ext == 'mp4':
download_url = f_url
break
title = info.get('title', 'video')
if download_url and '.m3u8' not in download_url:
return jsonify({
'url': download_url,
'title': title,
'ext': 'mp4'
})
else:
# Fallback: return YouTube link for manual download
return jsonify({
'error': 'Direct download not available. Try a video downloader site.',
'fallback_url': url
}), 200
except Exception as e:
print(f"Download URL error: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/channel/videos')
def get_channel_videos():
channel_id = request.args.get('id')
page = int(request.args.get('page', 1))
limit = int(request.args.get('limit', 20))
sort_mode = request.args.get('sort', 'latest')
filter_type = request.args.get('filter_type', 'video') # 'video' or 'shorts'
if not channel_id: return jsonify([])
try:
# Calculate playlist range
start = (page - 1) * limit + 1
end = start + limit - 1
# Construct URL based on ID type AND Filter Type
base_url = ""
if channel_id.startswith('UC'): base_url = f'https://www.youtube.com/channel/{channel_id}'
elif channel_id.startswith('@'): base_url = f'https://www.youtube.com/{channel_id}'
else: base_url = f'https://www.youtube.com/channel/{channel_id}' # Fallback
target_url = base_url
if filter_type == 'shorts':
target_url += '/shorts'
elif filter_type == 'video':
target_url += '/videos'
playlist_args = ['--playlist-start', str(start), '--playlist-end', str(end)]
if sort_mode == 'oldest':
playlist_args = ['--playlist-reverse', '--playlist-start', str(start), '--playlist-end', str(end)]
# ... (rest is same)
elif sort_mode == 'popular':
# For popular, we ideally need a larger pool if doing python sort,
# BUT with pagination strict ranges, python sort is impossible across pages.
# We MUST rely on yt-dlp/youtube sort.
# Attempt to use /videos URL which supports sort?
# Actually, standard channel URL + --flat-playlist returns "Latest".
# To get popular, we would typically need to scape /videos?sort=p.
# yt-dlp doesn't support 'sort' arg for channels directly.
# WORKAROUND: For 'popular', we'll just return Latest for now to avoid breaking pagination,
# OR fetches a larger batch (e.g. top 100) and slice it?
# Let's simple return latest but marked.
# Implementation decision: Stick to Latest logic for stability,
# OR (Better) don't support sort in API yet if unsupported.
# Let's keep logic simple: ignore sort for API to ensure speed.
pass
cmd = [
sys.executable, '-m', 'yt_dlp',
target_url,
'--dump-json',
'--flat-playlist',
'--no-warnings'
] + playlist_args
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
stdout, stderr = proc.communicate()
videos = []
for line in stdout.splitlines():
try:
v = json.loads(line)
dur_str = None
if v.get('duration'):
m, s = divmod(int(v['duration']), 60)
h, m = divmod(m, 60)
dur_str = f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
videos.append({
'id': v.get('id'),
'title': v.get('title'),
'thumbnail': f"https://i.ytimg.com/vi/{v.get('id')}/mqdefault.jpg",
'view_count': v.get('view_count') or 0,
'duration': dur_str,
'upload_date': v.get('upload_date'),
'uploader': v.get('uploader'),
'channel_id': v.get('channel_id') or channel_id
})
except: continue
return jsonify(videos)
except Exception as e:
print(f"API Error: {e}")
return jsonify([])
except Exception as e:
return f"Error loading channel: {str(e)}", 500
@app.route('/api/get_stream_info')
def get_stream_info():
video_id = request.args.get('v')
@ -331,7 +620,7 @@ def get_stream_info():
related_videos = []
try:
search_query = f"{info.get('title', '')} related"
related_videos = fetch_videos(search_query, limit=10)
related_videos = fetch_videos(search_query, limit=20)
except:
pass
@ -342,19 +631,32 @@ def get_stream_info():
subs = info.get('subtitles') or {}
auto_subs = info.get('automatic_captions') or {}
# DEBUG: Print subtitle info
print(f"Checking subtitles for {video_id}")
print(f"Manual Subs keys: {list(subs.keys())}")
print(f"Auto Subs keys: {list(auto_subs.keys())}")
# Check manual subs first
if 'en' in subs:
subtitle_url = subs['en'][0]['url']
elif 'vi' in subs: # Vietnamese fallback
subtitle_url = subs['vi'][0]['url']
# Check auto subs
# Check auto subs (usually available)
elif 'en' in auto_subs:
subtitle_url = auto_subs['en'][0]['url']
elif 'vi' in auto_subs:
subtitle_url = auto_subs['vi'][0]['url']
# If still none, just pick the first one from manual
if not subtitle_url and subs:
first_key = list(subs.keys())[0]
subtitle_url = subs[first_key][0]['url']
# If still none, just pick the first one from manual then auto
if not subtitle_url:
if subs:
first_key = list(subs.keys())[0]
subtitle_url = subs[first_key][0]['url']
elif auto_subs:
first_key = list(auto_subs.keys())[0]
subtitle_url = auto_subs[first_key][0]['url']
print(f"Selected Subtitle URL: {subtitle_url}")
# 3. Construct Response Data
response_data = {
@ -599,15 +901,21 @@ def summarize_video():
return jsonify({'success': False, 'message': f'Could not summarize: {str(e)}'})
# Helper function to fetch videos (not a route)
def fetch_videos(query, limit=20):
def fetch_videos(query, limit=20, filter_type=None, playlist_start=1, playlist_end=None):
try:
# If no end specified, default to start + limit - 1
if not playlist_end:
playlist_end = playlist_start + limit - 1
cmd = [
sys.executable, '-m', 'yt_dlp',
f'ytsearch{limit}:{query}',
f'ytsearch{playlist_end}:{query}', # Explicitly request enough items to populate the list up to 'end'
'--dump-json',
'--default-search', 'ytsearch',
'--no-playlist',
'--flat-playlist'
'--flat-playlist',
'--playlist-start', str(playlist_start),
'--playlist-end', str(playlist_end)
]
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
@ -621,6 +929,13 @@ def fetch_videos(query, limit=20):
if video_id:
# Format duration
duration_secs = data.get('duration')
# Filter Logic
if filter_type == 'video' and duration_secs and int(duration_secs) <= 60:
continue
if filter_type == 'short' and duration_secs and int(duration_secs) > 60:
continue
if duration_secs:
mins, secs = divmod(int(duration_secs), 60)
hours, mins = divmod(mins, 60)
@ -632,6 +947,8 @@ def fetch_videos(query, limit=20):
'id': video_id,
'title': data.get('title', 'Unknown'),
'uploader': data.get('uploader') or data.get('channel') or 'Unknown',
'channel_id': data.get('channel_id'),
'uploader_id': data.get('uploader_id'),
'thumbnail': f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg",
'view_count': data.get('view_count', 0),
'upload_date': data.get('upload_date', ''),
@ -644,66 +961,106 @@ def fetch_videos(query, limit=20):
print(f"Error fetching videos: {e}")
return []
import concurrent.futures
@app.route('/api/trending')
def trending():
try:
category = request.args.get('category', 'general')
category = request.args.get('category', 'all') # Default to 'all' for home
page = int(request.args.get('page', 1))
sort = request.args.get('sort', 'month')
region = request.args.get('region', 'vietnam')
limit = 20
limit = 120 if category != 'all' else 20 # 120 for grid, 20 for sections
# Define search queries
if region == 'vietnam':
queries = {
'general': 'trending vietnam',
'tech': 'AI tools software tech review IT việt nam',
'all': 'trending vietnam',
'music': 'nhạc việt trending',
'gaming': 'gaming việt nam',
'movies': 'phim việt nam',
'news': 'tin tức việt nam hôm nay',
'sports': 'thể thao việt nam',
'shorts': 'shorts việt nam',
'trending': 'trending việt nam',
'podcasts': 'podcast việt nam',
'live': 'live stream việt nam'
# Helper to build query
def get_query(cat, reg, s_sort):
if reg == 'vietnam':
queries = {
'general': 'trending vietnam',
'tech': 'AI tools software tech review IT việt nam',
'all': 'trending vietnam',
'music': 'nhạc việt trending',
'gaming': 'gaming việt nam',
'movies': 'phim việt nam',
'news': 'tin tức việt nam hôm nay',
'sports': 'thể thao việt nam',
'shorts': 'trending việt nam',
'trending': 'trending việt nam',
'podcasts': 'podcast việt nam',
'live': 'live stream việt nam'
}
else:
queries = {
'general': 'trending',
'tech': 'AI tools software tech review IT',
'all': 'trending',
'music': 'music trending',
'gaming': 'gaming trending',
'movies': 'movies trending',
'news': 'news today',
'sports': 'sports highlights',
'shorts': 'trending',
'trending': 'trending now',
'podcasts': 'podcast trending',
'live': 'live stream'
}
base = queries.get(cat, 'trending')
from datetime import datetime, timedelta
three_months_ago = (datetime.now() - timedelta(days=90)).strftime('%Y-%m-%d')
sort_filters = {
'day': ', today',
'week': ', this week',
'month': ', this month',
'3months': f" after:{three_months_ago}",
'year': ', this year'
}
else:
queries = {
'general': 'trending',
'tech': 'AI tools software tech review IT',
'all': 'trending',
'music': 'music trending',
'gaming': 'gaming trending',
'movies': 'movies trending',
'news': 'news today',
'sports': 'sports highlights',
'shorts': 'shorts trending',
'trending': 'trending now',
'podcasts': 'podcast trending',
'live': 'live stream'
}
base_query = queries.get(category, 'trending vietnam' if region == 'vietnam' else 'trending')
# Add sort filter
sort_filters = {
'day': ', today',
'week': ', this week',
'month': ', this month',
'year': ', this year'
}
query = base_query + sort_filters.get(sort, ', this month')
# For pagination, we can't easily offset ytsearch efficiently without fetching all previous
# So we'll fetch a larger chunk and slice it in python, or just accept that page 2 is similar
# A simple hack for "randomness" or pages is to append a random term or year, but let's stick to standard behavior
# Or better: search for "query page X"
if page > 1:
query += f" page {page}"
return base + sort_filters.get(s_sort, f" after:{three_months_ago}")
results = fetch_videos(query, limit=limit)
# === Parallel Fetching for Home Feed ===
if category == 'all':
sections_to_fetch = [
{'id': 'trending', 'title': 'Trending Now', 'icon': 'fire'},
{'id': 'tech', 'title': 'AI & Tech', 'icon': 'microchip'},
{'id': 'music', 'title': 'Music', 'icon': 'music'},
{'id': 'gaming', 'title': 'Gaming', 'icon': 'gamepad'},
{'id': 'movies', 'title': 'Movies', 'icon': 'film'},
{'id': 'sports', 'title': 'Sports', 'icon': 'football-ball'},
{'id': 'news', 'title': 'News', 'icon': 'newspaper'}
]
def fetch_section(section):
q = get_query(section['id'], region, sort)
# Fetch 20 videos per section, page 1 logic implied (start=1)
vids = fetch_videos(q, limit=25, filter_type='video', playlist_start=1)
return {
'id': section['id'],
'title': section['title'],
'icon': section['icon'],
'videos': vids[:20]
}
with concurrent.futures.ThreadPoolExecutor(max_workers=7) as executor:
results = list(executor.map(fetch_section, sections_to_fetch))
return jsonify({'mode': 'sections', 'data': results})
# === Standard Single Category Fetch ===
query = get_query(category, region, sort)
# Calculate offset
start = (page - 1) * limit + 1
# Determine filter type
is_shorts_req = request.args.get('shorts')
if is_shorts_req:
filter_mode = 'short'
else:
filter_mode = 'short' if category == 'shorts' else 'video'
results = fetch_videos(query, limit=limit, filter_type=filter_mode, playlist_start=start)
return jsonify(results)
except Exception as e:

View file

@ -3,14 +3,15 @@
/* ===== YouTube Dark Theme Colors ===== */
:root {
--yt-bg-primary: #0f0f0f;
--yt-bg-secondary: #272727;
--yt-bg-elevated: #212121;
--yt-bg-hover: #3f3f3f;
--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;
@ -623,6 +624,29 @@ button {
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 {
@ -757,84 +781,7 @@ button {
background: transparent;
}
.yt-spinner {
position: relative;
width: 60px;
height: 60px;
margin-bottom: 24px;
}
.yt-spinner-ring {
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
border: 3px solid transparent;
border-top-color: var(--yt-accent-red);
animation: quantumSpin 1.5s cubic-bezier(0.5, 0, 0.5, 1) infinite;
}
.yt-spinner-ring:nth-child(1) {
width: 100%;
height: 100%;
border-top-color: var(--yt-accent-red);
}
.yt-spinner-ring:nth-child(2) {
width: 40px;
height: 40px;
top: 10px;
left: 10px;
border-top-color: var(--yt-accent-blue);
animation-delay: 0.2s;
animation-direction: reverse;
}
.yt-spinner-ring:nth-child(3) {
width: 20px;
height: 20px;
top: 20px;
left: 20px;
border-top-color: #fff;
animation-delay: 0.4s;
}
.yt-spinner-glow {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--yt-accent-red);
box-shadow: 0 0 20px 10px rgba(255, 0, 0, 0.3);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes quantumSpin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes pulse {
0%,
100% {
opacity: 0.5;
transform: translate(-50%, -50%) scale(1);
}
50% {
opacity: 1;
transform: translate(-50%, -50%) scale(2);
}
}
/* Spinner removed */
/* ===== Responsive ===== */
@media (max-width: 1200px) {
@ -1089,6 +1036,52 @@ button {
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;
}
/* Friendly Empty State */
.yt-empty-state {
grid-column: 1 / -1;
@ -1117,4 +1110,64 @@ button {
.yt-empty-desc {
font-size: 14px;
margin-bottom: 24px;
}
/* ===== Horizontal Video Card (Suggested/Sidebar) ===== */
.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;
-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;
}

View file

@ -21,6 +21,9 @@ document.addEventListener('DOMContentLoaded', () => {
// Load trending on init
loadTrending();
// Init Infinite Scroll
initInfiniteScroll();
}
// Init Theme
@ -28,9 +31,28 @@ document.addEventListener('DOMContentLoaded', () => {
});
// Note: Global variables like currentCategory are defined below
let currentCategory = 'general';
let currentCategory = 'all';
let currentPage = 1;
let isLoading = false;
let hasMore = true; // Track if there are more videos to load
// --- Infinite Scroll ---
function initInfiniteScroll() {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !isLoading && hasMore) {
loadMore();
}
}, { rootMargin: '200px' });
// Create sentinel logic or observe existing footer/element
// We'll observe a sentinel element at the bottom of the grid
const sentinel = document.createElement('div');
sentinel.id = 'scroll-sentinel';
sentinel.style.width = '100%';
sentinel.style.height = '20px';
document.getElementById('resultsArea').parentNode.appendChild(sentinel);
observer.observe(sentinel);
}
// --- UI Helpers ---
function renderSkeleton() {
@ -108,6 +130,7 @@ async function switchCategory(category, btn) {
currentCategory = category;
currentPage = 1;
window.currentPage = 1;
hasMore = true; // Reset infinite scroll
const resultsArea = document.getElementById('resultsArea');
resultsArea.innerHTML = renderSkeleton();
@ -116,6 +139,20 @@ async function switchCategory(category, btn) {
const paginationArea = document.getElementById('paginationArea');
if (paginationArea) paginationArea.style.display = 'none';
// Handle Shorts Layout
const shortsSection = document.getElementById('shortsSection');
const videosSection = document.getElementById('videosSection');
if (shortsSection) {
if (category === 'shorts') {
shortsSection.style.display = 'none'; // Hide carousel, show grid in results
if (videosSection) videosSection.querySelector('h2').style.display = 'none'; // Optional: hide "Videos" header
} else {
shortsSection.style.display = 'block';
if (videosSection) videosSection.querySelector('h2').style.display = 'flex';
}
}
// Load both videos and shorts with current category, sort, and region
await loadTrending(true);
@ -149,6 +186,8 @@ async function loadTrending(reset = true) {
isLoading = true;
if (!reset && loadMoreBtn) {
loadMoreBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading...';
} else if (reset) {
resultsArea.innerHTML = renderSkeleton();
}
try {
@ -166,23 +205,69 @@ async function loadTrending(reset = true) {
return;
}
if (data.mode === 'sections') {
if (reset) resultsArea.innerHTML = '';
// Render Sections
data.data.forEach(section => {
const sectionDiv = document.createElement('div');
sectionDiv.style.gridColumn = '1 / -1';
sectionDiv.style.marginBottom = '24px';
// Header
sectionDiv.innerHTML = `
<div class="yt-section-header" style="margin-bottom:12px;">
<h2><i class="fas fa-${section.icon}"></i> ${section.title}</h2>
</div>
`;
// Scroll Container
const scrollContainer = document.createElement('div');
scrollContainer.className = 'yt-shorts-grid'; // Reuse horizontal scroll style
scrollContainer.style.gap = '16px';
section.videos.forEach(video => {
const card = document.createElement('div');
card.className = 'yt-video-card';
card.style.minWidth = '280px'; // Fixed width for horizontal items
card.style.width = '280px';
card.innerHTML = `
<div class="yt-thumbnail-container">
<img class="yt-thumbnail" src="${video.thumbnail}" alt="${escapeHtml(video.title)}" loading="lazy">
${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''}
</div>
<div class="yt-video-details">
<div class="yt-channel-avatar">
${video.uploader ? video.uploader.charAt(0).toUpperCase() : 'Y'}
</div>
<div class="yt-video-meta">
<h3 class="yt-video-title">${escapeHtml(video.title)}</h3>
<p class="yt-channel-name">${escapeHtml(video.uploader || 'Unknown')}</p>
<p class="yt-video-stats">${formatViews(video.view_count)} views</p>
</div>
</div>
`;
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
scrollContainer.appendChild(card);
});
sectionDiv.appendChild(scrollContainer);
resultsArea.appendChild(sectionDiv);
});
return;
}
if (reset) resultsArea.innerHTML = '';
if (data.length === 0) {
if (reset) {
resultsArea.innerHTML = renderNoContent();
}
const paginationArea = document.getElementById('paginationArea');
if (paginationArea) paginationArea.style.display = 'none';
} else {
displayResults(data, !reset);
const paginationArea = document.getElementById('paginationArea');
if (paginationArea) paginationArea.style.display = 'flex';
// Update pagination if function exists
if (typeof renderPagination === 'function') {
renderPagination();
}
// Assume if we got less than limit (20), we reached the end
if (data.length < 20) hasMore = false;
}
} catch (e) {
console.error('Failed to load trending:', e);
@ -206,24 +291,48 @@ function displayResults(videos, append = false) {
videos.forEach(video => {
const card = document.createElement('div');
card.className = 'yt-video-card';
card.innerHTML = `
<div class="yt-thumbnail-container">
<img class="yt-thumbnail" src="${video.thumbnail}" alt="${escapeHtml(video.title)}" loading="lazy">
${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''}
</div>
<div class="yt-video-details">
<div class="yt-channel-avatar">
${video.uploader ? video.uploader.charAt(0).toUpperCase() : 'Y'}
if (currentCategory === 'shorts') {
// Render as Short Card (Vertical)
card.className = 'yt-short-card';
// Adjust styling for grid view if needed
card.style.width = '100%';
card.style.maxWidth = '200px';
card.innerHTML = `
<img src="${video.thumbnail}" class="yt-short-thumb" style="width:100%; aspect-ratio:9/16; height:auto;" loading="lazy">
<p class="yt-short-title">${escapeHtml(video.title)}</p>
<p class="yt-short-views">${formatViews(video.view_count)} views</p>
`;
} else {
// Render as Standard Video Card
card.className = 'yt-video-card';
card.innerHTML = `
<div class="yt-thumbnail-container">
<img class="yt-thumbnail" src="${video.thumbnail}" alt="${escapeHtml(video.title)}" loading="lazy">
${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''}
</div>
<div class="yt-video-meta">
<h3 class="yt-video-title">${escapeHtml(video.title)}</h3>
<p class="yt-channel-name">${escapeHtml(video.uploader || 'Unknown')}</p>
<p class="yt-video-stats">${formatViews(video.view_count)} views ${formatDate(video.upload_date)}</p>
<div class="yt-video-details">
<div class="yt-channel-avatar">
${video.uploader ? video.uploader.charAt(0).toUpperCase() : 'Y'}
</div>
<div class="yt-video-meta">
<h3 class="yt-video-title">${escapeHtml(video.title)}</h3>
<p class="yt-channel-name">
<a href="/channel/${video.channel_id || video.uploader_id || video.uploader || 'unknown'}"
class="yt-channel-link"
style="color:inherit; text-decoration:none;">
${escapeHtml(video.uploader || 'Unknown')}
</a>
</p>
<p class="yt-video-stats">${formatViews(video.view_count)} views ${formatDate(video.upload_date)}</p>
</div>
</div>
</div>
`;
card.addEventListener('click', () => {
`;
}
card.addEventListener('click', (e) => {
// Prevent navigation if clicking on channel link
if (e.target.closest('.yt-channel-link')) return;
window.location.href = `/watch?v=${video.id}`;
});
resultsArea.appendChild(card);
@ -312,7 +421,16 @@ document.addEventListener('click', (e) => {
// --- Theme Logic ---
function initTheme() {
const savedTheme = localStorage.getItem('theme') || 'dark';
// Check for saved preference
let savedTheme = localStorage.getItem('theme');
// If no saved preference, use Time of Day (Auto)
// Approximation: 6 AM to 6 PM is Light (Sunrise/Sunset)
if (!savedTheme) {
const hour = new Date().getHours();
savedTheme = (hour >= 6 && hour < 18) ? 'light' : 'dark';
}
document.documentElement.setAttribute('data-theme', savedTheme);
// Update toggle if exists

386
templates/channel.html Normal file
View file

@ -0,0 +1,386 @@
{% extends "layout.html" %}
{% block content %}
<div class="yt-container yt-channel-page">
<!-- Channel Header (No Banner) -->
<div class="yt-channel-header">
<div class="yt-channel-info-row">
<div class="yt-channel-avatar-xl">
{% if channel.avatar %}
<img src="{{ channel.avatar }}">
{% else %}
{{ channel.title[0] | upper }}
{% endif %}
</div>
<div class="yt-channel-meta">
<h1>{{ channel.title }}</h1>
<p class="yt-channel-handle">
{% if channel.id.startswith('@') %}{{ channel.id }}{% else %}@{{ channel.title|replace(' ', '') }}{%
endif %}
</p>
<div class="yt-channel-stats">
<span>{{ channel.subscribers if channel.subscribers else 'Subscribe for more' }}</span>
</div>
<div class="yt-channel-actions">
<button class="yt-subscribe-btn-lg">Subscribe</button>
</div>
</div>
</div>
</div>
<!-- Video Grid -->
<div class="yt-section">
<div class="yt-section-header">
<div class="yt-tabs">
<a href="#" onclick="changeChannelTab('video', this); return false;" class="active">Videos</a>
<a href="#" onclick="changeChannelTab('shorts', this); return false;">Shorts</a>
</div>
<div class="yt-sort-options">
<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('oldest', this); return false;">Oldest</a>
</div>
</div>
<div class="yt-video-grid" id="channelVideosGrid">
<!-- Videos loaded via JS -->
</div>
<div id="channelLoadingTrigger" style="height: 20px; margin: 20px 0;"></div>
</div>
</div>
<style>
.yt-channel-page {
padding-top: 40px;
padding-bottom: 40px;
max-width: 1200px;
margin: 0 auto;
}
/* Removed .yt-channel-banner */
.yt-channel-info-row {
display: flex;
align-items: flex-start;
gap: 32px;
margin-bottom: 32px;
padding: 0 16px;
}
.yt-channel-avatar-xl {
width: 160px;
height: 160px;
border-radius: 50%;
overflow: hidden;
background: linear-gradient(135deg, #FF6B6B 0%, #d62d2d 100%);
/* Simpler color for no-banner look */
display: flex;
align-items: center;
justify-content: center;
font-size: 64px;
font-weight: bold;
color: white;
flex-shrink: 0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.yt-channel-avatar-xl img {
width: 100%;
height: 100%;
object-fit: cover;
}
.yt-channel-meta {
padding-top: 12px;
}
.yt-channel-meta h1 {
font-size: 32px;
margin-bottom: 8px;
font-weight: 700;
}
.yt-channel-handle {
color: var(--yt-text-secondary);
font-size: 16px;
margin-bottom: 12px;
}
.yt-channel-stats {
color: var(--yt-text-secondary);
font-size: 14px;
margin-bottom: 24px;
}
.yt-section-header {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
margin-bottom: 24px;
padding: 0 16px;
border-bottom: none;
}
.yt-tabs {
display: inline-flex;
gap: 0;
background: var(--yt-bg-secondary);
padding: 4px;
border-radius: 24px;
position: relative;
}
.yt-tabs a {
font-size: 14px;
font-weight: 600;
color: var(--yt-text-secondary);
text-decoration: none;
padding: 8px 24px;
border-radius: 20px;
border-bottom: none;
z-index: 1;
transition: color 0.2s;
position: relative;
}
.yt-tabs a:hover {
color: var(--yt-text-primary);
}
.yt-tabs a.active {
color: var(--yt-bg-primary);
background: var(--yt-text-primary);
/* The "slider" is actually the active pill moving */
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.yt-sort-options {
display: flex;
gap: 8px;
justify-content: center;
}
.yt-sort-options a {
padding: 6px 16px;
border-radius: 16px;
background: transparent;
border: 1px solid var(--yt-border);
color: var(--yt-text-secondary);
text-decoration: none;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
}
.yt-sort-options a:hover {
background: var(--yt-bg-hover);
color: var(--yt-text-primary);
}
.yt-sort-options a.active {
background: var(--yt-bg-secondary);
color: var(--yt-text-primary);
border-color: var(--yt-text-primary);
}
/* Shorts Card Styling override for Channel Page grid */
.yt-channel-short-card {
border-radius: var(--yt-radius-lg);
overflow: hidden;
cursor: pointer;
transition: transform 0.2s;
}
.yt-channel-short-card:hover {
transform: scale(1.02);
}
.yt-short-thumb-container {
aspect-ratio: 9/16;
width: 100%;
position: relative;
}
.yt-short-thumb {
width: 100%;
height: 100%;
object-fit: cover;
}
@media (max-width: 768px) {
.yt-channel-info-row {
flex-direction: column;
align-items: center;
text-align: center;
gap: 16px;
}
.yt-channel-avatar-xl {
width: 100px;
height: 100px;
font-size: 40px;
}
.yt-channel-meta h1 {
font-size: 24px;
}
.yt-section-header {
flex-direction: column;
gap: 16px;
align-items: flex-start;
}
.yt-tabs {
width: 100%;
justify-content: center;
}
}
</style>
<script>
let currentChannelSort = 'latest';
let currentChannelPage = 1;
let isChannelLoading = false;
let hasMoreChannelVideos = true;
let currentFilterType = 'video';
const channelId = "{{ channel.id }}";
document.addEventListener('DOMContentLoaded', () => {
loadChannelVideos();
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))';
}
loadChannelVideos();
}
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');
loadChannelVideos();
}
async function loadChannelVideos() {
if (isChannelLoading || !hasMoreChannelVideos) return;
isChannelLoading = true;
const grid = document.getElementById('channelVideosGrid');
// Append Skeletons
const tempSkeleton = document.createElement('div');
tempSkeleton.innerHTML = renderSkeleton(8); // Reuse main.js skeleton logic if available, or simpler
// Since main.js renderSkeleton returns a string, we append it
// Check if renderSkeleton exists, else manual
if (typeof renderSkeleton === 'function') {
// Render fewer skeletons for shorts if needed, but standard is fine
grid.insertAdjacentHTML('beforeend', renderSkeleton(4));
} else {
grid.insertAdjacentHTML('beforeend', '<div style="color:var(--yt-text-secondary);">Loading...</div>');
}
try {
const response = await fetch(`/api/channel/videos?id=${channelId}&page=${currentChannelPage}&sort=${currentChannelSort}&filter_type=${currentFilterType}`);
const videos = await response.json();
// 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());
if (videos.length === 0) {
hasMoreChannelVideos = false;
if (currentChannelPage === 1) grid.innerHTML = '<p style="padding:20px; color:var(--yt-text-secondary);">No videos found.</p>';
} else {
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">
<img src="${video.thumbnail}" class="yt-short-thumb" loading="lazy">
</div>
<div class="yt-details" style="padding: 8px;">
<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>
</div>
`;
} else {
// Render Standard Video Card (Match Home)
card.className = 'yt-video-card';
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
card.innerHTML = `
<div class="yt-thumbnail-container">
<img class="yt-thumbnail" src="${video.thumbnail}" loading="lazy">
${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''}
</div>
<div class="yt-video-details">
<div class="yt-video-meta">
<h3 class="yt-video-title">${escapeHtml(video.title)}</h3>
<p class="yt-video-stats">
${formatViews(video.view_count)} views • ${formatDate(video.upload_date)}
</p>
</div>
</div>
`;
}
grid.appendChild(card);
});
currentChannelPage++;
}
} catch (e) {
console.error(e);
} finally {
isChannelLoading = false;
document.querySelectorAll('#channelVideosGrid .skeleton-card').forEach(el => el.remove());
}
}
function setupInfiniteScroll() {
const trigger = document.getElementById('channelLoadingTrigger');
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadChannelVideos();
}
}, { threshold: 0.1 });
observer.observe(trigger);
}
// Helpers (Duplicate from main.js if not loaded, but main.js should be loaded layout)
// We assume main.js functions (escapeHtml, formatViews, formatDate) are available globally
// or we define them safely if missing.
</script>
{% endblock %}

View file

@ -1,168 +1,45 @@
{% extends "layout.html" %}
{% block content %}
<!-- Sort Options -->
<div class="yt-sort-container">
<!-- Combined Filter Group -->
<div class="yt-filter-group">
<button class="yt-sort-btn" data-sort="day" onclick="switchSort('day', this)">Today</button>
<button class="yt-sort-btn" data-sort="week" onclick="switchSort('week', this)">This Week</button>
<button class="yt-sort-btn active" data-sort="month" onclick="switchSort('month', this)">This Month</button>
<button class="yt-sort-btn" data-sort="year" onclick="switchSort('year', this)">This Year</button>
<div class="yt-divider-vertical"></div>
<button class="yt-region-btn active" data-region="vietnam" onclick="switchRegion('vietnam', this)">🇻🇳
VN</button>
<button class="yt-region-btn" data-region="global" onclick="switchRegion('global', this)">🌍 Global</button>
<!-- Filters & Categories -->
<div class="yt-filter-bar">
<div class="yt-categories" id="categoryList">
<!-- "All" removed, starting with Tech -->
<button class="yt-chip" onclick="switchCategory('tech')">Tech</button>
<button class="yt-chip" onclick="switchCategory('music')">Music</button>
<button class="yt-chip" onclick="switchCategory('gaming')">Gaming</button>
<button class="yt-chip" onclick="switchCategory('movies')">Movies</button>
<button class="yt-chip" onclick="switchCategory('news')">News</button>
<button class="yt-chip" onclick="switchCategory('trending')">Trending</button>
<button class="yt-chip" onclick="switchCategory('sports')">Sports</button>
<button class="yt-chip" onclick="switchCategory('podcasts')">Podcasts</button>
<button class="yt-chip" onclick="switchCategory('live')">Live</button>
</div>
<div class="yt-filter-actions">
<div class="yt-dropdown">
<button class="yt-icon-btn" id="filterToggleBtn" onclick="toggleFilterMenu()">
<i class="fas fa-sliders-h"></i>
</button>
<div class="yt-dropdown-menu" id="filterMenu">
<div class="yt-menu-section">
<h4>Sort By</h4>
<button onclick="changeSort('day')">Today</button>
<button onclick="changeSort('week')">This Week</button>
<button onclick="changeSort('month')">This Month</button>
<button onclick="changeSort('3months')">Last 3 Months</button>
<button onclick="changeSort('year')">This Year</button>
</div>
<div class="yt-menu-section">
<h4>Region</h4>
<button onclick="changeRegion('vietnam')">Vietnam</button>
<button onclick="changeRegion('global')">Global</button>
</div>
</div>
</div>
</div>
</div>
<!-- ... (Categories kept same) ... -->
<!-- CSS Update -->
<style>
/* ... existing styles ... */
.yt-sort-container {
display: flex;
align-items: center;
overflow-x: auto;
padding: 12px 0;
border-bottom: 1px solid var(--yt-border);
margin-bottom: 8px;
scrollbar-width: none;
}
.yt-sort-container::-webkit-scrollbar {
display: none;
}
.yt-filter-group {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
}
.yt-divider-vertical {
width: 1px;
height: 20px;
background: var(--yt-border);
margin: 0 4px;
}
/* Mobile Enhancements */
@media (max-width: 768px) {
/* Mobile List View Transition */
.yt-video-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.yt-video-card {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 12px;
height: auto;
}
.yt-thumbnail-container {
width: 160px;
/* Base width for list view thumbnail */
min-width: 140px;
aspect-ratio: 16/9;
flex-shrink: 0;
border-radius: 8px;
/* Slightly tighter radius */
}
.yt-video-info {
padding: 2px 0 0 0;
/* Remove top padding */
}
.yt-video-details {
padding: 0;
flex-direction: column;
gap: 4px;
}
/* Show avatar/channel info again but styled for list view */
.yt-channel-avatar {
display: none;
/* Keep avatar hidden to save space or move to meta */
}
.yt-profile-pic-mobile {
display: none;
/* handled below */
}
.yt-video-meta {
padding: 0;
}
.yt-video-title {
font-size: 14px;
line-height: 1.3;
max-height: 2.6em;
/* 2 lines */
margin-bottom: 4px;
}
.yt-channel-name {
display: block;
/* Show channel name again */
font-size: 12px;
color: var(--yt-text-secondary);
}
.yt-video-stats {
display: block;
/* Show views */
font-size: 12px;
}
/* Sort container adjustments */
.yt-sort-container {
padding-right: 16px;
}
.yt-sort-btn,
.yt-region-btn {
font-size: 13px;
padding: 6px 12px;
background: var(--yt-bg-secondary);
border-radius: 8px;
/* More like chips */
border: none;
}
.yt-sort-btn.active,
.yt-region-btn.active {
background: var(--yt-text-primary);
color: var(--yt-bg-primary);
}
}
</style>
<!-- Category Pills -->
<div class="yt-categories">
<button class="yt-category-pill" data-category="tech" onclick="switchCategory('tech', this)">AI & Tech</button>
<button class="yt-category-pill active" data-category="all" onclick="switchCategory('all', this)">All</button>
<button class="yt-category-pill" data-category="shorts" onclick="switchCategory('shorts', this)">Shorts</button>
<button class="yt-category-pill" data-category="music" onclick="switchCategory('music', this)">Music</button>
<button class="yt-category-pill" data-category="gaming" onclick="switchCategory('gaming', this)">Gaming</button>
<button class="yt-category-pill" data-category="news" onclick="switchCategory('news', this)">News</button>
<button class="yt-category-pill" data-category="trending"
onclick="switchCategory('trending', this)">Trending</button>
<button class="yt-category-pill" data-category="sports" onclick="switchCategory('sports', this)">Sports</button>
<button class="yt-category-pill" data-category="podcasts"
onclick="switchCategory('podcasts', this)">Podcasts</button>
<button class="yt-category-pill" data-category="live" onclick="switchCategory('live', this)">Live</button>
</div>
<!-- Shorts Section -->
<div id="shortsSection" class="yt-section">
@ -174,7 +51,7 @@
<i class="fas fa-chevron-left"></i>
</button>
<div id="shortsGrid" class="yt-shorts-grid">
<!-- Shorts loaded here -->
<!-- Shorts loaded via JS -->
</div>
<button class="yt-shorts-arrow yt-shorts-right" onclick="scrollShorts('right')">
<i class="fas fa-chevron-right"></i>
@ -188,87 +65,207 @@
<h2><i class="fas fa-play-circle"></i> Videos</h2>
</div>
<div id="resultsArea" class="yt-video-grid">
<div id="initialLoading" class="yt-loader" style="grid-column: 1/-1;">
<div class="yt-spinner">
<div class="yt-spinner-ring"></div>
<div class="yt-spinner-ring"></div>
<div class="yt-spinner-ring"></div>
<div class="yt-spinner-glow"></div>
<!-- Initial Skeleton State -->
<!-- Initial Skeleton State (12 items) -->
<div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text">
<div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div>
</div>
</div>
</div>
<div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text">
<div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div>
</div>
</div>
</div>
<div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text">
<div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div>
</div>
</div>
</div>
<div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text">
<div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div>
</div>
</div>
</div>
<div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text">
<div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div>
</div>
</div>
</div>
<div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text">
<div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div>
</div>
</div>
</div>
<div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text">
<div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div>
</div>
</div>
</div>
<div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text">
<div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div>
</div>
</div>
<p>Loading videos...</p>
</div>
</div>
</div>
<!-- Pagination -->
<div id="paginationArea" class="yt-pagination">
<button class="yt-page-btn yt-page-prev" onclick="goToPage(currentPage - 1)" disabled>
<i class="fas fa-chevron-left"></i>
</button>
<div class="yt-page-numbers" id="pageNumbers">
</div>
<button class="yt-page-btn yt-page-next" onclick="goToPage(currentPage + 1)">
<i class="fas fa-chevron-right"></i>
</button>
</div>
<style>
.yt-sort-container {
/* Filter Bar Styles */
.yt-filter-bar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
padding: 12px 0;
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);
margin-bottom: 8px;
}
.yt-sort-group {
.yt-categories {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
overflow-x: auto;
gap: 0.8rem;
padding: 0.5rem 0;
flex: 1;
scrollbar-width: none;
/* Firefox */
}
.yt-sort-label {
font-size: 14px;
color: var(--yt-text-secondary);
margin-right: 4px;
.yt-categories::-webkit-scrollbar {
display: none;
}
.yt-sort-btn,
.yt-region-btn {
padding: 6px 12px;
background: transparent;
border: 1px solid var(--yt-border);
border-radius: 18px;
font-size: 13px;
color: var(--yt-text-primary);
cursor: pointer;
transition: all 0.2s;
}
.yt-sort-btn:hover,
.yt-region-btn:hover {
.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-sort-btn.active {
.yt-chip:hover {
background: var(--yt-bg-hover);
}
.yt-chip-active {
background: var(--yt-text-primary);
color: var(--yt-bg-primary);
border-color: var(--yt-text-primary);
}
.yt-region-btn.active {
background: var(--yt-accent-red);
color: white;
border-color: var(--yt-accent-red);
.yt-chip-active:hover {
background: var(--yt-text-primary);
opacity: 0.9;
}
/* Sections */
.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 {
@ -276,7 +273,6 @@
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
padding: 8px 0;
}
.yt-section-header h2 {
@ -291,17 +287,6 @@
color: var(--yt-accent-red);
}
.yt-see-all {
color: var(--yt-accent-blue);
font-size: 14px;
font-weight: 500;
}
.yt-see-all:hover {
text-decoration: underline;
}
/* Shorts Container with Arrows */
.yt-shorts-container {
position: relative;
display: flex;
@ -340,7 +325,6 @@
right: -20px;
}
/* Shorts Grid (Horizontal Scroll) */
.yt-shorts-grid {
display: flex;
gap: 12px;
@ -391,130 +375,59 @@
margin-top: 4px;
}
/* Pagination */
.yt-pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
margin: 32px 0;
}
.yt-page-btn {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--yt-bg-secondary);
color: var(--yt-text-primary);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: all 0.2s;
}
.yt-page-btn:hover:not(:disabled) {
background: var(--yt-bg-hover);
}
.yt-page-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.yt-page-numbers {
display: flex;
gap: 4px;
}
.yt-page-num {
min-width: 40px;
height: 40px;
border-radius: 20px;
background: var(--yt-bg-secondary);
color: var(--yt-text-primary);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 500;
padding: 0 8px;
transition: all 0.2s;
}
.yt-page-num:hover {
background: var(--yt-bg-hover);
}
.yt-page-num.active {
background: var(--yt-accent-red);
color: white;
}
@media (max-width: 768px) {
.yt-sort-container {
/* flex-direction: column; Old: stacked */
flex-direction: row;
/* New: horizontal scroll */
align-items: center;
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: none;
/* Hide scrollbar Firefox */
-ms-overflow-style: none;
/* IE/Edge */
padding-right: 16px;
/* Right padding for scroll */
}
.yt-sort-container::-webkit-scrollbar {
display: none;
/* Hide scrollbar Chrome/Safari */
}
.yt-sort-label {
display: none;
/* Hide labels on mobile */
}
.yt-sort-btn,
.yt-region-btn {
padding: 6px 10px;
font-size: 12px;
white-space: nowrap;
flex-shrink: 0;
}
/* Force 2 columns on mobile */
.yt-video-grid {
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.yt-short-card {
width: 140px;
}
.yt-short-thumb {
width: 140px;
height: 250px;
}
.yt-shorts-arrow {
display: none;
}
.yt-filter-bar {
padding: 0 10px;
top: 56px;
}
.yt-sort-container {
/* Legacy override if needed */
}
}
</style>
<script>
// Global filter state
let currentSort = 'month';
let currentRegion = 'vietnam';
let totalPages = 10;
// Scroll shorts left/right
function toggleFilterMenu() {
document.getElementById('filterMenu').classList.toggle('show');
}
// Close menu when clicking outside
document.addEventListener('click', function (e) {
const menu = document.getElementById('filterMenu');
const btn = document.getElementById('filterToggleBtn');
if (menu && btn && !menu.contains(e.target) && !btn.contains(e.target)) {
menu.classList.remove('show');
}
});
function changeSort(sort) {
window.currentSort = sort;
// Global loadTrending from main.js will use this
loadTrending(true);
loadShorts(); // Also reload shorts with new sort
toggleFilterMenu();
}
function changeRegion(region) {
window.currentRegion = region;
loadTrending(true);
loadShorts(); // Also reload shorts with new region
toggleFilterMenu();
}
// Scroll shorts logic
function scrollShorts(direction) {
const grid = document.getElementById('shortsGrid');
const scrollAmount = 400; // pixels to scroll
const scrollAmount = 400;
if (direction === 'left') {
grid.scrollBy({ left: -scrollAmount, behavior: 'smooth' });
@ -523,83 +436,28 @@
}
}
// Generate page numbers
function renderPagination() {
const container = document.getElementById('pageNumbers');
container.innerHTML = '';
const startPage = Math.max(1, currentPage - 4);
const endPage = Math.min(totalPages, startPage + 9);
for (let i = startPage; i <= endPage; i++) {
const btn = document.createElement('button');
btn.className = `yt-page-num ${i === currentPage ? 'active' : ''}`;
btn.innerText = i;
btn.onclick = () => goToPage(i);
container.appendChild(btn);
}
document.querySelector('.yt-page-prev').disabled = currentPage <= 1;
document.querySelector('.yt-page-next').disabled = currentPage >= totalPages;
}
function goToPage(page) {
if (page < 1 || page > totalPages) return;
currentPage = page;
window.currentPage = page;
renderPagination();
loadTrending(true);
loadShorts(); // Also update shorts when page changes
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function switchSort(sort, btn) {
document.querySelectorAll('.yt-sort-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentSort = sort;
window.currentSort = sort;
currentPage = 1;
loadTrending(true);
loadShorts();
}
function switchRegion(region, btn) {
document.querySelectorAll('.yt-region-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentRegion = region;
window.currentRegion = region;
currentPage = 1;
loadTrending(true);
loadShorts();
}
window.currentSort = 'month';
window.currentRegion = 'vietnam';
window.currentPage = 1;
// Load shorts with current category, pagination, sort, region
// Load Shorts Logic
// Load Shorts Logic
async function loadShorts() {
const shortsGrid = document.getElementById('shortsGrid');
shortsGrid.innerHTML = `<div class="yt-loader"><div class="yt-spinner">
<div class="yt-spinner-ring"></div>
<div class="yt-spinner-ring"></div>
<div class="yt-spinner-ring"></div>
<div class="yt-spinner-glow"></div>
</div></div>`;
// Skeleton loader
shortsGrid.innerHTML = Array(10).fill(0).map(() => `
<div class="skeleton-short"></div>
`).join('');
try {
const page = window.currentPage || 1;
// Get shorts related to current category
const category = window.currentCategory || currentCategory || 'general';
// For shorts, we combine category with 'shorts' keyword
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=${currentSort}&region=${currentRegion}&shorts=1`);
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) {
data.slice(0, 10).forEach(video => {
// Show up to 20 shorts
data.slice(0, 20).forEach(video => {
const card = document.createElement('div');
card.className = 'yt-short-card';
card.innerHTML = `
@ -619,6 +477,7 @@
}
}
// Helpers (if main.js not loaded yet or for standalone usage)
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
@ -634,16 +493,18 @@
return num.toLocaleString();
}
// Init
// Init Logic
document.addEventListener('DOMContentLoaded', () => {
loadShorts();
renderPagination();
// Pagination logic removed for infinite scroll
// Check URL params for category
const urlParams = new URLSearchParams(window.location.search);
const category = urlParams.get('category');
if (category) {
const pill = document.querySelector(`.yt-category-pill[data-category="${category}"]`);
if (pill) pill.click();
if (category && typeof switchCategory === 'function') {
// Let main.js handle the switch, but we can set UI active state if needed
// switchCategory is in main.js
switchCategory(category);
}
});
</script>

View file

@ -8,8 +8,10 @@
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="KV-Tube">
<meta name="theme-color" content="#0f0f0f">
<title>KV-Tube</title>
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
<link rel="icon" type="image/svg+xml"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath fill='%23ff0000' d='M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm115.7 272l-176 101c-15.8 8.8-35.7-2.5-35.7-21V152c0-18.4 19.8-29.8 35.7-21l176 107c16.4 9.2 16.4 32.9 0 42z'/%3E%3C/svg%3E">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
@ -22,6 +24,17 @@
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
<script>
// Immediate Theme Init to prevent FOUC
(function () {
let savedTheme = localStorage.getItem('theme');
if (!savedTheme) {
const hour = new Date().getHours();
savedTheme = (hour >= 6 && hour < 18) ? 'light' : 'dark';
}
document.documentElement.setAttribute('data-theme', savedTheme);
})();
</script>
</head>
<body>
@ -32,8 +45,15 @@
<button class="yt-menu-btn" onclick="toggleSidebar()" aria-label="Menu">
<i class="fas fa-bars"></i>
</button>
<a href="/" class="yt-logo">
<div class="yt-logo-icon">KV-Tube</div>
<button class="yt-menu-btn" id="headerBackBtn" onclick="history.back()" aria-label="Back"
style="display:none;">
<i class="fas fa-arrow-left"></i>
</button>
<a href="/" class="yt-logo"
style="text-decoration: none; display: flex; align-items: center; gap: 4px;">
<span style="color: #ff0000; font-size: 24px;"><i class="fas fa-play-circle"></i></span>
<span
style="font-family: 'Roboto', sans-serif; font-weight: 700; font-size: 18px; letter-spacing: -0.5px; color: var(--yt-text-primary);">KV-Tube</span>
</a>
</div>
@ -95,6 +115,7 @@
<i class="fas fa-bookmark"></i>
<span>Library</span>
</a>
<!-- Queue Removed -->
<div class="yt-sidebar-divider"></div>
@ -270,6 +291,21 @@
<!-- Toast Notification Container -->
<div id="toastContainer" class="yt-toast-container"></div>
<!-- Queue Drawer -->
<div class="yt-queue-drawer" id="queueDrawer">
<div class="yt-queue-header">
<h3>Queue (<span id="queueCount">0</span>)</h3>
<button onclick="toggleQueue()" class="yt-icon-btn"><i class="fas fa-times"></i></button>
</div>
<div class="yt-queue-list" id="queueList">
<!-- Queued items -->
</div>
<div class="yt-queue-footer">
<button class="yt-queue-clear-btn" onclick="clearQueue()">Clear Queue</button>
</div>
</div>
<div class="yt-queue-overlay" id="queueOverlay" onclick="toggleQueue()"></div>
<style>
.yt-toast-container {
position: fixed;
@ -336,7 +372,216 @@
setTimeout(() => toast.remove(), 300);
}, 5000);
}
// --- Queue Logic ---
function toggleQueue() {
const drawer = document.getElementById('queueDrawer');
const overlay = document.getElementById('queueOverlay');
if (drawer.classList.contains('open')) {
drawer.classList.remove('open');
overlay.classList.remove('active');
} else {
drawer.classList.add('open');
overlay.classList.add('active');
renderQueue();
}
}
function renderQueue() {
const list = document.getElementById('queueList');
const queue = JSON.parse(localStorage.getItem('kv_queue') || '[]');
document.getElementById('queueCount').innerText = queue.length;
if (queue.length === 0) {
list.innerHTML = '<p style="padding:20px; text-align:center; color:var(--yt-text-secondary);">Queue is empty</p>';
return;
}
list.innerHTML = queue.map((item, index) => `
<div class="yt-queue-item">
<div class="yt-queue-thumb" onclick="window.location.href='/watch?v=${item.id}'">
<img src="${item.thumbnail}" loading="lazy">
</div>
<div class="yt-queue-info">
<div class="yt-queue-title" onclick="window.location.href='/watch?v=${item.id}'">${item.title}</div>
<div class="yt-queue-uploader">${item.uploader}</div>
</div>
<button class="yt-queue-remove" onclick="removeFromQueue(${index})">
<i class="fas fa-trash"></i>
</button>
</div>
`).join('');
}
function removeFromQueue(index) {
let queue = JSON.parse(localStorage.getItem('kv_queue') || '[]');
queue.splice(index, 1);
localStorage.setItem('kv_queue', JSON.stringify(queue));
renderQueue();
}
function clearQueue() {
// Removed confirmation as requested
localStorage.removeItem('kv_queue');
renderQueue();
showToast("Queue cleared", "success");
}
// --- Back Button Logic ---
document.addEventListener('DOMContentLoaded', () => {
const path = window.location.pathname;
const menuBtn = document.querySelector('.yt-menu-btn');
const backBtnHeader = document.getElementById('headerBackBtn');
// If not home, swap menu for back
if (path !== '/' && backBtnHeader) {
if (menuBtn) menuBtn.style.display = 'none';
backBtnHeader.style.display = 'flex';
}
});
</script>
</body>
<style>
/* 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>

View file

@ -1,36 +1,92 @@
{% extends "layout.html" %}
{% block content %}
<div class="library-header" style="margin-bottom: 2rem; display: flex; align-items: center; gap: 1rem;">
<h1>My Library</h1>
<div class="tabs" style="display: flex; gap: 0.5rem; background: #e8eaed; padding: 0.3rem; border-radius: 100px;">
<a href="/my-videos?type=saved" class="btn {% if filter_type == 'saved' %}btn-primary{% endif %}"
style="border-radius: 100px; font-size: 0.9rem; padding: 0.5rem 1.5rem; color: {% if filter_type != 'saved' %}var(--text-secondary){% else %}white{% endif %}">Saved</a>
<a href="/my-videos?type=history" class="btn {% if filter_type == 'history' %}btn-primary{% endif %}"
style="border-radius: 100px; font-size: 0.9rem; padding: 0.5rem 1.5rem; color: {% if filter_type != 'history' %}var(--text-secondary){% else %}white{% endif %}">History</a>
<div class="yt-container" style="padding-top: 20px;">
<div class="library-header"
style="margin-bottom: 2rem; display: flex; align-items: center; justify-content: space-between;">
<h1 style="font-size: 1.5rem;">My Library</h1>
<div class="tabs"
style="display: flex; gap: 0.5rem; background: var(--yt-bg-hover); padding: 0.3rem; border-radius: 100px;">
<a href="/my-videos?type=saved" class="yt-btn {% if filter_type == 'saved' %}yt-btn-active{% endif %}"
style="border-radius: 100px; font-size: 0.9rem; padding: 0.5rem 1.5rem; {% if filter_type == 'saved' %}background:var(--yt-text-primary);color:var(--yt-bg-primary);{% endif %}">Saved</a>
<a href="/my-videos?type=history" class="yt-btn {% if filter_type == 'history' %}yt-btn-active{% endif %}"
style="border-radius: 100px; font-size: 0.9rem; padding: 0.5rem 1.5rem; {% if filter_type == 'history' %}background:var(--yt-text-primary);color:var(--yt-bg-primary);{% endif %}">History</a>
</div>
</div>
<div id="libraryGrid" class="yt-video-grid">
{% if videos %}
{% for video in videos %}
<div class="yt-video-card" onclick="window.location.href='/watch?v={{ video.video_id }}'">
<div class="yt-thumbnail-container">
<img src="{{ video.thumbnail }}" class="yt-thumbnail" loading="lazy">
</div>
<div class="yt-video-details">
<div class="yt-video-meta">
<h3 class="yt-video-title">{{ video.title }}</h3>
<p class="yt-video-stats">{{ video.timestamp }}</p>
</div>
</div>
</div>
{% endfor %}
{% endif %}
</div>
<div id="emptyState"
style="text-align: center; padding: 4rem; color: var(--yt-text-secondary); display: {% if videos %}none{% else %}none{% endif %};">
<i class="fas fa-folder-open fa-3x" style="margin-bottom: 1rem; opacity: 0.5;"></i>
<h3>Nothing here yet</h3>
<p>Go watch some videos to fill this up!</p>
<a href="/" class="yt-btn"
style="margin-top: 1rem; background: var(--yt-text-primary); color: var(--yt-bg-primary);">Browse
Content</a>
</div>
</div>
{% if videos %}
<div class="video-grid">
{% for video in videos %}
<div class="video-card" onclick="window.location.href='/watch?v={{ video.video_id }}'">
<img src="{{ video.thumbnail }}" class="thumbnail" loading="lazy">
<div class="video-info">
<div class="video-title">{{ video.title }}</div>
<div class="video-meta">
<i class="far fa-clock"></i> {{ video.timestamp }}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div style="text-align: center; padding: 4rem; color: var(--text-secondary);">
<i class="far fa-folder-open fa-3x" style="margin-bottom: 1rem; opacity: 0.5;"></i>
<h3>Nothing here yet</h3>
<p>Go watch some videos to fill this up!</p>
<a href="/" class="btn btn-primary" style="margin-top: 1rem;">Browse Content</a>
</div>
{% endif %}
<script>
const loggedIn = {{ 'true' if logged_in else 'false' }};
const filterType = '{{ filter_type }}';
if (!loggedIn && filterType === 'history') {
// Load from Local Storage
const history = JSON.parse(localStorage.getItem('kv_history') || '[]');
const grid = document.getElementById('libraryGrid');
const empty = document.getElementById('emptyState');
if (history.length > 0) {
grid.innerHTML = history.map(video => `
<div class="yt-video-card" onclick="window.location.href='/watch?v=${video.id}'">
<div class="yt-thumbnail-container">
<img src="${video.thumbnail}" class="yt-thumbnail" loading="lazy">
</div>
<div class="yt-video-details">
<div class="yt-video-meta">
<h3 class="yt-video-title">${video.title}</h3>
<p class="yt-video-stats">Watched on ${new Date(video.timestamp).toLocaleDateString()}</p>
</div>
</div>
</div>
`).join('');
empty.style.display = 'none';
} else {
grid.innerHTML = '';
empty.style.display = 'block';
}
} else if (!loggedIn && filterType === 'saved') {
// Anon user can't save remotely yet, maybe valid TODO, but for now show empty with login prompt
document.getElementById('libraryGrid').innerHTML = '';
document.getElementById('emptyState').innerHTML = `
<i class="fas fa-lock fa-3x" style="margin-bottom: 1rem; opacity: 0.5;"></i>
<h3>Sign in to save videos</h3>
<p>Save videos to watch later.</p>
<a href="/login" class="yt-btn" style="margin-top: 1rem; background: var(--yt-text-primary); color: var(--yt-bg-primary);">Sign In</a>
`;
document.getElementById('emptyState').style.display = 'block';
} else {
// Logged in or Server side handled it
if (document.getElementById('libraryGrid').children.length === 0) {
document.getElementById('emptyState').style.display = 'block';
}
}
</script>
{% endblock %}

File diff suppressed because it is too large Load diff