Initial commit: KV-Tube v2.0 complete
This commit is contained in:
commit
fb65d88e6b
26 changed files with 6120 additions and 0 deletions
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
.DS_Store
|
||||
__pycache__/
|
||||
*.pyc
|
||||
venv/
|
||||
.env
|
||||
data/
|
||||
videos/
|
||||
*.db
|
||||
68
Dockerfile
Normal file
68
Dockerfile
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# Build stage
|
||||
FROM python:3.11-slim as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
python3-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create and activate virtual environment
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Runtime stage
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
libcairo2 \
|
||||
libpango-1.0-0 \
|
||||
libpangocairo-1.0-0 \
|
||||
libgdk-pixbuf-2.0-0 \
|
||||
libffi-dev \
|
||||
shared-mime-info \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy static ffmpeg
|
||||
COPY --from=mwader/static-ffmpeg:6.1 /ffmpeg /usr/local/bin/
|
||||
COPY --from=mwader/static-ffmpeg:6.1 /ffprobe /usr/local/bin/
|
||||
|
||||
# Copy virtual environment from builder
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy application code
|
||||
COPY app.py .
|
||||
COPY templates/ templates/
|
||||
COPY static/ static/
|
||||
|
||||
# Create directories for data persistence
|
||||
RUN mkdir -p /app/videos /app/data
|
||||
|
||||
# Create directories for data persistence
|
||||
RUN mkdir -p /app/videos /app/data
|
||||
|
||||
# Environment variables
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV FLASK_APP=app.py
|
||||
ENV FLASK_ENV=production
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:5001/ || exit 1
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5001
|
||||
|
||||
# Run with Gunicorn for production
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:5001", "--workers", "2", "--threads", "4", "--timeout", "120", "app:app"]
|
||||
103
README.md
Normal file
103
README.md
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# KV-Tube
|
||||
|
||||
A YouTube-like video streaming web application with a pixel-perfect YouTube dark theme UI.
|
||||
|
||||
## Recent Updates (v1.1)
|
||||
- 🚀 **Performance**: Reduced Docker image size by using static `ffmpeg`.
|
||||
- 📱 **Mobile UI**: Improved 2-column video grid layout and compact sort options on mobile.
|
||||
- 📦 **NAS Support**: Fixed permission issues by running as root and added multi-arch support (AMD64/ARM64).
|
||||
|
||||
## Features
|
||||
|
||||
- 🎬 **YouTube Video Playback** - Stream any YouTube video via HLS proxy
|
||||
- 🎨 **YouTube Dark Theme** - Pixel-perfect recreation of YouTube's UI
|
||||
- 📱 **Responsive Design** - Works on desktop, tablet, and mobile
|
||||
- 🔍 **Search** - Search YouTube videos directly
|
||||
- 📚 **Library** - Save videos and view history
|
||||
- 🎯 **Categories** - Browse by Music, Gaming, News, Sports, etc.
|
||||
- 🖥️ **Local Videos** - Play local video files
|
||||
|
||||
## Quick Start (Docker)
|
||||
|
||||
### Build and Run
|
||||
|
||||
```bash
|
||||
# Build the image
|
||||
docker build -t kv-tube .
|
||||
|
||||
# Run the container
|
||||
docker run -d -p 5001:5001 --name kv-tube kv-tube
|
||||
```
|
||||
|
||||
### Using Docker Compose
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
Access the app at: http://localhost:5001
|
||||
|
||||
## Synology NAS Deployment
|
||||
|
||||
### Option 1: Container Manager (Docker)
|
||||
|
||||
1. Open **Container Manager** on your Synology NAS
|
||||
2. Go to **Project** → **Create**
|
||||
3. Upload the `docker-compose.yml` file
|
||||
4. Click **Build** and wait for completion
|
||||
5. Access via `http://your-nas-ip:5001`
|
||||
|
||||
### Option 2: Manual Docker
|
||||
|
||||
```bash
|
||||
# SSH into your NAS
|
||||
ssh admin@your-nas-ip
|
||||
|
||||
# Navigate to your docker folder
|
||||
cd /volume1/docker
|
||||
|
||||
# Clone/copy the project
|
||||
git clone <repo-url> kv-tube
|
||||
cd kv-tube
|
||||
|
||||
# Build and run
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
kv-tube/
|
||||
├── app.py # Flask application
|
||||
├── requirements.txt # Python dependencies
|
||||
├── Dockerfile # Docker build config
|
||||
├── docker-compose.yml # Docker Compose config
|
||||
├── templates/ # HTML templates
|
||||
│ ├── layout.html # Base layout (header, sidebar)
|
||||
│ ├── index.html # Home page
|
||||
│ ├── watch.html # Video player page
|
||||
│ └── ...
|
||||
├── static/
|
||||
│ ├── css/style.css # YouTube-style CSS
|
||||
│ └── js/main.js # Frontend JavaScript
|
||||
└── kctube.db # SQLite database
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `FLASK_ENV` | production | Flask environment |
|
||||
| `PYTHONUNBUFFERED` | 1 | Python output buffering |
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Backend**: Flask + Gunicorn
|
||||
- **Frontend**: Vanilla JS + Artplayer
|
||||
- **Video**: yt-dlp + HLS.js
|
||||
- **Database**: SQLite
|
||||
- **Container**: Docker
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
778
app.py
Normal file
778
app.py
Normal file
|
|
@ -0,0 +1,778 @@
|
|||
from flask import Flask, render_template, request, redirect, url_for, jsonify, send_file, Response, stream_with_context, session, flash
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import json
|
||||
import requests
|
||||
import sqlite3
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
import yt_dlp
|
||||
from functools import wraps
|
||||
import yt_dlp
|
||||
from functools import wraps
|
||||
from youtube_transcript_api import YouTubeTranscriptApi, TranscriptsDisabled
|
||||
import re
|
||||
import heapq
|
||||
# nltk removed to avoid SSL/download issues. Using regex instead.
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = 'super_secret_key_change_this' # Required for sessions
|
||||
|
||||
# Ensure data directory exists for persistence
|
||||
DATA_DIR = "data"
|
||||
if not os.path.exists(DATA_DIR):
|
||||
os.makedirs(DATA_DIR)
|
||||
|
||||
DB_NAME = os.path.join(DATA_DIR, "kvtube.db")
|
||||
|
||||
# --- Database Setup ---
|
||||
def init_db():
|
||||
conn = sqlite3.connect(DB_NAME)
|
||||
c = conn.cursor()
|
||||
# Users Table
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL
|
||||
)''')
|
||||
# Saved/History Table
|
||||
# type: 'history' or 'saved'
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS user_videos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
video_id TEXT,
|
||||
title TEXT,
|
||||
thumbnail TEXT,
|
||||
type TEXT,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
)''')
|
||||
# Cache Table for video metadata/streams
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS video_cache (
|
||||
video_id TEXT PRIMARY KEY,
|
||||
data TEXT,
|
||||
expires_at DATETIME
|
||||
)''')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Run init
|
||||
init_db()
|
||||
|
||||
# --- Auth Helpers ---
|
||||
def login_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if 'user_id' not in session:
|
||||
return redirect(url_for('login'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
def get_db_connection():
|
||||
conn = sqlite3.connect(DB_NAME)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
# --- Auth Routes ---
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if request.method == 'POST':
|
||||
username = request.form['username']
|
||||
password = request.form['password']
|
||||
|
||||
conn = get_db_connection()
|
||||
user = conn.execute('SELECT * FROM users WHERE username = ?', (username,)).fetchone()
|
||||
conn.close()
|
||||
|
||||
if user and check_password_hash(user['password'], password):
|
||||
session['user_id'] = user['id']
|
||||
session['username'] = user['username']
|
||||
return redirect(url_for('index')) # Changed from 'home' to 'index'
|
||||
else:
|
||||
flash('Invalid username or password')
|
||||
|
||||
return render_template('login.html')
|
||||
|
||||
@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'
|
||||
|
||||
# Configuration for local video path - configurable via env var
|
||||
VIDEO_DIR = os.environ.get('KVTUBE_VIDEO_DIR', './videos')
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return render_template('index.html', page='home')
|
||||
|
||||
@app.route('/my-videos')
|
||||
@login_required
|
||||
def my_videos():
|
||||
filter_type = request.args.get('type', 'saved') # 'saved' or 'history'
|
||||
|
||||
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)
|
||||
|
||||
@app.route('/api/save_video', methods=['POST'])
|
||||
@login_required
|
||||
def save_video():
|
||||
data = request.json
|
||||
video_id = data.get('id')
|
||||
title = data.get('title')
|
||||
thumbnail = data.get('thumbnail')
|
||||
action_type = data.get('type', 'history') # 'history' or 'saved'
|
||||
|
||||
conn = get_db_connection()
|
||||
|
||||
# Check if already exists to prevent duplicates (optional, strictly for 'saved')
|
||||
if action_type == 'saved':
|
||||
exists = conn.execute('SELECT id FROM user_videos WHERE user_id = ? AND video_id = ? AND type = ?',
|
||||
(session['user_id'], video_id, 'saved')).fetchone()
|
||||
if exists:
|
||||
conn.close()
|
||||
return jsonify({'status': 'already_saved'})
|
||||
|
||||
conn.execute('INSERT INTO user_videos (user_id, video_id, title, thumbnail, type) VALUES (?, ?, ?, ?, ?)',
|
||||
(session['user_id'], video_id, title, thumbnail, action_type))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'status': 'success'})
|
||||
|
||||
@app.route('/stream/<path:filename>')
|
||||
def stream_local(filename):
|
||||
return send_from_directory(VIDEO_DIR, filename)
|
||||
|
||||
@app.route('/settings')
|
||||
def settings():
|
||||
return render_template('settings.html', page='settings')
|
||||
|
||||
@app.route('/video_proxy')
|
||||
def video_proxy():
|
||||
url = request.args.get('url')
|
||||
if not url:
|
||||
return "No URL provided", 400
|
||||
|
||||
# Forward headers to mimic browser and support seeking
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
}
|
||||
|
||||
# Support Range requests (scrubbing)
|
||||
range_header = request.headers.get('Range')
|
||||
if range_header:
|
||||
headers['Range'] = range_header
|
||||
|
||||
try:
|
||||
req = requests.get(url, headers=headers, stream=True, timeout=30)
|
||||
|
||||
# Handle HLS (M3U8) Rewriting - CRITICAL for 1080p+ and proper sync
|
||||
content_type = req.headers.get('content-type', '').lower()
|
||||
# Extract URL path without query params for checking extension
|
||||
url_path = url.split('?')[0]
|
||||
is_manifest = (url_path.endswith('.m3u8') or
|
||||
'application/x-mpegurl' in content_type or
|
||||
'application/vnd.apple.mpegurl' in content_type)
|
||||
|
||||
if is_manifest:
|
||||
content = req.text
|
||||
base_url = url.rsplit('/', 1)[0]
|
||||
new_lines = []
|
||||
|
||||
for line in content.splitlines():
|
||||
if line.strip() and not line.startswith('#'):
|
||||
# It's a segment or sub-playlist
|
||||
# If relative, make absolute
|
||||
if not line.startswith('http'):
|
||||
full_url = f"{base_url}/{line}"
|
||||
else:
|
||||
full_url = line
|
||||
|
||||
# Proxy it - use urllib.parse.quote with safe parameter
|
||||
from urllib.parse import quote
|
||||
quoted_url = quote(full_url, safe='')
|
||||
new_lines.append(f"/video_proxy?url={quoted_url}")
|
||||
else:
|
||||
new_lines.append(line)
|
||||
|
||||
return Response('\n'.join(new_lines), content_type='application/vnd.apple.mpegurl')
|
||||
|
||||
# Standard Stream Proxy (Binary)
|
||||
# We exclude headers that might confuse the browser/flask
|
||||
excluded_headers = ['content-encoding', 'content-length', 'transfer-encoding', 'connection']
|
||||
response_headers = [(name, value) for (name, value) in req.headers.items()
|
||||
if name.lower() not in excluded_headers]
|
||||
|
||||
return Response(stream_with_context(req.iter_content(chunk_size=8192)),
|
||||
status=req.status_code,
|
||||
headers=response_headers,
|
||||
content_type=req.headers.get('content-type'))
|
||||
except Exception as e:
|
||||
print(f"Proxy Error: {e}")
|
||||
return str(e), 500
|
||||
|
||||
@app.route('/watch')
|
||||
def watch():
|
||||
video_id = request.args.get('v')
|
||||
local_file = request.args.get('local')
|
||||
|
||||
if local_file:
|
||||
return render_template('watch.html', video_type='local', src=url_for('stream_local', filename=local_file), title=local_file)
|
||||
|
||||
if not video_id:
|
||||
return "No video ID provided", 400
|
||||
return render_template('watch.html', video_type='youtube', video_id=video_id)
|
||||
|
||||
@app.route('/api/get_stream_info')
|
||||
def get_stream_info():
|
||||
video_id = request.args.get('v')
|
||||
if not video_id:
|
||||
return jsonify({'error': 'No video ID'}), 400
|
||||
|
||||
try:
|
||||
# 1. Check Cache
|
||||
import time
|
||||
conn = get_db_connection()
|
||||
cached = conn.execute('SELECT data, expires_at FROM video_cache WHERE video_id = ?', (video_id,)).fetchone()
|
||||
|
||||
current_time = time.time()
|
||||
if cached:
|
||||
# Check expiry (stored as unix timestamp or datetime string, we'll use timestamp for simplicity)
|
||||
try:
|
||||
expires_at = float(cached['expires_at'])
|
||||
if current_time < expires_at:
|
||||
data = json.loads(cached['data'])
|
||||
conn.close()
|
||||
# Re-proxy the URL just in case, or use cached if valid.
|
||||
# Actually proxy url requires encoding, let's reconstruct it to be safe.
|
||||
from urllib.parse import quote
|
||||
proxied_url = f"/video_proxy?url={quote(data['original_url'], safe='')}"
|
||||
data['stream_url'] = proxied_url
|
||||
|
||||
# Add cache hit header for debug
|
||||
response = jsonify(data)
|
||||
response.headers['X-Cache'] = 'HIT'
|
||||
return response
|
||||
except:
|
||||
pass # Invalid cache, fall through
|
||||
|
||||
# 2. Fetch from YouTube (Library Optimization)
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
|
||||
ydl_opts = {
|
||||
'format': 'best[ext=mp4]/best',
|
||||
'noplaylist': True,
|
||||
'quiet': True,
|
||||
'no_warnings': True,
|
||||
'skip_download': True,
|
||||
'force_ipv4': True,
|
||||
'socket_timeout': 10,
|
||||
}
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
try:
|
||||
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
|
||||
|
||||
stream_url = info.get('url')
|
||||
if not stream_url:
|
||||
return jsonify({'error': 'No stream URL found in metadata'}), 500
|
||||
|
||||
# Fetch Related Videos (Fallback to search if not provided)
|
||||
# We use the title + " related" to find relevant content
|
||||
related_videos = []
|
||||
try:
|
||||
search_query = f"{info.get('title', '')} related"
|
||||
related_videos = fetch_videos(search_query, limit=10)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Extract Subtitles (English preferred)
|
||||
subtitle_url = None
|
||||
start_lang = 'en'
|
||||
|
||||
subs = info.get('subtitles') or {}
|
||||
auto_subs = info.get('automatic_captions') or {}
|
||||
|
||||
# Check manual subs first
|
||||
if 'en' in subs:
|
||||
subtitle_url = subs['en'][0]['url']
|
||||
elif 'vi' in subs: # Vietnamese fallback
|
||||
subtitle_url = subs['vi'][0]['url']
|
||||
# Check auto subs
|
||||
elif 'en' in auto_subs:
|
||||
subtitle_url = auto_subs['en'][0]['url']
|
||||
|
||||
# If still none, just pick the first one from manual
|
||||
if not subtitle_url and subs:
|
||||
first_key = list(subs.keys())[0]
|
||||
subtitle_url = subs[first_key][0]['url']
|
||||
|
||||
# 3. Construct Response Data
|
||||
response_data = {
|
||||
'original_url': stream_url,
|
||||
'title': info.get('title', 'Unknown Title'),
|
||||
'description': info.get('description', ''),
|
||||
'uploader': info.get('uploader', ''),
|
||||
'upload_date': info.get('upload_date', ''),
|
||||
'view_count': info.get('view_count', 0),
|
||||
'related': related_videos,
|
||||
'subtitle_url': subtitle_url
|
||||
}
|
||||
|
||||
# 4. Cache It (valid for 1 hour = 3600s)
|
||||
# YouTube URLs expire in ~6 hours usually.
|
||||
expiry = current_time + 3600
|
||||
conn.execute('INSERT OR REPLACE INTO video_cache (video_id, data, expires_at) VALUES (?, ?, ?)',
|
||||
(video_id, json.dumps(response_data), expiry))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# 5. Return Response
|
||||
from urllib.parse import quote
|
||||
proxied_url = f"/video_proxy?url={quote(stream_url, safe='')}"
|
||||
response_data['stream_url'] = proxied_url
|
||||
|
||||
response = jsonify(response_data)
|
||||
response.headers['X-Cache'] = 'MISS'
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/search')
|
||||
def search():
|
||||
query = request.args.get('q')
|
||||
if not query:
|
||||
return jsonify({'error': 'No query provided'}), 400
|
||||
|
||||
try:
|
||||
# Check if query is a YouTube URL
|
||||
import re
|
||||
# Regex to catch youtube.com/watch?v=, youtu.be/, shorts/, etc.
|
||||
youtube_regex = r'(https?://)?(www\.)?(youtube\.com/watch\?v=|youtu\.be/|youtube\.com/shorts/)([\w-]+)'
|
||||
match = re.search(youtube_regex, query)
|
||||
|
||||
if match:
|
||||
video_id = match.group(4)
|
||||
# Fetch direct metadata
|
||||
meta_cmd = [sys.executable, '-m', 'yt_dlp', '--dump-json', '--no-playlist', f'https://www.youtube.com/watch?v={video_id}']
|
||||
meta_proc = subprocess.run(meta_cmd, capture_output=True, text=True)
|
||||
|
||||
results = []
|
||||
search_title = ""
|
||||
|
||||
if meta_proc.returncode == 0:
|
||||
data = json.loads(meta_proc.stdout)
|
||||
search_title = data.get('title', '')
|
||||
|
||||
# Format duration
|
||||
duration_secs = data.get('duration')
|
||||
if duration_secs:
|
||||
mins, secs = divmod(int(duration_secs), 60)
|
||||
hours, mins = divmod(mins, 60)
|
||||
duration = f"{hours}:{mins:02d}:{secs:02d}" if hours else f"{mins}:{secs:02d}"
|
||||
else:
|
||||
duration = None
|
||||
|
||||
# Add the exact match first
|
||||
results.append({
|
||||
'id': data.get('id'),
|
||||
'title': data.get('title', 'Unknown'),
|
||||
'uploader': data.get('uploader') or data.get('channel') or 'Unknown',
|
||||
'thumbnail': f"https://i.ytimg.com/vi/{data.get('id')}/hqdefault.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
|
||||
})
|
||||
|
||||
# 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
|
||||
'--dump-json',
|
||||
'--default-search', 'ytsearch',
|
||||
'--no-playlist',
|
||||
'--flat-playlist'
|
||||
]
|
||||
rel_proc = subprocess.Popen(rel_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
stdout, _ = rel_proc.communicate()
|
||||
|
||||
for line in stdout.splitlines():
|
||||
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)
|
||||
h, m = divmod(m, 60)
|
||||
dur_str = f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
|
||||
else:
|
||||
dur_str = None
|
||||
|
||||
results.append({
|
||||
'id': r_id,
|
||||
'title': r_data.get('title', 'Unknown'),
|
||||
'uploader': r_data.get('uploader') or r_data.get('channel') or 'Unknown',
|
||||
'thumbnail': f"https://i.ytimg.com/vi/{r_id}/hqdefault.jpg",
|
||||
'view_count': r_data.get('view_count', 0),
|
||||
'upload_date': r_data.get('upload_date', ''),
|
||||
'duration': dur_str
|
||||
})
|
||||
except:
|
||||
continue
|
||||
|
||||
return jsonify(results)
|
||||
|
||||
else:
|
||||
# Standard Text Search
|
||||
cmd = [
|
||||
sys.executable, '-m', 'yt_dlp',
|
||||
f'ytsearch20:{query}',
|
||||
'--dump-json',
|
||||
'--default-search', 'ytsearch',
|
||||
'--no-playlist',
|
||||
'--flat-playlist'
|
||||
]
|
||||
|
||||
# Run command
|
||||
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
stdout, stderr = process.communicate()
|
||||
|
||||
results = []
|
||||
for line in stdout.splitlines():
|
||||
try:
|
||||
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)
|
||||
hours, mins = divmod(mins, 60)
|
||||
duration = f"{hours}:{mins:02d}:{secs:02d}" if hours else f"{mins}:{secs:02d}"
|
||||
else:
|
||||
duration = None
|
||||
|
||||
results.append({
|
||||
'id': video_id,
|
||||
'title': data.get('title', 'Unknown'),
|
||||
'uploader': data.get('uploader') or data.get('channel') or 'Unknown',
|
||||
'thumbnail': f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg",
|
||||
'view_count': data.get('view_count', 0),
|
||||
'upload_date': data.get('upload_date', ''),
|
||||
'duration': duration
|
||||
})
|
||||
except:
|
||||
continue
|
||||
|
||||
return jsonify(results)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
# --- Helper: Extractive Summarization ---
|
||||
def extractive_summary(text, num_sentences=5):
|
||||
# 1. Clean and parse text
|
||||
# Remove metadata like [Music] (common in auto-caps)
|
||||
clean_text = re.sub(r'\[.*?\]', '', text)
|
||||
clean_text = clean_text.replace('\n', ' ')
|
||||
|
||||
# 2. Split into sentences (simple punctuation split)
|
||||
sentences = re.split(r'(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=\.|\?)\s', clean_text)
|
||||
|
||||
# 3. Tokenize and Calculate Word Frequencies
|
||||
word_frequencies = {}
|
||||
stop_words = set(['the', 'a', 'an', 'and', 'or', 'but', 'is', 'are', 'was', 'were', 'to', 'of', 'in', 'on', 'at', 'for', 'width', 'that', 'this', 'it', 'you', 'i', 'we', 'they', 'he', 'she'])
|
||||
|
||||
for word in re.findall(r'\w+', clean_text.lower()):
|
||||
if word not in stop_words:
|
||||
if word not in word_frequencies:
|
||||
word_frequencies[word] = 1
|
||||
else:
|
||||
word_frequencies[word] += 1
|
||||
|
||||
if not word_frequencies:
|
||||
return "Not enough content to summarize."
|
||||
|
||||
# Normalize frequencies
|
||||
max_freq = max(word_frequencies.values())
|
||||
for word in word_frequencies:
|
||||
word_frequencies[word] = word_frequencies[word] / max_freq
|
||||
|
||||
# 4. Score Sentences
|
||||
sentence_scores = {}
|
||||
for sent in sentences:
|
||||
for word in re.findall(r'\w+', sent.lower()):
|
||||
if word in word_frequencies:
|
||||
if sent not in sentence_scores:
|
||||
sentence_scores[sent] = word_frequencies[word]
|
||||
else:
|
||||
sentence_scores[sent] += word_frequencies[word]
|
||||
|
||||
# 5. Extract Top N Sentences
|
||||
summary_sentences = heapq.nlargest(num_sentences, sentence_scores, key=sentence_scores.get)
|
||||
return ' '.join(summary_sentences)
|
||||
|
||||
@app.route('/api/summarize')
|
||||
def summarize_video():
|
||||
video_id = request.args.get('v')
|
||||
if not video_id:
|
||||
return jsonify({'error': 'No video ID'}), 400
|
||||
|
||||
try:
|
||||
# Fetch Transcript
|
||||
transcript_list = YouTubeTranscriptApi.list_transcripts(video_id)
|
||||
|
||||
# Try to find english or manually created first, then auto
|
||||
try:
|
||||
transcript = transcript_list.find_transcript(['en', 'vi'])
|
||||
except:
|
||||
# Fallback to whatever is available (likely auto-generated)
|
||||
transcript = transcript_list.find_generated_transcript(['en', 'vi'])
|
||||
|
||||
transcript_data = transcript.fetch()
|
||||
|
||||
# Combine text
|
||||
full_text = " ".join([entry['text'] for entry in transcript_data])
|
||||
|
||||
# Summarize
|
||||
summary = extractive_summary(full_text, num_sentences=7)
|
||||
|
||||
return jsonify({'success': True, 'summary': summary})
|
||||
|
||||
except TranscriptsDisabled:
|
||||
return jsonify({'success': False, 'message': 'Subtitles are disabled for this video.'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': f'Could not summarize: {str(e)}'})
|
||||
|
||||
@app.route('/api/trending')
|
||||
def fetch_videos(query, limit=20):
|
||||
try:
|
||||
cmd = [
|
||||
sys.executable, '-m', 'yt_dlp',
|
||||
f'ytsearch{limit}:{query}',
|
||||
'--dump-json',
|
||||
'--default-search', 'ytsearch',
|
||||
'--no-playlist',
|
||||
'--flat-playlist'
|
||||
]
|
||||
|
||||
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
stdout, stderr = process.communicate()
|
||||
|
||||
results = []
|
||||
for line in stdout.splitlines():
|
||||
try:
|
||||
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)
|
||||
hours, mins = divmod(mins, 60)
|
||||
duration = f"{hours}:{mins:02d}:{secs:02d}" if hours else f"{mins}:{secs:02d}"
|
||||
else:
|
||||
duration = None
|
||||
|
||||
results.append({
|
||||
'id': video_id,
|
||||
'title': data.get('title', 'Unknown'),
|
||||
'uploader': data.get('uploader') or data.get('channel') or 'Unknown',
|
||||
'thumbnail': f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg",
|
||||
'view_count': data.get('view_count', 0),
|
||||
'upload_date': data.get('upload_date', ''),
|
||||
'duration': duration
|
||||
})
|
||||
except:
|
||||
continue
|
||||
return results
|
||||
except Exception as e:
|
||||
print(f"Error fetching videos: {e}")
|
||||
return []
|
||||
|
||||
@app.route('/api/trending')
|
||||
def trending():
|
||||
try:
|
||||
category = request.args.get('category', 'general')
|
||||
page = int(request.args.get('page', 1))
|
||||
sort = request.args.get('sort', 'month')
|
||||
region = request.args.get('region', 'vietnam')
|
||||
limit = 20
|
||||
|
||||
# Define search queries
|
||||
if region == 'vietnam':
|
||||
queries = {
|
||||
'general': 'trending vietnam',
|
||||
'tech': 'AI tools software tech review IT việt nam',
|
||||
'all': 'trending vietnam',
|
||||
'music': 'nhạc việt trending',
|
||||
'gaming': 'gaming việt nam',
|
||||
'movies': 'phim việt nam',
|
||||
'news': 'tin tức việt nam hôm nay',
|
||||
'sports': 'thể thao việt nam',
|
||||
'shorts': 'shorts việt nam',
|
||||
'trending': 'trending việt nam',
|
||||
'podcasts': 'podcast việt nam',
|
||||
'live': 'live stream việt nam'
|
||||
}
|
||||
else:
|
||||
queries = {
|
||||
'general': 'trending',
|
||||
'tech': 'AI tools software tech review IT',
|
||||
'all': 'trending',
|
||||
'music': 'music trending',
|
||||
'gaming': 'gaming trending',
|
||||
'movies': 'movies trending',
|
||||
'news': 'news today',
|
||||
'sports': 'sports highlights',
|
||||
'shorts': 'shorts trending',
|
||||
'trending': 'trending now',
|
||||
'podcasts': 'podcast trending',
|
||||
'live': 'live stream'
|
||||
}
|
||||
|
||||
base_query = queries.get(category, 'trending vietnam' if region == 'vietnam' else 'trending')
|
||||
|
||||
# Add sort filter
|
||||
sort_filters = {
|
||||
'day': ', today',
|
||||
'week': ', this week',
|
||||
'month': ', this month',
|
||||
'year': ', this year'
|
||||
}
|
||||
query = base_query + sort_filters.get(sort, ', this month')
|
||||
|
||||
# For pagination, we can't easily offset ytsearch efficiently without fetching all previous
|
||||
# So we'll fetch a larger chunk and slice it in python, or just accept that page 2 is similar
|
||||
# A simple hack for "randomness" or pages is to append a random term or year, but let's stick to standard behavior
|
||||
# Or better: search for "query page X"
|
||||
if page > 1:
|
||||
query += f" page {page}"
|
||||
|
||||
results = fetch_videos(query, limit=limit)
|
||||
return jsonify(results)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/update_ytdlp', methods=['POST'])
|
||||
def update_ytdlp():
|
||||
try:
|
||||
# Run pip install -U yt-dlp
|
||||
cmd = [sys.executable, '-m', 'pip', 'install', '-U', 'yt-dlp']
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode == 0:
|
||||
# Check new version
|
||||
ver_cmd = [sys.executable, '-m', 'yt_dlp', '--version']
|
||||
ver_result = subprocess.run(ver_cmd, capture_output=True, text=True)
|
||||
version = ver_result.stdout.strip()
|
||||
return jsonify({'success': True, 'message': f'Updated successfully to {version}'})
|
||||
else:
|
||||
return jsonify({'success': False, 'message': f'Update failed: {result.stderr}'}), 500
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
@app.route('/api/comments')
|
||||
def get_comments():
|
||||
"""Get comments for a YouTube video"""
|
||||
video_id = request.args.get('v')
|
||||
if not video_id:
|
||||
return jsonify({'error': 'No video ID'}), 400
|
||||
|
||||
try:
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
cmd = [
|
||||
sys.executable, '-m', 'yt_dlp',
|
||||
url,
|
||||
'--write-comments',
|
||||
'--skip-download',
|
||||
'--dump-json'
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
|
||||
if result.returncode == 0:
|
||||
data = json.loads(result.stdout)
|
||||
comments_data = data.get('comments', [])
|
||||
|
||||
# Format comments for frontend
|
||||
comments = []
|
||||
for c in comments_data[:50]: # Limit to 50 comments
|
||||
comments.append({
|
||||
'author': c.get('author', 'Unknown'),
|
||||
'author_thumbnail': c.get('author_thumbnail', ''),
|
||||
'text': c.get('text', ''),
|
||||
'likes': c.get('like_count', 0),
|
||||
'time': c.get('time_text', ''),
|
||||
'is_pinned': c.get('is_pinned', False)
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'comments': comments,
|
||||
'count': data.get('comment_count', len(comments))
|
||||
})
|
||||
else:
|
||||
return jsonify({'comments': [], 'count': 0, 'error': 'Could not load comments'})
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({'comments': [], 'count': 0, 'error': 'Comments loading timed out'})
|
||||
except Exception as e:
|
||||
return jsonify({'comments': [], 'count': 0, 'error': str(e)})
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True, host='0.0.0.0', port=5001)
|
||||
46
doc/Product Requirements Document (PRD) - Khoavo-Tube
Normal file
46
doc/Product Requirements Document (PRD) - Khoavo-Tube
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
Product Requirements Document (PRD) - KCTube
|
||||
1. Product Overview
|
||||
Product Name: KCTube Version: 1.0 (In Development) Description: KCTube is a comprehensive media center web application designed to provide an ad-free YouTube experience, a curated movie streaming service, and a local video management system. It emphasizes privacy, absence of advertisements, and utility features like AI summarization and language learning tools.
|
||||
|
||||
2. User Personas
|
||||
The Binge Watcher: Wants uninterrupted access to YouTube content and movies without ads.
|
||||
The Archivist: Maintains a local collection of videos and wants a clean interface to organize and watch them securely.
|
||||
The Learner: Uses video content for educational purposes, specifically English learning.
|
||||
The Tech Enthusiast: Appreciates PWA support, torrent integration, and customizable settings.
|
||||
3. Core Features
|
||||
3.1. YouTube Viewer (Home)
|
||||
Ad-Free Experience: Plays YouTube videos without third-party advertisements.
|
||||
Search: Integrated search bar powered by yt-dlp to find videos, channels, and playlists.
|
||||
Playback: Custom video player with support for quality selection and playback speed.
|
||||
AI Summarization: Feature to summarize video content using Google Gemini API (Optional).
|
||||
3.2. local Video Manager ("My Videos")
|
||||
Secure Access: Password-protected section for personal video collections.
|
||||
File Management: Scans local directories for video files.
|
||||
Metadata: Extracts metadata (duration, format) and generates thumbnails using FFmpeg/MoviePy.
|
||||
Playback: Native HTML5 player for local files.
|
||||
3.3. Utilities
|
||||
Torrent Player: Interface for streaming/playing video content via torrents.
|
||||
Playlist Manager: Create and manage custom playlists of YouTube videos.
|
||||
Camera/Photo: ("Chụp ảnh") Feature to capture or manage photos (Webcam integration).
|
||||
Configuration: Web-based settings to manage application behavior (e.g., password, storage paths).
|
||||
4. Technical Architecture
|
||||
Backend: Python / Flask
|
||||
Frontend: HTML5, CSS3, JavaScript (Vanilla)
|
||||
Database/Storage: JSON-based local storage and file system.
|
||||
Video Processing: yt-dlp (YouTube), FFmpeg (Conversion/Thumbnail), MoviePy (Optional).
|
||||
AI Service: Google Gemini API (for summarization).
|
||||
Deployment: Docker container support (xehopnet/kctube).
|
||||
5. Non-Functional Requirements
|
||||
Performance: Fast load times and responsive UI.
|
||||
Compatibility: PWA-ready for installation on desktop and mobile.
|
||||
Reliability: graceful degradation if optional dependencies (MoviePy, Gemini) are missing.
|
||||
Privacy: No user tracking or external analytics.
|
||||
6. Known Limitations
|
||||
Search Reliability: Dependent on yt-dlp stability and YouTube's anti-bot measures.
|
||||
External APIs: Movie features rely on third-party APIs which may have downtime.
|
||||
Dependency Management: Some Python libraries (MoviePy, Numpy) require compilation tools.
|
||||
7. Future Roadmap
|
||||
Database: Migrate from JSON to SQLite for better performance with large libraries.
|
||||
User Accounts: Individual user profiles and history.
|
||||
Offline Mode: Enhanced offline capabilities for PWA.
|
||||
Casting: Support for Chromecast/AirPlay.
|
||||
29
docker-compose.yml
Normal file
29
docker-compose.yml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# KV-Tube Docker Compose for Synology NAS
|
||||
# Usage: docker-compose up -d
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
kv-tube:
|
||||
# build: .
|
||||
image: vndangkhoa/kvtube:latest
|
||||
container_name: kv-tube
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5011:5001"
|
||||
volumes:
|
||||
# Persist data (Easy setup: Just maps a folder)
|
||||
- ./data:/app/data
|
||||
# Local videos folder (Optional)
|
||||
# - ./videos:/app/youtube_downloads
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- FLASK_ENV=production
|
||||
healthcheck:
|
||||
test: [ "CMD", "curl", "-f", "http://localhost:5001/" ]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
29
generate_icons.py
Normal file
29
generate_icons.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
from PIL import Image, ImageDraw, ImageFont
|
||||
import os
|
||||
|
||||
def create_icon(size, output_path):
|
||||
"""Create a simple icon with the given size"""
|
||||
img = Image.new('RGB', (size, size), color='#ff0000')
|
||||
d = ImageDraw.Draw(img)
|
||||
|
||||
# Add text to the icon
|
||||
try:
|
||||
font_size = size // 3
|
||||
font = ImageFont.truetype("Arial", font_size)
|
||||
d.text((size//2, size//2), "KV", fill="white", anchor="mm", font=font, align="center")
|
||||
except:
|
||||
# Fallback if font loading fails
|
||||
d.rectangle([size//4, size//4, 3*size//4, 3*size//4], fill="white")
|
||||
|
||||
# Save the icon
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
img.save(output_path, 'PNG')
|
||||
|
||||
# Generate icons in different sizes
|
||||
icon_sizes = [192, 512]
|
||||
for size in icon_sizes:
|
||||
output_path = f"static/icons/icon-{size}x{size}.png"
|
||||
create_icon(size, output_path)
|
||||
print(f"Created icon: {output_path}")
|
||||
|
||||
print("Icons generated successfully!")
|
||||
1073
proxy_check.m3u8
Normal file
1073
proxy_check.m3u8
Normal file
File diff suppressed because it is too large
Load diff
16
requirements.txt
Normal file
16
requirements.txt
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
flask==3.0.2
|
||||
requests==2.31.0
|
||||
pytube==15.0.0
|
||||
python-dotenv==1.0.1
|
||||
yt-dlp
|
||||
moviepy
|
||||
numpy
|
||||
google-generativeai==0.8.3
|
||||
flask-cors==4.0.0
|
||||
youtube-transcript-api==0.6.2
|
||||
werkzeug==3.0.1
|
||||
Pillow
|
||||
pysrt==1.1.2
|
||||
cairosvg
|
||||
gunicorn==21.2.0
|
||||
gunicorn==21.2.0
|
||||
1
response.json
Normal file
1
response.json
Normal file
File diff suppressed because one or more lines are too long
1
response_error.json
Normal file
1
response_error.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"description":"\ud83c\udfa7 TOP 10 NH\u1ea0C VI\u1ec6T AI COVER HAY NH\u1ea4T 2025 | Ballad \u2013 Metal Rock \u2013 Cover C\u1ef1c K\u1ef3 C\u1ea3m X\u00fac\nTuy\u1ec3n t\u1eadp 10 ca kh\u00fac Vi\u1ec7t \u0111\u01b0\u1ee3c AI cover v\u1edbi nhi\u1ec1u phong c\u00e1ch: Ballad s\u00e2u l\u1eafng, Metal Rock b\u00f9ng n\u1ed5, v\u00e0 nh\u1eefng b\u1ea3n c\u1ef1c k\u1ef3 c\u1ea3m x\u00fac ch\u1ea1m \u0111\u1ebfn tr\u00e1i tim.\n\ud83c\udfb6 Danh s\u00e1ch b\u00e0i h\u00e1t:\n1.Hai M\u00f9a Noel \u2013 \u0110\u00e0i Ph\u01b0\u01a1ng Trang\n2.Gi\u1eadn H\u1eddn \u2013 Ng\u1ecdc S\u01a1n\n3.N\u1ed7i \u0110au Mu\u1ed9n M\u00e0ng \u2013 Ng\u00f4 Th\u1ee5y Mi\u00ean\n4.Tri\u1ec7u \u0110\u00f3a H\u1ed3ng \u2013 Nh\u1ea1c Ngo\u1ea1i L\u1eddi Vi\u1ec7t\n5.B\u00ean Nhau \u0110\u00eam Nay ( Dancing All Night) Nh\u1ea1c Ngo\u1ea1i L\u1eddi Vi\u1ec7t\n6.Xin C\u00f2n G\u1ecdi T\u00ean Nhau \u2013 Tr\u01b0\u1eddng Sa\n7.Ng\u00e0y Ch\u01b0a Gi\u00f4ng B\u00e3o \u2013 Phan M\u1ea1nh Qu\u1ef3nh\n8.V\u00ec Anh Y\u00eau Em \u2013 \u0110\u1ee9c Tr\u00ed\n9.T\u00ecnh Phai \u2013 B\u1ea3o Ch\u1ea5n\n10.M\u01b0a Chi\u1ec1u - Anh B\u1eb1ng\n\ud83d\udc9b H\u00e3y chia s\u1ebb c\u1ea3m x\u00fac c\u1ee7a b\u1ea1n\nB\u1ea1n th\u00edch phi\u00ean b\u1ea3n Ballad, Metal Rock, hay b\u1ea3n cover c\u1ef1c k\u1ef3 c\u1ea3m x\u00fac nh\u1ea5t?\nH\u00e3y \u0111\u1ec3 l\u1ea1i b\u00ecnh lu\u1eadn b\u00ean d\u01b0\u1edbi!\n\ud83d\udccc Like \u2013 Share \u2013 Subscribe\n\u0110\u1eebng qu\u00ean \u1ee7ng h\u1ed9 k\u00eanh \u0111\u1ec3 kh\u00f4ng b\u1ecf l\u1ee1 nh\u1eefng playlist AI Cover Nh\u1ea1c Vi\u1ec7t m\u1edbi nh\u1ea5t!\n#AICover #NhacVietHayNhat #MetalRockCover #CoverCamXuc #Top10Cover","original_url":"https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1765805811/ei/k7o_afmFHazapt8P1dC92Qg/ip/14.224.158.192/id/344b71adfe77a807/itag/96/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgoap/clen%3D44663091%3Bdur%3D2759.668%3Bgir%3Dyes%3Bitag%3D140%3Blmt%3D1765336826803662/sgovp/clen%3D331946345%3Bdur%3D2759.599%3Bgir%3Dyes%3Bitag%3D137%3Blmt%3D1765341180328971/rqh/1/hls_chunk_host/rr5---sn-i3belney.googlevideo.com/xpc/EgVo2aDSNQ%3D%3D/cps/80/met/1765784211,/mh/o0/mm/31,29/mn/sn-i3belney,sn-i3b7kn6s/ms/au,rdu/mv/m/mvi/5/pl/23/rms/au,au/initcwndbps/803750/bui/AYUSA3AaRTPVQR9AxZ34LEZZbskDQYky8w0H-64K2-Agba81LDhyJvj0g3xcpDInynrnA_DiQwpxZb1h/spc/wH4Qqx7pNgBE3ay_Sjas2uufs1KmfJ4_fwxH1n9h1fFKv_xcj6dMs5fl9awBNyrsJPrk2CdTAeSdnuESLCE2crs6/vprv/1/ns/aheANYTAhlRZjT8Yd9mq1RcR/playlist_type/CLEAN/dover/11/txp/5535534/mt/1765783743/fvip/4/keepalive/yes/fexp/51355912,51552689,51565115,51565682,51580968/n/Q2cHoLCNB42OyhS/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgoap,sgovp,rqh,xpc,bui,spc,vprv,ns,playlist_type/sig/AJfQdSswRQIgNonUW7lMmUnoMzBbnh31XHqEySuhEQwmfh4YL1nBQO4CIQDRd9XQFY___68jBfyHbyQGW5Qir54itGggV81mbCsnLQ%3D%3D/lsparams/hls_chunk_host,cps,met,mh,mm,mn,ms,mv,mvi,pl,rms,initcwndbps/lsig/APaTxxMwRQIhANb6xXunUvkchc6tqz4TmX0KNFDCLzJnudPYIRXxM4BHAiAVhVDt56hNjHqgVHQn_dify-P31t6iW_MsKa_40ZT3Jw%3D%3D/playlist/index.m3u8","related":[],"stream_url":"/video_proxy?url=https%3A%2F%2Fmanifest.googlevideo.com%2Fapi%2Fmanifest%2Fhls_playlist%2Fexpire%2F1765805811%2Fei%2Fk7o_afmFHazapt8P1dC92Qg%2Fip%2F14.224.158.192%2Fid%2F344b71adfe77a807%2Fitag%2F96%2Fsource%2Fyoutube%2Frequiressl%2Fyes%2Fratebypass%2Fyes%2Fpfa%2F1%2Fsgoap%2Fclen%253D44663091%253Bdur%253D2759.668%253Bgir%253Dyes%253Bitag%253D140%253Blmt%253D1765336826803662%2Fsgovp%2Fclen%253D331946345%253Bdur%253D2759.599%253Bgir%253Dyes%253Bitag%253D137%253Blmt%253D1765341180328971%2Frqh%2F1%2Fhls_chunk_host%2Frr5---sn-i3belney.googlevideo.com%2Fxpc%2FEgVo2aDSNQ%253D%253D%2Fcps%2F80%2Fmet%2F1765784211%2C%2Fmh%2Fo0%2Fmm%2F31%2C29%2Fmn%2Fsn-i3belney%2Csn-i3b7kn6s%2Fms%2Fau%2Crdu%2Fmv%2Fm%2Fmvi%2F5%2Fpl%2F23%2Frms%2Fau%2Cau%2Finitcwndbps%2F803750%2Fbui%2FAYUSA3AaRTPVQR9AxZ34LEZZbskDQYky8w0H-64K2-Agba81LDhyJvj0g3xcpDInynrnA_DiQwpxZb1h%2Fspc%2FwH4Qqx7pNgBE3ay_Sjas2uufs1KmfJ4_fwxH1n9h1fFKv_xcj6dMs5fl9awBNyrsJPrk2CdTAeSdnuESLCE2crs6%2Fvprv%2F1%2Fns%2FaheANYTAhlRZjT8Yd9mq1RcR%2Fplaylist_type%2FCLEAN%2Fdover%2F11%2Ftxp%2F5535534%2Fmt%2F1765783743%2Ffvip%2F4%2Fkeepalive%2Fyes%2Ffexp%2F51355912%2C51552689%2C51565115%2C51565682%2C51580968%2Fn%2FQ2cHoLCNB42OyhS%2Fsparams%2Fexpire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cratebypass%2Cpfa%2Csgoap%2Csgovp%2Crqh%2Cxpc%2Cbui%2Cspc%2Cvprv%2Cns%2Cplaylist_type%2Fsig%2FAJfQdSswRQIgNonUW7lMmUnoMzBbnh31XHqEySuhEQwmfh4YL1nBQO4CIQDRd9XQFY___68jBfyHbyQGW5Qir54itGggV81mbCsnLQ%253D%253D%2Flsparams%2Fhls_chunk_host%2Ccps%2Cmet%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Crms%2Cinitcwndbps%2Flsig%2FAPaTxxMwRQIhANb6xXunUvkchc6tqz4TmX0KNFDCLzJnudPYIRXxM4BHAiAVhVDt56hNjHqgVHQn_dify-P31t6iW_MsKa_40ZT3Jw%253D%253D%2Fplaylist%2Findex.m3u8","title":"Top 10 B\u1ea3n Cover Nh\u1ea1c Vi\u1ec7t B\u1ea5t H\u1ee7 - Nh\u1eefng B\u1ea3n Nh\u1ea1c Cover \u0110\u1ec9nh Cao","upload_date":"20251209","uploader":"NDT - Music","view_count":36225}
|
||||
1120
static/css/style.css
Normal file
1120
static/css/style.css
Normal file
File diff suppressed because it is too large
Load diff
BIN
static/favicon.ico
Normal file
BIN
static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
BIN
static/icons/icon-192x192.png
Normal file
BIN
static/icons/icon-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
BIN
static/icons/icon-512x512.png
Normal file
BIN
static/icons/icon-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
2
static/js/hls.min.js
vendored
Normal file
2
static/js/hls.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
369
static/js/main.js
Normal file
369
static/js/main.js
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
// KV-Tube Main JavaScript - YouTube Clone
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const resultsArea = document.getElementById('resultsArea');
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Load trending on init
|
||||
loadTrending();
|
||||
}
|
||||
|
||||
// Init Theme
|
||||
initTheme();
|
||||
});
|
||||
|
||||
// Note: Global variables like currentCategory are defined below
|
||||
let currentCategory = 'general';
|
||||
let currentPage = 1;
|
||||
let isLoading = false;
|
||||
|
||||
// --- UI Helpers ---
|
||||
function renderSkeleton() {
|
||||
// Generate 8 skeleton cards
|
||||
return Array(8).fill(0).map(() => `
|
||||
<div class="yt-video-card skeleton-card">
|
||||
<div class="skeleton-thumb skeleton"></div>
|
||||
<div class="skeleton-details">
|
||||
<div class="skeleton-avatar skeleton"></div>
|
||||
<div class="skeleton-text">
|
||||
<div class="skeleton-title skeleton"></div>
|
||||
<div class="skeleton-meta skeleton"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderNoContent(message = 'Try searching for something else', title = 'No videos found') {
|
||||
return `
|
||||
<div class="yt-empty-state">
|
||||
<div class="yt-empty-icon"><i class="fas fa-film"></i></div>
|
||||
<div class="yt-empty-title">${title}</div>
|
||||
<div class="yt-empty-desc">${message}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Search YouTube videos
|
||||
async function searchYouTube(query) {
|
||||
if (isLoading) return;
|
||||
|
||||
const resultsArea = document.getElementById('resultsArea');
|
||||
const loadMoreArea = document.getElementById('loadMoreArea');
|
||||
|
||||
isLoading = true;
|
||||
resultsArea.innerHTML = renderSkeleton();
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
resultsArea.innerHTML = `<div class="yt-loader" style="grid-column: 1/-1;"><p style="color:#f00;">Error: ${data.error}</p></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
displayResults(data, false);
|
||||
if (loadMoreArea) loadMoreArea.style.display = 'none';
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
resultsArea.innerHTML = `<div class="yt-loader" style="grid-column: 1/-1;"><p style="color:#f00;">Failed to fetch results</p></div>`;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Switch category
|
||||
async function switchCategory(category, btn) {
|
||||
if (isLoading) return;
|
||||
|
||||
// Update UI (Pills)
|
||||
document.querySelectorAll('.yt-category-pill').forEach(b => b.classList.remove('active'));
|
||||
if (btn && btn.classList) btn.classList.add('active');
|
||||
|
||||
// Update UI (Sidebar)
|
||||
document.querySelectorAll('.yt-sidebar-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
if (item.getAttribute('data-category') === category) {
|
||||
item.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Reset state
|
||||
currentCategory = category;
|
||||
currentPage = 1;
|
||||
window.currentPage = 1;
|
||||
|
||||
const resultsArea = document.getElementById('resultsArea');
|
||||
resultsArea.innerHTML = renderSkeleton();
|
||||
|
||||
// Hide pagination while loading
|
||||
const paginationArea = document.getElementById('paginationArea');
|
||||
if (paginationArea) paginationArea.style.display = 'none';
|
||||
|
||||
// Load both videos and shorts with current category, sort, and region
|
||||
await loadTrending(true);
|
||||
|
||||
// Also reload shorts to match category
|
||||
if (typeof loadShorts === 'function') {
|
||||
loadShorts();
|
||||
}
|
||||
|
||||
// Render pagination
|
||||
if (typeof renderPagination === 'function') {
|
||||
renderPagination();
|
||||
}
|
||||
}
|
||||
|
||||
// Load more videos
|
||||
async function loadMore() {
|
||||
currentPage++;
|
||||
await loadTrending(false);
|
||||
}
|
||||
|
||||
// Load trending videos
|
||||
async function loadTrending(reset = true) {
|
||||
if (isLoading && reset) isLoading = false;
|
||||
|
||||
const resultsArea = document.getElementById('resultsArea');
|
||||
const loadMoreArea = document.getElementById('loadMoreArea');
|
||||
const loadMoreBtn = document.getElementById('loadMoreBtn');
|
||||
|
||||
if (!resultsArea) return; // Exit if not on home page
|
||||
|
||||
isLoading = true;
|
||||
if (!reset && loadMoreBtn) {
|
||||
loadMoreBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading...';
|
||||
}
|
||||
|
||||
try {
|
||||
// Get sort and region values from page (if available)
|
||||
const sortValue = window.currentSort || 'month';
|
||||
const regionValue = window.currentRegion || 'vietnam';
|
||||
const response = await fetch(`/api/trending?category=${currentCategory}&page=${currentPage}&sort=${sortValue}®ion=${regionValue}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
console.error('Trending error:', data.error);
|
||||
if (reset) {
|
||||
resultsArea.innerHTML = renderNoContent(`Error: ${data.error}`, 'Something went wrong');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (reset) resultsArea.innerHTML = '';
|
||||
|
||||
if (data.length === 0) {
|
||||
if (reset) {
|
||||
resultsArea.innerHTML = renderNoContent();
|
||||
}
|
||||
const paginationArea = document.getElementById('paginationArea');
|
||||
if (paginationArea) paginationArea.style.display = 'none';
|
||||
} else {
|
||||
displayResults(data, !reset);
|
||||
const paginationArea = document.getElementById('paginationArea');
|
||||
if (paginationArea) paginationArea.style.display = 'flex';
|
||||
|
||||
// Update pagination if function exists
|
||||
if (typeof renderPagination === 'function') {
|
||||
renderPagination();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load trending:', e);
|
||||
if (reset) {
|
||||
resultsArea.innerHTML = `<div class="yt-loader" style="grid-column: 1/-1;"><p style="color:#f00;">Connection error</p></div>`;
|
||||
}
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Display results with YouTube-style cards
|
||||
function displayResults(videos, append = false) {
|
||||
const resultsArea = document.getElementById('resultsArea');
|
||||
if (!append) resultsArea.innerHTML = '';
|
||||
|
||||
if (videos.length === 0 && !append) {
|
||||
resultsArea.innerHTML = renderNoContent();
|
||||
return;
|
||||
}
|
||||
|
||||
videos.forEach(video => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'yt-video-card';
|
||||
card.innerHTML = `
|
||||
<div class="yt-thumbnail-container">
|
||||
<img class="yt-thumbnail" src="${video.thumbnail}" alt="${escapeHtml(video.title)}" loading="lazy">
|
||||
${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''}
|
||||
</div>
|
||||
<div class="yt-video-details">
|
||||
<div class="yt-channel-avatar">
|
||||
${video.uploader ? video.uploader.charAt(0).toUpperCase() : 'Y'}
|
||||
</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 • ${formatDate(video.upload_date)}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
card.addEventListener('click', () => {
|
||||
window.location.href = `/watch?v=${video.id}`;
|
||||
});
|
||||
resultsArea.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
// Format view count (YouTube style)
|
||||
function formatViews(views) {
|
||||
if (!views) return '0';
|
||||
const num = parseInt(views);
|
||||
if (num >= 1000000000) return (num / 1000000000).toFixed(1) + 'B';
|
||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
||||
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
// Format date (YouTube style: "2 hours ago", "3 days ago", etc.)
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return 'Recently';
|
||||
|
||||
// Handle YYYYMMDD format
|
||||
if (/^\d{8}$/.test(dateStr)) {
|
||||
const year = dateStr.substring(0, 4);
|
||||
const month = dateStr.substring(4, 6);
|
||||
const day = dateStr.substring(6, 8);
|
||||
dateStr = `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return 'Recently';
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
const diffWeek = Math.floor(diffDay / 7);
|
||||
const diffMonth = Math.floor(diffDay / 30);
|
||||
const diffYear = Math.floor(diffDay / 365);
|
||||
|
||||
if (diffYear > 0) return `${diffYear} year${diffYear > 1 ? 's' : ''} ago`;
|
||||
if (diffMonth > 0) return `${diffMonth} month${diffMonth > 1 ? 's' : ''} ago`;
|
||||
if (diffWeek > 0) return `${diffWeek} week${diffWeek > 1 ? 's' : ''} ago`;
|
||||
if (diffDay > 0) return `${diffDay} day${diffDay > 1 ? 's' : ''} ago`;
|
||||
if (diffHour > 0) return `${diffHour} hour${diffHour > 1 ? 's' : ''} ago`;
|
||||
if (diffMin > 0) return `${diffMin} minute${diffMin > 1 ? 's' : ''} ago`;
|
||||
return 'Just now';
|
||||
}
|
||||
|
||||
// Escape HTML to prevent XSS
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Sidebar toggle (for mobile)
|
||||
function toggleSidebar() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const main = document.getElementById('mainContent');
|
||||
|
||||
if (window.innerWidth <= 1024) {
|
||||
sidebar.classList.toggle('open');
|
||||
} else {
|
||||
sidebar.classList.toggle('collapsed');
|
||||
main.classList.toggle('sidebar-collapsed');
|
||||
localStorage.setItem('sidebarCollapsed', sidebar.classList.contains('collapsed'));
|
||||
}
|
||||
}
|
||||
|
||||
// Close sidebar when clicking outside (mobile)
|
||||
document.addEventListener('click', (e) => {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const menuBtn = document.querySelector('.yt-menu-btn');
|
||||
|
||||
if (window.innerWidth <= 1024 &&
|
||||
sidebar &&
|
||||
sidebar.classList.contains('open') &&
|
||||
!sidebar.contains(e.target) &&
|
||||
menuBtn && !menuBtn.contains(e.target)) {
|
||||
sidebar.classList.remove('open');
|
||||
}
|
||||
});
|
||||
|
||||
// --- Theme Logic ---
|
||||
function initTheme() {
|
||||
const savedTheme = localStorage.getItem('theme') || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
|
||||
// Update toggle if exists
|
||||
const toggle = document.getElementById('themeToggle');
|
||||
if (toggle) {
|
||||
toggle.checked = savedTheme === 'dark';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const current = document.documentElement.getAttribute('data-theme');
|
||||
const newTheme = current === 'light' ? 'dark' : 'light';
|
||||
|
||||
document.documentElement.setAttribute('data-theme', newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
}
|
||||
|
||||
// --- Profile Logic ---
|
||||
async function updateProfile(e) {
|
||||
if (e) e.preventDefault();
|
||||
|
||||
const displayName = document.getElementById('displayName').value;
|
||||
const btn = e.target.querySelector('button');
|
||||
const originalText = btn.innerHTML;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Saving...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/update_profile', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username: displayName })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showToast('Profile updated successfully!', 'success');
|
||||
// Update UI immediately
|
||||
const avatarName = document.querySelector('.yt-avatar');
|
||||
if (avatarName) avatarName.title = displayName;
|
||||
} else {
|
||||
showToast(data.message || 'Update failed', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Network error', 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
}
|
||||
}
|
||||
25
static/manifest.json
Normal file
25
static/manifest.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "KV-Tube",
|
||||
"short_name": "KV-Tube",
|
||||
"description": "A self-hosted YouTube alternative with local video support",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0f0f0f",
|
||||
"theme_color": "#ff0000",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"orientation": "portrait",
|
||||
"scope": "/",
|
||||
"prefer_related_applications": false
|
||||
}
|
||||
35
static/sw.js
Normal file
35
static/sw.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
const CACHE_NAME = 'kv-tube-v1';
|
||||
const STATIC_CACHE_URLS = [
|
||||
'/',
|
||||
'/static/css/style.css',
|
||||
'/static/js/main.js',
|
||||
'/static/icons/icon-192x192.png',
|
||||
'/static/icons/icon-512x512.png',
|
||||
'/static/favicon.ico'
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then(cache => cache.addAll(STATIC_CACHE_URLS))
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then(response => response || fetch(event.request))
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then(cacheNames => {
|
||||
return Promise.all(
|
||||
cacheNames.filter(cacheName => {
|
||||
return cacheName.startsWith('kv-tube-') && cacheName !== CACHE_NAME;
|
||||
}).map(cacheName => caches.delete(cacheName))
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
650
templates/index.html
Normal file
650
templates/index.html
Normal file
|
|
@ -0,0 +1,650 @@
|
|||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Sort Options -->
|
||||
<div class="yt-sort-container">
|
||||
<!-- Combined Filter Group -->
|
||||
<div class="yt-filter-group">
|
||||
<button class="yt-sort-btn" data-sort="day" onclick="switchSort('day', this)">Today</button>
|
||||
<button class="yt-sort-btn" data-sort="week" onclick="switchSort('week', this)">This Week</button>
|
||||
<button class="yt-sort-btn active" data-sort="month" onclick="switchSort('month', this)">This Month</button>
|
||||
<button class="yt-sort-btn" data-sort="year" onclick="switchSort('year', this)">This Year</button>
|
||||
<div class="yt-divider-vertical"></div>
|
||||
<button class="yt-region-btn active" data-region="vietnam" onclick="switchRegion('vietnam', this)">🇻🇳
|
||||
VN</button>
|
||||
<button class="yt-region-btn" data-region="global" onclick="switchRegion('global', this)">🌍 Global</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ... (Categories kept same) ... -->
|
||||
|
||||
<!-- CSS Update -->
|
||||
<style>
|
||||
/* ... existing styles ... */
|
||||
|
||||
.yt-sort-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--yt-border);
|
||||
margin-bottom: 8px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.yt-sort-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.yt-filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.yt-divider-vertical {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--yt-border);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* Mobile Enhancements */
|
||||
@media (max-width: 768px) {
|
||||
|
||||
/* Mobile List View Transition */
|
||||
.yt-video-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.yt-video-card {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.yt-thumbnail-container {
|
||||
width: 160px;
|
||||
/* Base width for list view thumbnail */
|
||||
min-width: 140px;
|
||||
aspect-ratio: 16/9;
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
/* Slightly tighter radius */
|
||||
}
|
||||
|
||||
.yt-video-info {
|
||||
padding: 2px 0 0 0;
|
||||
/* Remove top padding */
|
||||
}
|
||||
|
||||
.yt-video-details {
|
||||
padding: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Show avatar/channel info again but styled for list view */
|
||||
.yt-channel-avatar {
|
||||
display: none;
|
||||
/* Keep avatar hidden to save space or move to meta */
|
||||
}
|
||||
|
||||
.yt-profile-pic-mobile {
|
||||
display: none;
|
||||
/* handled below */
|
||||
}
|
||||
|
||||
.yt-video-meta {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.yt-video-title {
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
max-height: 2.6em;
|
||||
/* 2 lines */
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.yt-channel-name {
|
||||
display: block;
|
||||
/* Show channel name again */
|
||||
font-size: 12px;
|
||||
color: var(--yt-text-secondary);
|
||||
}
|
||||
|
||||
.yt-video-stats {
|
||||
display: block;
|
||||
/* Show views */
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Sort container adjustments */
|
||||
.yt-sort-container {
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.yt-sort-btn,
|
||||
.yt-region-btn {
|
||||
font-size: 13px;
|
||||
padding: 6px 12px;
|
||||
background: var(--yt-bg-secondary);
|
||||
border-radius: 8px;
|
||||
/* More like chips */
|
||||
border: none;
|
||||
}
|
||||
|
||||
.yt-sort-btn.active,
|
||||
.yt-region-btn.active {
|
||||
background: var(--yt-text-primary);
|
||||
color: var(--yt-bg-primary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Category Pills -->
|
||||
<div class="yt-categories">
|
||||
<button class="yt-category-pill" data-category="tech" onclick="switchCategory('tech', this)">AI & Tech</button>
|
||||
<button class="yt-category-pill active" data-category="all" onclick="switchCategory('all', this)">All</button>
|
||||
<button class="yt-category-pill" data-category="shorts" onclick="switchCategory('shorts', this)">Shorts</button>
|
||||
<button class="yt-category-pill" data-category="music" onclick="switchCategory('music', this)">Music</button>
|
||||
<button class="yt-category-pill" data-category="gaming" onclick="switchCategory('gaming', this)">Gaming</button>
|
||||
<button class="yt-category-pill" data-category="news" onclick="switchCategory('news', this)">News</button>
|
||||
<button class="yt-category-pill" data-category="trending"
|
||||
onclick="switchCategory('trending', this)">Trending</button>
|
||||
<button class="yt-category-pill" data-category="sports" onclick="switchCategory('sports', this)">Sports</button>
|
||||
<button class="yt-category-pill" data-category="podcasts"
|
||||
onclick="switchCategory('podcasts', this)">Podcasts</button>
|
||||
<button class="yt-category-pill" data-category="live" onclick="switchCategory('live', this)">Live</button>
|
||||
</div>
|
||||
|
||||
<!-- Shorts Section -->
|
||||
<div id="shortsSection" class="yt-section">
|
||||
<div class="yt-section-header">
|
||||
<h2><i class="fas fa-bolt"></i> Shorts</h2>
|
||||
</div>
|
||||
<div class="yt-shorts-container">
|
||||
<button class="yt-shorts-arrow yt-shorts-left" onclick="scrollShorts('left')">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
<div id="shortsGrid" class="yt-shorts-grid">
|
||||
<!-- Shorts loaded here -->
|
||||
</div>
|
||||
<button class="yt-shorts-arrow yt-shorts-right" onclick="scrollShorts('right')">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Videos Section -->
|
||||
<div id="videosSection" class="yt-section">
|
||||
<div class="yt-section-header">
|
||||
<h2><i class="fas fa-play-circle"></i> Videos</h2>
|
||||
</div>
|
||||
<div id="resultsArea" class="yt-video-grid">
|
||||
<div id="initialLoading" class="yt-loader" style="grid-column: 1/-1;">
|
||||
<div class="yt-spinner">
|
||||
<div class="yt-spinner-ring"></div>
|
||||
<div class="yt-spinner-ring"></div>
|
||||
<div class="yt-spinner-ring"></div>
|
||||
<div class="yt-spinner-glow"></div>
|
||||
</div>
|
||||
<p>Loading videos...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div id="paginationArea" class="yt-pagination">
|
||||
<button class="yt-page-btn yt-page-prev" onclick="goToPage(currentPage - 1)" disabled>
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
<div class="yt-page-numbers" id="pageNumbers">
|
||||
</div>
|
||||
<button class="yt-page-btn yt-page-next" onclick="goToPage(currentPage + 1)">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.yt-sort-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--yt-border);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.yt-sort-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.yt-sort-label {
|
||||
font-size: 14px;
|
||||
color: var(--yt-text-secondary);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.yt-sort-btn,
|
||||
.yt-region-btn {
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--yt-border);
|
||||
border-radius: 18px;
|
||||
font-size: 13px;
|
||||
color: var(--yt-text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.yt-sort-btn:hover,
|
||||
.yt-region-btn:hover {
|
||||
background: var(--yt-bg-secondary);
|
||||
}
|
||||
|
||||
.yt-sort-btn.active {
|
||||
background: var(--yt-text-primary);
|
||||
color: var(--yt-bg-primary);
|
||||
border-color: var(--yt-text-primary);
|
||||
}
|
||||
|
||||
.yt-region-btn.active {
|
||||
background: var(--yt-accent-red);
|
||||
color: white;
|
||||
border-color: var(--yt-accent-red);
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.yt-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.yt-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.yt-section-header h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.yt-section-header h2 i {
|
||||
color: var(--yt-accent-red);
|
||||
}
|
||||
|
||||
.yt-see-all {
|
||||
color: var(--yt-accent-blue);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.yt-see-all:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Shorts Container with Arrows */
|
||||
.yt-shorts-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.yt-shorts-arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--yt-bg-primary);
|
||||
border: 1px solid var(--yt-border);
|
||||
color: var(--yt-text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.yt-shorts-arrow:hover {
|
||||
background: var(--yt-bg-secondary);
|
||||
transform: translateY(-50%) scale(1.1);
|
||||
}
|
||||
|
||||
.yt-shorts-left {
|
||||
left: -20px;
|
||||
}
|
||||
|
||||
.yt-shorts-right {
|
||||
right: -20px;
|
||||
}
|
||||
|
||||
/* Shorts Grid (Horizontal Scroll) */
|
||||
.yt-shorts-grid {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
overflow-x: auto;
|
||||
padding: 8px 0;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.yt-shorts-grid::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.yt-short-card {
|
||||
flex-shrink: 0;
|
||||
width: 180px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.yt-short-card:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.yt-short-thumb {
|
||||
width: 180px;
|
||||
height: 320px;
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
background: var(--yt-bg-secondary);
|
||||
}
|
||||
|
||||
.yt-short-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-top: 8px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.yt-short-views {
|
||||
font-size: 12px;
|
||||
color: var(--yt-text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.yt-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 32px 0;
|
||||
}
|
||||
|
||||
.yt-page-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--yt-bg-secondary);
|
||||
color: var(--yt-text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.yt-page-btn:hover:not(:disabled) {
|
||||
background: var(--yt-bg-hover);
|
||||
}
|
||||
|
||||
.yt-page-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.yt-page-numbers {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.yt-page-num {
|
||||
min-width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 20px;
|
||||
background: var(--yt-bg-secondary);
|
||||
color: var(--yt-text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding: 0 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.yt-page-num:hover {
|
||||
background: var(--yt-bg-hover);
|
||||
}
|
||||
|
||||
.yt-page-num.active {
|
||||
background: var(--yt-accent-red);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.yt-sort-container {
|
||||
/* flex-direction: column; Old: stacked */
|
||||
flex-direction: row;
|
||||
/* New: horizontal scroll */
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
/* Hide scrollbar Firefox */
|
||||
-ms-overflow-style: none;
|
||||
/* IE/Edge */
|
||||
padding-right: 16px;
|
||||
/* Right padding for scroll */
|
||||
}
|
||||
|
||||
.yt-sort-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
/* Hide scrollbar Chrome/Safari */
|
||||
}
|
||||
|
||||
.yt-sort-label {
|
||||
display: none;
|
||||
/* Hide labels on mobile */
|
||||
}
|
||||
|
||||
.yt-sort-btn,
|
||||
.yt-region-btn {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Force 2 columns on mobile */
|
||||
.yt-video-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.yt-short-card {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.yt-short-thumb {
|
||||
width: 140px;
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
.yt-shorts-arrow {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
let currentSort = 'month';
|
||||
let currentRegion = 'vietnam';
|
||||
let totalPages = 10;
|
||||
|
||||
// Scroll shorts left/right
|
||||
function scrollShorts(direction) {
|
||||
const grid = document.getElementById('shortsGrid');
|
||||
const scrollAmount = 400; // pixels to scroll
|
||||
|
||||
if (direction === 'left') {
|
||||
grid.scrollBy({ left: -scrollAmount, behavior: 'smooth' });
|
||||
} else {
|
||||
grid.scrollBy({ left: scrollAmount, behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
|
||||
// Generate page numbers
|
||||
function renderPagination() {
|
||||
const container = document.getElementById('pageNumbers');
|
||||
container.innerHTML = '';
|
||||
|
||||
const startPage = Math.max(1, currentPage - 4);
|
||||
const endPage = Math.min(totalPages, startPage + 9);
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = `yt-page-num ${i === currentPage ? 'active' : ''}`;
|
||||
btn.innerText = i;
|
||||
btn.onclick = () => goToPage(i);
|
||||
container.appendChild(btn);
|
||||
}
|
||||
|
||||
document.querySelector('.yt-page-prev').disabled = currentPage <= 1;
|
||||
document.querySelector('.yt-page-next').disabled = currentPage >= totalPages;
|
||||
}
|
||||
|
||||
function goToPage(page) {
|
||||
if (page < 1 || page > totalPages) return;
|
||||
currentPage = page;
|
||||
window.currentPage = page;
|
||||
renderPagination();
|
||||
loadTrending(true);
|
||||
loadShorts(); // Also update shorts when page changes
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function switchSort(sort, btn) {
|
||||
document.querySelectorAll('.yt-sort-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
currentSort = sort;
|
||||
window.currentSort = sort;
|
||||
currentPage = 1;
|
||||
loadTrending(true);
|
||||
loadShorts();
|
||||
}
|
||||
|
||||
function switchRegion(region, btn) {
|
||||
document.querySelectorAll('.yt-region-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
currentRegion = region;
|
||||
window.currentRegion = region;
|
||||
currentPage = 1;
|
||||
loadTrending(true);
|
||||
loadShorts();
|
||||
}
|
||||
|
||||
window.currentSort = 'month';
|
||||
window.currentRegion = 'vietnam';
|
||||
window.currentPage = 1;
|
||||
|
||||
// Load shorts with current category, pagination, sort, region
|
||||
async function loadShorts() {
|
||||
const shortsGrid = document.getElementById('shortsGrid');
|
||||
shortsGrid.innerHTML = `<div class="yt-loader"><div class="yt-spinner">
|
||||
<div class="yt-spinner-ring"></div>
|
||||
<div class="yt-spinner-ring"></div>
|
||||
<div class="yt-spinner-ring"></div>
|
||||
<div class="yt-spinner-glow"></div>
|
||||
</div></div>`;
|
||||
|
||||
try {
|
||||
const page = window.currentPage || 1;
|
||||
// Get shorts related to current category
|
||||
const category = window.currentCategory || currentCategory || 'general';
|
||||
// For shorts, we combine category with 'shorts' keyword
|
||||
const shortsCategory = category === 'all' || category === 'general' ? 'shorts' : category;
|
||||
|
||||
const response = await fetch(`/api/trending?category=${shortsCategory}&page=${page}&sort=${currentSort}®ion=${currentRegion}&shorts=1`);
|
||||
const data = await response.json();
|
||||
|
||||
shortsGrid.innerHTML = '';
|
||||
if (data && data.length > 0) {
|
||||
data.slice(0, 10).forEach(video => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'yt-short-card';
|
||||
card.innerHTML = `
|
||||
<img src="${video.thumbnail}" class="yt-short-thumb" loading="lazy">
|
||||
<p class="yt-short-title">${escapeHtml(video.title)}</p>
|
||||
<p class="yt-short-views">${formatViews(video.view_count)} views</p>
|
||||
`;
|
||||
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
|
||||
shortsGrid.appendChild(card);
|
||||
});
|
||||
} else {
|
||||
shortsGrid.innerHTML = '<p style="color:var(--yt-text-secondary);padding:20px;">No shorts found</p>';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading shorts:', e);
|
||||
shortsGrid.innerHTML = '<p style="color:var(--yt-text-secondary);padding:20px;">Could not load shorts</p>';
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// Init
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadShorts();
|
||||
renderPagination();
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const category = urlParams.get('category');
|
||||
if (category) {
|
||||
const pill = document.querySelector(`.yt-category-pill[data-category="${category}"]`);
|
||||
if (pill) pill.click();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
342
templates/layout.html
Normal file
342
templates/layout.html
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="KV-Tube">
|
||||
<title>KV-Tube</title>
|
||||
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="{{ request.url }}">
|
||||
<meta property="og:title" content="KV-Tube - Video Streaming">
|
||||
<meta property="og:description" content="Stream your favorite videos on KV-Tube.">
|
||||
<meta property="og:image" content="{{ url_for('static', filename='og-image.jpg', _external=True) }}">
|
||||
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="app-wrapper">
|
||||
<!-- YouTube-style Header -->
|
||||
<header class="yt-header">
|
||||
<div class="yt-header-start">
|
||||
<button class="yt-menu-btn" onclick="toggleSidebar()" aria-label="Menu">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
<a href="/" class="yt-logo">
|
||||
<div class="yt-logo-icon">KV-Tube</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="yt-header-center">
|
||||
<form class="yt-search-form" action="/" method="get" onsubmit="handleSearch(event)">
|
||||
<input type="text" id="searchInput" class="yt-search-input" placeholder="Search">
|
||||
<button type="submit" class="yt-search-btn" aria-label="Search">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="yt-header-end">
|
||||
<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 %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Mobile Search Bar -->
|
||||
<div class="yt-mobile-search-bar" id="mobileSearchBar">
|
||||
<button onclick="toggleMobileSearch()" class="yt-back-btn">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
</button>
|
||||
<input type="text" id="mobileSearchInput" placeholder="Search"
|
||||
onkeypress="if(event.key==='Enter'){searchYouTube(this.value);toggleMobileSearch();}">
|
||||
</div>
|
||||
|
||||
<!-- YouTube-style Sidebar -->
|
||||
<aside class="yt-sidebar" id="sidebar">
|
||||
<a href="/" class="yt-sidebar-item {% if request.path == '/' %}active{% endif %}" data-category="all">
|
||||
<i class="fas fa-home"></i>
|
||||
<span>Home</span>
|
||||
</a>
|
||||
<a href="javascript:void(0)" class="yt-sidebar-item" data-category="shorts"
|
||||
onclick="navigateCategory('shorts')">
|
||||
<i class="fas fa-bolt"></i>
|
||||
<span>Shorts</span>
|
||||
</a>
|
||||
|
||||
<div class="yt-sidebar-divider"></div>
|
||||
|
||||
<a href="/my-videos?type=history" class="yt-sidebar-item" data-category="history">
|
||||
<i class="fas fa-history"></i>
|
||||
<span>History</span>
|
||||
</a>
|
||||
<a href="/my-videos?type=saved"
|
||||
class="yt-sidebar-item {% if request.path == '/my-videos' %}active{% endif %}" data-category="saved">
|
||||
<i class="fas fa-bookmark"></i>
|
||||
<span>Library</span>
|
||||
</a>
|
||||
|
||||
<div class="yt-sidebar-divider"></div>
|
||||
|
||||
<div class="yt-sidebar-title">Explore</div>
|
||||
<a href="javascript:void(0)" class="yt-sidebar-item" data-category="tech"
|
||||
onclick="navigateCategory('tech')">
|
||||
<i class="fas fa-microchip"></i>
|
||||
<span>AI & Tech</span>
|
||||
</a>
|
||||
<a href="javascript:void(0)" class="yt-sidebar-item" data-category="trending"
|
||||
onclick="navigateCategory('trending')">
|
||||
<i class="fas fa-fire"></i>
|
||||
<span>Trending</span>
|
||||
</a>
|
||||
<a href="javascript:void(0)" class="yt-sidebar-item" data-category="music"
|
||||
onclick="navigateCategory('music')">
|
||||
<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="sports"
|
||||
onclick="navigateCategory('sports')">
|
||||
<i class="fas fa-football-ball"></i>
|
||||
<span>Sports</span>
|
||||
</a>
|
||||
|
||||
<div class="yt-sidebar-divider"></div>
|
||||
|
||||
<a href="/settings" class="yt-sidebar-item {% if request.path == '/settings' %}active{% endif %}">
|
||||
<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) -->
|
||||
<div class="yt-sidebar-overlay" id="sidebarOverlay" onclick="closeSidebar()"></div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="yt-main" id="mainContent">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
<script>
|
||||
// Register Service Worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('{{ url_for("static", filename="sw.js") }}')
|
||||
.then(registration => {
|
||||
console.log('ServiceWorker registration successful');
|
||||
})
|
||||
.catch(err => {
|
||||
console.log('ServiceWorker registration failed: ', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add to Home Screen prompt
|
||||
let deferredPrompt;
|
||||
const addBtn = document.querySelector('.add-button');
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
// Prevent Chrome 67 and earlier from automatically showing the prompt
|
||||
e.preventDefault();
|
||||
// Stash the event so it can be triggered later
|
||||
deferredPrompt = e;
|
||||
// Show the button
|
||||
if (addBtn) addBtn.style.display = 'block';
|
||||
|
||||
// Show the install button if it exists
|
||||
const installButton = document.getElementById('install-button');
|
||||
if (installButton) {
|
||||
installButton.style.display = 'block';
|
||||
installButton.addEventListener('click', () => {
|
||||
// Show the prompt
|
||||
deferredPrompt.prompt();
|
||||
// Wait for the user to respond to the prompt
|
||||
deferredPrompt.userChoice.then((choiceResult) => {
|
||||
if (choiceResult.outcome === 'accepted') {
|
||||
console.log('User accepted the install prompt');
|
||||
} else {
|
||||
console.log('User dismissed the install prompt');
|
||||
}
|
||||
deferredPrompt = null;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sidebar toggle
|
||||
function toggleSidebar() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const main = document.getElementById('mainContent');
|
||||
const overlay = document.getElementById('sidebarOverlay');
|
||||
|
||||
if (window.innerWidth <= 1024) {
|
||||
sidebar.classList.toggle('open');
|
||||
overlay.classList.toggle('active');
|
||||
} else {
|
||||
sidebar.classList.toggle('collapsed');
|
||||
main.classList.toggle('sidebar-collapsed');
|
||||
localStorage.setItem('sidebarCollapsed', sidebar.classList.contains('collapsed'));
|
||||
}
|
||||
}
|
||||
|
||||
function closeSidebar() {
|
||||
document.getElementById('sidebar').classList.remove('open');
|
||||
document.getElementById('sidebarOverlay').classList.remove('active');
|
||||
}
|
||||
|
||||
// Restore sidebar state (desktop only)
|
||||
if (window.innerWidth > 1024 && localStorage.getItem('sidebarCollapsed') === 'true') {
|
||||
document.getElementById('sidebar').classList.add('collapsed');
|
||||
document.getElementById('mainContent').classList.add('sidebar-collapsed');
|
||||
}
|
||||
|
||||
// Mobile search toggle
|
||||
function toggleMobileSearch() {
|
||||
const searchBar = document.getElementById('mobileSearchBar');
|
||||
searchBar.classList.toggle('active');
|
||||
if (searchBar.classList.contains('active')) {
|
||||
document.getElementById('mobileSearchInput').focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Search handler
|
||||
function handleSearch(e) {
|
||||
e.preventDefault();
|
||||
const query = document.getElementById('searchInput').value.trim();
|
||||
if (query && typeof searchYouTube === 'function') {
|
||||
searchYouTube(query);
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to category (syncs sidebar with top pills)
|
||||
function navigateCategory(category) {
|
||||
// Close mobile sidebar
|
||||
closeSidebar();
|
||||
|
||||
// If on home page, trigger category switch
|
||||
if (window.location.pathname === '/') {
|
||||
const pill = document.querySelector(`.yt-category-pill[data-category="${category}"]`);
|
||||
if (pill) {
|
||||
pill.click();
|
||||
} else if (typeof switchCategory === 'function') {
|
||||
// Create a mock button for the function
|
||||
const pills = document.querySelectorAll('.yt-category-pill');
|
||||
pills.forEach(p => p.classList.remove('active'));
|
||||
switchCategory(category, { classList: { add: () => { } } });
|
||||
}
|
||||
} else {
|
||||
// Navigate to home with category
|
||||
window.location.href = `/?category=${category}`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<!-- Toast Notification Container -->
|
||||
<div id="toastContainer" class="yt-toast-container"></div>
|
||||
|
||||
<style>
|
||||
.yt-toast-container {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 24px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.yt-toast {
|
||||
background: #1f1f1f;
|
||||
color: #fff;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
font-size: 14px;
|
||||
animation: slideUp 0.3s ease;
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 280px;
|
||||
border-left: 4px solid #3ea6ff;
|
||||
}
|
||||
|
||||
.yt-toast.error {
|
||||
border-left-color: #ff4e45;
|
||||
}
|
||||
|
||||
.yt-toast.success {
|
||||
border-left-color: #2ba640;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function showToast(message, type = 'info') {
|
||||
const container = document.getElementById('toastContainer');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `yt-toast ${type}`;
|
||||
toast.innerHTML = `<span>${message}</span>`;
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
// Remove after 3 seconds
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0';
|
||||
toast.style.transform = 'translateY(20px)';
|
||||
toast.style.transition = 'all 0.3s';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 5000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
212
templates/login.html
Normal file
212
templates/login.html
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="yt-auth-container">
|
||||
<div class="yt-auth-card">
|
||||
<!-- Logo -->
|
||||
<div class="yt-auth-logo">
|
||||
<div class="yt-logo-icon" style="font-size: 24px;">KV-Tube</div>
|
||||
</div>
|
||||
|
||||
<h2>Sign in</h2>
|
||||
<p>to continue to KV-Tube</p>
|
||||
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
<div class="yt-auth-alert">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
{{ messages[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" action="/login" class="yt-auth-form">
|
||||
<div class="yt-form-group">
|
||||
<input type="text" name="username" class="yt-form-input" placeholder=" " required id="username">
|
||||
<label for="username" class="yt-form-label">Username</label>
|
||||
</div>
|
||||
|
||||
<div class="yt-form-group">
|
||||
<input type="password" name="password" class="yt-form-input" placeholder=" " required id="password">
|
||||
<label for="password" class="yt-form-label">Password</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="yt-auth-submit">
|
||||
<i class="fas fa-sign-in-alt"></i>
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="yt-auth-divider">
|
||||
<span>or</span>
|
||||
</div>
|
||||
|
||||
<p class="yt-auth-footer">
|
||||
New to KV-Tube?
|
||||
<a href="/register" class="yt-auth-link">Create an account</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.yt-auth-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: calc(100vh - var(--yt-header-height) - 100px);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.yt-auth-card {
|
||||
background: var(--yt-bg-secondary);
|
||||
border-radius: 16px;
|
||||
padding: 48px 40px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
text-align: center;
|
||||
border: 1px solid var(--yt-border);
|
||||
}
|
||||
|
||||
.yt-auth-logo {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.yt-auth-card h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: var(--yt-text-primary);
|
||||
}
|
||||
|
||||
.yt-auth-card>p {
|
||||
color: var(--yt-text-secondary);
|
||||
font-size: 14px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.yt-auth-alert {
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
color: #f44336;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 24px;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.yt-auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.yt-form-group {
|
||||
position: relative;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.yt-form-input {
|
||||
width: 100%;
|
||||
padding: 16px 14px;
|
||||
font-size: 16px;
|
||||
color: var(--yt-text-primary);
|
||||
background: var(--yt-bg-primary);
|
||||
border: 1px solid var(--yt-border);
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.yt-form-input:focus {
|
||||
border-color: var(--yt-accent-blue);
|
||||
box-shadow: 0 0 0 3px rgba(62, 166, 255, 0.2);
|
||||
}
|
||||
|
||||
.yt-form-label {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 16px;
|
||||
color: var(--yt-text-secondary);
|
||||
pointer-events: none;
|
||||
transition: all 0.2s;
|
||||
background: var(--yt-bg-primary);
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.yt-form-input:focus+.yt-form-label,
|
||||
.yt-form-input:not(:placeholder-shown)+.yt-form-label {
|
||||
top: 0;
|
||||
font-size: 12px;
|
||||
color: var(--yt-accent-blue);
|
||||
}
|
||||
|
||||
.yt-auth-submit {
|
||||
background: var(--yt-accent-blue);
|
||||
color: white;
|
||||
padding: 14px 24px;
|
||||
border-radius: 24px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
transition: background 0.2s, transform 0.1s;
|
||||
}
|
||||
|
||||
.yt-auth-submit:hover {
|
||||
background: #258fd9;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.yt-auth-submit:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.yt-auth-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.yt-auth-divider::before,
|
||||
.yt-auth-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--yt-border);
|
||||
}
|
||||
|
||||
.yt-auth-divider span {
|
||||
padding: 0 16px;
|
||||
color: var(--yt-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.yt-auth-footer {
|
||||
color: var(--yt-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.yt-auth-link {
|
||||
color: var(--yt-accent-blue);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.yt-auth-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.yt-auth-card {
|
||||
padding: 32px 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
36
templates/my_videos.html
Normal file
36
templates/my_videos.html
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="library-header" style="margin-bottom: 2rem; display: flex; align-items: center; gap: 1rem;">
|
||||
<h1>My Library</h1>
|
||||
<div class="tabs" style="display: flex; gap: 0.5rem; background: #e8eaed; padding: 0.3rem; border-radius: 100px;">
|
||||
<a href="/my-videos?type=saved" class="btn {% if filter_type == 'saved' %}btn-primary{% endif %}"
|
||||
style="border-radius: 100px; font-size: 0.9rem; padding: 0.5rem 1.5rem; color: {% if filter_type != 'saved' %}var(--text-secondary){% else %}white{% endif %}">Saved</a>
|
||||
<a href="/my-videos?type=history" class="btn {% if filter_type == 'history' %}btn-primary{% endif %}"
|
||||
style="border-radius: 100px; font-size: 0.9rem; padding: 0.5rem 1.5rem; color: {% if filter_type != 'history' %}var(--text-secondary){% else %}white{% endif %}">History</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if videos %}
|
||||
<div class="video-grid">
|
||||
{% for video in videos %}
|
||||
<div class="video-card" onclick="window.location.href='/watch?v={{ video.video_id }}'">
|
||||
<img src="{{ video.thumbnail }}" class="thumbnail" loading="lazy">
|
||||
<div class="video-info">
|
||||
<div class="video-title">{{ video.title }}</div>
|
||||
<div class="video-meta">
|
||||
<i class="far fa-clock"></i> {{ video.timestamp }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="text-align: center; padding: 4rem; color: var(--text-secondary);">
|
||||
<i class="far 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>
|
||||
<a href="/" class="btn btn-primary" style="margin-top: 1rem;">Browse Content</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
212
templates/register.html
Normal file
212
templates/register.html
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="yt-auth-container">
|
||||
<div class="yt-auth-card">
|
||||
<!-- Logo -->
|
||||
<div class="yt-auth-logo">
|
||||
<div class="yt-logo-icon" style="font-size: 24px;">KV-Tube</div>
|
||||
</div>
|
||||
|
||||
<h2>Create account</h2>
|
||||
<p>to start watching on KV-Tube</p>
|
||||
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
<div class="yt-auth-alert">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
{{ messages[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" action="/register" class="yt-auth-form">
|
||||
<div class="yt-form-group">
|
||||
<input type="text" name="username" class="yt-form-input" placeholder=" " required id="username">
|
||||
<label for="username" class="yt-form-label">Username</label>
|
||||
</div>
|
||||
|
||||
<div class="yt-form-group">
|
||||
<input type="password" name="password" class="yt-form-input" placeholder=" " required id="password">
|
||||
<label for="password" class="yt-form-label">Password</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="yt-auth-submit">
|
||||
<i class="fas fa-user-plus"></i>
|
||||
Create Account
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="yt-auth-divider">
|
||||
<span>or</span>
|
||||
</div>
|
||||
|
||||
<p class="yt-auth-footer">
|
||||
Already have an account?
|
||||
<a href="/login" class="yt-auth-link">Sign in</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.yt-auth-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: calc(100vh - var(--yt-header-height) - 100px);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.yt-auth-card {
|
||||
background: var(--yt-bg-secondary);
|
||||
border-radius: 16px;
|
||||
padding: 48px 40px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
text-align: center;
|
||||
border: 1px solid var(--yt-border);
|
||||
}
|
||||
|
||||
.yt-auth-logo {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.yt-auth-card h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: var(--yt-text-primary);
|
||||
}
|
||||
|
||||
.yt-auth-card>p {
|
||||
color: var(--yt-text-secondary);
|
||||
font-size: 14px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.yt-auth-alert {
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
color: #f44336;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 24px;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.yt-auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.yt-form-group {
|
||||
position: relative;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.yt-form-input {
|
||||
width: 100%;
|
||||
padding: 16px 14px;
|
||||
font-size: 16px;
|
||||
color: var(--yt-text-primary);
|
||||
background: var(--yt-bg-primary);
|
||||
border: 1px solid var(--yt-border);
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.yt-form-input:focus {
|
||||
border-color: var(--yt-accent-blue);
|
||||
box-shadow: 0 0 0 3px rgba(62, 166, 255, 0.2);
|
||||
}
|
||||
|
||||
.yt-form-label {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 16px;
|
||||
color: var(--yt-text-secondary);
|
||||
pointer-events: none;
|
||||
transition: all 0.2s;
|
||||
background: var(--yt-bg-primary);
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.yt-form-input:focus+.yt-form-label,
|
||||
.yt-form-input:not(:placeholder-shown)+.yt-form-label {
|
||||
top: 0;
|
||||
font-size: 12px;
|
||||
color: var(--yt-accent-blue);
|
||||
}
|
||||
|
||||
.yt-auth-submit {
|
||||
background: var(--yt-accent-blue);
|
||||
color: white;
|
||||
padding: 14px 24px;
|
||||
border-radius: 24px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
transition: background 0.2s, transform 0.1s;
|
||||
}
|
||||
|
||||
.yt-auth-submit:hover {
|
||||
background: #258fd9;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.yt-auth-submit:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.yt-auth-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.yt-auth-divider::before,
|
||||
.yt-auth-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--yt-border);
|
||||
}
|
||||
|
||||
.yt-auth-divider span {
|
||||
padding: 0 16px;
|
||||
color: var(--yt-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.yt-auth-footer {
|
||||
color: var(--yt-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.yt-auth-link {
|
||||
color: var(--yt-accent-blue);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.yt-auth-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.yt-auth-card {
|
||||
padding: 32px 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
235
templates/settings.html
Normal file
235
templates/settings.html
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="yt-settings-container">
|
||||
<h2 class="yt-settings-title">Settings</h2>
|
||||
|
||||
<div class="yt-settings-card">
|
||||
<h3>Appearance</h3>
|
||||
<p class="yt-settings-desc">Customize how KV-Tube looks on your device.</p>
|
||||
<div class="yt-setting-row">
|
||||
<span>Dark Mode</span>
|
||||
<label class="yt-switch">
|
||||
<input type="checkbox" id="themeToggle" checked onchange="toggleTheme()">
|
||||
<span class="yt-slider round"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if session.get('user_id') %}
|
||||
<div class="yt-settings-card">
|
||||
<h3>Profile</h3>
|
||||
<p class="yt-settings-desc">Update your public profile information.</p>
|
||||
<form id="profileForm" onsubmit="updateProfile(event)">
|
||||
<div class="yt-form-group">
|
||||
<label>Display Name</label>
|
||||
<input type="text" class="yt-form-input" id="displayName" value="{{ session.username }}" required>
|
||||
</div>
|
||||
<button type="submit" class="yt-update-btn">Save Changes</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="yt-settings-card">
|
||||
<h3>System Updates</h3>
|
||||
<p class="yt-settings-desc">Manage core components of KV-Tube.</p>
|
||||
|
||||
<div class="yt-update-section">
|
||||
<div class="yt-update-info">
|
||||
<div>
|
||||
<h4>yt-dlp</h4>
|
||||
<span class="yt-update-subtitle">Core video extraction engine</span>
|
||||
</div>
|
||||
<button id="updateBtn" onclick="updateYtDlp()" class="yt-update-btn">
|
||||
<i class="fas fa-sync-alt"></i> Check for Updates
|
||||
</button>
|
||||
</div>
|
||||
<div id="updateStatus" class="yt-update-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="yt-settings-card">
|
||||
<h3>About</h3>
|
||||
<p class="yt-settings-desc">KV-Tube v1.0</p>
|
||||
<p class="yt-settings-desc">A YouTube-like streaming application.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.yt-settings-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.yt-settings-title {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.yt-settings-card {
|
||||
background: var(--yt-bg-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.yt-settings-card h3 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.yt-settings-desc {
|
||||
color: var(--yt-text-secondary);
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.yt-update-section {
|
||||
background: var(--yt-bg-elevated);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.yt-update-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.yt-update-info h4 {
|
||||
font-size: 16px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.yt-update-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--yt-text-secondary);
|
||||
}
|
||||
|
||||
.yt-update-btn {
|
||||
background: var(--yt-accent-red);
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 24px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.yt-update-btn:hover {
|
||||
opacity: 0.9;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.yt-update-btn:disabled {
|
||||
background: var(--yt-bg-hover);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.yt-update-status {
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.yt-setting-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.yt-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 48px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.yt-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.yt-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #666;
|
||||
transition: .4s;
|
||||
}
|
||||
|
||||
.yt-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: .4s;
|
||||
}
|
||||
|
||||
input:checked+.yt-slider {
|
||||
background-color: var(--yt-accent-blue);
|
||||
}
|
||||
|
||||
input:checked+.yt-slider:before {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
.yt-slider.round {
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.yt-slider.round:before {
|
||||
border-radius: 50%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
async function updateYtDlp() {
|
||||
const btn = document.getElementById('updateBtn');
|
||||
const status = document.getElementById('updateStatus');
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Updating...';
|
||||
status.style.color = 'var(--yt-text-secondary)';
|
||||
status.innerText = 'Running pip install -U yt-dlp... This may take a moment.';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/update_ytdlp', { method: 'POST' });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
status.style.color = '#4caf50';
|
||||
status.innerText = '✓ ' + data.message;
|
||||
btn.innerHTML = '<i class="fas fa-check"></i> Updated';
|
||||
} else {
|
||||
status.style.color = '#f44336';
|
||||
status.innerText = '✗ ' + data.message;
|
||||
btn.innerHTML = '<i class="fas fa-exclamation-triangle"></i> Failed';
|
||||
btn.disabled = false;
|
||||
}
|
||||
} catch (e) {
|
||||
status.style.color = '#f44336';
|
||||
status.innerText = '✗ Network error: ' + e.message;
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = 'Retry';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
730
templates/watch.html
Normal file
730
templates/watch.html
Normal file
|
|
@ -0,0 +1,730 @@
|
|||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Artplayer -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/artplayer/dist/artplayer.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
|
||||
|
||||
<div class="yt-watch-layout">
|
||||
<!-- Player Section -->
|
||||
<div class="yt-player-section">
|
||||
<div class="yt-player-container">
|
||||
<div id="artplayer-app" style="width: 100%; height: 100%;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div id="loading" class="yt-loader">
|
||||
<div class="yt-spinner">
|
||||
<div class="yt-spinner-ring"></div>
|
||||
<div class="yt-spinner-ring"></div>
|
||||
<div class="yt-spinner-ring"></div>
|
||||
<div class="yt-spinner-glow"></div>
|
||||
</div>
|
||||
<p>Loading video...</p>
|
||||
</div>
|
||||
|
||||
<!-- Video Info -->
|
||||
<div class="yt-video-info" id="videoInfo" style="display:none;">
|
||||
<h1 id="videoTitle">Loading...</h1>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="yt-video-actions">
|
||||
<button class="yt-action-btn" id="likeBtn">
|
||||
<i class="fas fa-thumbs-up"></i>
|
||||
<span id="likeCount">Like</span>
|
||||
</button>
|
||||
<button class="yt-action-btn" id="dislikeBtn">
|
||||
<i class="fas fa-thumbs-down"></i>
|
||||
</button>
|
||||
<button class="yt-action-btn" id="shareBtn">
|
||||
<i class="fas fa-share"></i>
|
||||
Share
|
||||
</button>
|
||||
<a class="yt-action-btn" id="downloadBtn" href="#" target="_blank">
|
||||
<i class="fas fa-download"></i>
|
||||
Download
|
||||
</a>
|
||||
<button class="yt-action-btn" id="saveBtn">
|
||||
<i class="far fa-bookmark"></i>
|
||||
Save
|
||||
</button>
|
||||
<button class="yt-action-btn" id="summarizeBtn" onclick="summarizeVideo()">
|
||||
<i class="fas fa-magic"></i>
|
||||
Summarize
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Summary Box (Hidden by default) -->
|
||||
<div class="yt-description-box" id="summaryBox"
|
||||
style="display:none; margin-bottom:16px; border: 1px solid var(--yt-accent-blue); background: rgba(62, 166, 255, 0.1);">
|
||||
<p class="yt-description-stats" style="color: var(--yt-accent-blue);"><i class="fas fa-sparkles"></i> AI
|
||||
Summary</p>
|
||||
<p class="yt-description-text" id="summaryText">Generating summary...</p>
|
||||
</div>
|
||||
|
||||
<!-- Channel Info -->
|
||||
<div class="yt-channel-info">
|
||||
<div class="yt-channel-details">
|
||||
<div class="yt-channel-avatar-lg" id="channelAvatar">
|
||||
<span id="channelAvatarLetter"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p style="font-weight: 500;" id="channelName">Loading...</p>
|
||||
<p class="yt-video-stats" id="viewCount">0 views</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="yt-subscribe-btn">Subscribe</button>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="yt-description-box" id="descriptionBox" onclick="toggleDescription()">
|
||||
<p class="yt-description-stats" id="descStats"></p>
|
||||
<p class="yt-description-text" id="videoDesc">Loading description...</p>
|
||||
</div>
|
||||
|
||||
<!-- Comments Section (Collapsible) -->
|
||||
<div class="yt-comments-section" id="commentsSection">
|
||||
<button class="yt-comments-toggle" id="commentsToggle" onclick="toggleComments()">
|
||||
<div class="yt-comments-preview">
|
||||
<span id="commentCountDisplay">Comments</span>
|
||||
<i class="fas fa-chevron-down" id="commentsChevron"></i>
|
||||
</div>
|
||||
</button>
|
||||
<div class="yt-comments-content" id="commentsContent" style="display: none;">
|
||||
<div class="yt-comments-header">
|
||||
<h3><span id="commentCount">0</span> Comments</h3>
|
||||
</div>
|
||||
<div class="yt-comments-list" id="commentsList">
|
||||
<!-- Comments loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Suggested Videos -->
|
||||
<div class="yt-suggested" id="relatedVideos">
|
||||
<h3 style="margin-bottom: 16px; font-size: 16px;">Related Videos</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.yt-player-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
background: #000;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#loading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: #000;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.yt-watch-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
gap: 24px;
|
||||
max-width: 1800px;
|
||||
}
|
||||
|
||||
.yt-channel-avatar-lg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Comments Section - Collapsible */
|
||||
.yt-comments-section {
|
||||
margin-top: 24px;
|
||||
border-top: 1px solid var(--yt-border);
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.yt-comments-toggle {
|
||||
width: 100%;
|
||||
background: var(--yt-bg-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.yt-comments-toggle:hover {
|
||||
background: var(--yt-bg-hover);
|
||||
}
|
||||
|
||||
.yt-comments-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: var(--yt-text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.yt-comments-preview i {
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.yt-comments-preview i.rotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.yt-comments-content {
|
||||
margin-top: 16px;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.yt-comments-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.yt-comments-header h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.yt-comments-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Hide standard info in shorts mode */
|
||||
.shorts-mode .yt-video-info,
|
||||
.shorts-mode .yt-suggested {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
/* Hide time display on mobile to save space for other controls */
|
||||
.art-control-time {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.yt-comment {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.yt-comment-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--yt-bg-hover);
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
color: var(--yt-text-primary);
|
||||
}
|
||||
|
||||
.yt-comment-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.yt-comment-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.yt-comment-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.yt-comment-author {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--yt-text-primary);
|
||||
}
|
||||
|
||||
.yt-comment-time {
|
||||
font-size: 12px;
|
||||
color: var(--yt-text-secondary);
|
||||
}
|
||||
|
||||
.yt-comment-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--yt-text-primary);
|
||||
margin-bottom: 8px;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.yt-comment-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.yt-comment-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--yt-text-secondary);
|
||||
padding: 4px 8px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.yt-comment-action:hover {
|
||||
background: var(--yt-bg-secondary);
|
||||
}
|
||||
|
||||
.yt-pinned-badge {
|
||||
background: var(--yt-bg-secondary);
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 2px;
|
||||
color: var(--yt-text-secondary);
|
||||
}
|
||||
|
||||
.yt-no-comments {
|
||||
text-align: center;
|
||||
color: var(--yt-text-secondary);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.yt-watch-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- HLS Support (Local) -->
|
||||
<script src="/static/js/hls.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/artplayer/5.1.1/artplayer.js"></script>
|
||||
<script>
|
||||
let commentsLoaded = false;
|
||||
|
||||
function initArtplayer(url, poster, type = 'auto') {
|
||||
return new Artplayer({
|
||||
container: '#artplayer-app',
|
||||
url: url,
|
||||
poster: poster,
|
||||
type: type,
|
||||
volume: 0.5,
|
||||
muted: false,
|
||||
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,
|
||||
backdrop: true,
|
||||
playsInline: true,
|
||||
autoPlayback: true,
|
||||
lock: true,
|
||||
fastForward: true,
|
||||
autoOrientation: true,
|
||||
theme: '#ff0000',
|
||||
lang: navigator.language.toLowerCase(),
|
||||
moreVideoAttr: {
|
||||
crossOrigin: 'anonymous',
|
||||
},
|
||||
customType: {
|
||||
m3u8: function (video, url) {
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls({
|
||||
maxBufferLength: 30,
|
||||
maxMaxBufferLength: 60,
|
||||
debug: false, // Set to true if needed
|
||||
});
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(video);
|
||||
|
||||
// HLS Error Handling
|
||||
hls.on(Hls.Events.ERROR, function (event, data) {
|
||||
console.error('HLS Error:', data);
|
||||
if (data.fatal) {
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
console.error('fatal network error encountered, try to recover');
|
||||
hls.startLoad();
|
||||
showToast("Stream network error. Retrying...", "error");
|
||||
break;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
console.error('fatal media error encountered, try to recover');
|
||||
hls.recoverMediaError();
|
||||
showToast("Stream media error. Recovering...", "warning");
|
||||
break;
|
||||
default:
|
||||
console.error('fatal error, cannot recover');
|
||||
hls.destroy();
|
||||
showToast("Fatal stream error. Playback failed.", "error");
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = url;
|
||||
} else {
|
||||
showToast("Your browser does not support HLS playback.", "error");
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted: function (art) {
|
||||
// Check metadata when video is ready/loaded
|
||||
function checkVertical() {
|
||||
const video = art.video;
|
||||
if (video.videoHeight > 0 && video.videoHeight > video.videoWidth) {
|
||||
console.log('Vertical video detected:', video.videoWidth, video.videoHeight);
|
||||
// Use strict string format "W/H" without spaces for safer parsing
|
||||
const ratio = `${video.videoWidth}/${video.videoHeight}`;
|
||||
|
||||
// Set Artplayer Aspect Ratio
|
||||
art.aspectRatio = ratio;
|
||||
art.video.style.objectFit = 'contain';
|
||||
|
||||
// Adjust container styling
|
||||
const container = document.querySelector('.yt-player-container');
|
||||
if (container) {
|
||||
container.style.aspectRatio = ratio;
|
||||
container.style.width = '100%';
|
||||
container.style.maxWidth = '100%'; /* Critical for preventing overflow */
|
||||
|
||||
// On Desktop, limit width
|
||||
if (window.innerWidth > 768) {
|
||||
container.style.maxWidth = '450px';
|
||||
container.style.margin = '0 auto';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check immediately
|
||||
checkVertical();
|
||||
|
||||
// And on metadata load
|
||||
art.on('video:loadedmetadata', checkVertical);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function toggleDescription() {
|
||||
const desc = document.getElementById('videoDesc');
|
||||
desc.style.webkitLineClamp = desc.style.webkitLineClamp === 'unset' ? '3' : 'unset';
|
||||
}
|
||||
|
||||
function toggleComments() {
|
||||
const content = document.getElementById('commentsContent');
|
||||
const chevron = document.getElementById('commentsChevron');
|
||||
const videoId = "{{ video_id }}";
|
||||
|
||||
if (content.style.display === 'none') {
|
||||
content.style.display = 'block';
|
||||
chevron.classList.add('rotated');
|
||||
|
||||
// Load comments only once
|
||||
if (!commentsLoaded) {
|
||||
loadComments(videoId);
|
||||
commentsLoaded = true;
|
||||
}
|
||||
} else {
|
||||
content.style.display = 'none';
|
||||
chevron.classList.remove('rotated');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadComments(videoId) {
|
||||
const commentsList = document.getElementById('commentsList');
|
||||
commentsList.innerHTML = `
|
||||
<div class="yt-loader">
|
||||
<div class="yt-spinner"></div>
|
||||
<p>Loading comments...</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/comments?v=${videoId}`);
|
||||
const data = await response.json();
|
||||
|
||||
document.getElementById('commentCount').innerText = formatViews(data.count);
|
||||
document.getElementById('commentCountDisplay').innerText = `${formatViews(data.count)} Comments`;
|
||||
|
||||
commentsList.innerHTML = '';
|
||||
|
||||
if (data.comments && data.comments.length > 0) {
|
||||
data.comments.forEach(comment => {
|
||||
const commentEl = document.createElement('div');
|
||||
commentEl.className = 'yt-comment';
|
||||
commentEl.innerHTML = `
|
||||
<div class="yt-comment-avatar">
|
||||
${comment.author_thumbnail
|
||||
? `<img src="${comment.author_thumbnail}" alt="">`
|
||||
: escapeHtml(comment.author.charAt(0).toUpperCase())
|
||||
}
|
||||
</div>
|
||||
<div class="yt-comment-content">
|
||||
<div class="yt-comment-header">
|
||||
${comment.is_pinned ? '<span class="yt-pinned-badge">📌 Pinned</span>' : ''}
|
||||
<span class="yt-comment-author">${escapeHtml(comment.author)}</span>
|
||||
<span class="yt-comment-time">${comment.time || ''}</span>
|
||||
</div>
|
||||
<p class="yt-comment-text">${escapeHtml(comment.text)}</p>
|
||||
<div class="yt-comment-actions">
|
||||
<button class="yt-comment-action">
|
||||
<i class="fas fa-thumbs-up"></i>
|
||||
${comment.likes > 0 ? formatViews(comment.likes) : ''}
|
||||
</button>
|
||||
<button class="yt-comment-action">
|
||||
<i class="fas fa-thumbs-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
commentsList.appendChild(commentEl);
|
||||
});
|
||||
} else {
|
||||
commentsList.innerHTML = `<p class="yt-no-comments">Comments are disabled or unavailable for this video.</p>`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading comments:', e);
|
||||
commentsList.innerHTML = `<p class="yt-no-comments">Could not load comments.</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const videoType = "{{ video_type }}";
|
||||
const loading = document.getElementById('loading');
|
||||
const videoInfo = document.getElementById('videoInfo');
|
||||
const videoId = "{{ video_id }}";
|
||||
|
||||
if (videoType === 'local') {
|
||||
const player = initArtplayer("{{ src }}", "");
|
||||
document.getElementById('videoTitle').innerText = "{{ title }}";
|
||||
document.getElementById('channelName').innerText = "Local Video";
|
||||
document.getElementById('channelAvatarLetter').innerText = 'L';
|
||||
loading.style.display = 'none';
|
||||
videoInfo.style.display = 'block';
|
||||
document.getElementById('commentsSection').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/get_stream_info?v=${videoId}`);
|
||||
|
||||
if (response.status === 504) {
|
||||
throw new Error("Server Timeout (504): content source is slow. Please refresh.");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server Error (${response.status})`);
|
||||
}
|
||||
|
||||
// Check content type to avoid JSON syntax errors if HTML is returned
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (!contentType || !contentType.includes("application/json")) {
|
||||
throw new Error("Received invalid response from server");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
loading.innerHTML = `<p style="color:#f00; text-align:center;">${data.error}</p>`;
|
||||
showToast(data.error, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const posterUrl = `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`;
|
||||
|
||||
// Determine video type
|
||||
let streamType = 'auto';
|
||||
if (data.original_url && (data.original_url.includes('.m3u8') || data.original_url.includes('manifest'))) {
|
||||
streamType = 'm3u8';
|
||||
}
|
||||
|
||||
const player = initArtplayer(data.stream_url, posterUrl, streamType);
|
||||
|
||||
loading.style.display = 'none';
|
||||
videoInfo.style.display = 'block';
|
||||
|
||||
document.getElementById('videoTitle').innerText = data.title || 'Untitled';
|
||||
document.getElementById('channelName').innerText = data.uploader || 'Unknown';
|
||||
document.getElementById('viewCount').innerText = formatViews(data.view_count) + ' views';
|
||||
document.getElementById('videoDesc').innerText = data.description || 'No description';
|
||||
document.getElementById('descStats').innerText = `${formatViews(data.view_count)} views • ${data.upload_date || 'Recently'}`;
|
||||
document.getElementById('downloadBtn').href = data.stream_url;
|
||||
|
||||
const uploaderName = data.uploader || 'Unknown';
|
||||
document.getElementById('channelAvatarLetter').innerText = uploaderName.charAt(0).toUpperCase();
|
||||
|
||||
// Save to History
|
||||
fetch('/api/save_video', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: videoId,
|
||||
title: data.title,
|
||||
thumbnail: `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`,
|
||||
type: 'history'
|
||||
})
|
||||
});
|
||||
|
||||
// Subtitle Config
|
||||
// Add subtitle to player config if available
|
||||
player.subtitle.url = data.subtitle_url || '';
|
||||
if (data.subtitle_url) {
|
||||
player.subtitle.show = true;
|
||||
player.notice.show = 'CC Enabled';
|
||||
}
|
||||
|
||||
document.getElementById('saveBtn').onclick = async () => {
|
||||
const res = await fetch('/api/save_video', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: videoId,
|
||||
title: data.title,
|
||||
thumbnail: `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`,
|
||||
type: 'saved'
|
||||
})
|
||||
});
|
||||
const resData = await res.json();
|
||||
if (resData.status === 'success' || resData.status === 'already_saved') {
|
||||
document.getElementById('saveBtn').innerHTML = '<i class="fas fa-bookmark"></i> Saved';
|
||||
showToast('Video saved to library', 'success');
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('shareBtn').onclick = () => {
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
showToast('Link copied to clipboard!', 'success');
|
||||
};
|
||||
|
||||
// Related Videos
|
||||
const relatedContainer = document.getElementById('relatedVideos');
|
||||
if (data.related && data.related.length > 0) {
|
||||
data.related.forEach(vid => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'yt-suggested-card';
|
||||
card.innerHTML = `
|
||||
<img src="${vid.thumbnail}" class="yt-suggested-thumb" loading="lazy">
|
||||
<div class="yt-suggested-info">
|
||||
<p class="yt-suggested-title">${escapeHtml(vid.title)}</p>
|
||||
<p class="yt-suggested-channel">${escapeHtml(vid.uploader || 'Unknown')}</p>
|
||||
<p class="yt-suggested-stats">${formatViews(vid.view_count)} views</p>
|
||||
</div>
|
||||
`;
|
||||
card.onclick = () => window.location.href = `/watch?v=${vid.id}`;
|
||||
relatedContainer.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
loading.innerHTML = `
|
||||
<div style="display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%; gap:16px;">
|
||||
<i class="fas fa-wifi" style="font-size: 32px; color: #ff4e45;"></i>
|
||||
<p style="color:#fff;">${e.message || 'Connection Error'}</p>
|
||||
<button onclick="window.location.reload()" style="background:#3ea6ff; color:white; border:none; padding:10px 20px; border-radius:18px; cursor:pointer; font-weight:500;">Try Again</button>
|
||||
</div>
|
||||
`;
|
||||
showToast(e.message || 'Connection Error', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
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 escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
async function summarizeVideo() {
|
||||
const videoId = "{{ video_id }}";
|
||||
const btn = document.getElementById('summarizeBtn');
|
||||
const box = document.getElementById('summaryBox');
|
||||
const text = document.getElementById('summaryText');
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Generating...';
|
||||
|
||||
box.style.display = 'block';
|
||||
text.innerText = 'Analyzing transcript and extracting key insights...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/summarize?v=${videoId}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
text.innerText = data.summary;
|
||||
} else {
|
||||
text.innerText = data.message || 'Could not generate summary.';
|
||||
}
|
||||
} catch (e) {
|
||||
text.innerText = 'Network error during summarization.';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-magic"></i> Summarize';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Loading…
Reference in a new issue