diff --git a/app.py b/app.py index 6bb6b73..d2a7c64 100644 --- a/app.py +++ b/app.py @@ -59,84 +59,15 @@ def init_db(): # Run init init_db() -# --- Auth Helpers --- -def login_required(f): - @wraps(f) - def decorated_function(*args, **kwargs): - if 'user_id' not in session: - return redirect(url_for('login')) - return f(*args, **kwargs) - return decorated_function - def get_db_connection(): conn = sqlite3.connect(DB_NAME) conn.row_factory = sqlite3.Row return conn -# --- Auth Routes --- -@app.route('/login', methods=['GET', 'POST']) -def login(): - if request.method == 'POST': - username = request.form['username'] - password = request.form['password'] - - conn = get_db_connection() - user = conn.execute('SELECT * FROM users WHERE username = ?', (username,)).fetchone() - conn.close() - - if user and check_password_hash(user['password'], password): - session['user_id'] = user['id'] - session['username'] = user['username'] - return redirect(url_for('index')) # Changed from 'home' to 'index' - else: - flash('Invalid username or password') - - return render_template('login.html') +# --- Auth Helpers Removed --- +# Use client-side storage for all user data -@app.route('/register', methods=['GET', 'POST']) -def register(): - if request.method == 'POST': - username = request.form['username'] - password = request.form['password'] - hashed_pw = generate_password_hash(password) - - try: - conn = get_db_connection() - conn.execute('INSERT INTO users (username, password) VALUES (?, ?)', (username, hashed_pw)) - conn.commit() - conn.close() - flash('Registration successful! Please login.') - return redirect(url_for('login')) - except sqlite3.IntegrityError: - flash('Username already exists') - - return render_template('register.html') - -@app.route('/logout') -@app.route('/api/update_profile', methods=['POST']) -@login_required -def update_profile(): - data = request.json - new_username = data.get('username') - - if not new_username: - return jsonify({'success': False, 'message': 'Username is required'}), 400 - - try: - conn = get_db_connection() - conn.execute('UPDATE users SET username = ? WHERE id = ?', - (new_username, session['user_id'])) - conn.commit() - conn.close() - - session['username'] = new_username - return jsonify({'success': True, 'message': 'Profile updated'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -def logout(): - session.clear() - return redirect(url_for('index')) # Changed from 'home' to 'index' +# --- Auth Routes Removed --- @app.template_filter('format_views') def format_views(views): @@ -188,26 +119,20 @@ VIDEO_DIR = os.environ.get('KVTUBE_VIDEO_DIR', './videos') def index(): return render_template('index.html', page='home') +@app.route('/results') +def results(): + query = request.args.get('search_query', '') + return render_template('index.html', page='results', query=query) + @app.route('/my-videos') def my_videos(): - filter_type = request.args.get('type', 'history') # 'saved' or 'history' - - videos = [] - logged_in = 'user_id' in session - - 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) + # Purely client-side rendering now + return render_template('my_videos.html') @app.route('/api/save_video', methods=['POST']) -@login_required +def save_video(): + # Deprecated endpoint - client-side handled + return jsonify({'success': True, 'message': 'Use local storage'}) def save_video(): data = request.json video_id = data.get('id') @@ -483,11 +408,32 @@ def get_channel_videos(): start = (page - 1) * limit + 1 end = start + limit - 1 + # Resolve channel_id if it's not a proper YouTube ID + resolved_id = channel_id + if not channel_id.startswith('UC') and not channel_id.startswith('@'): + # Try to resolve by searching + 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, timeout=15) + if proc_search.returncode == 0: + first_result = json.loads(proc_search.stdout.splitlines()[0]) + if first_result.get('channel_id'): + resolved_id = first_result.get('channel_id') + except: pass + # 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 + if resolved_id.startswith('UC'): + base_url = f'https://www.youtube.com/channel/{resolved_id}' + elif resolved_id.startswith('@'): + base_url = f'https://www.youtube.com/{resolved_id}' + else: + base_url = f'https://www.youtube.com/channel/{resolved_id}' target_url = base_url if filter_type == 'shorts': @@ -499,23 +445,6 @@ def get_channel_videos(): 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', @@ -545,8 +474,9 @@ def get_channel_videos(): '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 + 'uploader': v.get('uploader') or v.get('channel') or v.get('uploader_id') or '', + 'channel': v.get('channel') or v.get('uploader') or '', + 'channel_id': v.get('channel_id') or resolved_id }) except: continue @@ -554,9 +484,6 @@ def get_channel_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(): @@ -609,7 +536,7 @@ def get_stream_info(): info = ydl.extract_info(url, download=False) except Exception as e: print(f"❌ yt-dlp error for {video_id}: {str(e)}") - return jsonify({'error': 'Stream extraction failed'}), 500 + return jsonify({'error': f'Stream extraction failed: {str(e)}'}), 500 stream_url = info.get('url') if not stream_url: @@ -664,6 +591,8 @@ def get_stream_info(): 'title': info.get('title', 'Unknown Title'), 'description': info.get('description', ''), 'uploader': info.get('uploader', ''), + 'uploader_id': info.get('uploader_id', ''), + 'channel_id': info.get('channel_id', ''), 'upload_date': info.get('upload_date', ''), 'view_count': info.get('view_count', 0), 'related': related_videos, @@ -724,24 +653,24 @@ def search(): duration = f"{hours}:{mins:02d}:{secs:02d}" if hours else f"{mins}:{secs:02d}" else: duration = None - - # Add the exact match first + results.append({ - 'id': data.get('id'), - 'title': data.get('title', 'Unknown'), + 'id': video_id, + 'title': search_title, 'uploader': data.get('uploader') or data.get('channel') or 'Unknown', - 'thumbnail': f"https://i.ytimg.com/vi/{data.get('id')}/hqdefault.jpg", + 'thumbnail': f"https://i.ytimg.com/vi/{video_id}/mqdefault.jpg", 'view_count': data.get('view_count', 0), 'upload_date': data.get('upload_date', ''), 'duration': duration, - 'is_exact_match': True # Flag for frontend highlighting if desired + 'description': data.get('description', ''), + 'is_exact_match': True }) - + # Now fetch related/similar videos using title if search_title: rel_cmd = [ sys.executable, '-m', 'yt_dlp', - f'ytsearch19:{search_title}', # Get 19 more to make ~20 total + f'ytsearch19:{search_title}', '--dump-json', '--default-search', 'ytsearch', '--no-playlist', @@ -754,9 +683,7 @@ def search(): try: r_data = json.loads(line) r_id = r_data.get('id') - # Don't duplicate the exact match if r_id != video_id: - # Helper to format duration (dup code, could be function) r_dur = r_data.get('duration') if r_dur: m, s = divmod(int(r_dur), 60) @@ -776,7 +703,7 @@ def search(): }) except: continue - + return jsonify(results) else: @@ -790,7 +717,6 @@ def search(): '--flat-playlist' ] - # Run command process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) stdout, stderr = process.communicate() @@ -800,7 +726,6 @@ def search(): data = json.loads(line) video_id = data.get('id') if video_id: - # Format duration duration_secs = data.get('duration') if duration_secs: mins, secs = divmod(int(duration_secs), 60) @@ -822,9 +747,52 @@ def search(): continue return jsonify(results) + except Exception as e: + print(f"Search Error: {e}") return jsonify({'error': str(e)}), 500 +@app.route('/api/channel') +def get_channel_videos_simple(): + channel_id = request.args.get('id') + if not channel_id: + return jsonify({'error': 'No channel ID provided'}), 400 + + try: + # Construct Channel URL + if channel_id.startswith('http'): + url = channel_id + elif channel_id.startswith('@'): + url = f"https://www.youtube.com/{channel_id}" + elif len(channel_id) == 24 and channel_id.startswith('UC'): # Standard Channel ID + url = f"https://www.youtube.com/channel/{channel_id}" + else: + url = f"https://www.youtube.com/{channel_id}" + + # Fetch videos (flat playlist to be fast) + cmd = [sys.executable, '-m', 'yt_dlp', '--dump-json', '--flat-playlist', '--playlist-end', '20', url] + proc = subprocess.run(cmd, capture_output=True, text=True) + + if proc.returncode != 0: + return jsonify({'error': 'Failed to fetch channel videos', 'details': proc.stderr}), 500 + + videos = [] + for line in proc.stdout.splitlines(): + try: + v = json.loads(line) + if v.get('id') and v.get('title'): + videos.append(sanitize_video_data(v)) + except json.JSONDecodeError: + continue + + return jsonify(videos) + + except Exception as e: + print(f"Channel Fetch Error: {e}") + return jsonify({'error': str(e)}), 500 + + + # --- Helper: Extractive Summarization --- def extractive_summary(text, num_sentences=5): # 1. Clean and parse text @@ -1025,21 +993,21 @@ def trending(): {'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'} + {'id': 'news', 'title': 'News', 'icon': 'newspaper'}, + {'id': 'gaming', 'title': 'Gaming', 'icon': 'gamepad'}, + {'id': 'sports', 'title': 'Sports', 'icon': 'football-ball'} ] 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) + # Fetch 80 videos per section to guarantee density (target: 50+ after filters) + vids = fetch_videos(q, limit=80, filter_type='video', playlist_start=1) return { 'id': section['id'], 'title': section['title'], 'icon': section['icon'], - 'videos': vids[:20] + 'videos': vids[:60] } with concurrent.futures.ThreadPoolExecutor(max_workers=7) as executor: diff --git a/static/css/style.css b/static/css/style.css index 5b39747..c9e0d0f 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -335,6 +335,8 @@ button { padding: 16px 0; margin: 0; border-radius: 0; + justify-content: center; + text-align: center; } .yt-sidebar.collapsed .yt-sidebar-item span { @@ -409,6 +411,42 @@ button { gap: 16px 16px; } +/* 4-Row Horizontal Grid for Sections */ +.yt-section-grid { + display: grid; + grid-template-rows: repeat(4, min-content); + /* Force 4 rows */ + grid-auto-flow: column; + /* Fill columns first (horizontal scroll) */ + grid-auto-columns: 280px; + /* Fixed column width */ + gap: 16px; + overflow-x: auto; + padding-bottom: 16px; + /* Space for scrollbar if any (hidden typically) */ + scrollbar-width: none; +} + +@media (max-width: 768px) { + .yt-section-grid { + grid-template-rows: 1fr; + /* Single row per container */ + grid-auto-columns: 80%; + /* Peek effect */ + gap: 12px; + } + + /* Adjust video card size for single row */ + .yt-section-grid .yt-video-card { + width: 100%; + /* Fill the column width */ + } +} + +.yt-section-grid::-webkit-scrollbar { + display: none; +} + /* ===== Video Card (YouTube Style) ===== */ .yt-video-card { cursor: pointer; @@ -565,6 +603,16 @@ button { background: var(--yt-bg-hover); } +.yt-action-btn.active { + background: var(--yt-text-primary); + color: var(--yt-bg-primary); +} + +.yt-subscribe-btn.subscribed { + background: var(--yt-bg-secondary); + color: var(--yt-text-primary); +} + .yt-channel-info { display: flex; align-items: center; @@ -836,6 +884,15 @@ button { justify-content: center; } + .yt-header-start, + .yt-header-end { + min-width: auto; + } + + .yt-logo span:last-child { + display: none; + } + .yt-search-input { padding: 0 12px; font-size: 14px; diff --git a/static/js/main.js b/static/js/main.js index 218e402..59c3fae 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -4,26 +4,40 @@ document.addEventListener('DOMContentLoaded', () => { const searchInput = document.getElementById('searchInput'); const resultsArea = document.getElementById('resultsArea'); + // Check APP_CONFIG if available (set in index.html) + const socketConfig = window.APP_CONFIG || {}; + const pageType = socketConfig.page || 'home'; + if (searchInput) { searchInput.addEventListener('keypress', async (e) => { if (e.key === 'Enter') { e.preventDefault(); const query = searchInput.value.trim(); if (query) { - // Check if on search page already, if not redirect - // Since we are SPA-ish, we just call searchYouTube - // But if we want a dedicated search page URL, we could do: - // window.history.pushState({}, '', `/?q=${encodeURIComponent(query)}`); - searchYouTube(query); + window.location.href = `/results?search_query=${encodeURIComponent(query)}`; } } }); - // Load trending on init - loadTrending(); + // Handle Page Initialization - only if resultsArea exists (not on channel.html) + if (resultsArea) { + if (pageType === 'channel' && socketConfig.channelId) { + console.log("Loading Channel:", socketConfig.channelId); + loadChannelVideos(socketConfig.channelId); + } else if (pageType === 'results' || socketConfig.query) { + const q = socketConfig.query || new URLSearchParams(window.location.search).get('search_query'); + if (q) { + if (searchInput) searchInput.value = q; + searchYouTube(q); + } + } else { + // Default Home + loadTrending(); + } - // Init Infinite Scroll - initInfiniteScroll(); + // Init Infinite Scroll + initInfiniteScroll(); + } } // Init Theme @@ -71,11 +85,16 @@ function initInfiniteScroll() { // Create sentinel logic or observe existing footer/element // We'll observe a sentinel element at the bottom of the grid + // Create sentinel logic or observe existing footer/element + // We'll observe a sentinel element at the bottom of the grid + const resultsArea = document.getElementById('resultsArea'); + if (!resultsArea) return; // Exit if not on home page + const sentinel = document.createElement('div'); sentinel.id = 'scroll-sentinel'; sentinel.style.width = '100%'; sentinel.style.height = '20px'; - document.getElementById('resultsArea').parentNode.appendChild(sentinel); + resultsArea.parentNode.appendChild(sentinel); observer.observe(sentinel); } @@ -234,6 +253,9 @@ async function loadTrending(reset = true) { if (reset) resultsArea.innerHTML = ''; // Render Sections + // Render Sections + const isMobile = window.innerWidth <= 768; + data.data.forEach(section => { const sectionDiv = document.createElement('div'); sectionDiv.style.gridColumn = '1 / -1'; @@ -246,38 +268,54 @@ async function loadTrending(reset = true) { `; - // Scroll Container - const scrollContainer = document.createElement('div'); - scrollContainer.className = 'yt-shorts-grid'; // Reuse horizontal scroll style - scrollContainer.style.gap = '16px'; + const videos = section.videos || []; + let chunks = []; - 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'; + if (isMobile) { + // Split into 4 chunks (rows) for independent scrolling + // Each chunk gets ~1/4 of videos, or at least some + const chunkSize = Math.ceil(videos.length / 4); + for (let i = 0; i < 4; i++) { + const chunk = videos.slice(i * chunkSize, (i + 1) * chunkSize); + if (chunk.length > 0) chunks.push(chunk); + } + } else { + // Desktop: 1 big chunk (grid handles layout) + chunks.push(videos); + } - card.innerHTML = ` -
- ${escapeHtml(video.title)} - ${video.duration ? `${video.duration}` : ''} -
-
-
- ${video.uploader ? video.uploader.charAt(0).toUpperCase() : 'Y'} + chunks.forEach(chunk => { + // Scroll Container + const scrollContainer = document.createElement('div'); + scrollContainer.className = 'yt-section-grid'; + + chunk.forEach(video => { + const card = document.createElement('div'); + 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

+
+
+ ${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); + `; + card.onclick = () => window.location.href = `/watch?v=${video.id}`; + scrollContainer.appendChild(card); + }); + + sectionDiv.appendChild(scrollContainer); }); - sectionDiv.appendChild(scrollContainer); resultsArea.appendChild(sectionDiv); }); if (window.observeImages) window.observeImages(); @@ -536,3 +574,130 @@ async function updateProfile(e) { btn.innerHTML = originalText; } } + +// --- Local Storage Helpers --- +function getLibrary(type) { + return JSON.parse(localStorage.getItem(`kv_${type}`) || '[]'); +} + +function saveToLibrary(type, item) { + let lib = getLibrary(type); + // Filter out nulls/invalid items to self-heal storage + lib = lib.filter(i => i && i.id); + + // Avoid duplicates + if (!lib.some(i => i.id === item.id)) { + lib.unshift(item); // Add to top + localStorage.setItem(`kv_${type}`, JSON.stringify(lib)); + showToast(`Saved to ${type}`, 'success'); + } +} + +function removeFromLibrary(type, id) { + let lib = getLibrary(type); + lib = lib.filter(i => i && i.id !== id); + localStorage.setItem(`kv_${type}`, JSON.stringify(lib)); + showToast(`Removed from ${type}`, 'info'); + // Refresh if on library page + if (window.location.pathname === '/my-videos') { + location.reload(); + } +} + +function isInLibrary(type, id) { + const lib = getLibrary(type); + return lib.some(i => i && i.id === id); +} + +// --- Subscription Logic --- +function toggleSubscribe(channelId, channelName, avatarUrl, btnElement) { + event.stopPropagation(); // Prevent card clicks + + if (isInLibrary('subscriptions', channelId)) { + removeFromLibrary('subscriptions', channelId); + if (btnElement) { + btnElement.classList.remove('subscribed'); + btnElement.innerHTML = 'Subscribe'; + } + } else { + saveToLibrary('subscriptions', { + id: channelId, + title: channelName, + thumbnail: avatarUrl, + timestamp: new Date().toISOString() + }); + if (btnElement) { + btnElement.classList.add('subscribed'); + btnElement.innerHTML = 'Subscribed'; + } + } +} + +function checkSubscriptionStatus(channelId, btnElement) { + if (isInLibrary('subscriptions', channelId)) { + btnElement.classList.add('subscribed'); + btnElement.innerHTML = 'Subscribed'; + } +} + +// Load Channel Videos +async function loadChannelVideos(channelId) { + const resultsArea = document.getElementById('resultsArea'); + if (!resultsArea) return; // Guard: only works on pages with resultsArea + + isLoading = true; + resultsArea.innerHTML = renderSkeleton(); + + try { + const response = await fetch(`/api/channel?id=${encodeURIComponent(channelId)}`); + const data = await response.json(); + + if (data.error) { + resultsArea.innerHTML = renderNoContent(`Error: ${data.error}`, "Could not load channel."); + return; + } + + // Render header + const headerHtml = ` +
+
+ ${channelId.startsWith('UC') ? channelId[0] : (data[0]?.uploader?.[0] || 'C')} +
+
+

${data[0]?.uploader || 'Channel Content'}

+

${data.length} Videos

+
+
+
+ `; + + // Videos + const videosHtml = data.map(video => ` +
+
+ ${escapeHtml(video.title)} + ${video.duration ? `${video.duration}` : ''} +
+
+
+

${escapeHtml(video.title)}

+
+ ${formatViews(video.views)} views + • ${video.uploaded} +
+
+
+
+ `).join(''); + + resultsArea.innerHTML = headerHtml + videosHtml + '
'; + + if (window.observeImages) window.observeImages(); + + } catch (e) { + console.error("Channel Load Error:", e); + resultsArea.innerHTML = renderNoContent("Failed to load channel", "Please try again later."); + } finally { + isLoading = false; + } +} diff --git a/templates/channel.html b/templates/channel.html index 805d312..1677564 100644 --- a/templates/channel.html +++ b/templates/channel.html @@ -6,24 +6,26 @@
-
+
{% if channel.avatar %} {% else %} - {{ channel.title[0] | upper }} + {{ channel.title[0] | upper if channel.title and channel.title != + 'Loading...' else 'C' }} {% endif %}
-

{{ channel.title }}

-

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

{{ channel.title if channel.title and channel.title != 'Loading...' else + 'Loading...' }}

+

+ {% if channel.title and channel.title != 'Loading...' %}@{{ channel.title|replace(' ', '') }}{% else + %}@Loading...{% endif %}

- {{ channel.subscribers if channel.subscribers else 'Subscribe for more' }} + Subscribe for more
- +
@@ -243,6 +245,8 @@ {% endblock %} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index d5b9504..2b62ead 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,19 +1,26 @@ {% extends "layout.html" %} {% block content %} +
- - + +
diff --git a/templates/layout.html b/templates/layout.html index a96f761..4c8fa56 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -67,16 +67,7 @@ - {% if session.get('user_id') %} -
- {{ session.username[0]|upper }} -
- {% else %} - - {% endif %} +
@@ -108,9 +99,16 @@ History + class="yt-sidebar-item {% if request.path == '/my-videos' and request.args.get('type') == 'saved' %}active{% endif %}" + data-category="saved"> - Library + Saved + + + + Subscriptions @@ -132,16 +130,16 @@ Music - - - Gaming - News + + + Gaming + @@ -154,12 +152,6 @@ Settings - {% if session.get('user_id') %} - - - Sign out - - {% endif %} diff --git a/templates/my_videos.html b/templates/my_videos.html index 0be4f8c..65bc77b 100644 --- a/templates/my_videos.html +++ b/templates/my_videos.html @@ -3,40 +3,35 @@ {% block content %}
-

My Library

+ style="margin-bottom: 3rem; display: flex; flex-direction: column; align-items: center; gap: 1.5rem;"> +

My Library

- Saved - History + style="display: flex; gap: 0.5rem; background: var(--yt-bg-secondary); padding: 0.4rem; border-radius: 100px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); align-items: center;"> + History + Saved + Subscriptions
+ + +
+
- {% if videos %} - {% for video in videos %} -
-
- -
-
-
-

{{ video.title }}

-

{{ video.timestamp }}

-
-
-
- {% endfor %} - {% endif %} +
-
+ + {% endblock %} \ No newline at end of file diff --git a/templates/watch.html b/templates/watch.html index 48922e8..732988f 100644 --- a/templates/watch.html +++ b/templates/watch.html @@ -13,6 +13,8 @@
+ +
@@ -86,7 +88,7 @@

0 views

- +
@@ -192,18 +194,44 @@ overflow: hidden; } - #loading { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: var(--yt-bg-secondary); - z-index: 10; - /* Skeleton shimmer */ - 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; + + + /* Movable Mini Player Styles */ + .yt-mini-mode { + position: fixed; + bottom: 20px; + right: 20px; + width: 400px !important; + height: auto !important; + aspect-ratio: 16/9; + z-index: 10000; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5); + border-radius: 12px; + cursor: grab; + transition: width 0.3s, height 0.3s; + /* Smooth resize, but NOT top/left so drag is instant */ + } + + .yt-mini-mode:active { + cursor: grabbing; + } + + /* Placeholder to prevent layout shift */ + .yt-player-placeholder { + display: none; + width: 100%; + aspect-ratio: 16/9; + background: rgba(0, 0, 0, 0.1); + /* Optional visual cue, usually transparent */ + } + + @media (max-width: 768px) { + .yt-mini-mode { + width: 250px !important; + bottom: 80px; + /* Above bottom nav if existed, or just higher */ + right: 10px; + } } /* Skeleton Utils */ @@ -674,10 +702,36 @@