feat: Channel page, subscription management, remove buttons, UI polish
This commit is contained in:
parent
4472f6852a
commit
a988bdaac3
8 changed files with 993 additions and 316 deletions
236
app.py
236
app.py
|
|
@ -59,84 +59,15 @@ def init_db():
|
||||||
# Run init
|
# Run init
|
||||||
init_db()
|
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():
|
def get_db_connection():
|
||||||
conn = sqlite3.connect(DB_NAME)
|
conn = sqlite3.connect(DB_NAME)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
# --- Auth Routes ---
|
# --- Auth Helpers Removed ---
|
||||||
@app.route('/login', methods=['GET', 'POST'])
|
# Use client-side storage for all user data
|
||||||
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')
|
|
||||||
|
|
||||||
@app.route('/register', methods=['GET', 'POST'])
|
# --- Auth Routes Removed ---
|
||||||
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'
|
|
||||||
|
|
||||||
@app.template_filter('format_views')
|
@app.template_filter('format_views')
|
||||||
def format_views(views):
|
def format_views(views):
|
||||||
|
|
@ -188,26 +119,20 @@ VIDEO_DIR = os.environ.get('KVTUBE_VIDEO_DIR', './videos')
|
||||||
def index():
|
def index():
|
||||||
return render_template('index.html', page='home')
|
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')
|
@app.route('/my-videos')
|
||||||
def my_videos():
|
def my_videos():
|
||||||
filter_type = request.args.get('type', 'history') # 'saved' or 'history'
|
# Purely client-side rendering now
|
||||||
|
return render_template('my_videos.html')
|
||||||
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)
|
|
||||||
|
|
||||||
@app.route('/api/save_video', methods=['POST'])
|
@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():
|
def save_video():
|
||||||
data = request.json
|
data = request.json
|
||||||
video_id = data.get('id')
|
video_id = data.get('id')
|
||||||
|
|
@ -483,11 +408,32 @@ def get_channel_videos():
|
||||||
start = (page - 1) * limit + 1
|
start = (page - 1) * limit + 1
|
||||||
end = start + 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
|
# Construct URL based on ID type AND Filter Type
|
||||||
base_url = ""
|
if resolved_id.startswith('UC'):
|
||||||
if channel_id.startswith('UC'): base_url = f'https://www.youtube.com/channel/{channel_id}'
|
base_url = f'https://www.youtube.com/channel/{resolved_id}'
|
||||||
elif channel_id.startswith('@'): base_url = f'https://www.youtube.com/{channel_id}'
|
elif resolved_id.startswith('@'):
|
||||||
else: base_url = f'https://www.youtube.com/channel/{channel_id}' # Fallback
|
base_url = f'https://www.youtube.com/{resolved_id}'
|
||||||
|
else:
|
||||||
|
base_url = f'https://www.youtube.com/channel/{resolved_id}'
|
||||||
|
|
||||||
target_url = base_url
|
target_url = base_url
|
||||||
if filter_type == 'shorts':
|
if filter_type == 'shorts':
|
||||||
|
|
@ -499,23 +445,6 @@ def get_channel_videos():
|
||||||
|
|
||||||
if sort_mode == 'oldest':
|
if sort_mode == 'oldest':
|
||||||
playlist_args = ['--playlist-reverse', '--playlist-start', str(start), '--playlist-end', str(end)]
|
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 = [
|
cmd = [
|
||||||
sys.executable, '-m', 'yt_dlp',
|
sys.executable, '-m', 'yt_dlp',
|
||||||
|
|
@ -545,8 +474,9 @@ def get_channel_videos():
|
||||||
'view_count': v.get('view_count') or 0,
|
'view_count': v.get('view_count') or 0,
|
||||||
'duration': dur_str,
|
'duration': dur_str,
|
||||||
'upload_date': v.get('upload_date'),
|
'upload_date': v.get('upload_date'),
|
||||||
'uploader': v.get('uploader'),
|
'uploader': v.get('uploader') or v.get('channel') or v.get('uploader_id') or '',
|
||||||
'channel_id': v.get('channel_id') or channel_id
|
'channel': v.get('channel') or v.get('uploader') or '',
|
||||||
|
'channel_id': v.get('channel_id') or resolved_id
|
||||||
})
|
})
|
||||||
except: continue
|
except: continue
|
||||||
|
|
||||||
|
|
@ -554,9 +484,6 @@ def get_channel_videos():
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"API Error: {e}")
|
print(f"API Error: {e}")
|
||||||
return jsonify([])
|
return jsonify([])
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error loading channel: {str(e)}", 500
|
|
||||||
|
|
||||||
@app.route('/api/get_stream_info')
|
@app.route('/api/get_stream_info')
|
||||||
def get_stream_info():
|
def get_stream_info():
|
||||||
|
|
@ -609,7 +536,7 @@ def get_stream_info():
|
||||||
info = ydl.extract_info(url, download=False)
|
info = ydl.extract_info(url, download=False)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ yt-dlp error for {video_id}: {str(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')
|
stream_url = info.get('url')
|
||||||
if not stream_url:
|
if not stream_url:
|
||||||
|
|
@ -664,6 +591,8 @@ def get_stream_info():
|
||||||
'title': info.get('title', 'Unknown Title'),
|
'title': info.get('title', 'Unknown Title'),
|
||||||
'description': info.get('description', ''),
|
'description': info.get('description', ''),
|
||||||
'uploader': info.get('uploader', ''),
|
'uploader': info.get('uploader', ''),
|
||||||
|
'uploader_id': info.get('uploader_id', ''),
|
||||||
|
'channel_id': info.get('channel_id', ''),
|
||||||
'upload_date': info.get('upload_date', ''),
|
'upload_date': info.get('upload_date', ''),
|
||||||
'view_count': info.get('view_count', 0),
|
'view_count': info.get('view_count', 0),
|
||||||
'related': related_videos,
|
'related': related_videos,
|
||||||
|
|
@ -724,24 +653,24 @@ def search():
|
||||||
duration = f"{hours}:{mins:02d}:{secs:02d}" if hours else f"{mins}:{secs:02d}"
|
duration = f"{hours}:{mins:02d}:{secs:02d}" if hours else f"{mins}:{secs:02d}"
|
||||||
else:
|
else:
|
||||||
duration = None
|
duration = None
|
||||||
|
|
||||||
# Add the exact match first
|
|
||||||
results.append({
|
results.append({
|
||||||
'id': data.get('id'),
|
'id': video_id,
|
||||||
'title': data.get('title', 'Unknown'),
|
'title': search_title,
|
||||||
'uploader': data.get('uploader') or data.get('channel') or 'Unknown',
|
'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),
|
'view_count': data.get('view_count', 0),
|
||||||
'upload_date': data.get('upload_date', ''),
|
'upload_date': data.get('upload_date', ''),
|
||||||
'duration': duration,
|
'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
|
# Now fetch related/similar videos using title
|
||||||
if search_title:
|
if search_title:
|
||||||
rel_cmd = [
|
rel_cmd = [
|
||||||
sys.executable, '-m', 'yt_dlp',
|
sys.executable, '-m', 'yt_dlp',
|
||||||
f'ytsearch19:{search_title}', # Get 19 more to make ~20 total
|
f'ytsearch19:{search_title}',
|
||||||
'--dump-json',
|
'--dump-json',
|
||||||
'--default-search', 'ytsearch',
|
'--default-search', 'ytsearch',
|
||||||
'--no-playlist',
|
'--no-playlist',
|
||||||
|
|
@ -754,9 +683,7 @@ def search():
|
||||||
try:
|
try:
|
||||||
r_data = json.loads(line)
|
r_data = json.loads(line)
|
||||||
r_id = r_data.get('id')
|
r_id = r_data.get('id')
|
||||||
# Don't duplicate the exact match
|
|
||||||
if r_id != video_id:
|
if r_id != video_id:
|
||||||
# Helper to format duration (dup code, could be function)
|
|
||||||
r_dur = r_data.get('duration')
|
r_dur = r_data.get('duration')
|
||||||
if r_dur:
|
if r_dur:
|
||||||
m, s = divmod(int(r_dur), 60)
|
m, s = divmod(int(r_dur), 60)
|
||||||
|
|
@ -776,7 +703,7 @@ def search():
|
||||||
})
|
})
|
||||||
except:
|
except:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return jsonify(results)
|
return jsonify(results)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|
@ -790,7 +717,6 @@ def search():
|
||||||
'--flat-playlist'
|
'--flat-playlist'
|
||||||
]
|
]
|
||||||
|
|
||||||
# Run command
|
|
||||||
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||||
stdout, stderr = process.communicate()
|
stdout, stderr = process.communicate()
|
||||||
|
|
||||||
|
|
@ -800,7 +726,6 @@ def search():
|
||||||
data = json.loads(line)
|
data = json.loads(line)
|
||||||
video_id = data.get('id')
|
video_id = data.get('id')
|
||||||
if video_id:
|
if video_id:
|
||||||
# Format duration
|
|
||||||
duration_secs = data.get('duration')
|
duration_secs = data.get('duration')
|
||||||
if duration_secs:
|
if duration_secs:
|
||||||
mins, secs = divmod(int(duration_secs), 60)
|
mins, secs = divmod(int(duration_secs), 60)
|
||||||
|
|
@ -822,9 +747,52 @@ def search():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return jsonify(results)
|
return jsonify(results)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
print(f"Search Error: {e}")
|
||||||
return jsonify({'error': str(e)}), 500
|
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 ---
|
# --- Helper: Extractive Summarization ---
|
||||||
def extractive_summary(text, num_sentences=5):
|
def extractive_summary(text, num_sentences=5):
|
||||||
# 1. Clean and parse text
|
# 1. Clean and parse text
|
||||||
|
|
@ -1025,21 +993,21 @@ def trending():
|
||||||
{'id': 'trending', 'title': 'Trending Now', 'icon': 'fire'},
|
{'id': 'trending', 'title': 'Trending Now', 'icon': 'fire'},
|
||||||
{'id': 'tech', 'title': 'AI & Tech', 'icon': 'microchip'},
|
{'id': 'tech', 'title': 'AI & Tech', 'icon': 'microchip'},
|
||||||
{'id': 'music', 'title': 'Music', 'icon': 'music'},
|
{'id': 'music', 'title': 'Music', 'icon': 'music'},
|
||||||
{'id': 'gaming', 'title': 'Gaming', 'icon': 'gamepad'},
|
|
||||||
{'id': 'movies', 'title': 'Movies', 'icon': 'film'},
|
{'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):
|
def fetch_section(section):
|
||||||
q = get_query(section['id'], region, sort)
|
q = get_query(section['id'], region, sort)
|
||||||
# Fetch 20 videos per section, page 1 logic implied (start=1)
|
# Fetch 80 videos per section to guarantee density (target: 50+ after filters)
|
||||||
vids = fetch_videos(q, limit=25, filter_type='video', playlist_start=1)
|
vids = fetch_videos(q, limit=80, filter_type='video', playlist_start=1)
|
||||||
return {
|
return {
|
||||||
'id': section['id'],
|
'id': section['id'],
|
||||||
'title': section['title'],
|
'title': section['title'],
|
||||||
'icon': section['icon'],
|
'icon': section['icon'],
|
||||||
'videos': vids[:20]
|
'videos': vids[:60]
|
||||||
}
|
}
|
||||||
|
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=7) as executor:
|
with concurrent.futures.ThreadPoolExecutor(max_workers=7) as executor:
|
||||||
|
|
|
||||||
|
|
@ -335,6 +335,8 @@ button {
|
||||||
padding: 16px 0;
|
padding: 16px 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-sidebar.collapsed .yt-sidebar-item span {
|
.yt-sidebar.collapsed .yt-sidebar-item span {
|
||||||
|
|
@ -409,6 +411,42 @@ button {
|
||||||
gap: 16px 16px;
|
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) ===== */
|
/* ===== Video Card (YouTube Style) ===== */
|
||||||
.yt-video-card {
|
.yt-video-card {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -565,6 +603,16 @@ button {
|
||||||
background: var(--yt-bg-hover);
|
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 {
|
.yt-channel-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -836,6 +884,15 @@ button {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.yt-header-start,
|
||||||
|
.yt-header-end {
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-logo span:last-child {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.yt-search-input {
|
.yt-search-input {
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|
|
||||||
|
|
@ -4,26 +4,40 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
const searchInput = document.getElementById('searchInput');
|
const searchInput = document.getElementById('searchInput');
|
||||||
const resultsArea = document.getElementById('resultsArea');
|
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) {
|
if (searchInput) {
|
||||||
searchInput.addEventListener('keypress', async (e) => {
|
searchInput.addEventListener('keypress', async (e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const query = searchInput.value.trim();
|
const query = searchInput.value.trim();
|
||||||
if (query) {
|
if (query) {
|
||||||
// Check if on search page already, if not redirect
|
window.location.href = `/results?search_query=${encodeURIComponent(query)}`;
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load trending on init
|
// Handle Page Initialization - only if resultsArea exists (not on channel.html)
|
||||||
loadTrending();
|
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
|
// Init Infinite Scroll
|
||||||
initInfiniteScroll();
|
initInfiniteScroll();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init Theme
|
// Init Theme
|
||||||
|
|
@ -71,11 +85,16 @@ function initInfiniteScroll() {
|
||||||
|
|
||||||
// Create sentinel logic or observe existing footer/element
|
// Create sentinel logic or observe existing footer/element
|
||||||
// We'll observe a sentinel element at the bottom of the grid
|
// 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');
|
const sentinel = document.createElement('div');
|
||||||
sentinel.id = 'scroll-sentinel';
|
sentinel.id = 'scroll-sentinel';
|
||||||
sentinel.style.width = '100%';
|
sentinel.style.width = '100%';
|
||||||
sentinel.style.height = '20px';
|
sentinel.style.height = '20px';
|
||||||
document.getElementById('resultsArea').parentNode.appendChild(sentinel);
|
resultsArea.parentNode.appendChild(sentinel);
|
||||||
observer.observe(sentinel);
|
observer.observe(sentinel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -234,6 +253,9 @@ async function loadTrending(reset = true) {
|
||||||
if (reset) resultsArea.innerHTML = '';
|
if (reset) resultsArea.innerHTML = '';
|
||||||
|
|
||||||
// Render Sections
|
// Render Sections
|
||||||
|
// Render Sections
|
||||||
|
const isMobile = window.innerWidth <= 768;
|
||||||
|
|
||||||
data.data.forEach(section => {
|
data.data.forEach(section => {
|
||||||
const sectionDiv = document.createElement('div');
|
const sectionDiv = document.createElement('div');
|
||||||
sectionDiv.style.gridColumn = '1 / -1';
|
sectionDiv.style.gridColumn = '1 / -1';
|
||||||
|
|
@ -246,38 +268,54 @@ async function loadTrending(reset = true) {
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Scroll Container
|
const videos = section.videos || [];
|
||||||
const scrollContainer = document.createElement('div');
|
let chunks = [];
|
||||||
scrollContainer.className = 'yt-shorts-grid'; // Reuse horizontal scroll style
|
|
||||||
scrollContainer.style.gap = '16px';
|
|
||||||
|
|
||||||
section.videos.forEach(video => {
|
if (isMobile) {
|
||||||
const card = document.createElement('div');
|
// Split into 4 chunks (rows) for independent scrolling
|
||||||
card.className = 'yt-video-card';
|
// Each chunk gets ~1/4 of videos, or at least some
|
||||||
card.style.minWidth = '280px'; // Fixed width for horizontal items
|
const chunkSize = Math.ceil(videos.length / 4);
|
||||||
card.style.width = '280px';
|
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 = `
|
chunks.forEach(chunk => {
|
||||||
<div class="yt-thumbnail-container">
|
// Scroll Container
|
||||||
<img class="yt-thumbnail" data-src="${video.thumbnail}" alt="${escapeHtml(video.title)}">
|
const scrollContainer = document.createElement('div');
|
||||||
${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''}
|
scrollContainer.className = 'yt-section-grid';
|
||||||
</div>
|
|
||||||
<div class="yt-video-details">
|
chunk.forEach(video => {
|
||||||
<div class="yt-channel-avatar">
|
const card = document.createElement('div');
|
||||||
${video.uploader ? video.uploader.charAt(0).toUpperCase() : 'Y'}
|
card.className = 'yt-video-card';
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<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>
|
||||||
<div class="yt-video-meta">
|
<div class="yt-video-details">
|
||||||
<h3 class="yt-video-title">${escapeHtml(video.title)}</h3>
|
<div class="yt-channel-avatar">
|
||||||
<p class="yt-channel-name">${escapeHtml(video.uploader || 'Unknown')}</p>
|
${video.uploader ? video.uploader.charAt(0).toUpperCase() : 'Y'}
|
||||||
<p class="yt-video-stats">${formatViews(video.view_count)} views</p>
|
</div>
|
||||||
|
<div class="yt-video-meta">
|
||||||
|
<h3 class="yt-video-title">${escapeHtml(video.title)}</h3>
|
||||||
|
<p class="yt-channel-name">${escapeHtml(video.uploader || 'Unknown')}</p>
|
||||||
|
<p class="yt-video-stats">${formatViews(video.view_count)} views</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
`;
|
||||||
`;
|
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
|
||||||
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
|
scrollContainer.appendChild(card);
|
||||||
scrollContainer.appendChild(card);
|
});
|
||||||
|
|
||||||
|
sectionDiv.appendChild(scrollContainer);
|
||||||
});
|
});
|
||||||
|
|
||||||
sectionDiv.appendChild(scrollContainer);
|
|
||||||
resultsArea.appendChild(sectionDiv);
|
resultsArea.appendChild(sectionDiv);
|
||||||
});
|
});
|
||||||
if (window.observeImages) window.observeImages();
|
if (window.observeImages) window.observeImages();
|
||||||
|
|
@ -536,3 +574,130 @@ async function updateProfile(e) {
|
||||||
btn.innerHTML = originalText;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,24 +6,26 @@
|
||||||
<!-- Channel Header (No Banner) -->
|
<!-- Channel Header (No Banner) -->
|
||||||
<div class="yt-channel-header">
|
<div class="yt-channel-header">
|
||||||
<div class="yt-channel-info-row">
|
<div class="yt-channel-info-row">
|
||||||
<div class="yt-channel-avatar-xl">
|
<div class="yt-channel-avatar-xl" id="channelAvatarLarge">
|
||||||
{% if channel.avatar %}
|
{% if channel.avatar %}
|
||||||
<img src="{{ channel.avatar }}">
|
<img src="{{ channel.avatar }}">
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ channel.title[0] | upper }}
|
<span id="channelAvatarLetter">{{ channel.title[0] | upper if channel.title and channel.title !=
|
||||||
|
'Loading...' else 'C' }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="yt-channel-meta">
|
<div class="yt-channel-meta">
|
||||||
<h1>{{ channel.title }}</h1>
|
<h1 id="channelTitle">{{ channel.title if channel.title and channel.title != 'Loading...' else
|
||||||
<p class="yt-channel-handle">
|
'Loading...' }}</h1>
|
||||||
{% if channel.id.startswith('@') %}{{ channel.id }}{% else %}@{{ channel.title|replace(' ', '') }}{%
|
<p class="yt-channel-handle" id="channelHandle">
|
||||||
endif %}
|
{% if channel.title and channel.title != 'Loading...' %}@{{ channel.title|replace(' ', '') }}{% else
|
||||||
|
%}@Loading...{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<div class="yt-channel-stats">
|
<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>
|
||||||
<div class="yt-channel-actions">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -243,6 +245,8 @@
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
console.log("Channel.html script loaded, channelId will be:", "{{ channel.id }}");
|
||||||
|
|
||||||
let currentChannelSort = 'latest';
|
let currentChannelSort = 'latest';
|
||||||
let currentChannelPage = 1;
|
let currentChannelPage = 1;
|
||||||
let isChannelLoading = false;
|
let isChannelLoading = false;
|
||||||
|
|
@ -251,7 +255,13 @@
|
||||||
const channelId = "{{ channel.id }}";
|
const channelId = "{{ channel.id }}";
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
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();
|
setupInfiniteScroll();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -274,7 +284,7 @@
|
||||||
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(320px, 1fr))';
|
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(320px, 1fr))';
|
||||||
}
|
}
|
||||||
|
|
||||||
loadChannelVideos();
|
fetchChannelContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeChannelSort(sort, btn) {
|
function changeChannelSort(sort, btn) {
|
||||||
|
|
@ -288,40 +298,72 @@
|
||||||
document.querySelectorAll('.yt-sort-options a').forEach(a => a.classList.remove('active'));
|
document.querySelectorAll('.yt-sort-options a').forEach(a => a.classList.remove('active'));
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
|
|
||||||
loadChannelVideos();
|
fetchChannelContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadChannelVideos() {
|
async function fetchChannelContent() {
|
||||||
if (isChannelLoading || !hasMoreChannelVideos) return;
|
console.log("fetchChannelContent() called");
|
||||||
|
if (isChannelLoading || !hasMoreChannelVideos) {
|
||||||
|
console.log("Early return:", { isChannelLoading, hasMoreChannelVideos });
|
||||||
|
return;
|
||||||
|
}
|
||||||
isChannelLoading = true;
|
isChannelLoading = true;
|
||||||
|
|
||||||
const grid = document.getElementById('channelVideosGrid');
|
const grid = document.getElementById('channelVideosGrid');
|
||||||
|
|
||||||
// Append Skeletons
|
// Append Loading indicator
|
||||||
const tempSkeleton = document.createElement('div');
|
|
||||||
tempSkeleton.innerHTML = renderSkeleton(8); // Reuse main.js skeleton logic if available, or simpler
|
|
||||||
// Since main.js renderSkeleton returns a string, we append it
|
|
||||||
// Check if renderSkeleton exists, else manual
|
|
||||||
if (typeof renderSkeleton === 'function') {
|
if (typeof renderSkeleton === 'function') {
|
||||||
// Render fewer skeletons for shorts if needed, but standard is fine
|
|
||||||
grid.insertAdjacentHTML('beforeend', renderSkeleton(4));
|
grid.insertAdjacentHTML('beforeend', renderSkeleton(4));
|
||||||
} else {
|
} 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 {
|
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 response = await fetch(`/api/channel/videos?id=${channelId}&page=${currentChannelPage}&sort=${currentChannelSort}&filter_type=${currentFilterType}`);
|
||||||
const videos = await response.json();
|
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?
|
// Remove skeletons (simple way: remove last N children or just clear all if page 1?
|
||||||
// Better: mark skeletons with class and remove)
|
// Better: mark skeletons with class and remove)
|
||||||
// For simplicity in this v1: We just clear skeletons by removing elements with 'skeleton-card' class
|
// 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());
|
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;
|
hasMoreChannelVideos = false;
|
||||||
if (currentChannelPage === 1) grid.innerHTML = '<p style="padding:20px; color:var(--yt-text-secondary);">No videos found.</p>';
|
if (currentChannelPage === 1) grid.innerHTML = '<p style="padding:20px; color:var(--yt-text-secondary);">No videos found.</p>';
|
||||||
} else {
|
} 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 => {
|
videos.forEach(video => {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
|
|
||||||
|
|
@ -331,7 +373,7 @@
|
||||||
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
|
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="yt-short-thumb-container">
|
<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>
|
||||||
<div class="yt-details" style="padding: 8px;">
|
<div class="yt-details" style="padding: 8px;">
|
||||||
<h3 class="yt-video-title" style="font-size: 14px; margin-bottom: 4px;">${escapeHtml(video.title)}</h3>
|
<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.onclick = () => window.location.href = `/watch?v=${video.id}`;
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="yt-thumbnail-container">
|
<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>` : ''}
|
${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="yt-video-details">
|
<div class="yt-video-details">
|
||||||
|
|
@ -373,14 +415,47 @@
|
||||||
const trigger = document.getElementById('channelLoadingTrigger');
|
const trigger = document.getElementById('channelLoadingTrigger');
|
||||||
const observer = new IntersectionObserver((entries) => {
|
const observer = new IntersectionObserver((entries) => {
|
||||||
if (entries[0].isIntersecting) {
|
if (entries[0].isIntersecting) {
|
||||||
loadChannelVideos();
|
fetchChannelContent();
|
||||||
}
|
}
|
||||||
}, { threshold: 0.1 });
|
}, { threshold: 0.1 });
|
||||||
observer.observe(trigger);
|
observer.observe(trigger);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helpers (Duplicate from main.js if not loaded, but main.js should be loaded layout)
|
// Helpers - Define locally to ensure availability
|
||||||
// We assume main.js functions (escapeHtml, formatViews, formatDate) are available globally
|
function escapeHtml(text) {
|
||||||
// or we define them safely if missing.
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -1,19 +1,26 @@
|
||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<script>
|
||||||
|
window.APP_CONFIG = {
|
||||||
|
page: '{{ page|default("home") }}',
|
||||||
|
channelId: '{{ channel_id|default("") }}',
|
||||||
|
query: '{{ query|default("") }}'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
<!-- Filters & Categories -->
|
<!-- Filters & Categories -->
|
||||||
<div class="yt-filter-bar">
|
<div class="yt-filter-bar">
|
||||||
<div class="yt-categories" id="categoryList">
|
<div class="yt-categories" id="categoryList">
|
||||||
<!-- "All" removed, starting with Tech -->
|
<!-- "All" removed, starting with Tech -->
|
||||||
<button class="yt-chip" onclick="switchCategory('tech')">Tech</button>
|
<button class="yt-chip" onclick="switchCategory('tech')">Tech</button>
|
||||||
<button class="yt-chip" onclick="switchCategory('music')">Music</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('movies')">Movies</button>
|
||||||
<button class="yt-chip" onclick="switchCategory('news')">News</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('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('podcasts')">Podcasts</button>
|
||||||
<button class="yt-chip" onclick="switchCategory('live')">Live</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>
|
||||||
|
|
||||||
<div class="yt-filter-actions">
|
<div class="yt-filter-actions">
|
||||||
|
|
|
||||||
|
|
@ -67,16 +67,7 @@
|
||||||
<!-- <button class="yt-icon-btn yt-mobile-search" onclick="toggleMobileSearch()" aria-label="Search">
|
<!-- <button class="yt-icon-btn yt-mobile-search" onclick="toggleMobileSearch()" aria-label="Search">
|
||||||
<i class="fas fa-search"></i>
|
<i class="fas fa-search"></i>
|
||||||
</button> -->
|
</button> -->
|
||||||
{% if session.get('user_id') %}
|
<!-- User Avatar Removed -->
|
||||||
<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 %}
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -108,9 +99,16 @@
|
||||||
<span>History</span>
|
<span>History</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/my-videos?type=saved"
|
<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>
|
<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>
|
</a>
|
||||||
<!-- Queue Removed -->
|
<!-- Queue Removed -->
|
||||||
|
|
||||||
|
|
@ -132,16 +130,16 @@
|
||||||
<i class="fas fa-music"></i>
|
<i class="fas fa-music"></i>
|
||||||
<span>Music</span>
|
<span>Music</span>
|
||||||
</a>
|
</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"
|
<a href="javascript:void(0)" class="yt-sidebar-item" data-category="news"
|
||||||
onclick="navigateCategory('news')">
|
onclick="navigateCategory('news')">
|
||||||
<i class="fas fa-newspaper"></i>
|
<i class="fas fa-newspaper"></i>
|
||||||
<span>News</span>
|
<span>News</span>
|
||||||
</a>
|
</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"
|
<a href="javascript:void(0)" class="yt-sidebar-item" data-category="sports"
|
||||||
onclick="navigateCategory('sports')">
|
onclick="navigateCategory('sports')">
|
||||||
<i class="fas fa-football-ball"></i>
|
<i class="fas fa-football-ball"></i>
|
||||||
|
|
@ -154,12 +152,6 @@
|
||||||
<i class="fas fa-cog"></i>
|
<i class="fas fa-cog"></i>
|
||||||
<span>Settings</span>
|
<span>Settings</span>
|
||||||
</a>
|
</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>
|
</aside>
|
||||||
|
|
||||||
<!-- Sidebar Overlay (Mobile) -->
|
<!-- Sidebar Overlay (Mobile) -->
|
||||||
|
|
|
||||||
|
|
@ -3,40 +3,35 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="yt-container" style="padding-top: 20px;">
|
<div class="yt-container" style="padding-top: 20px;">
|
||||||
<div class="library-header"
|
<div class="library-header"
|
||||||
style="margin-bottom: 2rem; display: flex; align-items: center; justify-content: space-between;">
|
style="margin-bottom: 3rem; display: flex; flex-direction: column; align-items: center; gap: 1.5rem;">
|
||||||
<h1 style="font-size: 1.5rem;">My Library</h1>
|
<h1 style="font-size: 2rem; font-weight: 700;">My Library</h1>
|
||||||
<div class="tabs"
|
<div class="tabs"
|
||||||
style="display: flex; gap: 0.5rem; background: var(--yt-bg-hover); padding: 0.3rem; border-radius: 100px;">
|
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=saved" class="yt-btn {% if filter_type == 'saved' %}yt-btn-active{% endif %}"
|
<a href="/my-videos?type=history" class="yt-btn" id="tab-history"
|
||||||
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>
|
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=history" class="yt-btn {% if filter_type == 'history' %}yt-btn-active{% endif %}"
|
<a href="/my-videos?type=saved" class="yt-btn" id="tab-saved"
|
||||||
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>
|
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>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
<!-- Video Grid -->
|
||||||
<div id="libraryGrid" class="yt-video-grid">
|
<div id="libraryGrid" class="yt-video-grid">
|
||||||
{% if videos %}
|
<!-- JS will populate this -->
|
||||||
{% for video in videos %}
|
|
||||||
<div class="yt-video-card" onclick="window.location.href='/watch?v={{ video.video_id }}'">
|
|
||||||
<div class="yt-thumbnail-container">
|
|
||||||
<img src="{{ video.thumbnail }}" class="yt-thumbnail" loading="lazy">
|
|
||||||
</div>
|
|
||||||
<div class="yt-video-details">
|
|
||||||
<div class="yt-video-meta">
|
|
||||||
<h3 class="yt-video-title">{{ video.title }}</h3>
|
|
||||||
<p class="yt-video-stats">{{ video.timestamp }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="emptyState"
|
<!-- Empty State -->
|
||||||
style="text-align: center; padding: 4rem; color: var(--yt-text-secondary); display: {% if videos %}none{% else %}none{% endif %};">
|
<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>
|
<i class="fas fa-folder-open fa-3x" style="margin-bottom: 1rem; opacity: 0.5;"></i>
|
||||||
<h3>Nothing here yet</h3>
|
<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"
|
<a href="/" class="yt-btn"
|
||||||
style="margin-top: 1rem; background: var(--yt-text-primary); color: var(--yt-bg-primary);">Browse
|
style="margin-top: 1rem; background: var(--yt-text-primary); color: var(--yt-bg-primary);">Browse
|
||||||
Content</a>
|
Content</a>
|
||||||
|
|
@ -44,49 +39,195 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const loggedIn = {{ 'true' if logged_in else 'false' }};
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const filterType = '{{ filter_type }}';
|
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 grid = document.getElementById('libraryGrid');
|
||||||
const empty = document.getElementById('emptyState');
|
const empty = document.getElementById('emptyState');
|
||||||
|
const emptyMsg = document.getElementById('emptyMsg');
|
||||||
|
|
||||||
if (history.length > 0) {
|
// Mapping URL type to localStorage key suffix
|
||||||
grid.innerHTML = history.map(video => `
|
// saved -> kv_saved
|
||||||
<div class="yt-video-card" onclick="window.location.href='/watch?v=${video.id}'">
|
// history -> kv_history
|
||||||
<div class="yt-thumbnail-container">
|
// subscriptions -> kv_subscriptions
|
||||||
<img src="${video.thumbnail}" class="yt-thumbnail" loading="lazy">
|
const storageKey = `kv_${type}`;
|
||||||
</div>
|
const data = JSON.parse(localStorage.getItem(storageKey) || '[]').filter(i => i && i.id);
|
||||||
<div class="yt-video-details">
|
|
||||||
<div class="yt-video-meta">
|
// Show Clear Button if there is data
|
||||||
<h3 class="yt-video-title">${video.title}</h3>
|
if (data.length > 0) {
|
||||||
<p class="yt-video-stats">Watched on ${new Date(video.timestamp).toLocaleDateString()}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
empty.style.display = 'none';
|
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="${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">${video.uploader}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${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 {
|
} else {
|
||||||
grid.innerHTML = '';
|
grid.innerHTML = '';
|
||||||
empty.style.display = 'block';
|
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 = '';
|
function clearLibrary() {
|
||||||
document.getElementById('emptyState').innerHTML = `
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
<i class="fas fa-lock fa-3x" style="margin-bottom: 1rem; opacity: 0.5;"></i>
|
const type = urlParams.get('type') || 'history';
|
||||||
<h3>Sign in to save videos</h3>
|
const typeName = type.charAt(0).toUpperCase() + type.slice(1);
|
||||||
<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>
|
if (confirm(`Are you sure you want to clear your ${typeName}? This cannot be undone.`)) {
|
||||||
`;
|
const storageKey = `kv_${type}`;
|
||||||
document.getElementById('emptyState').style.display = 'block';
|
localStorage.removeItem(storageKey);
|
||||||
} else {
|
// Reload to reflect changes
|
||||||
// Logged in or Server side handled it
|
window.location.reload();
|
||||||
if (document.getElementById('libraryGrid').children.length === 0) {
|
|
||||||
document.getElementById('emptyState').style.display = 'block';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -13,6 +13,8 @@
|
||||||
<!-- Loading State (Confined to Player) -->
|
<!-- Loading State (Confined to Player) -->
|
||||||
<div id="loading" class="yt-loader"></div>
|
<div id="loading" class="yt-loader"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Placeholder for Mini Mode -->
|
||||||
|
<div id="playerPlaceholder" class="yt-player-placeholder"></div>
|
||||||
|
|
||||||
<!-- Info Skeleton -->
|
<!-- Info Skeleton -->
|
||||||
<div id="infoSkeleton" style="margin-top:20px;">
|
<div id="infoSkeleton" style="margin-top:20px;">
|
||||||
|
|
@ -86,7 +88,7 @@
|
||||||
<p class="yt-video-stats" id="viewCount">0 views</p>
|
<p class="yt-video-stats" id="viewCount">0 views</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="yt-subscribe-btn">Subscribe</button>
|
<button class="yt-subscribe-btn" id="subscribeBtn">Subscribe</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
|
|
@ -192,18 +194,44 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
#loading {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
/* Movable Mini Player Styles */
|
||||||
left: 0;
|
.yt-mini-mode {
|
||||||
right: 0;
|
position: fixed;
|
||||||
bottom: 0;
|
bottom: 20px;
|
||||||
background: var(--yt-bg-secondary);
|
right: 20px;
|
||||||
z-index: 10;
|
width: 400px !important;
|
||||||
/* Skeleton shimmer */
|
height: auto !important;
|
||||||
background: linear-gradient(90deg, var(--yt-bg-secondary) 25%, var(--yt-bg-hover) 50%, var(--yt-bg-secondary) 75%);
|
aspect-ratio: 16/9;
|
||||||
background-size: 200% 100%;
|
z-index: 10000;
|
||||||
animation: shimmer 1.5s infinite;
|
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 */
|
/* Skeleton Utils */
|
||||||
|
|
@ -674,10 +702,36 @@
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/artplayer/5.1.1/artplayer.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/artplayer/5.1.1/artplayer.js"></script>
|
||||||
<script>
|
<script>
|
||||||
let commentsLoaded = false;
|
let commentsLoaded = false;
|
||||||
// Current video data for Queue
|
// Current video data for Queue/History/Saved - Populated by JS or Server
|
||||||
let currentVideoData = {};
|
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') {
|
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({
|
const art = new Artplayer({
|
||||||
container: '#artplayer-app',
|
container: '#artplayer-app',
|
||||||
url: url,
|
url: url,
|
||||||
|
|
@ -689,14 +743,6 @@
|
||||||
autoplay: false,
|
autoplay: false,
|
||||||
pip: true,
|
pip: true,
|
||||||
autoSize: false,
|
autoSize: false,
|
||||||
autoMini: true,
|
|
||||||
screenshot: true,
|
|
||||||
setting: true,
|
|
||||||
loop: false,
|
|
||||||
flip: true,
|
|
||||||
playbackRate: true,
|
|
||||||
aspectRatio: true,
|
|
||||||
fullscreen: true,
|
|
||||||
fullscreenWeb: true,
|
fullscreenWeb: true,
|
||||||
miniProgressBar: true,
|
miniProgressBar: true,
|
||||||
mutex: true,
|
mutex: true,
|
||||||
|
|
@ -707,15 +753,18 @@
|
||||||
fastForward: true,
|
fastForward: true,
|
||||||
autoOrientation: true,
|
autoOrientation: true,
|
||||||
theme: '#ff0000',
|
theme: '#ff0000',
|
||||||
subtitle: {
|
autoMini: false, // Custom mini mode implemented below
|
||||||
url: subtitleUrl,
|
...(subtitleUrl ? {
|
||||||
type: 'vtt',
|
subtitle: {
|
||||||
style: {
|
url: subtitleUrl,
|
||||||
color: '#fff',
|
type: 'vtt',
|
||||||
fontSize: '20px',
|
style: {
|
||||||
},
|
color: '#fff',
|
||||||
encoding: 'utf-8',
|
fontSize: '20px',
|
||||||
},
|
},
|
||||||
|
encoding: 'utf-8',
|
||||||
|
}
|
||||||
|
} : {}),
|
||||||
lang: navigator.language.toLowerCase(),
|
lang: navigator.language.toLowerCase(),
|
||||||
moreVideoAttr: {
|
moreVideoAttr: {
|
||||||
crossOrigin: 'anonymous',
|
crossOrigin: 'anonymous',
|
||||||
|
|
@ -780,11 +829,136 @@
|
||||||
}
|
}
|
||||||
checkVertical();
|
checkVertical();
|
||||||
art.on('video:loadedmetadata', checkVertical);
|
art.on('video:loadedmetadata', checkVertical);
|
||||||
|
|
||||||
|
// --- Custom Mini Player Logic ---
|
||||||
|
setupMiniPlayer();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return art;
|
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 ---
|
// --- Loop Logic ---
|
||||||
function toggleLoop(btn) {
|
function toggleLoop(btn) {
|
||||||
if (!window.player) {
|
if (!window.player) {
|
||||||
|
|
@ -1180,7 +1354,14 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
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
|
// Check content type to avoid JSON syntax errors if HTML is returned
|
||||||
|
|
@ -1197,6 +1378,7 @@
|
||||||
title: data.title,
|
title: data.title,
|
||||||
thumbnail: `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`,
|
thumbnail: `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`,
|
||||||
uploader: data.uploader || 'Unknown',
|
uploader: data.uploader || 'Unknown',
|
||||||
|
channel_id: data.channel_id || data.uploader_id || '',
|
||||||
duration: data.duration
|
duration: data.duration
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1280,9 +1462,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save Button - Local Storage based
|
// Save Button - Local Storage based
|
||||||
document.getElementById('saveBtn').onclick = () => {
|
// Save Button handler is setup in DOMContentLoaded below
|
||||||
saveToLibrary();
|
// Just update state here
|
||||||
};
|
// document.getElementById('saveBtn').onclick setup moved to line ~1600
|
||||||
|
|
||||||
// Check if already saved
|
// Check if already saved
|
||||||
updateSaveButtonState();
|
updateSaveButtonState();
|
||||||
|
|
@ -1376,6 +1558,96 @@
|
||||||
btn.innerHTML = '<i class="fas fa-magic"></i> Summarize with AI';
|
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 ---
|
// --- Related Videos Infinite Scroll Functions ---
|
||||||
let currentVideoTitle = '';
|
let currentVideoTitle = '';
|
||||||
let relatedPage = 1;
|
let relatedPage = 1;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue