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
230
app.py
230
app.py
|
|
@ -59,84 +59,15 @@ def init_db():
|
|||
# Run init
|
||||
init_db()
|
||||
|
||||
# --- Auth Helpers ---
|
||||
def login_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if 'user_id' not in session:
|
||||
return redirect(url_for('login'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
def get_db_connection():
|
||||
conn = sqlite3.connect(DB_NAME)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
# --- Auth Routes ---
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if request.method == 'POST':
|
||||
username = request.form['username']
|
||||
password = request.form['password']
|
||||
# --- 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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) -->
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue