feat: Channel page, subscription management, remove buttons, UI polish

This commit is contained in:
KV-Tube Deployer 2025-12-17 23:28:45 +07:00
parent 4472f6852a
commit a988bdaac3
8 changed files with 993 additions and 316 deletions

230
app.py
View file

@ -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']
# --- Auth Helpers Removed ---
# Use client-side storage for all user data
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')
@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':
@ -500,23 +446,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',
target_url,
@ -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
@ -555,9 +485,6 @@ def get_channel_videos():
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')
@ -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,
@ -725,23 +654,23 @@ def search():
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)
@ -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:

View file

@ -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;

View file

@ -4,27 +4,41 @@ 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
// 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 Theme
initTheme();
@ -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,20 +268,34 @@ async function loadTrending(reset = true) {
</div>
`;
const videos = section.videos || [];
let chunks = [];
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);
}
chunks.forEach(chunk => {
// Scroll Container
const scrollContainer = document.createElement('div');
scrollContainer.className = 'yt-shorts-grid'; // Reuse horizontal scroll style
scrollContainer.style.gap = '16px';
scrollContainer.className = 'yt-section-grid';
section.videos.forEach(video => {
chunk.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" data-src="${video.thumbnail}" alt="${escapeHtml(video.title)}">
<img class="yt-thumbnail" src="${video.thumbnail}" loading="lazy" onload="this.classList.add('loaded')" alt="${escapeHtml(video.title)}">
${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''}
</div>
<div class="yt-video-details">
@ -278,6 +314,8 @@ async function loadTrending(reset = true) {
});
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 = `
<div class="yt-channel-header" style="padding: 24px 0; border-bottom: 1px solid var(--yt-border); margin-bottom: 24px; display: flex; align-items: center; gap: 20px;">
<div class="yt-channel-avatar-xl" style="width: 80px; height: 80px; border-radius: 50%; background: var(--yt-accent-blue); display: flex; align-items: center; justify-content: center; font-size: 32px; color: white; font-weight: bold;">
${channelId.startsWith('UC') ? channelId[0] : (data[0]?.uploader?.[0] || 'C')}
</div>
<div>
<h1 style="font-size: 24px; margin: 0 0 8px 0;">${data[0]?.uploader || 'Channel Content'}</h1>
<p style="color: var(--yt-text-secondary); margin: 0;">${data.length} Videos</p>
</div>
</div>
<div class="yt-video-grid">
`;
// Videos
const videosHtml = data.map(video => `
<div class="yt-video-card" onclick="window.location.href='/watch?v=${video.id}'">
<div class="yt-thumbnail-container">
<img class="yt-thumbnail" src="${video.thumbnail}" loading="lazy" onload="this.classList.add('loaded')" alt="${escapeHtml(video.title)}">
${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>
<div class="yt-video-info">
<span>${formatViews(video.views)} views</span>
<span> ${video.uploaded}</span>
</div>
</div>
</div>
</div>
`).join('');
resultsArea.innerHTML = headerHtml + videosHtml + '</div>';
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;
}
}

View file

@ -6,24 +6,26 @@
<!-- Channel Header (No Banner) -->
<div class="yt-channel-header">
<div class="yt-channel-info-row">
<div class="yt-channel-avatar-xl">
<div class="yt-channel-avatar-xl" id="channelAvatarLarge">
{% if channel.avatar %}
<img src="{{ channel.avatar }}">
{% else %}
{{ channel.title[0] | upper }}
<span id="channelAvatarLetter">{{ channel.title[0] | upper if channel.title and channel.title !=
'Loading...' else 'C' }}</span>
{% 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 %}
<h1 id="channelTitle">{{ channel.title if channel.title and channel.title != 'Loading...' else
'Loading...' }}</h1>
<p class="yt-channel-handle" id="channelHandle">
{% if channel.title and channel.title != 'Loading...' %}@{{ channel.title|replace(' ', '') }}{% else
%}@Loading...{% endif %}
</p>
<div class="yt-channel-stats">
<span>{{ channel.subscribers if channel.subscribers else 'Subscribe for more' }}</span>
<span id="channelStats">Subscribe for more</span>
</div>
<div class="yt-channel-actions">
<button class="yt-subscribe-btn-lg">Subscribe</button>
<button class="yt-subscribe-btn-lg" id="subscribeChannelBtn">Subscribe</button>
</div>
</div>
</div>
@ -243,6 +245,8 @@
</style>
<script>
console.log("Channel.html script loaded, channelId will be:", "{{ channel.id }}");
let currentChannelSort = 'latest';
let currentChannelPage = 1;
let isChannelLoading = false;
@ -251,7 +255,13 @@
const channelId = "{{ channel.id }}";
document.addEventListener('DOMContentLoaded', () => {
loadChannelVideos();
console.log("DOMContentLoaded fired, calling fetchChannelContent...");
console.log("typeof fetchChannelContent:", typeof fetchChannelContent);
if (typeof fetchChannelContent === 'function') {
fetchChannelContent();
} else {
console.error("fetchChannelContent is NOT a function!");
}
setupInfiniteScroll();
});
@ -274,7 +284,7 @@
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(320px, 1fr))';
}
loadChannelVideos();
fetchChannelContent();
}
function changeChannelSort(sort, btn) {
@ -288,40 +298,72 @@
document.querySelectorAll('.yt-sort-options a').forEach(a => a.classList.remove('active'));
btn.classList.add('active');
loadChannelVideos();
fetchChannelContent();
}
async function loadChannelVideos() {
if (isChannelLoading || !hasMoreChannelVideos) return;
async function fetchChannelContent() {
console.log("fetchChannelContent() called");
if (isChannelLoading || !hasMoreChannelVideos) {
console.log("Early return:", { 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
// Append Loading indicator
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>');
grid.insertAdjacentHTML('beforeend', '<div class="loading-text" style="color:var(--yt-text-secondary); padding: 20px;">Loading videos...</div>');
}
try {
console.log(`Fetching: /api/channel/videos?id=${channelId}&page=${currentChannelPage}`);
const response = await fetch(`/api/channel/videos?id=${channelId}&page=${currentChannelPage}&sort=${currentChannelSort}&filter_type=${currentFilterType}`);
const videos = await response.json();
console.log("Channel Videos Response:", videos);
// 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) {
// Check if response is an error
if (videos.error) {
hasMoreChannelVideos = false;
grid.innerHTML = `<p style="padding:20px; color:var(--yt-text-secondary);">Error: ${videos.error}</p>`;
return;
}
if (!Array.isArray(videos) || videos.length === 0) {
hasMoreChannelVideos = false;
if (currentChannelPage === 1) grid.innerHTML = '<p style="padding:20px; color:var(--yt-text-secondary);">No videos found.</p>';
} else {
// Update channel header with uploader info from first video (on first page only)
if (currentChannelPage === 1 && videos[0]) {
// Try multiple sources for channel name
let channelName = videos[0].uploader || videos[0].channel || '';
// If still empty, try to get from video title (sometimes includes " - ChannelName")
if (!channelName && videos[0].title) {
const parts = videos[0].title.split(' - ');
if (parts.length > 1) channelName = parts[parts.length - 1];
}
// Final fallback: use channel ID
if (!channelName) channelName = channelId;
document.getElementById('channelTitle').textContent = channelName;
document.getElementById('channelHandle').textContent = '@' + channelName.replace(/\s+/g, '');
const avatarLetter = document.getElementById('channelAvatarLetter');
if (avatarLetter) avatarLetter.textContent = channelName.charAt(0).toUpperCase();
// Update browser URL to show friendly name
const friendlyUrl = `/channel/@${encodeURIComponent(channelName.replace(/\s+/g, ''))}`;
window.history.replaceState({ channelId: channelId }, '', friendlyUrl);
}
videos.forEach(video => {
const card = document.createElement('div');
@ -331,7 +373,7 @@
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">
<img src="${video.thumbnail}" class="yt-short-thumb loaded" loading="lazy" onload="this.classList.add('loaded')">
</div>
<div class="yt-details" style="padding: 8px;">
<h3 class="yt-video-title" style="font-size: 14px; margin-bottom: 4px;">${escapeHtml(video.title)}</h3>
@ -344,7 +386,7 @@
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">
<img class="yt-thumbnail loaded" src="${video.thumbnail}" loading="lazy" onload="this.classList.add('loaded')">
${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''}
</div>
<div class="yt-video-details">
@ -373,14 +415,47 @@
const trigger = document.getElementById('channelLoadingTrigger');
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadChannelVideos();
fetchChannelContent();
}
}, { 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.
// Helpers - Define locally to ensure availability
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatViews(views) {
if (!views) return '0';
const num = parseInt(views);
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(0) + 'K';
return num.toLocaleString();
}
function formatDate(dateStr) {
if (!dateStr) return 'Recently';
try {
// Format: YYYYMMDD
const year = dateStr.substring(0, 4);
const month = dateStr.substring(4, 6);
const day = dateStr.substring(6, 8);
const date = new Date(year, month - 1, day);
const now = new Date();
const diff = now - date;
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days < 1) return 'Today';
if (days < 7) return `${days} days ago`;
if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
if (days < 365) return `${Math.floor(days / 30)} months ago`;
return `${Math.floor(days / 365)} years ago`;
} catch (e) {
return 'Recently';
}
}
</script>
{% endblock %}

View file

@ -1,19 +1,26 @@
{% extends "layout.html" %}
{% block content %}
<script>
window.APP_CONFIG = {
page: '{{ page|default("home") }}',
channelId: '{{ channel_id|default("") }}',
query: '{{ query|default("") }}'
};
</script>
<!-- Filters & Categories -->
<div class="yt-filter-bar">
<div class="yt-categories" id="categoryList">
<!-- "All" removed, starting with Tech -->
<button class="yt-chip" onclick="switchCategory('tech')">Tech</button>
<button class="yt-chip" onclick="switchCategory('music')">Music</button>
<button class="yt-chip" onclick="switchCategory('gaming')">Gaming</button>
<button class="yt-chip" onclick="switchCategory('movies')">Movies</button>
<button class="yt-chip" onclick="switchCategory('news')">News</button>
<button class="yt-chip" onclick="switchCategory('trending')">Trending</button>
<button class="yt-chip" onclick="switchCategory('sports')">Sports</button>
<button class="yt-chip" onclick="switchCategory('podcasts')">Podcasts</button>
<button class="yt-chip" onclick="switchCategory('live')">Live</button>
<button class="yt-chip" onclick="switchCategory('gaming')">Gaming</button>
<button class="yt-chip" onclick="switchCategory('sports')">Sports</button>
</div>
<div class="yt-filter-actions">

View file

@ -67,16 +67,7 @@
<!-- <button class="yt-icon-btn yt-mobile-search" onclick="toggleMobileSearch()" aria-label="Search">
<i class="fas fa-search"></i>
</button> -->
{% if session.get('user_id') %}
<div class="yt-avatar" title="{{ session.username }}">
{{ session.username[0]|upper }}
</div>
{% else %}
<a href="/login" class="yt-signin-btn">
<i class="fas fa-user"></i>
<span class="yt-signin-text">Sign in</span>
</a>
{% endif %}
<!-- User Avatar Removed -->
</div>
</header>
@ -108,9 +99,16 @@
<span>History</span>
</a>
<a href="/my-videos?type=saved"
class="yt-sidebar-item {% if request.path == '/my-videos' %}active{% endif %}" data-category="saved">
class="yt-sidebar-item {% if request.path == '/my-videos' and request.args.get('type') == 'saved' %}active{% endif %}"
data-category="saved">
<i class="fas fa-bookmark"></i>
<span>Library</span>
<span>Saved</span>
</a>
<a href="/my-videos?type=subscriptions"
class="yt-sidebar-item {% if request.path == '/my-videos' and request.args.get('type') == 'subscriptions' %}active{% endif %}"
data-category="subscriptions">
<i class="fas fa-play-circle"></i>
<span>Subscriptions</span>
</a>
<!-- Queue Removed -->
@ -132,16 +130,16 @@
<i class="fas fa-music"></i>
<span>Music</span>
</a>
<a href="javascript:void(0)" class="yt-sidebar-item" data-category="gaming"
onclick="navigateCategory('gaming')">
<i class="fas fa-gamepad"></i>
<span>Gaming</span>
</a>
<a href="javascript:void(0)" class="yt-sidebar-item" data-category="news"
onclick="navigateCategory('news')">
<i class="fas fa-newspaper"></i>
<span>News</span>
</a>
<a href="javascript:void(0)" class="yt-sidebar-item" data-category="gaming"
onclick="navigateCategory('gaming')">
<i class="fas fa-gamepad"></i>
<span>Gaming</span>
</a>
<a href="javascript:void(0)" class="yt-sidebar-item" data-category="sports"
onclick="navigateCategory('sports')">
<i class="fas fa-football-ball"></i>
@ -154,12 +152,6 @@
<i class="fas fa-cog"></i>
<span>Settings</span>
</a>
{% if session.get('user_id') %}
<a href="/logout" class="yt-sidebar-item">
<i class="fas fa-sign-out-alt"></i>
<span>Sign out</span>
</a>
{% endif %}
</aside>
<!-- Sidebar Overlay (Mobile) -->

View file

@ -3,40 +3,35 @@
{% block content %}
<div class="yt-container" style="padding-top: 20px;">
<div class="library-header"
style="margin-bottom: 2rem; display: flex; align-items: center; justify-content: space-between;">
<h1 style="font-size: 1.5rem;">My Library</h1>
style="margin-bottom: 3rem; display: flex; flex-direction: column; align-items: center; gap: 1.5rem;">
<h1 style="font-size: 2rem; font-weight: 700;">My Library</h1>
<div class="tabs"
style="display: flex; gap: 0.5rem; background: var(--yt-bg-hover); padding: 0.3rem; border-radius: 100px;">
<a href="/my-videos?type=saved" class="yt-btn {% if filter_type == 'saved' %}yt-btn-active{% endif %}"
style="border-radius: 100px; font-size: 0.9rem; padding: 0.5rem 1.5rem; {% if filter_type == 'saved' %}background:var(--yt-text-primary);color:var(--yt-bg-primary);{% endif %}">Saved</a>
<a href="/my-videos?type=history" class="yt-btn {% if filter_type == 'history' %}yt-btn-active{% endif %}"
style="border-radius: 100px; font-size: 0.9rem; padding: 0.5rem 1.5rem; {% if filter_type == 'history' %}background:var(--yt-text-primary);color:var(--yt-bg-primary);{% endif %}">History</a>
</div>
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;">
<a href="/my-videos?type=history" class="yt-btn" id="tab-history"
style="border-radius: 100px; font-size: 0.95rem; padding: 0.6rem 2rem; font-weight:500; transition: all 0.2s;">History</a>
<a href="/my-videos?type=saved" class="yt-btn" id="tab-saved"
style="border-radius: 100px; font-size: 0.95rem; padding: 0.6rem 2rem; font-weight:500; transition: all 0.2s;">Saved</a>
<a href="/my-videos?type=subscriptions" class="yt-btn" id="tab-subscriptions"
style="border-radius: 100px; font-size: 0.95rem; padding: 0.6rem 2rem; font-weight:500; transition: all 0.2s;">Subscriptions</a>
</div>
<!-- Clear Button (Hidden by default) -->
<button id="clearBtn" onclick="clearLibrary()" class="yt-btn"
style="display:none; color: var(--yt-text-secondary); background: transparent; border: 1px solid var(--yt-border); margin-top: 10px; font-size: 0.9rem;">
<i class="fas fa-trash-alt"></i> Clear <span id="clearType">All</span>
</button>
</div>
<!-- Video Grid -->
<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 %}
<!-- JS will populate this -->
</div>
<div id="emptyState"
style="text-align: center; padding: 4rem; color: var(--yt-text-secondary); display: {% if videos %}none{% else %}none{% endif %};">
<!-- Empty State -->
<div id="emptyState" style="text-align: center; padding: 4rem; color: var(--yt-text-secondary); display: none;">
<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>
<p id="emptyMsg">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>
@ -44,49 +39,195 @@
</div>
<script>
const loggedIn = {{ 'true' if logged_in else 'false' }};
const filterType = '{{ filter_type }}';
document.addEventListener('DOMContentLoaded', () => {
const urlParams = new URLSearchParams(window.location.search);
// Default to history if no type or invalid type
const type = urlParams.get('type') || 'history';
// Update Active Tab UI
const activeTab = document.getElementById(`tab-${type}`);
if (activeTab) {
activeTab.style.background = 'var(--yt-text-primary)';
activeTab.style.color = 'var(--yt-bg-primary)';
}
if (!loggedIn && filterType === 'history') {
// Load from Local Storage
const history = JSON.parse(localStorage.getItem('kv_history') || '[]');
const grid = document.getElementById('libraryGrid');
const empty = document.getElementById('emptyState');
const emptyMsg = document.getElementById('emptyMsg');
if (history.length > 0) {
grid.innerHTML = history.map(video => `
<div class="yt-video-card" onclick="window.location.href='/watch?v=${video.id}'">
// Mapping URL type to localStorage key suffix
// saved -> kv_saved
// history -> kv_history
// subscriptions -> kv_subscriptions
const storageKey = `kv_${type}`;
const data = JSON.parse(localStorage.getItem(storageKey) || '[]').filter(i => i && i.id);
// Show Clear Button if there is data
if (data.length > 0) {
empty.style.display = 'none';
const clearBtn = document.getElementById('clearBtn');
const clearTypeSpan = document.getElementById('clearType');
if (clearBtn) {
clearBtn.style.display = 'inline-flex';
clearBtn.style.alignItems = 'center';
clearBtn.style.gap = '8px';
// Format type name for display
const typeName = type.charAt(0).toUpperCase() + type.slice(1);
clearTypeSpan.innerText = typeName;
}
if (type === 'subscriptions') {
// Render Channel Cards with improved design
grid.style.display = 'grid';
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(200px, 1fr))';
grid.style.gap = '24px';
grid.style.padding = '20px 0';
grid.innerHTML = data.map(channel => {
const avatarHtml = channel.thumbnail
? `<img src="${channel.thumbnail}" style="width:120px; height:120px; border-radius:50%; object-fit:cover; border: 3px solid var(--yt-border); transition: transform 0.3s, border-color 0.3s;">`
: `<div style="width:120px; height:120px; border-radius:50%; background: linear-gradient(135deg, #FF6B6B 0%, #d62d2d 100%); display:flex; align-items:center; justify-content:center; font-size:48px; font-weight:bold; color:white; border: 3px solid var(--yt-border); transition: transform 0.3s;">${channel.letter || channel.title.charAt(0).toUpperCase()}</div>`;
return `
<div class="subscription-card" onclick="window.location.href='/channel/${channel.id}'"
style="text-align:center; cursor:pointer; padding: 24px 16px; background: var(--yt-bg-secondary); border-radius: 16px; transition: all 0.3s; border: 1px solid transparent;"
onmouseenter="this.style.transform='translateY(-4px)'; this.style.boxShadow='0 8px 24px rgba(0,0,0,0.3)'; this.style.borderColor='var(--yt-border)';"
onmouseleave="this.style.transform='none'; this.style.boxShadow='none'; this.style.borderColor='transparent';">
<div style="display:flex; justify-content:center; margin-bottom:16px;">
${avatarHtml}
</div>
<h3 style="font-size:1.1rem; margin-bottom:8px; color: var(--yt-text-primary); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${channel.title}</h3>
<p style="font-size: 0.85rem; color: var(--yt-text-secondary); margin-bottom: 12px;">@${channel.title.replace(/\s+/g, '')}</p>
<button onclick="event.stopPropagation(); toggleSubscribe('${channel.id}', '${channel.title.replace(/'/g, "\\'")}', '${channel.thumbnail || ''}', this)"
style="padding:10px 20px; font-size:13px; background: linear-gradient(135deg, #cc0000, #ff4444); color: white; border: none; border-radius: 24px; cursor: pointer; font-weight: 600; transition: all 0.2s; box-shadow: 0 2px 8px rgba(204,0,0,0.3);"
onmouseenter="this.style.transform='scale(1.05)'; this.style.boxShadow='0 4px 12px rgba(204,0,0,0.5)';"
onmouseleave="this.style.transform='scale(1)'; this.style.boxShadow='0 2px 8px rgba(204,0,0,0.3)';">
<i class="fas fa-user-minus"></i> Unsubscribe
</button>
</div>
`}).join('');
} else {
// Render Video Cards (History/Saved)
grid.innerHTML = data.map(video => {
// Robust fallback chain: maxres -> hq -> mq
const thumb = video.thumbnail || `https://i.ytimg.com/vi/${video.id}/maxresdefault.jpg`;
const showRemove = type === 'saved' || type === 'history';
return `
<div class="yt-video-card" style="position: relative;">
<div onclick="window.location.href='/watch?v=${video.id}'" style="cursor: pointer;">
<div class="yt-thumbnail-container">
<img src="${video.thumbnail}" class="yt-thumbnail" loading="lazy">
<img src="${thumb}" class="yt-thumbnail" loading="lazy" referrerpolicy="no-referrer"
onload="this.classList.add('loaded')"
onerror="
if (this.src.includes('maxresdefault')) this.src='https://i.ytimg.com/vi/${video.id}/hqdefault.jpg';
else if (this.src.includes('hqdefault')) this.src='https://i.ytimg.com/vi/${video.id}/mqdefault.jpg';
else this.style.display='none';
">
<div class="yt-duration">${video.duration || ''}</div>
</div>
<div class="yt-video-details">
<div class="yt-video-meta">
<h3 class="yt-video-title">${video.title}</h3>
<p class="yt-video-stats">Watched on ${new Date(video.timestamp).toLocaleDateString()}</p>
<p class="yt-video-stats">${video.uploader}</p>
</div>
</div>
</div>
`).join('');
empty.style.display = 'none';
${showRemove ? `
<button onclick="event.stopPropagation(); removeVideo('${video.id}', '${type}', this)"
style="position: absolute; top: 8px; right: 8px; width: 28px; height: 28px; background: rgba(0,0,0,0.7); color: white; border: none; border-radius: 50%; cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; opacity: 0.8; transition: all 0.2s; z-index: 10;"
onmouseenter="this.style.opacity='1'; this.style.background='#cc0000';"
onmouseleave="this.style.opacity='0.8'; this.style.background='rgba(0,0,0,0.7)';"
title="Remove">
<i class="fas fa-times"></i>
</button>` : ''}
</div>
`}).join('');
}
} else {
grid.innerHTML = '';
empty.style.display = 'block';
if (type === 'subscriptions') {
emptyMsg.innerText = "You haven't subscribed to any channels yet.";
} else if (type === 'saved') {
emptyMsg.innerText = "No saved videos yet.";
}
} 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';
}
});
function clearLibrary() {
const urlParams = new URLSearchParams(window.location.search);
const type = urlParams.get('type') || 'history';
const typeName = type.charAt(0).toUpperCase() + type.slice(1);
if (confirm(`Are you sure you want to clear your ${typeName}? This cannot be undone.`)) {
const storageKey = `kv_${type}`;
localStorage.removeItem(storageKey);
// Reload to reflect changes
window.location.reload();
}
}
// Local toggleSubscribe for my_videos page - removes card visually
function toggleSubscribe(channelId, channelName, avatar, btnElement) {
event.stopPropagation();
// Remove from library
const key = 'kv_subscriptions';
let data = JSON.parse(localStorage.getItem(key) || '[]');
data = data.filter(item => item.id !== channelId);
localStorage.setItem(key, JSON.stringify(data));
// Remove the card from UI
const card = btnElement.closest('.yt-channel-card');
if (card) {
card.style.transition = 'opacity 0.3s, transform 0.3s';
card.style.opacity = '0';
card.style.transform = 'scale(0.8)';
setTimeout(() => card.remove(), 300);
}
// Show empty state if no more subscriptions
setTimeout(() => {
const grid = document.getElementById('libraryGrid');
if (grid && grid.children.length === 0) {
grid.innerHTML = '';
document.getElementById('emptyState').style.display = 'block';
document.getElementById('emptyMessage').innerText = "You haven't subscribed to any channels yet.";
}
}, 350);
}
// Remove individual video from saved/history
function removeVideo(videoId, type, btnElement) {
event.stopPropagation();
const key = `kv_${type}`;
let data = JSON.parse(localStorage.getItem(key) || '[]');
data = data.filter(item => item.id !== videoId);
localStorage.setItem(key, JSON.stringify(data));
// Remove the card from UI with animation
const card = btnElement.closest('.yt-video-card');
if (card) {
card.style.transition = 'opacity 0.3s, transform 0.3s';
card.style.opacity = '0';
card.style.transform = 'scale(0.9)';
setTimeout(() => card.remove(), 300);
}
// Show empty state if no more videos
setTimeout(() => {
const grid = document.getElementById('libraryGrid');
if (grid && grid.children.length === 0) {
grid.innerHTML = '';
document.getElementById('emptyState').style.display = 'block';
const typeName = type === 'saved' ? 'No saved videos yet.' : 'No history yet.';
document.getElementById('emptyMessage').innerText = typeName;
}
}, 350);
}
</script>
{% endblock %}

View file

@ -13,6 +13,8 @@
<!-- Loading State (Confined to Player) -->
<div id="loading" class="yt-loader"></div>
</div>
<!-- Placeholder for Mini Mode -->
<div id="playerPlaceholder" class="yt-player-placeholder"></div>
<!-- Info Skeleton -->
<div id="infoSkeleton" style="margin-top:20px;">
@ -86,7 +88,7 @@
<p class="yt-video-stats" id="viewCount">0 views</p>
</div>
</div>
<button class="yt-subscribe-btn">Subscribe</button>
<button class="yt-subscribe-btn" id="subscribeBtn">Subscribe</button>
</div>
<!-- Description -->
@ -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 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/artplayer/5.1.1/artplayer.js"></script>
<script>
let commentsLoaded = false;
// Current video data for Queue
let currentVideoData = {};
// Current video data for Queue/History/Saved - Populated by JS or Server
let currentVideoData = {
id: "{{ request.args.get('v') }}",
title: document.title.replace(' - KV-Tube', ''), // Initial fallback
thumbnail: "", // Will be updated by Artplayer or API
uploader: ""
};
function initArtplayer(url, poster, subtitleUrl = '', type = 'auto') {
// Update currentVideoData with poster (thumbnail)
if (poster) {
// Use stable YouTube thumbnail URL to prevent expiration of signed URLs
currentVideoData.thumbnail = `https://i.ytimg.com/vi/${currentVideoData.id}/hqdefault.jpg`;
// Auto-save to history now that we have the thumbnail
// Delay slightly to ensure title is also updated if possible
setTimeout(() => {
// If title is still loading, try to grab it from DOM if updated
const titleEl = document.getElementById('videoTitle');
if (titleEl && titleEl.innerText !== 'Loading...') {
currentVideoData.title = titleEl.innerText;
}
const uploaderEl = document.getElementById('channelName');
if (uploaderEl && uploaderEl.innerText !== 'Loading...') {
currentVideoData.uploader = uploaderEl.innerText;
}
saveToLibrary('history', currentVideoData);
}, 2000);
}
const art = new Artplayer({
container: '#artplayer-app',
url: url,
@ -689,14 +743,6 @@
autoplay: false,
pip: true,
autoSize: false,
autoMini: true,
screenshot: true,
setting: true,
loop: false,
flip: true,
playbackRate: true,
aspectRatio: true,
fullscreen: true,
fullscreenWeb: true,
miniProgressBar: true,
mutex: true,
@ -707,6 +753,8 @@
fastForward: true,
autoOrientation: true,
theme: '#ff0000',
autoMini: false, // Custom mini mode implemented below
...(subtitleUrl ? {
subtitle: {
url: subtitleUrl,
type: 'vtt',
@ -715,7 +763,8 @@
fontSize: '20px',
},
encoding: 'utf-8',
},
}
} : {}),
lang: navigator.language.toLowerCase(),
moreVideoAttr: {
crossOrigin: 'anonymous',
@ -780,11 +829,136 @@
}
checkVertical();
art.on('video:loadedmetadata', checkVertical);
// --- Custom Mini Player Logic ---
setupMiniPlayer();
},
});
return art;
}
// --- Movable Mini Player Logic ---
function setupMiniPlayer() {
const playerContainer = document.querySelector('.yt-player-container');
const placeholder = document.getElementById('playerPlaceholder');
const playerSection = document.querySelector('.yt-player-section');
// Scroll Observer
const observer = new IntersectionObserver((entries) => {
// If player section top is out of view (scrolling down)
const entry = entries[0];
if (!entry.isIntersecting && entry.boundingClientRect.top < 0) {
enableMiniMode();
} else {
disableMiniMode();
}
}, { threshold: 0, rootMargin: '-100px 0px 0px 0px' }); // Trigger when header passes
observer.observe(playerSection);
function enableMiniMode() {
if (playerContainer.classList.contains('yt-mini-mode')) return;
playerContainer.classList.add('yt-mini-mode');
placeholder.style.display = 'block';
// Reset to default bottom-right if not previously moved?
// Alternatively, just let it use CSS default.
}
function disableMiniMode() {
if (!playerContainer.classList.contains('yt-mini-mode')) return;
playerContainer.classList.remove('yt-mini-mode');
placeholder.style.display = 'none';
// Reset styles to ensure normal layout
playerContainer.style.top = '';
playerContainer.style.left = '';
playerContainer.style.bottom = '';
playerContainer.style.right = '';
playerContainer.style.transform = '';
}
// Drag Logic
let isDragging = false;
let startX, startY, initialLeft, initialTop;
playerContainer.addEventListener('mousedown', dragStart);
document.addEventListener('mousemove', drag);
document.addEventListener('mouseup', dragEnd);
// Touch support
playerContainer.addEventListener('touchstart', dragStart, { passive: false });
document.addEventListener('touchmove', drag, { passive: false });
document.addEventListener('touchend', dragEnd);
function dragStart(e) {
if (!playerContainer.classList.contains('yt-mini-mode')) return;
// Don't drag if clicking controls (could be tricky, but basic grab works)
// Filter out clicks on seekbar or buttons if needed, but container grab is ok usually.
if (e.target.closest('.art-controls') || e.target.closest('.art-video')) {
// Allow interaction with controls, but maybe handle drag on edges/title if existed.
// For now, let's allow grab anywhere but maybe standard controls prevent propagation?
// Actually Artplayer captures clicks. We might need a specific handle or overlay.
// But user asked for "movable". Let's try grabbing the container directly.
}
// For better UX, maybe only drag when holding header or empty space?
// Given artplayer fills it, we drag the whole thing.
isDragging = true;
const clientX = e.type === 'touchstart' ? e.touches[0].clientX : e.clientX;
const clientY = e.type === 'touchstart' ? e.touches[0].clientY : e.clientY;
// Get current position
const rect = playerContainer.getBoundingClientRect();
startX = clientX;
startY = clientY;
initialLeft = rect.left;
initialTop = rect.top;
// Unset bottom/right to switch to top/left positioning for dragging
playerContainer.style.bottom = 'auto';
playerContainer.style.right = 'auto';
playerContainer.style.left = initialLeft + 'px';
playerContainer.style.top = initialTop + 'px';
e.preventDefault(); // Prevent text selection
}
function drag(e) {
if (!isDragging) return;
const clientX = e.type === 'touchmove' ? e.touches[0].clientX : e.clientX;
const clientY = e.type === 'touchmove' ? e.touches[0].clientY : e.clientY;
const dx = clientX - startX;
const dy = clientY - startY;
let newLeft = initialLeft + dx;
let newTop = initialTop + dy;
// Boundaries
const winWidth = window.innerWidth;
const winHeight = window.innerHeight;
const rect = playerContainer.getBoundingClientRect();
if (newLeft < 0) newLeft = 0;
if (newTop < 0) newTop = 0;
if (newLeft + rect.width > winWidth) newLeft = winWidth - rect.width;
if (newTop + rect.height > winHeight) newTop = winHeight - rect.height;
playerContainer.style.left = newLeft + 'px';
playerContainer.style.top = newTop + 'px';
e.preventDefault();
}
function dragEnd() {
isDragging = false;
}
}
// --- Loop Logic ---
function toggleLoop(btn) {
if (!window.player) {
@ -1180,7 +1354,14 @@
}
if (!response.ok) {
throw new Error(`Server Error (${response.status})`);
let errorMessage = `Server Error (${response.status})`;
try {
const errData = await response.json();
if (errData.error) errorMessage = errData.error;
} catch (e) {
// If JSON parse fails, keep generic error
}
throw new Error(errorMessage);
}
// Check content type to avoid JSON syntax errors if HTML is returned
@ -1197,6 +1378,7 @@
title: data.title,
thumbnail: `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`,
uploader: data.uploader || 'Unknown',
channel_id: data.channel_id || data.uploader_id || '',
duration: data.duration
};
@ -1280,9 +1462,9 @@
}
// Save Button - Local Storage based
document.getElementById('saveBtn').onclick = () => {
saveToLibrary();
};
// Save Button handler is setup in DOMContentLoaded below
// Just update state here
// document.getElementById('saveBtn').onclick setup moved to line ~1600
// Check if already saved
updateSaveButtonState();
@ -1376,6 +1558,96 @@
btn.innerHTML = '<i class="fas fa-magic"></i> Summarize with AI';
}
// --- Save Button Logic ---
document.addEventListener('DOMContentLoaded', () => {
const saveBtn = document.getElementById('saveBtn');
if (saveBtn) {
// Check initial state
if (isInLibrary('saved', currentVideoData.id)) {
saveBtn.innerHTML = '<i class="fas fa-bookmark"></i> Saved';
saveBtn.classList.add('active');
}
saveBtn.onclick = () => {
console.log("[Debug] Save Clicked. Current Data:", currentVideoData);
if (!currentVideoData || !currentVideoData.id) {
showToast("Error: Video data not ready yet", "error");
return;
}
// Ensure data is up to date
const titleEl = document.getElementById('videoTitle');
if (titleEl) currentVideoData.title = titleEl.innerText;
const uploaderEl = document.getElementById('channelName');
if (uploaderEl) currentVideoData.uploader = uploaderEl.innerText;
console.log("[Debug] Saving:", currentVideoData);
if (isInLibrary('saved', currentVideoData.id)) {
removeFromLibrary('saved', currentVideoData.id);
saveBtn.innerHTML = '<i class="far fa-bookmark"></i> Save';
saveBtn.classList.remove('active');
} else {
saveToLibrary('saved', currentVideoData);
saveBtn.innerHTML = '<i class="fas fa-bookmark"></i> Saved';
saveBtn.classList.add('active');
}
};
}
// --- Subscribe Button Logic ---
const subBtn = document.getElementById('subscribeBtn');
if (subBtn) {
const updateSubState = () => {
// Check against ID or Uploader name (fallback)
const key = currentVideoData.channel_id || currentVideoData.uploader;
if (!key) return;
if (isInLibrary('subscriptions', key)) {
subBtn.innerHTML = '<i class="fas fa-check-circle"></i> Subscribed';
subBtn.classList.add('subscribed');
} else {
subBtn.innerHTML = '<i class="fas fa-user-plus"></i> Subscribe';
subBtn.classList.remove('subscribed');
}
};
// Polling/Delay check as data populates
setTimeout(updateSubState, 1000);
setTimeout(updateSubState, 3000);
subBtn.onclick = () => {
if (!currentVideoData.uploader) {
showToast("Channel data not ready", "error");
return;
}
// Capture avatar image or fallback to letter content
const avatarImg = document.querySelector('#channelAvatar img');
const avatarLetter = document.getElementById('channelAvatarLetter');
const channelItem = {
id: currentVideoData.channel_id || currentVideoData.uploader,
title: currentVideoData.uploader,
thumbnail: avatarImg ? avatarImg.src : '',
letter: avatarLetter ? avatarLetter.innerText : (currentVideoData.uploader[0] || '?'),
type: 'channel'
};
if (isInLibrary('subscriptions', channelItem.id)) {
removeFromLibrary('subscriptions', channelItem.id);
subBtn.innerHTML = '<i class="fas fa-user-plus"></i> Subscribe';
subBtn.classList.remove('subscribed');
} else {
saveToLibrary('subscriptions', channelItem);
subBtn.innerHTML = '<i class="fas fa-check-circle"></i> Subscribed';
subBtn.classList.add('subscribed');
}
};
}
});
// --- Related Videos Infinite Scroll Functions ---
let currentVideoTitle = '';
let relatedPage = 1;