Fix layout, queue, infinite scroll, and mobile responsiveness
This commit is contained in:
parent
bf47ec62ea
commit
665a8209e4
8 changed files with 2942 additions and 1144 deletions
495
app.py
495
app.py
|
|
@ -138,6 +138,49 @@ def logout():
|
||||||
session.clear()
|
session.clear()
|
||||||
return redirect(url_for('index')) # Changed from 'home' to 'index'
|
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
|
# Configuration for local video path - configurable via env var
|
||||||
VIDEO_DIR = os.environ.get('KVTUBE_VIDEO_DIR', './videos')
|
VIDEO_DIR = os.environ.get('KVTUBE_VIDEO_DIR', './videos')
|
||||||
|
|
||||||
|
|
@ -146,19 +189,22 @@ def index():
|
||||||
return render_template('index.html', page='home')
|
return render_template('index.html', page='home')
|
||||||
|
|
||||||
@app.route('/my-videos')
|
@app.route('/my-videos')
|
||||||
@login_required
|
|
||||||
def my_videos():
|
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 = []
|
||||||
videos = conn.execute('''
|
logged_in = 'user_id' in session
|
||||||
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)
|
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'])
|
@app.route('/api/save_video', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
|
|
@ -269,6 +315,249 @@ def watch():
|
||||||
return "No video ID provided", 400
|
return "No video ID provided", 400
|
||||||
return render_template('watch.html', video_type='youtube', video_id=video_id)
|
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')
|
@app.route('/api/get_stream_info')
|
||||||
def get_stream_info():
|
def get_stream_info():
|
||||||
video_id = request.args.get('v')
|
video_id = request.args.get('v')
|
||||||
|
|
@ -331,7 +620,7 @@ def get_stream_info():
|
||||||
related_videos = []
|
related_videos = []
|
||||||
try:
|
try:
|
||||||
search_query = f"{info.get('title', '')} related"
|
search_query = f"{info.get('title', '')} related"
|
||||||
related_videos = fetch_videos(search_query, limit=10)
|
related_videos = fetch_videos(search_query, limit=20)
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -342,19 +631,32 @@ def get_stream_info():
|
||||||
subs = info.get('subtitles') or {}
|
subs = info.get('subtitles') or {}
|
||||||
auto_subs = info.get('automatic_captions') 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
|
# Check manual subs first
|
||||||
if 'en' in subs:
|
if 'en' in subs:
|
||||||
subtitle_url = subs['en'][0]['url']
|
subtitle_url = subs['en'][0]['url']
|
||||||
elif 'vi' in subs: # Vietnamese fallback
|
elif 'vi' in subs: # Vietnamese fallback
|
||||||
subtitle_url = subs['vi'][0]['url']
|
subtitle_url = subs['vi'][0]['url']
|
||||||
# Check auto subs
|
# Check auto subs (usually available)
|
||||||
elif 'en' in auto_subs:
|
elif 'en' in auto_subs:
|
||||||
subtitle_url = auto_subs['en'][0]['url']
|
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 still none, just pick the first one from manual then auto
|
||||||
if not subtitle_url and subs:
|
if not subtitle_url:
|
||||||
first_key = list(subs.keys())[0]
|
if subs:
|
||||||
subtitle_url = subs[first_key][0]['url']
|
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
|
# 3. Construct Response Data
|
||||||
response_data = {
|
response_data = {
|
||||||
|
|
@ -599,15 +901,21 @@ def summarize_video():
|
||||||
return jsonify({'success': False, 'message': f'Could not summarize: {str(e)}'})
|
return jsonify({'success': False, 'message': f'Could not summarize: {str(e)}'})
|
||||||
|
|
||||||
# Helper function to fetch videos (not a route)
|
# 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:
|
try:
|
||||||
|
# If no end specified, default to start + limit - 1
|
||||||
|
if not playlist_end:
|
||||||
|
playlist_end = playlist_start + limit - 1
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
sys.executable, '-m', 'yt_dlp',
|
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',
|
'--dump-json',
|
||||||
'--default-search', 'ytsearch',
|
'--default-search', 'ytsearch',
|
||||||
'--no-playlist',
|
'--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)
|
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:
|
if video_id:
|
||||||
# Format duration
|
# Format duration
|
||||||
duration_secs = data.get('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:
|
if duration_secs:
|
||||||
mins, secs = divmod(int(duration_secs), 60)
|
mins, secs = divmod(int(duration_secs), 60)
|
||||||
hours, mins = divmod(mins, 60)
|
hours, mins = divmod(mins, 60)
|
||||||
|
|
@ -632,6 +947,8 @@ def fetch_videos(query, limit=20):
|
||||||
'id': video_id,
|
'id': video_id,
|
||||||
'title': data.get('title', 'Unknown'),
|
'title': data.get('title', 'Unknown'),
|
||||||
'uploader': data.get('uploader') or data.get('channel') or '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",
|
'thumbnail': f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg",
|
||||||
'view_count': data.get('view_count', 0),
|
'view_count': data.get('view_count', 0),
|
||||||
'upload_date': data.get('upload_date', ''),
|
'upload_date': data.get('upload_date', ''),
|
||||||
|
|
@ -644,66 +961,106 @@ def fetch_videos(query, limit=20):
|
||||||
print(f"Error fetching videos: {e}")
|
print(f"Error fetching videos: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
import concurrent.futures
|
||||||
|
|
||||||
@app.route('/api/trending')
|
@app.route('/api/trending')
|
||||||
def trending():
|
def trending():
|
||||||
try:
|
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))
|
page = int(request.args.get('page', 1))
|
||||||
sort = request.args.get('sort', 'month')
|
sort = request.args.get('sort', 'month')
|
||||||
region = request.args.get('region', 'vietnam')
|
region = request.args.get('region', 'vietnam')
|
||||||
limit = 20
|
limit = 120 if category != 'all' else 20 # 120 for grid, 20 for sections
|
||||||
|
|
||||||
# Define search queries
|
# Helper to build query
|
||||||
if region == 'vietnam':
|
def get_query(cat, reg, s_sort):
|
||||||
queries = {
|
if reg == 'vietnam':
|
||||||
'general': 'trending vietnam',
|
queries = {
|
||||||
'tech': 'AI tools software tech review IT việt nam',
|
'general': 'trending vietnam',
|
||||||
'all': 'trending vietnam',
|
'tech': 'AI tools software tech review IT việt nam',
|
||||||
'music': 'nhạc việt trending',
|
'all': 'trending vietnam',
|
||||||
'gaming': 'gaming việt nam',
|
'music': 'nhạc việt trending',
|
||||||
'movies': 'phim việt nam',
|
'gaming': 'gaming việt nam',
|
||||||
'news': 'tin tức việt nam hôm nay',
|
'movies': 'phim việt nam',
|
||||||
'sports': 'thể thao việt nam',
|
'news': 'tin tức việt nam hôm nay',
|
||||||
'shorts': 'shorts việt nam',
|
'sports': 'thể thao việt nam',
|
||||||
'trending': 'trending việt nam',
|
'shorts': 'trending việt nam',
|
||||||
'podcasts': 'podcast việt nam',
|
'trending': 'trending việt nam',
|
||||||
'live': 'live stream 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'
|
||||||
}
|
}
|
||||||
|
return base + sort_filters.get(s_sort, f" after:{three_months_ago}")
|
||||||
|
|
||||||
|
# === 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:
|
else:
|
||||||
queries = {
|
filter_mode = 'short' if category == 'shorts' else 'video'
|
||||||
'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')
|
results = fetch_videos(query, limit=limit, filter_type=filter_mode, playlist_start=start)
|
||||||
|
|
||||||
# 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}"
|
|
||||||
|
|
||||||
results = fetch_videos(query, limit=limit)
|
|
||||||
return jsonify(results)
|
return jsonify(results)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,15 @@
|
||||||
/* ===== YouTube Dark Theme Colors ===== */
|
/* ===== YouTube Dark Theme Colors ===== */
|
||||||
:root {
|
:root {
|
||||||
--yt-bg-primary: #0f0f0f;
|
--yt-bg-primary: #0f0f0f;
|
||||||
--yt-bg-secondary: #272727;
|
--yt-bg-secondary: #333333;
|
||||||
--yt-bg-elevated: #212121;
|
--yt-bg-elevated: #282828;
|
||||||
--yt-bg-hover: #3f3f3f;
|
--yt-bg-hover: #444444;
|
||||||
--yt-bg-active: #3ea6ff;
|
--yt-bg-active: #3ea6ff;
|
||||||
|
|
||||||
--yt-text-primary: #f1f1f1;
|
--yt-text-primary: #f1f1f1;
|
||||||
--yt-text-secondary: #aaaaaa;
|
--yt-text-secondary: #aaaaaa;
|
||||||
--yt-text-disabled: #717171;
|
--yt-text-disabled: #717171;
|
||||||
|
--yt-static-white: #ffffff;
|
||||||
|
|
||||||
--yt-accent-red: #ff0000;
|
--yt-accent-red: #ff0000;
|
||||||
--yt-accent-blue: #3ea6ff;
|
--yt-accent-blue: #3ea6ff;
|
||||||
|
|
@ -623,6 +624,29 @@ button {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
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 {
|
.yt-suggested-card {
|
||||||
|
|
@ -757,84 +781,7 @@ button {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-spinner {
|
/* Spinner removed */
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Responsive ===== */
|
/* ===== Responsive ===== */
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
|
|
@ -1089,6 +1036,52 @@ button {
|
||||||
width: 60%;
|
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 */
|
/* Friendly Empty State */
|
||||||
.yt-empty-state {
|
.yt-empty-state {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
|
|
@ -1118,3 +1111,63 @@ button {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-bottom: 24px;
|
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;
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|
||||||
// Load trending on init
|
// Load trending on init
|
||||||
loadTrending();
|
loadTrending();
|
||||||
|
|
||||||
|
// Init Infinite Scroll
|
||||||
|
initInfiniteScroll();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init Theme
|
// Init Theme
|
||||||
|
|
@ -28,9 +31,28 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Note: Global variables like currentCategory are defined below
|
// Note: Global variables like currentCategory are defined below
|
||||||
let currentCategory = 'general';
|
let currentCategory = 'all';
|
||||||
let currentPage = 1;
|
let currentPage = 1;
|
||||||
let isLoading = false;
|
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 ---
|
// --- UI Helpers ---
|
||||||
function renderSkeleton() {
|
function renderSkeleton() {
|
||||||
|
|
@ -108,6 +130,7 @@ async function switchCategory(category, btn) {
|
||||||
currentCategory = category;
|
currentCategory = category;
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
window.currentPage = 1;
|
window.currentPage = 1;
|
||||||
|
hasMore = true; // Reset infinite scroll
|
||||||
|
|
||||||
const resultsArea = document.getElementById('resultsArea');
|
const resultsArea = document.getElementById('resultsArea');
|
||||||
resultsArea.innerHTML = renderSkeleton();
|
resultsArea.innerHTML = renderSkeleton();
|
||||||
|
|
@ -116,6 +139,20 @@ async function switchCategory(category, btn) {
|
||||||
const paginationArea = document.getElementById('paginationArea');
|
const paginationArea = document.getElementById('paginationArea');
|
||||||
if (paginationArea) paginationArea.style.display = 'none';
|
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
|
// Load both videos and shorts with current category, sort, and region
|
||||||
await loadTrending(true);
|
await loadTrending(true);
|
||||||
|
|
||||||
|
|
@ -149,6 +186,8 @@ async function loadTrending(reset = true) {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
if (!reset && loadMoreBtn) {
|
if (!reset && loadMoreBtn) {
|
||||||
loadMoreBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading...';
|
loadMoreBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading...';
|
||||||
|
} else if (reset) {
|
||||||
|
resultsArea.innerHTML = renderSkeleton();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -166,23 +205,69 @@ async function loadTrending(reset = true) {
|
||||||
return;
|
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 (reset) resultsArea.innerHTML = '';
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
if (reset) {
|
if (reset) {
|
||||||
resultsArea.innerHTML = renderNoContent();
|
resultsArea.innerHTML = renderNoContent();
|
||||||
}
|
}
|
||||||
const paginationArea = document.getElementById('paginationArea');
|
|
||||||
if (paginationArea) paginationArea.style.display = 'none';
|
|
||||||
} else {
|
} else {
|
||||||
displayResults(data, !reset);
|
displayResults(data, !reset);
|
||||||
const paginationArea = document.getElementById('paginationArea');
|
// Assume if we got less than limit (20), we reached the end
|
||||||
if (paginationArea) paginationArea.style.display = 'flex';
|
if (data.length < 20) hasMore = false;
|
||||||
|
|
||||||
// Update pagination if function exists
|
|
||||||
if (typeof renderPagination === 'function') {
|
|
||||||
renderPagination();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load trending:', e);
|
console.error('Failed to load trending:', e);
|
||||||
|
|
@ -206,24 +291,48 @@ function displayResults(videos, append = false) {
|
||||||
|
|
||||||
videos.forEach(video => {
|
videos.forEach(video => {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'yt-video-card';
|
|
||||||
card.innerHTML = `
|
if (currentCategory === 'shorts') {
|
||||||
<div class="yt-thumbnail-container">
|
// Render as Short Card (Vertical)
|
||||||
<img class="yt-thumbnail" src="${video.thumbnail}" alt="${escapeHtml(video.title)}" loading="lazy">
|
card.className = 'yt-short-card';
|
||||||
${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''}
|
// Adjust styling for grid view if needed
|
||||||
</div>
|
card.style.width = '100%';
|
||||||
<div class="yt-video-details">
|
card.style.maxWidth = '200px';
|
||||||
<div class="yt-channel-avatar">
|
card.innerHTML = `
|
||||||
${video.uploader ? video.uploader.charAt(0).toUpperCase() : 'Y'}
|
<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>
|
||||||
<div class="yt-video-meta">
|
<div class="yt-video-details">
|
||||||
<h3 class="yt-video-title">${escapeHtml(video.title)}</h3>
|
<div class="yt-channel-avatar">
|
||||||
<p class="yt-channel-name">${escapeHtml(video.uploader || 'Unknown')}</p>
|
${video.uploader ? video.uploader.charAt(0).toUpperCase() : 'Y'}
|
||||||
<p class="yt-video-stats">${formatViews(video.view_count)} views • ${formatDate(video.upload_date)}</p>
|
</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>
|
||||||
</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}`;
|
window.location.href = `/watch?v=${video.id}`;
|
||||||
});
|
});
|
||||||
resultsArea.appendChild(card);
|
resultsArea.appendChild(card);
|
||||||
|
|
@ -312,7 +421,16 @@ document.addEventListener('click', (e) => {
|
||||||
|
|
||||||
// --- Theme Logic ---
|
// --- Theme Logic ---
|
||||||
function initTheme() {
|
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);
|
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||||
|
|
||||||
// Update toggle if exists
|
// Update toggle if exists
|
||||||
|
|
|
||||||
386
templates/channel.html
Normal file
386
templates/channel.html
Normal 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 %}
|
||||||
|
|
@ -1,168 +1,45 @@
|
||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Sort Options -->
|
<!-- Filters & Categories -->
|
||||||
<div class="yt-sort-container">
|
<div class="yt-filter-bar">
|
||||||
<!-- Combined Filter Group -->
|
<div class="yt-categories" id="categoryList">
|
||||||
<div class="yt-filter-group">
|
<!-- "All" removed, starting with Tech -->
|
||||||
<button class="yt-sort-btn" data-sort="day" onclick="switchSort('day', this)">Today</button>
|
<button class="yt-chip" onclick="switchCategory('tech')">Tech</button>
|
||||||
<button class="yt-sort-btn" data-sort="week" onclick="switchSort('week', this)">This Week</button>
|
<button class="yt-chip" onclick="switchCategory('music')">Music</button>
|
||||||
<button class="yt-sort-btn active" data-sort="month" onclick="switchSort('month', this)">This Month</button>
|
<button class="yt-chip" onclick="switchCategory('gaming')">Gaming</button>
|
||||||
<button class="yt-sort-btn" data-sort="year" onclick="switchSort('year', this)">This Year</button>
|
<button class="yt-chip" onclick="switchCategory('movies')">Movies</button>
|
||||||
<div class="yt-divider-vertical"></div>
|
<button class="yt-chip" onclick="switchCategory('news')">News</button>
|
||||||
<button class="yt-region-btn active" data-region="vietnam" onclick="switchRegion('vietnam', this)">🇻🇳
|
<button class="yt-chip" onclick="switchCategory('trending')">Trending</button>
|
||||||
VN</button>
|
<button class="yt-chip" onclick="switchCategory('sports')">Sports</button>
|
||||||
<button class="yt-region-btn" data-region="global" onclick="switchRegion('global', this)">🌍 Global</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>
|
||||||
</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 -->
|
<!-- Shorts Section -->
|
||||||
<div id="shortsSection" class="yt-section">
|
<div id="shortsSection" class="yt-section">
|
||||||
|
|
@ -174,7 +51,7 @@
|
||||||
<i class="fas fa-chevron-left"></i>
|
<i class="fas fa-chevron-left"></i>
|
||||||
</button>
|
</button>
|
||||||
<div id="shortsGrid" class="yt-shorts-grid">
|
<div id="shortsGrid" class="yt-shorts-grid">
|
||||||
<!-- Shorts loaded here -->
|
<!-- Shorts loaded via JS -->
|
||||||
</div>
|
</div>
|
||||||
<button class="yt-shorts-arrow yt-shorts-right" onclick="scrollShorts('right')">
|
<button class="yt-shorts-arrow yt-shorts-right" onclick="scrollShorts('right')">
|
||||||
<i class="fas fa-chevron-right"></i>
|
<i class="fas fa-chevron-right"></i>
|
||||||
|
|
@ -188,87 +65,207 @@
|
||||||
<h2><i class="fas fa-play-circle"></i> Videos</h2>
|
<h2><i class="fas fa-play-circle"></i> Videos</h2>
|
||||||
</div>
|
</div>
|
||||||
<div id="resultsArea" class="yt-video-grid">
|
<div id="resultsArea" class="yt-video-grid">
|
||||||
<div id="initialLoading" class="yt-loader" style="grid-column: 1/-1;">
|
<!-- Initial Skeleton State -->
|
||||||
<div class="yt-spinner">
|
<!-- Initial Skeleton State (12 items) -->
|
||||||
<div class="yt-spinner-ring"></div>
|
<div class="yt-video-card skeleton-card">
|
||||||
<div class="yt-spinner-ring"></div>
|
<div class="skeleton-thumb skeleton"></div>
|
||||||
<div class="yt-spinner-ring"></div>
|
<div class="skeleton-details">
|
||||||
<div class="yt-spinner-glow"></div>
|
<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>
|
</div>
|
||||||
<p>Loading videos...</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
<style>
|
||||||
.yt-sort-container {
|
/* Filter Bar Styles */
|
||||||
|
.yt-filter-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 1rem;
|
||||||
flex-wrap: wrap;
|
padding: 0 1rem;
|
||||||
gap: 16px;
|
margin-bottom: 1rem;
|
||||||
padding: 12px 0;
|
position: sticky;
|
||||||
|
top: 56px;
|
||||||
|
/* Adjust based on header height */
|
||||||
|
z-index: 99;
|
||||||
|
background: var(--yt-bg-primary);
|
||||||
border-bottom: 1px solid var(--yt-border);
|
border-bottom: 1px solid var(--yt-border);
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-sort-group {
|
.yt-categories {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
overflow-x: auto;
|
||||||
gap: 8px;
|
gap: 0.8rem;
|
||||||
flex-wrap: wrap;
|
padding: 0.5rem 0;
|
||||||
|
flex: 1;
|
||||||
|
scrollbar-width: none;
|
||||||
|
/* Firefox */
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-sort-label {
|
.yt-categories::-webkit-scrollbar {
|
||||||
font-size: 14px;
|
display: none;
|
||||||
color: var(--yt-text-secondary);
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-sort-btn,
|
.yt-chip {
|
||||||
.yt-region-btn {
|
padding: 0.5rem 1rem;
|
||||||
padding: 6px 12px;
|
border-radius: 8px;
|
||||||
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 {
|
|
||||||
background: var(--yt-bg-secondary);
|
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);
|
background: var(--yt-text-primary);
|
||||||
color: var(--yt-bg-primary);
|
color: var(--yt-bg-primary);
|
||||||
border-color: var(--yt-text-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-region-btn.active {
|
.yt-chip-active:hover {
|
||||||
background: var(--yt-accent-red);
|
background: var(--yt-text-primary);
|
||||||
color: white;
|
opacity: 0.9;
|
||||||
border-color: var(--yt-accent-red);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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 {
|
.yt-section {
|
||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
|
padding: 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-section-header {
|
.yt-section-header {
|
||||||
|
|
@ -276,7 +273,6 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
padding: 8px 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-section-header h2 {
|
.yt-section-header h2 {
|
||||||
|
|
@ -291,17 +287,6 @@
|
||||||
color: var(--yt-accent-red);
|
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 {
|
.yt-shorts-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -340,7 +325,6 @@
|
||||||
right: -20px;
|
right: -20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shorts Grid (Horizontal Scroll) */
|
|
||||||
.yt-shorts-grid {
|
.yt-shorts-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|
@ -391,130 +375,59 @@
|
||||||
margin-top: 4px;
|
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) {
|
@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 {
|
.yt-shorts-arrow {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.yt-filter-bar {
|
||||||
|
padding: 0 10px;
|
||||||
|
top: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-sort-container {
|
||||||
|
/* Legacy override if needed */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// Global filter state
|
||||||
let currentSort = 'month';
|
let currentSort = 'month';
|
||||||
let currentRegion = 'vietnam';
|
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) {
|
function scrollShorts(direction) {
|
||||||
const grid = document.getElementById('shortsGrid');
|
const grid = document.getElementById('shortsGrid');
|
||||||
const scrollAmount = 400; // pixels to scroll
|
const scrollAmount = 400;
|
||||||
|
|
||||||
if (direction === 'left') {
|
if (direction === 'left') {
|
||||||
grid.scrollBy({ left: -scrollAmount, behavior: 'smooth' });
|
grid.scrollBy({ left: -scrollAmount, behavior: 'smooth' });
|
||||||
|
|
@ -523,83 +436,28 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate page numbers
|
// Load Shorts Logic
|
||||||
function renderPagination() {
|
// Load Shorts Logic
|
||||||
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
|
|
||||||
async function loadShorts() {
|
async function loadShorts() {
|
||||||
const shortsGrid = document.getElementById('shortsGrid');
|
const shortsGrid = document.getElementById('shortsGrid');
|
||||||
shortsGrid.innerHTML = `<div class="yt-loader"><div class="yt-spinner">
|
|
||||||
<div class="yt-spinner-ring"></div>
|
// Skeleton loader
|
||||||
<div class="yt-spinner-ring"></div>
|
shortsGrid.innerHTML = Array(10).fill(0).map(() => `
|
||||||
<div class="yt-spinner-ring"></div>
|
<div class="skeleton-short"></div>
|
||||||
<div class="yt-spinner-glow"></div>
|
`).join('');
|
||||||
</div></div>`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const page = window.currentPage || 1;
|
const page = 1;
|
||||||
// Get shorts related to current category
|
const category = window.currentCategory || 'general';
|
||||||
const category = window.currentCategory || currentCategory || 'general';
|
|
||||||
// For shorts, we combine category with 'shorts' keyword
|
|
||||||
const shortsCategory = category === 'all' || category === 'general' ? 'shorts' : category;
|
const shortsCategory = category === 'all' || category === 'general' ? 'shorts' : category;
|
||||||
|
|
||||||
const response = await fetch(`/api/trending?category=${shortsCategory}&page=${page}&sort=${currentSort}®ion=${currentRegion}&shorts=1`);
|
const response = await fetch(`/api/trending?category=${shortsCategory}&page=${page}&sort=${window.currentSort}®ion=${window.currentRegion}&shorts=1`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
shortsGrid.innerHTML = '';
|
shortsGrid.innerHTML = '';
|
||||||
if (data && data.length > 0) {
|
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');
|
const card = document.createElement('div');
|
||||||
card.className = 'yt-short-card';
|
card.className = 'yt-short-card';
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
|
|
@ -619,6 +477,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helpers (if main.js not loaded yet or for standalone usage)
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
|
|
@ -634,16 +493,18 @@
|
||||||
return num.toLocaleString();
|
return num.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init
|
// Init Logic
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
loadShorts();
|
loadShorts();
|
||||||
renderPagination();
|
// Pagination logic removed for infinite scroll
|
||||||
|
|
||||||
|
// Check URL params for category
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const category = urlParams.get('category');
|
const category = urlParams.get('category');
|
||||||
if (category) {
|
if (category && typeof switchCategory === 'function') {
|
||||||
const pill = document.querySelector(`.yt-category-pill[data-category="${category}"]`);
|
// Let main.js handle the switch, but we can set UI active state if needed
|
||||||
if (pill) pill.click();
|
// switchCategory is in main.js
|
||||||
|
switchCategory(category);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,10 @@
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<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-status-bar-style" content="black-translucent">
|
||||||
<meta name="apple-mobile-web-app-title" content="KV-Tube">
|
<meta name="apple-mobile-web-app-title" content="KV-Tube">
|
||||||
|
<meta name="theme-color" content="#0f0f0f">
|
||||||
<title>KV-Tube</title>
|
<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 -->
|
<!-- Open Graph / Facebook -->
|
||||||
<meta property="og:type" content="website">
|
<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 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="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||||
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
|
<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>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -32,8 +45,15 @@
|
||||||
<button class="yt-menu-btn" onclick="toggleSidebar()" aria-label="Menu">
|
<button class="yt-menu-btn" onclick="toggleSidebar()" aria-label="Menu">
|
||||||
<i class="fas fa-bars"></i>
|
<i class="fas fa-bars"></i>
|
||||||
</button>
|
</button>
|
||||||
<a href="/" class="yt-logo">
|
<button class="yt-menu-btn" id="headerBackBtn" onclick="history.back()" aria-label="Back"
|
||||||
<div class="yt-logo-icon">KV-Tube</div>
|
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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -95,6 +115,7 @@
|
||||||
<i class="fas fa-bookmark"></i>
|
<i class="fas fa-bookmark"></i>
|
||||||
<span>Library</span>
|
<span>Library</span>
|
||||||
</a>
|
</a>
|
||||||
|
<!-- Queue Removed -->
|
||||||
|
|
||||||
<div class="yt-sidebar-divider"></div>
|
<div class="yt-sidebar-divider"></div>
|
||||||
|
|
||||||
|
|
@ -270,6 +291,21 @@
|
||||||
<!-- Toast Notification Container -->
|
<!-- Toast Notification Container -->
|
||||||
<div id="toastContainer" class="yt-toast-container"></div>
|
<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>
|
<style>
|
||||||
.yt-toast-container {
|
.yt-toast-container {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
@ -336,7 +372,216 @@
|
||||||
setTimeout(() => toast.remove(), 300);
|
setTimeout(() => toast.remove(), 300);
|
||||||
}, 5000);
|
}, 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>
|
</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>
|
</html>
|
||||||
|
|
@ -1,36 +1,92 @@
|
||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="library-header" style="margin-bottom: 2rem; display: flex; align-items: center; gap: 1rem;">
|
<div class="yt-container" style="padding-top: 20px;">
|
||||||
<h1>My Library</h1>
|
<div class="library-header"
|
||||||
<div class="tabs" style="display: flex; gap: 0.5rem; background: #e8eaed; padding: 0.3rem; border-radius: 100px;">
|
style="margin-bottom: 2rem; display: flex; align-items: center; justify-content: space-between;">
|
||||||
<a href="/my-videos?type=saved" class="btn {% if filter_type == 'saved' %}btn-primary{% endif %}"
|
<h1 style="font-size: 1.5rem;">My Library</h1>
|
||||||
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>
|
<div class="tabs"
|
||||||
<a href="/my-videos?type=history" class="btn {% if filter_type == 'history' %}btn-primary{% endif %}"
|
style="display: flex; gap: 0.5rem; background: var(--yt-bg-hover); padding: 0.3rem; border-radius: 100px;">
|
||||||
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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if videos %}
|
<script>
|
||||||
<div class="video-grid">
|
const loggedIn = {{ 'true' if logged_in else 'false' }};
|
||||||
{% for video in videos %}
|
const filterType = '{{ filter_type }}';
|
||||||
<div class="video-card" onclick="window.location.href='/watch?v={{ video.video_id }}'">
|
|
||||||
<img src="{{ video.thumbnail }}" class="thumbnail" loading="lazy">
|
if (!loggedIn && filterType === 'history') {
|
||||||
<div class="video-info">
|
// Load from Local Storage
|
||||||
<div class="video-title">{{ video.title }}</div>
|
const history = JSON.parse(localStorage.getItem('kv_history') || '[]');
|
||||||
<div class="video-meta">
|
const grid = document.getElementById('libraryGrid');
|
||||||
<i class="far fa-clock"></i> {{ video.timestamp }}
|
const empty = document.getElementById('emptyState');
|
||||||
</div>
|
|
||||||
</div>
|
if (history.length > 0) {
|
||||||
</div>
|
grid.innerHTML = history.map(video => `
|
||||||
{% endfor %}
|
<div class="yt-video-card" onclick="window.location.href='/watch?v=${video.id}'">
|
||||||
</div>
|
<div class="yt-thumbnail-container">
|
||||||
{% else %}
|
<img src="${video.thumbnail}" class="yt-thumbnail" loading="lazy">
|
||||||
<div style="text-align: center; padding: 4rem; color: var(--text-secondary);">
|
</div>
|
||||||
<i class="far fa-folder-open fa-3x" style="margin-bottom: 1rem; opacity: 0.5;"></i>
|
<div class="yt-video-details">
|
||||||
<h3>Nothing here yet</h3>
|
<div class="yt-video-meta">
|
||||||
<p>Go watch some videos to fill this up!</p>
|
<h3 class="yt-video-title">${video.title}</h3>
|
||||||
<a href="/" class="btn btn-primary" style="margin-top: 1rem;">Browse Content</a>
|
<p class="yt-video-stats">Watched on ${new Date(video.timestamp).toLocaleDateString()}</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
</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 %}
|
{% endblock %}
|
||||||
1768
templates/watch.html
1768
templates/watch.html
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue