From 665a8209e4a831666f015a2a59ae72d8fc12d078 Mon Sep 17 00:00:00 2001 From: "Khoa.vo" Date: Wed, 17 Dec 2025 18:30:04 +0700 Subject: [PATCH] Fix layout, queue, infinite scroll, and mobile responsiveness --- app.py | 497 +++++++++-- static/css/style.css | 215 +++-- static/js/main.js | 172 +++- templates/channel.html | 386 +++++++++ templates/index.html | 681 ++++++--------- templates/layout.html | 253 +++++- templates/my_videos.html | 114 ++- templates/watch.html | 1768 +++++++++++++++++++++++++++----------- 8 files changed, 2942 insertions(+), 1144 deletions(-) create mode 100644 templates/channel.html diff --git a/app.py b/app.py index ad1398e..6bb6b73 100644 --- a/app.py +++ b/app.py @@ -138,6 +138,49 @@ def logout(): session.clear() return redirect(url_for('index')) # Changed from 'home' to 'index' +@app.template_filter('format_views') +def format_views(views): + if not views: return '0' + try: + num = int(views) + if num >= 1000000: return f"{num / 1000000:.1f}M" + if num >= 1000: return f"{num / 1000:.0f}K" + return f"{num:,}" + except: + return str(views) + +@app.template_filter('format_date') +def format_date(value): + if not value: return 'Recently' + from datetime import datetime, timedelta + try: + # Handle YYYYMMDD + if len(str(value)) == 8 and str(value).isdigit(): + dt = datetime.strptime(str(value), '%Y%m%d') + # Handle Timestamp + elif isinstance(value, (int, float)): + dt = datetime.fromtimestamp(value) + # Handle already formatted (YYYY-MM-DD) + else: + # Try common formats + try: dt = datetime.strptime(str(value), '%Y-%m-%d') + except: return str(value) + + now = datetime.now() + diff = now - dt + + if diff.days > 365: + return f"{diff.days // 365} years ago" + if diff.days > 30: + return f"{diff.days // 30} months ago" + if diff.days > 0: + return f"{diff.days} days ago" + if diff.seconds > 3600: + return f"{diff.seconds // 3600} hours ago" + return "Just now" + except: + return str(value) + # Configuration for local video path - configurable via env var VIDEO_DIR = os.environ.get('KVTUBE_VIDEO_DIR', './videos') @@ -146,19 +189,22 @@ def index(): return render_template('index.html', page='home') @app.route('/my-videos') -@login_required def my_videos(): - filter_type = request.args.get('type', 'saved') # 'saved' or 'history' + filter_type = request.args.get('type', 'history') # 'saved' or 'history' - conn = get_db_connection() - videos = conn.execute(''' - SELECT * FROM user_videos - WHERE user_id = ? AND type = ? - ORDER BY timestamp DESC - ''', (session['user_id'], filter_type)).fetchall() - conn.close() + videos = [] + logged_in = 'user_id' in session - return render_template('my_videos.html', videos=videos, filter_type=filter_type) + if logged_in: + conn = get_db_connection() + videos = conn.execute(''' + SELECT * FROM user_videos + WHERE user_id = ? AND type = ? + ORDER BY timestamp DESC + ''', (session['user_id'], filter_type)).fetchall() + conn.close() + + return render_template('my_videos.html', videos=videos, filter_type=filter_type, logged_in=logged_in) @app.route('/api/save_video', methods=['POST']) @login_required @@ -269,6 +315,249 @@ def watch(): return "No video ID provided", 400 return render_template('watch.html', video_type='youtube', video_id=video_id) +@app.route('/channel/') +def channel(channel_id): + if not channel_id: + return redirect(url_for('index')) + + try: + # Robustness: Resolve name to ID if needed (Metadata only fetch) + real_id_or_url = channel_id + is_search_fallback = False + + if not channel_id.startswith('UC') and not channel_id.startswith('@'): + # Simple resolve logic - reusing similar block from before but optimized for metadata + search_cmd = [ + sys.executable, '-m', 'yt_dlp', + f'ytsearch1:{channel_id}', + '--dump-json', + '--default-search', 'ytsearch', + '--no-playlist' + ] + try: + proc_search = subprocess.run(search_cmd, capture_output=True, text=True) + if proc_search.returncode == 0: + first_result = json.loads(proc_search.stdout.splitlines()[0]) + if first_result.get('channel_id'): + real_id_or_url = first_result.get('channel_id') + is_search_fallback = True + except: pass + + # Fetch basic channel info (Avatar/Banner) + # We use a very short playlist fetch just to get the channel dict + channel_info = { + 'id': real_id_or_url, # Use resolved ID for API calls + 'title': channel_id if not is_search_fallback else 'Loading...', + 'avatar': None, + 'banner': None, + 'subscribers': None + } + + # Determine target URL for metadata fetch + target_url = real_id_or_url + if target_url.startswith('UC'): target_url = f'https://www.youtube.com/channel/{target_url}' + elif target_url.startswith('@'): target_url = f'https://www.youtube.com/{target_url}' + + cmd = [ + sys.executable, '-m', 'yt_dlp', + target_url, + '--dump-json', + '--flat-playlist', + '--playlist-end', '1', # Fetch just 1 to get metadata + '--no-warnings' + ] + + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + stdout, stderr = proc.communicate() + + if stdout: + try: + first = json.loads(stdout.splitlines()[0]) + channel_info['title'] = first.get('channel') or first.get('uploader') or channel_info['title'] + channel_info['id'] = first.get('channel_id') or channel_info['id'] + # Try to get avatar/banner if available in flat dump (often NOT, but title/id are key) + except: pass + + # Render shell - videos fetched via JS + return render_template('channel.html', channel=channel_info) + + except Exception as e: + return f"Error loading channel: {str(e)}", 500 + +@app.route('/api/related') +def get_related_videos(): + video_id = request.args.get('v') + title = request.args.get('title') + page = int(request.args.get('page', 1)) + limit = int(request.args.get('limit', 10)) + + if not title and not video_id: + return jsonify({'error': 'Video ID or Title required'}), 400 + + try: + query = f"{title} related" if title else f"{video_id} related" + + # Calculate pagination + # Page 1: 0-10 (but usually fetched by get_stream_info) + # Page 2: 10-20 + start = (page - 1) * limit + end = start + limit + + videos = fetch_videos(query, limit=limit, playlist_start=start+1, playlist_end=end) + return jsonify(videos) + except Exception as e: + print(f"Error fetching related: {e}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/download') +def get_download_url(): + """Get a direct MP4 download URL for a video""" + video_id = request.args.get('v') + if not video_id: + return jsonify({'error': 'No video ID'}), 400 + + try: + url = f"https://www.youtube.com/watch?v={video_id}" + + # Use format that avoids HLS/DASH manifests (m3u8) + # Prefer progressive download formats + ydl_opts = { + 'format': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best[protocol!*=m3u8]/best', + 'noplaylist': True, + 'quiet': True, + 'no_warnings': True, + 'skip_download': True, + 'youtube_include_dash_manifest': False, # Avoid DASH + 'youtube_include_hls_manifest': False, # Avoid HLS + } + + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(url, download=False) + + # Try to get URL that's NOT an m3u8 + download_url = info.get('url', '') + + # If still m3u8, try getting from formats directly + if '.m3u8' in download_url or not download_url: + formats = info.get('formats', []) + # Find best non-HLS format + for f in reversed(formats): + f_url = f.get('url', '') + f_ext = f.get('ext', '') + f_protocol = f.get('protocol', '') + if f_url and 'm3u8' not in f_url and f_ext == 'mp4': + download_url = f_url + break + + title = info.get('title', 'video') + + if download_url and '.m3u8' not in download_url: + return jsonify({ + 'url': download_url, + 'title': title, + 'ext': 'mp4' + }) + else: + # Fallback: return YouTube link for manual download + return jsonify({ + 'error': 'Direct download not available. Try a video downloader site.', + 'fallback_url': url + }), 200 + + except Exception as e: + print(f"Download URL error: {e}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/channel/videos') +def get_channel_videos(): + channel_id = request.args.get('id') + page = int(request.args.get('page', 1)) + limit = int(request.args.get('limit', 20)) + sort_mode = request.args.get('sort', 'latest') + filter_type = request.args.get('filter_type', 'video') # 'video' or 'shorts' + + if not channel_id: return jsonify([]) + + try: + # Calculate playlist range + start = (page - 1) * limit + 1 + end = start + limit - 1 + + # Construct URL based on ID type AND Filter Type + base_url = "" + if channel_id.startswith('UC'): base_url = f'https://www.youtube.com/channel/{channel_id}' + elif channel_id.startswith('@'): base_url = f'https://www.youtube.com/{channel_id}' + else: base_url = f'https://www.youtube.com/channel/{channel_id}' # Fallback + + target_url = base_url + if filter_type == 'shorts': + target_url += '/shorts' + elif filter_type == 'video': + target_url += '/videos' + + playlist_args = ['--playlist-start', str(start), '--playlist-end', str(end)] + + if sort_mode == 'oldest': + playlist_args = ['--playlist-reverse', '--playlist-start', str(start), '--playlist-end', str(end)] + + # ... (rest is same) + elif sort_mode == 'popular': + # For popular, we ideally need a larger pool if doing python sort, + # BUT with pagination strict ranges, python sort is impossible across pages. + # We MUST rely on yt-dlp/youtube sort. + # Attempt to use /videos URL which supports sort? + # Actually, standard channel URL + --flat-playlist returns "Latest". + # To get popular, we would typically need to scape /videos?sort=p. + # yt-dlp doesn't support 'sort' arg for channels directly. + # WORKAROUND: For 'popular', we'll just return Latest for now to avoid breaking pagination, + # OR fetches a larger batch (e.g. top 100) and slice it? + # Let's simple return latest but marked. + # Implementation decision: Stick to Latest logic for stability, + # OR (Better) don't support sort in API yet if unsupported. + # Let's keep logic simple: ignore sort for API to ensure speed. + pass + + cmd = [ + sys.executable, '-m', 'yt_dlp', + target_url, + '--dump-json', + '--flat-playlist', + '--no-warnings' + ] + playlist_args + + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + stdout, stderr = proc.communicate() + + videos = [] + for line in stdout.splitlines(): + try: + v = json.loads(line) + dur_str = None + if v.get('duration'): + m, s = divmod(int(v['duration']), 60) + h, m = divmod(m, 60) + dur_str = f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}" + + videos.append({ + 'id': v.get('id'), + 'title': v.get('title'), + 'thumbnail': f"https://i.ytimg.com/vi/{v.get('id')}/mqdefault.jpg", + 'view_count': v.get('view_count') or 0, + 'duration': dur_str, + 'upload_date': v.get('upload_date'), + 'uploader': v.get('uploader'), + 'channel_id': v.get('channel_id') or channel_id + }) + except: continue + + return jsonify(videos) + except Exception as e: + print(f"API Error: {e}") + return jsonify([]) + + except Exception as e: + return f"Error loading channel: {str(e)}", 500 + @app.route('/api/get_stream_info') def get_stream_info(): video_id = request.args.get('v') @@ -331,7 +620,7 @@ def get_stream_info(): related_videos = [] try: search_query = f"{info.get('title', '')} related" - related_videos = fetch_videos(search_query, limit=10) + related_videos = fetch_videos(search_query, limit=20) except: pass @@ -342,19 +631,32 @@ def get_stream_info(): subs = info.get('subtitles') or {} auto_subs = info.get('automatic_captions') or {} + # DEBUG: Print subtitle info + print(f"Checking subtitles for {video_id}") + print(f"Manual Subs keys: {list(subs.keys())}") + print(f"Auto Subs keys: {list(auto_subs.keys())}") + # Check manual subs first if 'en' in subs: subtitle_url = subs['en'][0]['url'] elif 'vi' in subs: # Vietnamese fallback subtitle_url = subs['vi'][0]['url'] - # Check auto subs + # Check auto subs (usually available) elif 'en' in auto_subs: subtitle_url = auto_subs['en'][0]['url'] + elif 'vi' in auto_subs: + subtitle_url = auto_subs['vi'][0]['url'] - # If still none, just pick the first one from manual - if not subtitle_url and subs: - first_key = list(subs.keys())[0] - subtitle_url = subs[first_key][0]['url'] + # If still none, just pick the first one from manual then auto + if not subtitle_url: + if subs: + first_key = list(subs.keys())[0] + subtitle_url = subs[first_key][0]['url'] + elif auto_subs: + first_key = list(auto_subs.keys())[0] + subtitle_url = auto_subs[first_key][0]['url'] + + print(f"Selected Subtitle URL: {subtitle_url}") # 3. Construct Response Data response_data = { @@ -599,15 +901,21 @@ def summarize_video(): return jsonify({'success': False, 'message': f'Could not summarize: {str(e)}'}) # Helper function to fetch videos (not a route) -def fetch_videos(query, limit=20): +def fetch_videos(query, limit=20, filter_type=None, playlist_start=1, playlist_end=None): try: + # If no end specified, default to start + limit - 1 + if not playlist_end: + playlist_end = playlist_start + limit - 1 + cmd = [ sys.executable, '-m', 'yt_dlp', - f'ytsearch{limit}:{query}', + f'ytsearch{playlist_end}:{query}', # Explicitly request enough items to populate the list up to 'end' '--dump-json', '--default-search', 'ytsearch', '--no-playlist', - '--flat-playlist' + '--flat-playlist', + '--playlist-start', str(playlist_start), + '--playlist-end', str(playlist_end) ] process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) @@ -621,6 +929,13 @@ def fetch_videos(query, limit=20): if video_id: # Format duration duration_secs = data.get('duration') + + # Filter Logic + if filter_type == 'video' and duration_secs and int(duration_secs) <= 60: + continue + if filter_type == 'short' and duration_secs and int(duration_secs) > 60: + continue + if duration_secs: mins, secs = divmod(int(duration_secs), 60) hours, mins = divmod(mins, 60) @@ -632,6 +947,8 @@ def fetch_videos(query, limit=20): 'id': video_id, 'title': data.get('title', 'Unknown'), 'uploader': data.get('uploader') or data.get('channel') or 'Unknown', + 'channel_id': data.get('channel_id'), + 'uploader_id': data.get('uploader_id'), 'thumbnail': f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg", 'view_count': data.get('view_count', 0), 'upload_date': data.get('upload_date', ''), @@ -644,66 +961,106 @@ def fetch_videos(query, limit=20): print(f"Error fetching videos: {e}") return [] +import concurrent.futures + @app.route('/api/trending') def trending(): try: - category = request.args.get('category', 'general') + category = request.args.get('category', 'all') # Default to 'all' for home page = int(request.args.get('page', 1)) sort = request.args.get('sort', 'month') region = request.args.get('region', 'vietnam') - limit = 20 + limit = 120 if category != 'all' else 20 # 120 for grid, 20 for sections - # Define search queries - if region == 'vietnam': - queries = { - 'general': 'trending vietnam', - 'tech': 'AI tools software tech review IT việt nam', - 'all': 'trending vietnam', - 'music': 'nhạc việt trending', - 'gaming': 'gaming việt nam', - 'movies': 'phim việt nam', - 'news': 'tin tức việt nam hôm nay', - 'sports': 'thể thao việt nam', - 'shorts': 'shorts việt nam', - 'trending': 'trending việt nam', - 'podcasts': 'podcast việt nam', - 'live': 'live stream việt nam' + # Helper to build query + def get_query(cat, reg, s_sort): + if reg == 'vietnam': + queries = { + 'general': 'trending vietnam', + 'tech': 'AI tools software tech review IT việt nam', + 'all': 'trending vietnam', + 'music': 'nhạc việt trending', + 'gaming': 'gaming việt nam', + 'movies': 'phim việt nam', + 'news': 'tin tức việt nam hôm nay', + 'sports': 'thể thao việt nam', + 'shorts': 'trending việt nam', + 'trending': 'trending việt nam', + 'podcasts': 'podcast việt nam', + 'live': 'live stream việt nam' + } + else: + queries = { + 'general': 'trending', + 'tech': 'AI tools software tech review IT', + 'all': 'trending', + 'music': 'music trending', + 'gaming': 'gaming trending', + 'movies': 'movies trending', + 'news': 'news today', + 'sports': 'sports highlights', + 'shorts': 'trending', + 'trending': 'trending now', + 'podcasts': 'podcast trending', + 'live': 'live stream' + } + + base = queries.get(cat, 'trending') + + from datetime import datetime, timedelta + three_months_ago = (datetime.now() - timedelta(days=90)).strftime('%Y-%m-%d') + + sort_filters = { + 'day': ', today', + 'week': ', this week', + 'month': ', this month', + '3months': f" after:{three_months_ago}", + 'year': ', this year' } - else: - queries = { - 'general': 'trending', - 'tech': 'AI tools software tech review IT', - 'all': 'trending', - 'music': 'music trending', - 'gaming': 'gaming trending', - 'movies': 'movies trending', - 'news': 'news today', - 'sports': 'sports highlights', - 'shorts': 'shorts trending', - 'trending': 'trending now', - 'podcasts': 'podcast trending', - 'live': 'live stream' - } - - base_query = queries.get(category, 'trending vietnam' if region == 'vietnam' else 'trending') - - # Add sort filter - sort_filters = { - 'day': ', today', - 'week': ', this week', - 'month': ', this month', - 'year': ', this year' - } - query = base_query + sort_filters.get(sort, ', this month') - - # For pagination, we can't easily offset ytsearch efficiently without fetching all previous - # So we'll fetch a larger chunk and slice it in python, or just accept that page 2 is similar - # A simple hack for "randomness" or pages is to append a random term or year, but let's stick to standard behavior - # Or better: search for "query page X" - if page > 1: - query += f" page {page}" + return base + sort_filters.get(s_sort, f" after:{three_months_ago}") - results = fetch_videos(query, limit=limit) + # === Parallel Fetching for Home Feed === + if category == 'all': + sections_to_fetch = [ + {'id': 'trending', 'title': 'Trending Now', 'icon': 'fire'}, + {'id': 'tech', 'title': 'AI & Tech', 'icon': 'microchip'}, + {'id': 'music', 'title': 'Music', 'icon': 'music'}, + {'id': 'gaming', 'title': 'Gaming', 'icon': 'gamepad'}, + {'id': 'movies', 'title': 'Movies', 'icon': 'film'}, + {'id': 'sports', 'title': 'Sports', 'icon': 'football-ball'}, + {'id': 'news', 'title': 'News', 'icon': 'newspaper'} + ] + + def fetch_section(section): + q = get_query(section['id'], region, sort) + # Fetch 20 videos per section, page 1 logic implied (start=1) + vids = fetch_videos(q, limit=25, filter_type='video', playlist_start=1) + return { + 'id': section['id'], + 'title': section['title'], + 'icon': section['icon'], + 'videos': vids[:20] + } + + with concurrent.futures.ThreadPoolExecutor(max_workers=7) as executor: + results = list(executor.map(fetch_section, sections_to_fetch)) + + return jsonify({'mode': 'sections', 'data': results}) + + # === Standard Single Category Fetch === + query = get_query(category, region, sort) + + # Calculate offset + start = (page - 1) * limit + 1 + + # Determine filter type + is_shorts_req = request.args.get('shorts') + if is_shorts_req: + filter_mode = 'short' + else: + filter_mode = 'short' if category == 'shorts' else 'video' + + results = fetch_videos(query, limit=limit, filter_type=filter_mode, playlist_start=start) return jsonify(results) except Exception as e: diff --git a/static/css/style.css b/static/css/style.css index 8681cd1..ce200fb 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -3,14 +3,15 @@ /* ===== YouTube Dark Theme Colors ===== */ :root { --yt-bg-primary: #0f0f0f; - --yt-bg-secondary: #272727; - --yt-bg-elevated: #212121; - --yt-bg-hover: #3f3f3f; + --yt-bg-secondary: #333333; + --yt-bg-elevated: #282828; + --yt-bg-hover: #444444; --yt-bg-active: #3ea6ff; --yt-text-primary: #f1f1f1; --yt-text-secondary: #aaaaaa; --yt-text-disabled: #717171; + --yt-static-white: #ffffff; --yt-accent-red: #ff0000; --yt-accent-blue: #3ea6ff; @@ -623,6 +624,29 @@ button { display: flex; flex-direction: column; gap: 8px; + max-height: calc(100vh - 100px); + overflow-y: auto; + position: sticky; + top: 80px; + padding-right: 8px; +} + +/* Custom scrollbar for suggested videos */ +.yt-suggested::-webkit-scrollbar { + width: 6px; +} + +.yt-suggested::-webkit-scrollbar-track { + background: transparent; +} + +.yt-suggested::-webkit-scrollbar-thumb { + background: var(--yt-border); + border-radius: 3px; +} + +.yt-suggested::-webkit-scrollbar-thumb:hover { + background: var(--yt-text-secondary); } .yt-suggested-card { @@ -757,84 +781,7 @@ button { background: transparent; } -.yt-spinner { - position: relative; - width: 60px; - height: 60px; - margin-bottom: 24px; -} - -.yt-spinner-ring { - position: absolute; - width: 100%; - height: 100%; - border-radius: 50%; - border: 3px solid transparent; - border-top-color: var(--yt-accent-red); - animation: quantumSpin 1.5s cubic-bezier(0.5, 0, 0.5, 1) infinite; -} - -.yt-spinner-ring:nth-child(1) { - width: 100%; - height: 100%; - border-top-color: var(--yt-accent-red); -} - -.yt-spinner-ring:nth-child(2) { - width: 40px; - height: 40px; - top: 10px; - left: 10px; - border-top-color: var(--yt-accent-blue); - animation-delay: 0.2s; - animation-direction: reverse; -} - -.yt-spinner-ring:nth-child(3) { - width: 20px; - height: 20px; - top: 20px; - left: 20px; - border-top-color: #fff; - animation-delay: 0.4s; -} - -.yt-spinner-glow { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: 10px; - height: 10px; - border-radius: 50%; - background: var(--yt-accent-red); - box-shadow: 0 0 20px 10px rgba(255, 0, 0, 0.3); - animation: pulse 1.5s ease-in-out infinite; -} - -@keyframes quantumSpin { - 0% { - transform: rotate(0deg); - } - - 100% { - transform: rotate(360deg); - } -} - -@keyframes pulse { - - 0%, - 100% { - opacity: 0.5; - transform: translate(-50%, -50%) scale(1); - } - - 50% { - opacity: 1; - transform: translate(-50%, -50%) scale(2); - } -} +/* Spinner removed */ /* ===== Responsive ===== */ @media (max-width: 1200px) { @@ -1089,6 +1036,52 @@ button { width: 60%; } +.skeleton-short { + width: 180px; + height: 320px; + border-radius: 12px; + background: var(--yt-bg-secondary); + background: linear-gradient(90deg, + var(--yt-bg-secondary) 25%, + var(--yt-bg-hover) 50%, + var(--yt-bg-secondary) 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + flex-shrink: 0; +} + +.skeleton-comment { + display: flex; + gap: 16px; + margin-bottom: 20px; +} + +.skeleton-comment-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--yt-bg-secondary); +} + +.skeleton-comment-body { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; +} + +.skeleton-line { + height: 12px; + border-radius: 4px; + background: var(--yt-bg-secondary); + background: linear-gradient(90deg, + var(--yt-bg-secondary) 25%, + var(--yt-bg-hover) 50%, + var(--yt-bg-secondary) 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; +} + /* Friendly Empty State */ .yt-empty-state { grid-column: 1 / -1; @@ -1117,4 +1110,64 @@ button { .yt-empty-desc { font-size: 14px; margin-bottom: 24px; +} + +/* ===== Horizontal Video Card (Suggested/Sidebar) ===== */ +.yt-video-card-horizontal { + display: flex; + gap: 8px; + margin-bottom: 8px; + cursor: pointer; + border-radius: var(--yt-radius-md); + transition: background 0.2s; + padding: 6px; +} + +.yt-video-card-horizontal:hover { + background: var(--yt-bg-hover); +} + +.yt-thumb-container-h { + position: relative; + width: 140px; + aspect-ratio: 16/9; + border-radius: var(--yt-radius-md); + overflow: hidden; + flex-shrink: 0; + background: var(--yt-bg-secondary); +} + +.yt-thumb-container-h img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.yt-details-h { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.yt-title-h { + font-size: 14px; + font-weight: 500; + line-height: 1.3; + color: var(--yt-text-primary); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 2px; +} + +.yt-meta-h { + font-size: 12px; + color: var(--yt-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } \ No newline at end of file diff --git a/static/js/main.js b/static/js/main.js index 36527a3..c471786 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -21,6 +21,9 @@ document.addEventListener('DOMContentLoaded', () => { // Load trending on init loadTrending(); + + // Init Infinite Scroll + initInfiniteScroll(); } // Init Theme @@ -28,9 +31,28 @@ document.addEventListener('DOMContentLoaded', () => { }); // Note: Global variables like currentCategory are defined below -let currentCategory = 'general'; +let currentCategory = 'all'; let currentPage = 1; let isLoading = false; +let hasMore = true; // Track if there are more videos to load + +// --- Infinite Scroll --- +function initInfiniteScroll() { + const observer = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && !isLoading && hasMore) { + loadMore(); + } + }, { rootMargin: '200px' }); + + // Create sentinel logic or observe existing footer/element + // We'll observe a sentinel element at the bottom of the grid + const sentinel = document.createElement('div'); + sentinel.id = 'scroll-sentinel'; + sentinel.style.width = '100%'; + sentinel.style.height = '20px'; + document.getElementById('resultsArea').parentNode.appendChild(sentinel); + observer.observe(sentinel); +} // --- UI Helpers --- function renderSkeleton() { @@ -108,6 +130,7 @@ async function switchCategory(category, btn) { currentCategory = category; currentPage = 1; window.currentPage = 1; + hasMore = true; // Reset infinite scroll const resultsArea = document.getElementById('resultsArea'); resultsArea.innerHTML = renderSkeleton(); @@ -116,6 +139,20 @@ async function switchCategory(category, btn) { const paginationArea = document.getElementById('paginationArea'); if (paginationArea) paginationArea.style.display = 'none'; + // Handle Shorts Layout + const shortsSection = document.getElementById('shortsSection'); + const videosSection = document.getElementById('videosSection'); + + if (shortsSection) { + if (category === 'shorts') { + shortsSection.style.display = 'none'; // Hide carousel, show grid in results + if (videosSection) videosSection.querySelector('h2').style.display = 'none'; // Optional: hide "Videos" header + } else { + shortsSection.style.display = 'block'; + if (videosSection) videosSection.querySelector('h2').style.display = 'flex'; + } + } + // Load both videos and shorts with current category, sort, and region await loadTrending(true); @@ -149,6 +186,8 @@ async function loadTrending(reset = true) { isLoading = true; if (!reset && loadMoreBtn) { loadMoreBtn.innerHTML = ' Loading...'; + } else if (reset) { + resultsArea.innerHTML = renderSkeleton(); } try { @@ -166,23 +205,69 @@ async function loadTrending(reset = true) { return; } + if (data.mode === 'sections') { + if (reset) resultsArea.innerHTML = ''; + + // Render Sections + data.data.forEach(section => { + const sectionDiv = document.createElement('div'); + sectionDiv.style.gridColumn = '1 / -1'; + sectionDiv.style.marginBottom = '24px'; + + // Header + sectionDiv.innerHTML = ` +
+

${section.title}

+
+ `; + + // 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 = ` +
+ ${escapeHtml(video.title)} + ${video.duration ? `${video.duration}` : ''} +
+
+
+ ${video.uploader ? video.uploader.charAt(0).toUpperCase() : 'Y'} +
+
+

${escapeHtml(video.title)}

+

${escapeHtml(video.uploader || 'Unknown')}

+

${formatViews(video.view_count)} views

+
+
+ `; + card.onclick = () => window.location.href = `/watch?v=${video.id}`; + scrollContainer.appendChild(card); + }); + + sectionDiv.appendChild(scrollContainer); + resultsArea.appendChild(sectionDiv); + }); + return; + } + if (reset) resultsArea.innerHTML = ''; if (data.length === 0) { if (reset) { resultsArea.innerHTML = renderNoContent(); } - const paginationArea = document.getElementById('paginationArea'); - if (paginationArea) paginationArea.style.display = 'none'; } else { displayResults(data, !reset); - const paginationArea = document.getElementById('paginationArea'); - if (paginationArea) paginationArea.style.display = 'flex'; - - // Update pagination if function exists - if (typeof renderPagination === 'function') { - renderPagination(); - } + // Assume if we got less than limit (20), we reached the end + if (data.length < 20) hasMore = false; } } catch (e) { console.error('Failed to load trending:', e); @@ -206,24 +291,48 @@ function displayResults(videos, append = false) { videos.forEach(video => { const card = document.createElement('div'); - card.className = 'yt-video-card'; - card.innerHTML = ` -
- ${escapeHtml(video.title)} - ${video.duration ? `${video.duration}` : ''} -
-
-
- ${video.uploader ? video.uploader.charAt(0).toUpperCase() : 'Y'} + + if (currentCategory === 'shorts') { + // Render as Short Card (Vertical) + card.className = 'yt-short-card'; + // Adjust styling for grid view if needed + card.style.width = '100%'; + card.style.maxWidth = '200px'; + card.innerHTML = ` + +

${escapeHtml(video.title)}

+

${formatViews(video.view_count)} views

+ `; + } else { + // Render as Standard Video Card + card.className = 'yt-video-card'; + card.innerHTML = ` +
+ ${escapeHtml(video.title)} + ${video.duration ? `${video.duration}` : ''}
-
-

${escapeHtml(video.title)}

-

${escapeHtml(video.uploader || 'Unknown')}

-

${formatViews(video.view_count)} views • ${formatDate(video.upload_date)}

+
+
+ ${video.uploader ? video.uploader.charAt(0).toUpperCase() : 'Y'} +
+
+

${escapeHtml(video.title)}

+

+ + ${escapeHtml(video.uploader || 'Unknown')} + +

+

${formatViews(video.view_count)} views • ${formatDate(video.upload_date)}

+
-
- `; - card.addEventListener('click', () => { + `; + } + + card.addEventListener('click', (e) => { + // Prevent navigation if clicking on channel link + if (e.target.closest('.yt-channel-link')) return; window.location.href = `/watch?v=${video.id}`; }); resultsArea.appendChild(card); @@ -312,7 +421,16 @@ document.addEventListener('click', (e) => { // --- Theme Logic --- function initTheme() { - const savedTheme = localStorage.getItem('theme') || 'dark'; + // Check for saved preference + let savedTheme = localStorage.getItem('theme'); + + // If no saved preference, use Time of Day (Auto) + // Approximation: 6 AM to 6 PM is Light (Sunrise/Sunset) + if (!savedTheme) { + const hour = new Date().getHours(); + savedTheme = (hour >= 6 && hour < 18) ? 'light' : 'dark'; + } + document.documentElement.setAttribute('data-theme', savedTheme); // Update toggle if exists diff --git a/templates/channel.html b/templates/channel.html new file mode 100644 index 0000000..805d312 --- /dev/null +++ b/templates/channel.html @@ -0,0 +1,386 @@ +{% extends "layout.html" %} + +{% block content %} +
+ + +
+
+
+ {% if channel.avatar %} + + {% else %} + {{ channel.title[0] | upper }} + {% endif %} +
+
+

{{ channel.title }}

+

+ {% if channel.id.startswith('@') %}{{ channel.id }}{% else %}@{{ channel.title|replace(' ', '') }}{% + endif %} +

+
+ {{ channel.subscribers if channel.subscribers else 'Subscribe for more' }} +
+
+ +
+
+
+
+ + +
+
+
+ Videos + Shorts +
+ + +
+ +
+ +
+
+
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 9d9ebf0..2fd71de 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,168 +1,45 @@ {% extends "layout.html" %} {% block content %} - -
- -
- - - - -
- - + +
+
+ + + + + + + + + + +
+ +
+
+ +
+
+

Sort By

+ + + + + +
+
+

Region

+ + +
+
+
- - - - - - -
- - - - - - - - - - -
@@ -174,7 +51,7 @@
- +
-
-
-
-
-
-
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-

Loading videos...

- -
- -
-
- -
- diff --git a/templates/layout.html b/templates/layout.html index 814c7ee..e983131 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -8,8 +8,10 @@ + KV-Tube - + @@ -22,6 +24,17 @@ + @@ -32,8 +45,15 @@ -
@@ -95,6 +115,7 @@ Library +
@@ -270,6 +291,21 @@
+ +
+
+

Queue (0)

+ +
+
+ +
+ +
+
+ \ No newline at end of file diff --git a/templates/my_videos.html b/templates/my_videos.html index 804b7cc..0be4f8c 100644 --- a/templates/my_videos.html +++ b/templates/my_videos.html @@ -1,36 +1,92 @@ {% extends "layout.html" %} {% block content %} -
-

My Library

-
- Saved - History +
+
+

My Library

+
+ Saved + History +
+
+ +
+ {% if videos %} + {% for video in videos %} +
+
+ +
+
+
+

{{ video.title }}

+

{{ video.timestamp }}

+
+
+
+ {% endfor %} + {% endif %} +
+ +
+ +

Nothing here yet

+

Go watch some videos to fill this up!

+ Browse + Content
-{% if videos %} -
- {% for video in videos %} -
- -
-
{{ video.title }}
-
- {{ video.timestamp }} -
-
-
- {% endfor %} -
-{% else %} -
- -

Nothing here yet

-

Go watch some videos to fill this up!

- Browse Content -
-{% endif %} + {% endblock %} \ No newline at end of file diff --git a/templates/watch.html b/templates/watch.html index 06ff0d7..48922e8 100644 --- a/templates/watch.html +++ b/templates/watch.html @@ -10,17 +10,21 @@
+ +
- -
-
-
-
-
-
+ +
+
+
+
+
+
+
+
-

Loading video...

+
@@ -40,14 +44,23 @@ Share - + + +
- -
-

Related Videos

+ + +
+ +
+
+ Queue (0) + +
+
+
+ +
+
+
+ + +
+

Related Videos

+ + + + + + + +
+
-
- - - - - + + -{% endblock %} \ No newline at end of file + + function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + async function summarizeVideo() { + const videoId = "{{ video_id }}"; + const btn = document.getElementById('summarizeBtn'); + const box = document.getElementById('summaryBox'); + const text = document.getElementById('summaryText'); + + btn.disabled = true; + btn.innerHTML = ' Generating...'; + + box.style.display = 'block'; + text.innerText = 'Analyzing transcript and extracting key insights...'; + + try { + const response = await fetch(`/api/summarize?v=${videoId}`); + const data = await response.json(); + + if (data.success) { + text.innerText = data.summary; + } else { + text.innerText = data.message || 'Could not generate summary.'; + } + } catch (e) { + text.innerText = 'Network error during summarization.'; + } + btn.disabled = false; + btn.innerHTML = ' Summarize with AI'; + } + + // --- Related Videos Infinite Scroll Functions --- + let currentVideoTitle = ''; + let relatedPage = 1; + let relatedIsLoading = false; + + function setupRelatedObserver() { + const observer = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && !relatedIsLoading && currentVideoTitle) { + loadMoreRelated(); + } + }, { rootMargin: '200px' }); + + const sentinel = document.getElementById('relatedSentinel'); + if (sentinel) observer.observe(sentinel); + } + + async function loadMoreRelated() { + if (relatedIsLoading) return; + relatedIsLoading = true; + + try { + // Show mini loader + const sentinel = document.getElementById('relatedSentinel'); + if (sentinel) sentinel.innerHTML = '
'; + + const response = await fetch(`/api/related?title=${encodeURIComponent(currentVideoTitle)}&page=${relatedPage}`); + const videos = await response.json(); + + if (videos && videos.length > 0) { + renderRelated(videos); + relatedPage++; + if (sentinel) sentinel.innerHTML = ''; // Clear loader + } else { + if (sentinel) sentinel.remove(); // Stop observing if no more data + } + } catch (e) { + console.error("Failed to load related:", e); + } finally { + relatedIsLoading = false; + } + } + + function renderRelated(videos) { + const container = document.getElementById('relatedVideos'); + const sentinel = document.getElementById('relatedSentinel'); // Insert before sentinel + + const fragment = document.createDocumentFragment(); + + videos.forEach(video => { + const card = document.createElement('div'); + card.className = 'yt-video-card-horizontal'; + card.onclick = () => window.location.href = `/watch?v=${video.id}`; + card.innerHTML = ` +
+ + ${video.duration ? `
${video.duration}
` : ''} +
+
+
${escapeHtml(video.title)}
+
${escapeHtml(video.uploader || 'Unknown')}
+
${formatViews(video.views)} • ${formatDate(video.uploaded)}
+
+ `; + fragment.appendChild(card); + }); + + if (sentinel) { + container.insertBefore(fragment, sentinel); + } else { + container.appendChild(fragment); // Fallback + } + } + + {% endblock %} \ No newline at end of file