chore: initial commit with premium Liquid Glass UI and performance optimizations

This commit is contained in:
Khoa.vo 2025-12-23 18:30:09 +07:00
commit 6ca4a168be
69 changed files with 19323 additions and 0 deletions

21
.gitignore vendored Normal file
View file

@ -0,0 +1,21 @@
# Python
__pycache__/
*.py[cod]
*$py.class
venv/
venv_local/
.env
# Node.js
node_modules/
npm-debug.log
yarn-error.log
.DS_Store
# Frontend build
frontend/dist/
frontend/.cache/
# Debug files
*.png
debug_*.py

52
Dockerfile Normal file
View file

@ -0,0 +1,52 @@
# --- Stage 1: Build Frontend ---
FROM node:20-slim AS frontend-builder
WORKDIR /build-frontend
COPY frontend/package*.json ./
RUN npm install
COPY frontend/ ./
RUN npm run build
# --- Stage 2: Final Image ---
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
curl \
libnss3 \
libnspr4 \
libatk1.0-0 \
libatk-bridge2.0-0 \
libcups2 \
libdrm2 \
libxkbcommon0 \
libxcomposite1 \
libxdamage1 \
libxrandr2 \
libgbm1 \
libasound2 \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Install Playwright browsers (needed for scraping)
RUN playwright install chromium
RUN playwright install-deps chromium
# Copy Backend code
COPY backend/ .
# Copy built Frontend to Backend's static directory
COPY --from=frontend-builder /build-frontend/dist ./static
# Create data directory for persistence
RUN mkdir -p /app/data
# Expose port (Unified: both API and Frontend)
EXPOSE 8000
# Start unified app
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

96
README.md Normal file
View file

@ -0,0 +1,96 @@
# StreamFlow - Premium Cinema Experience 🎬
[![Docker Image](https://img.shields.io/docker/v/vndangkhoa/streamflow?label=DockerHub&logo=docker)](https://hub.docker.com/r/vndangkhoa/streamflow)
[![GitHub](https://img.shields.io/github/v/release/vndangkhoa/Streamflow?label=GitHub&logo=github)](https://github.com/vndangkhoa/Streamflow)
StreamFlow is a high-fidelity movie streaming application designed for NAS enthusiasts and home cinema lovers. It combines a premium **Apple TV+ inspired aesthetic** with a lightweight, high-performance backend, now consolidated into a **single Docker image** for effortless deployment.
---
## 💎 Premium Features
### 🧊 Liquid Glass UI
- **Immersive Design**: Deep frosted-glass effects (40px+ blur) with Apple-style deep occlusion.
- **Micro-interactions**: 1px translucent borders, 3D card scaling, and smooth state transitions.
- **Cinematic Hero**: Dynamic full-screen backdrops that change based on featured content.
- **Dark Mode Perfected**: A custom OLED-friendly palette optimized for theater viewing.
### 📱 Native PWA Experience
- **Installable**: Full Progressive Web App (PWA) support. Add to Home Screen on iOS and Android.
- **Native Feel**: Runs in standalone mode without browser chrome for a truly native app experience.
- **Custom Icons**: High-resolution 'Liquid Glass' app icons for your home screen.
### 🐳 Unified NAS Architecture
- **Single-Container Deployment**: Backend and Frontend are bundled into one efficient image.
- **Low Overhead**: Zero-bypass streaming shifts heavy video load 100% to the client side.
- **NAS-Optimized**: Designed to run smoothly on Synology, QNAP, and Unraid (linux/amd64).
### 🍅 Rich Metadata
- **Rotten Tomatoes Ratings**: Real-time integration of "Fresh" and "Rotten" score badges.
- **Smart Catalog**: Automatically categories Phim Lẻ, Phim Bộ, Hoạt Hình, and Cinema releases.
- **Watch History**: Cross-device history and "My List" bookmarks saved to Redis.
---
## <20> One-Step Deployment
Copy this into your `docker-compose.yml` and run `docker-compose up -d`:
```yaml
version: '3.8'
services:
# StreamFlow Unified (Backend + Frontend)
app:
image: vndangkhoa/streamflow:latest
platform: linux/amd64
ports:
- "3478:8000"
environment:
- REDIS_URL=redis://redis:6379
- DATABASE_URL=sqlite:///./data/streamflow.db
- PYTHONUNBUFFERED=1
volumes:
- ./backend/data:/app/data
depends_on:
redis:
condition: service_healthy
restart: unless-stopped
# Redis Cache & History
redis:
image: redis:7-alpine
platform: linux/amd64
ports:
- "6379:6379"
volumes:
- ./redis-data:/data
restart: unless-stopped
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
```
### 🏁 Accessing the App
- **UI & API**: [http://localhost:3478](http://localhost:3478)
- **API Docs**: [http://localhost:3478/docs](http://localhost:3478/docs)
---
## 🛠 Tech Stack
- **Backend**: FastAPI (Python 3.11), SQLAlchemy, Redis
- **Frontend**: Vanilla JS (ES6+), Vite, ArtPlayer.js
- **Scraping**: Defensive `aiohttp` & `yt-dlp` integration
- **Deployment**: Multi-stage Docker Build (Alpine/Debian-slim)
## 📝 Credits
Movie data provided by `ophim` API.
Designed with ❤️ by [vndangkhoa](https://github.com/vndangkhoa).
---
> [!TIP]
> **Synology Tip**: Use the **Container Manager** (formerly Docker) on Synology. Create a new "Project" using the YAML above for the best management experience.

42
backend/Dockerfile Normal file
View file

@ -0,0 +1,42 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies for yt-dlp and playwright
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
curl \
libnss3 \
libnspr4 \
libatk1.0-0 \
libatk-bridge2.0-0 \
libcups2 \
libdrm2 \
libxkbcommon0 \
libxcomposite1 \
libxdamage1 \
libxrandr2 \
libgbm1 \
libasound2 \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Install Playwright browsers
RUN playwright install chromium
RUN playwright install-deps chromium
# Copy application code
COPY . .
# Create data directory
RUN mkdir -p /app/data
# Expose port
EXPOSE 8000
# Run the application
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

159
backend/auto_updater.py Normal file
View file

@ -0,0 +1,159 @@
"""
Auto-Updater Module for KV-Netflix
Handles automatic updates for yt-dlp, Playwright, and other dependencies
"""
import subprocess
import sys
import logging
from typing import Dict, Optional, Tuple
from datetime import datetime
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def get_package_version(package_name: str) -> Optional[str]:
"""Get installed version of a Python package"""
try:
result = subprocess.run(
[sys.executable, "-m", "pip", "show", package_name],
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0:
for line in result.stdout.split("\n"):
if line.startswith("Version:"):
return line.split(":")[1].strip()
return None
except Exception as e:
logger.error(f"Error getting version for {package_name}: {e}")
return None
def update_package(package_name: str) -> Tuple[bool, str]:
"""Update a Python package to the latest version"""
try:
logger.info(f"Updating {package_name}...")
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "--upgrade", package_name],
capture_output=True,
text=True,
timeout=120
)
if result.returncode == 0:
new_version = get_package_version(package_name)
logger.info(f"{package_name} updated to version {new_version}")
return True, f"Updated to {new_version}"
else:
logger.error(f"Failed to update {package_name}: {result.stderr}")
return False, result.stderr[:200]
except subprocess.TimeoutExpired:
return False, "Update timed out"
except Exception as e:
logger.error(f"Error updating {package_name}: {e}")
return False, str(e)
def update_yt_dlp() -> Tuple[bool, str]:
"""Update yt-dlp to the latest version"""
return update_package("yt-dlp")
def update_playwright() -> Tuple[bool, str]:
"""Update Playwright and install browsers"""
# First update the package
success, msg = update_package("playwright")
if not success:
return success, msg
# Then update browsers
try:
logger.info("Updating Playwright browsers...")
result = subprocess.run(
[sys.executable, "-m", "playwright", "install", "chromium"],
capture_output=True,
text=True,
timeout=300 # Browser downloads can take a while
)
if result.returncode == 0:
logger.info("✓ Playwright browsers updated")
return True, msg + " + browsers updated"
else:
logger.warning(f"Browser update had issues: {result.stderr[:100]}")
return True, msg + " (browser update may have issues)"
except subprocess.TimeoutExpired:
return True, msg + " (browser update timed out)"
except Exception as e:
return True, msg + f" (browser update error: {str(e)[:50]})"
def get_all_versions() -> Dict[str, Optional[str]]:
"""Get versions of all managed packages"""
packages = ["yt-dlp", "playwright", "aiohttp", "beautifulsoup4", "lxml"]
versions = {}
for pkg in packages:
versions[pkg] = get_package_version(pkg)
return versions
def update_all_dependencies() -> Dict[str, Tuple[bool, str]]:
"""Update all managed dependencies"""
results = {}
# Update yt-dlp (most frequently updated)
results["yt-dlp"] = update_yt_dlp()
# Update Playwright (includes browser updates)
results["playwright"] = update_playwright()
# Update scraping dependencies
results["aiohttp"] = update_package("aiohttp")
results["beautifulsoup4"] = update_package("beautifulsoup4")
results["lxml"] = update_package("lxml")
return results
async def check_and_update_on_startup():
"""Run update check on application startup (async wrapper)"""
import asyncio
def _check():
logger.info("=" * 50)
logger.info("KV-Netflix Auto-Update Check")
logger.info("=" * 50)
versions = get_all_versions()
logger.info("Current versions:")
for pkg, ver in versions.items():
logger.info(f" {pkg}: {ver or 'Not installed'}")
# Only update yt-dlp on startup (it updates frequently)
# Other updates should be triggered manually
success, msg = update_yt_dlp()
if success:
logger.info(f"✓ yt-dlp: {msg}")
else:
logger.warning(f"⚠ yt-dlp update failed: {msg}")
logger.info("=" * 50)
return {"yt-dlp": (success, msg)}
# Run in executor to avoid blocking
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, _check)
# For direct testing
if __name__ == "__main__":
print("Testing auto-updater...")
print("\nCurrent versions:")
for pkg, ver in get_all_versions().items():
print(f" {pkg}: {ver}")
print("\nUpdating yt-dlp...")
success, msg = update_yt_dlp()
print(f" Result: {msg}")

92
backend/cache.py Normal file
View file

@ -0,0 +1,92 @@
"""
Cache module - Redis with in-memory fallback for development
"""
import json
import time
from typing import Optional, Any
import os
# Try to import redis, fall back to in-memory if not available
try:
import redis
REDIS_AVAILABLE = True
except ImportError:
REDIS_AVAILABLE = False
class InMemoryCache:
"""Simple in-memory cache with TTL support for development"""
def __init__(self):
self._cache: dict[str, tuple[Any, float]] = {}
def get(self, key: str) -> Optional[str]:
if key in self._cache:
value, expiry = self._cache[key]
if time.time() < expiry:
return value
del self._cache[key]
return None
def set(self, key: str, value: str, ex: int = 10800) -> None:
self._cache[key] = (value, time.time() + ex)
def delete(self, key: str) -> None:
self._cache.pop(key, None)
def exists(self, key: str) -> bool:
return self.get(key) is not None
class CacheManager:
"""
Cache manager with Redis support and in-memory fallback.
Default TTL: 3 hours (10800 seconds)
"""
DEFAULT_TTL = 10800 # 3 hours
def __init__(self):
self.client = None
self.is_redis = False
self._connect()
def _connect(self):
redis_url = os.getenv("REDIS_URL", "redis://localhost:6379")
if REDIS_AVAILABLE:
try:
self.client = redis.from_url(redis_url, decode_responses=True)
self.client.ping()
self.is_redis = True
print("✓ Connected to Redis")
except Exception as e:
print(f"⚠ Redis not available ({e}), using in-memory cache")
self.client = InMemoryCache()
else:
print("⚠ Redis package not installed, using in-memory cache")
self.client = InMemoryCache()
def get(self, key: str) -> Optional[Any]:
"""Get cached data by key"""
data = self.client.get(key)
if data:
try:
return json.loads(data)
except:
return data
return None
def set(self, key: str, value: Any, ttl: int = None) -> None:
"""Cache data by key"""
if isinstance(value, (dict, list)):
value = json.dumps(value)
self.client.set(key, value, ex=ttl or self.DEFAULT_TTL)
def invalidate(self, key: str) -> None:
"""Remove cached data"""
self.client.delete(key)
# Singleton instance
cache = CacheManager()

View file

@ -0,0 +1,328 @@
"""
Category Discovery Module for PhimMoiChill
Automatically discovers and maps all available categories
"""
import asyncio
import aiohttp
import ssl
from bs4 import BeautifulSoup
from dataclasses import dataclass, asdict
from typing import List, Dict, Optional
from urllib.parse import urljoin
BASE_URL = "https://phimmoichill.network"
@dataclass
class Category:
"""Category metadata"""
id: str
name: str
slug: str
type: str # 'type', 'genre', 'country', 'year'
url: str
parent: Optional[str] = None
movie_count: int = 0
def to_dict(self):
return asdict(self)
class CategoryDiscovery:
"""Discovers categories from PhimMoiChill navigation"""
def __init__(self):
self.session: Optional[aiohttp.ClientSession] = None
self.headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'vi-VN,vi;q=0.9,en-US;q=0.8,en;q=0.7',
}
async def _get_session(self) -> aiohttp.ClientSession:
if not self.session:
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
connector = aiohttp.TCPConnector(ssl=ssl_context)
self.session = aiohttp.ClientSession(headers=self.headers, connector=connector)
return self.session
async def close(self):
if self.session:
await self.session.close()
self.session = None
async def _fetch_html(self, url: str) -> str:
"""Fetch HTML content"""
session = await self._get_session()
async with session.get(url) as response:
if response.status == 200:
return await response.text()
raise Exception(f"Failed to fetch {url}: {response.status}")
async def discover_all_categories(self) -> Dict[str, List[Category]]:
"""
Discover all categories from PhimMoiChill
Returns organized structure of categories
"""
try:
html = await self._fetch_html(BASE_URL)
soup = BeautifulSoup(html, 'lxml')
categories = {
'types': [],
'genres': [],
'countries': [],
'years': []
}
# Discover main types (phim-le, phim-bo, etc.)
categories['types'] = await self._discover_main_types(soup)
# Discover genres (the-loai/*)
categories['genres'] = await self._discover_genres(soup)
# Discover countries (quoc-gia/*)
categories['countries'] = await self._discover_countries(soup)
# Generate year categories
categories['years'] = self._generate_year_categories()
return categories
except Exception as e:
print(f"Error discovering categories: {e}")
return self._get_fallback_categories()
async def _discover_main_types(self, soup: BeautifulSoup) -> List[Category]:
"""Discover main content types"""
types = []
# Look for navigation menu with main types
nav_links = soup.select('nav a, .menu a, .navigation a')
# Known type patterns
type_patterns = {
'phim-le': 'Movies',
'phim-bo': 'TV Series',
'tv-shows': 'TV Shows',
'hoat-hinh': 'Animation'
}
for link in nav_links:
href = link.get('href', '')
text = link.get_text(strip=True)
for slug, name in type_patterns.items():
if slug in href:
types.append(Category(
id=slug,
name=text or name,
slug=f'danh-sach/{slug}',
type='type',
url=urljoin(BASE_URL, f'/danh-sach/{slug}')
))
break
# Ensure we have at least the basic types
if not types:
for slug, name in type_patterns.items():
types.append(Category(
id=slug,
name=name,
slug=f'danh-sach/{slug}',
type='type',
url=urljoin(BASE_URL, f'/danh-sach/{slug}')
))
return types
async def _discover_genres(self, soup: BeautifulSoup) -> List[Category]:
"""Discover genre categories"""
genres = []
# Look for genre menu/dropdown
genre_links = soup.select('a[href*="the-loai/"]')
seen_genres = set()
for link in genre_links:
href = link.get('href', '')
text = link.get_text(strip=True)
# Extract genre slug from URL
if '/the-loai/' in href:
slug = href.split('/the-loai/')[-1].split('/')[0].split('?')[0]
if slug and slug not in seen_genres:
seen_genres.add(slug)
genres.append(Category(
id=slug,
name=text or slug.replace('-', ' ').title(),
slug=f'the-loai/{slug}',
type='genre',
url=urljoin(BASE_URL, f'/the-loai/{slug}')
))
# Fallback: common genres
if not genres:
genres = self._get_fallback_genres()
return genres
async def _discover_countries(self, soup: BeautifulSoup) -> List[Category]:
"""Discover country categories"""
countries = []
# Look for country menu/dropdown
country_links = soup.select('a[href*="quoc-gia/"]')
seen_countries = set()
for link in country_links:
href = link.get('href', '')
text = link.get_text(strip=True)
# Extract country slug from URL
if '/quoc-gia/' in href:
slug = href.split('/quoc-gia/')[-1].split('/')[0].split('?')[0]
if slug and slug not in seen_countries:
seen_countries.add(slug)
countries.append(Category(
id=slug,
name=text or slug.replace('-', ' ').title(),
slug=f'quoc-gia/{slug}',
type='country',
url=urljoin(BASE_URL, f'/quoc-gia/{slug}')
))
# Fallback: common countries
if not countries:
countries = self._get_fallback_countries()
return countries
def _generate_year_categories(self) -> List[Category]:
"""Generate year-based categories"""
from datetime import datetime
current_year = datetime.now().year
years = []
for year in range(current_year, current_year - 10, -1):
years.append(Category(
id=str(year),
name=str(year),
slug=f'nam/{year}',
type='year',
url=urljoin(BASE_URL, f'/nam/{year}')
))
return years
def _get_fallback_genres(self) -> List[Category]:
"""Fallback genres if discovery fails"""
genres_map = {
'hanh-dong': 'Action',
'kinh-di': 'Horror',
'tinh-cam': 'Romance',
'hai-huoc': 'Comedy',
'vien-tuong': 'Sci-Fi',
'phieu-luu': 'Adventure',
'bi-an': 'Mystery',
'chien-tranh': 'War',
'tam-ly': 'Psychological',
'gia-dinh': 'Family'
}
return [
Category(
id=slug,
name=name,
slug=f'the-loai/{slug}',
type='genre',
url=urljoin(BASE_URL, f'/the-loai/{slug}')
)
for slug, name in genres_map.items()
]
def _get_fallback_countries(self) -> List[Category]:
"""Fallback countries if discovery fails"""
countries_map = {
'my': 'United States',
'han-quoc': 'South Korea',
'nhat-ban': 'Japan',
'trung-quoc': 'China',
'thai-lan': 'Thailand',
'au-my': 'Europe & Americas',
'viet-nam': 'Vietnam'
}
return [
Category(
id=slug,
name=name,
slug=f'quoc-gia/{slug}',
type='country',
url=urljoin(BASE_URL, f'/quoc-gia/{slug}')
)
for slug, name in countries_map.items()
]
def _get_fallback_categories(self) -> Dict[str, List[Category]]:
"""Complete fallback if discovery fails"""
return {
'types': [
Category('phim-le', 'Movies', 'danh-sach/phim-le', 'type', f'{BASE_URL}/danh-sach/phim-le'),
Category('phim-bo', 'TV Series', 'danh-sach/phim-bo', 'type', f'{BASE_URL}/danh-sach/phim-bo'),
Category('hoat-hinh', 'Animation', 'danh-sach/hoat-hinh', 'type', f'{BASE_URL}/danh-sach/hoat-hinh'),
],
'genres': self._get_fallback_genres(),
'countries': self._get_fallback_countries(),
'years': self._generate_year_categories()
}
# Singleton instance
_discovery_instance = None
async def get_categories() -> Dict[str, List[Dict]]:
"""Get all categories (cached)"""
global _discovery_instance
discovery = CategoryDiscovery()
try:
categories = await discovery.discover_all_categories()
# Convert to dict format
return {
key: [cat.to_dict() for cat in cat_list]
for key, cat_list in categories.items()
}
finally:
await discovery.close()
def get_categories_sync() -> Dict[str, List[Dict]]:
"""Synchronous wrapper for getting categories"""
return asyncio.run(get_categories())
# CLI testing
if __name__ == "__main__":
import json
print("Discovering categories from PhimMoiChill...")
categories = get_categories_sync()
print("\n" + "="*50)
print("DISCOVERED CATEGORIES")
print("="*50)
for cat_type, cat_list in categories.items():
print(f"\n{cat_type.upper()}: {len(cat_list)} categories")
for cat in cat_list[:5]: # Show first 5
print(f" - {cat['name']} ({cat['slug']})")
if len(cat_list) > 5:
print(f" ... and {len(cat_list) - 5} more")
print("\n" + "="*50)
print(f"Total categories: {sum(len(cats) for cats in categories.values())}")
print("="*50)

234
backend/category_scraper.py Normal file
View file

@ -0,0 +1,234 @@
"""
Category Scraper for PhimMoiChill
Orchestrates category-based crawling to build themed sections
"""
import asyncio
from typing import Dict, List, Any
from rophim_scraper import RophimScraper
from category_discovery import get_categories
class PhimMoiChillCategoryScraper:
"""
Advanced scraper that looks for categories first, then crawls them.
"""
def __init__(self):
self.scraper = RophimScraper()
async def close(self):
await self.scraper.close()
async def get_all_sections(self) -> Dict[str, List[Dict]]:
"""
Build complete homepage structure by crawling key categories
"""
# 1. Discover Categories (Cached)
discovered = await get_categories()
# 2. Map discovered categories to UI sections
# We look for specific slugs in the discovered lists
tasks = []
# Define what we want to fetch
# Format: (section_key, category_expected_slug, fallback_slug)
sections_to_fetch = [
# Hot -> Phim Le Page 1
('hot', 'danh-sach/phim-le'),
# New Releases -> Phim Le Page 2 (Variation)
('new_releases', 'danh-sach/phim-le'),
# Series -> Phim Bo
('series', 'danh-sach/phim-bo'),
# Animation -> Hoat Hinh
('animated', 'danh-sach/hoat-hinh'),
# Cinema -> Phim Chieu Rap
('cinema', 'the-loai/phim-chieu-rap'),
# Top 10 -> Phim Le Page 1
('top10', 'danh-sach/phim-le'),
# Vietnamese
('vietnamese', 'quoc-gia/viet-nam')
]
results = {}
# Parallel fetch
async def fetch_section(key, slug):
try:
# Use scraper to get movies for this category
limit = 10 if key == 'top10' else 42 # Increased for 2-3 rows
# Fetch Page 2 for New Releases to allow variety from Hot (Page 1)
page = 2 if key == 'new_releases' else 1
movies = await self.scraper.get_category(slug, page=page, limit=limit)
# Fallback for cinema if empty - try action genre
if key == 'cinema' and not movies:
movies = await self.scraper.get_category('the-loai/hanh-dong', page=1, limit=limit)
# Convert to dict and enrich
movie_dicts = []
for idx, m in enumerate(movies, 1):
d = m.__dict__
# Add Metadata Badges
if key == 'top10':
d['ranking'] = idx
d['badge'] = f'TOP {idx}'
elif key == 'hot':
d['badge'] = 'HOT'
elif key == 'new_releases':
d['badge'] = 'NEW'
elif key == 'cinema':
d['badge'] = 'CINEMA'
movie_dicts.append(d)
return key, movie_dicts
except Exception as e:
print(f"Error fetching section {key} ({slug}): {e}")
return key, []
pending_tasks = [fetch_section(key, slug) for key, slug in sections_to_fetch]
fetched_results = await asyncio.gather(*pending_tasks)
for key, movies in fetched_results:
results[key] = movies
return results
# Individual fetchers for specific endpoints
async def get_hot_movies(self, limit=24):
movies = await self.scraper.get_category('danh-sach/phim-le', 1, limit)
return [m.__dict__ for m in movies]
async def get_new_releases(self, limit=24):
# Fetch page 2 for variety? Or just page 1
movies = await self.scraper.get_category('danh-sach/phim-le', 1, limit)
return [m.__dict__ for m in movies]
async def get_cinema_releases(self, limit=24):
# Try finding a cinema category
movies = await self.scraper.get_category('the-loai/phim-chieu-rap', 1, limit)
if not movies:
# Fallback: Phim Le
movies = await self.scraper.get_category('danh-sach/phim-le', 1, limit)
return [m.__dict__ for m in movies]
async def get_top_10(self):
movies = await self.scraper.get_category('danh-sach/phim-le', 1, 10)
return [m.__dict__ for m in movies]
async def get_mixed_sections(self, page: int) -> List[Dict[str, Any]]:
"""
Fetch subsequent pages of Main Categories for infinite scroll.
Strategy: Keep the same structure (Hot, Series, etc.) but load Page N.
"""
# Define the main structure to repeat
main_categories = [
{'title': 'Phim Hot (Movies)', 'slug': 'danh-sach/phim-le'},
{'title': 'Phim Bộ Mới (Series)', 'slug': 'danh-sach/phim-bo'},
{'title': 'Hoạt Hình & Anime', 'slug': 'danh-sach/hoat-hinh'},
{'title': 'Phim Chiếu Rạp', 'slug': 'the-loai/phim-chieu-rap'},
{'title': 'Phim Việt Nam', 'slug': 'quoc-gia/viet-nam'}
]
tasks = []
async def fetch_dynamic(cat):
try:
# Use large limit for multi-row display
movies = await self.scraper.get_category(cat['slug'], page, 84)
if not movies: return None
# Optional: Differentiate title for clarity, or keep same?
# User asked to "keep the same structure".
# We can append " - Page N" or just leave as is.
# Let's leave as is but maybe ensures frontend renders it.
return {
'title': cat['title'],
'key': cat['slug'],
'movies': [m.__dict__ for m in movies]
}
except:
return None
tasks = [fetch_dynamic(cat) for cat in main_categories]
results = await asyncio.gather(*tasks)
return [r for r in results if r is not None]
async def get_view_sections(self, view: str, page: int) -> List[Dict[str, Any]]:
"""
Fetch structured sections for specific views (Movies, Series, etc.)
mimicking the Main Page design with sliders.
"""
sub_sections = []
if view == 'movies':
sub_sections = [
{'title': 'Phim Lẻ Mới', 'slug': 'danh-sach/phim-le'},
{'title': 'Hành Động', 'slug': 'the-loai/hanh-dong'},
{'title': 'Tình Cảm', 'slug': 'the-loai/tinh-cam'},
{'title': 'Kinh Dị', 'slug': 'the-loai/kinh-di'},
{'title': 'Viễn Tưởng', 'slug': 'the-loai/vien-tuong'},
{'title': 'Hài Hước', 'slug': 'the-loai/hai-huoc'}
]
elif view == 'series':
sub_sections = [
{'title': 'Phim Bộ Mới', 'slug': 'danh-sach/phim-bo'},
{'title': 'Hàn Quốc', 'slug': 'quoc-gia/han-quoc'},
{'title': 'Trung Quốc', 'slug': 'quoc-gia/trung-quoc'},
{'title': 'Âu Mỹ', 'slug': 'quoc-gia/au-my'},
{'title': 'Thái Lan', 'slug': 'quoc-gia/thai-lan'}
]
elif view == 'animation':
sub_sections = [
{'title': 'Anime Mới', 'slug': 'danh-sach/hoat-hinh'},
{'title': 'Học Đường', 'slug': 'the-loai/hoc-duong'},
{'title': 'Nhật Bản', 'slug': 'quoc-gia/nhat-ban'}
]
elif view == 'cinema':
sub_sections = [
{'title': 'Phim Chiếu Rạp Hot', 'slug': 'the-loai/phim-chieu-rap'},
{'title': 'Hành Động', 'slug': 'the-loai/hanh-dong'},
{'title': 'Hài Hước', 'slug': 'the-loai/hai-huoc'}
]
if not sub_sections: return []
tasks = []
async def fetch_section(cat):
try:
# Fetch larger batch for multi-row
movies = await self.scraper.get_category(cat['slug'], page, 84)
if not movies: return None
return {
'title': cat['title'],
'key': cat['slug'],
'movies': [m.__dict__ for m in movies]
}
except: return None
tasks = [fetch_section(cat) for cat in sub_sections]
results = await asyncio.gather(*tasks)
return [r for r in results if r is not None]
# Wrapper function for main.py (Sync compatibility)
def get_categories_sync() -> Dict[str, List[Dict]]:
"""Synchronous wrapper to get all category sections"""
async def _run():
scraper = PhimMoiChillCategoryScraper()
try:
return await scraper.get_all_sections()
finally:
await scraper.close()
try:
return asyncio.run(_run())
except Exception as e:
print(f"Sync Category Crawl Error: {e}")
return {
'hot': [], 'new_releases': [], 'top10': [],
'cinema': [], 'vietnamese': [], 'animated': [], 'series': []
}

98
backend/database.py Normal file
View file

@ -0,0 +1,98 @@
"""
Database module - SQLAlchemy with SQLite for video metadata
"""
from datetime import datetime
from typing import Optional
from sqlalchemy import create_engine, Column, Integer, String, DateTime, Text
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
import os
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./streamflow.db")
engine = create_engine(
DATABASE_URL,
connect_args={"check_same_thread": False} if "sqlite" in DATABASE_URL else {}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
class Video(Base):
"""Video metadata model"""
__tablename__ = "videos"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(500), index=True)
description = Column(Text, nullable=True)
thumbnail = Column(String(1000), nullable=True)
source_url = Column(String(2000), unique=True, index=True)
duration = Column(Integer, default=0)
resolution = Column(String(20), nullable=True)
category = Column(String(100), index=True, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def init_db():
"""Initialize database tables"""
Base.metadata.create_all(bind=engine)
def get_db():
"""Dependency for getting database session"""
db = SessionLocal()
try:
yield db
finally:
db.close()
class VideoRepository:
"""CRUD operations for videos"""
def __init__(self, db):
self.db = db
def create(self, title: str, source_url: str, **kwargs) -> Video:
video = Video(title=title, source_url=source_url, **kwargs)
self.db.add(video)
self.db.commit()
self.db.refresh(video)
return video
def get_by_id(self, video_id: int) -> Optional[Video]:
return self.db.query(Video).filter(Video.id == video_id).first()
def get_by_url(self, source_url: str) -> Optional[Video]:
return self.db.query(Video).filter(Video.source_url == source_url).first()
def search(self, query: str, limit: int = 20) -> list[Video]:
return self.db.query(Video).filter(
Video.title.ilike(f"%{query}%")
).limit(limit).all()
def get_all(self, skip: int = 0, limit: int = 50) -> list[Video]:
return self.db.query(Video).offset(skip).limit(limit).all()
def get_by_category(self, category: str, limit: int = 20) -> list[Video]:
return self.db.query(Video).filter(
Video.category == category
).limit(limit).all()
def update(self, video_id: int, **kwargs) -> Optional[Video]:
video = self.get_by_id(video_id)
if video:
for key, value in kwargs.items():
setattr(video, key, value)
self.db.commit()
self.db.refresh(video)
return video
def delete(self, video_id: int) -> bool:
video = self.get_by_id(video_id)
if video:
self.db.delete(video)
self.db.commit()
return True
return False

1118
backend/main.py Normal file

File diff suppressed because it is too large Load diff

30
backend/requirements.txt Normal file
View file

@ -0,0 +1,30 @@
# KV-Netflix Backend Dependencies
# Core Framework
fastapi>=0.104.0
uvicorn[standard]>=0.24.0
pydantic>=2.5.0
# Database
sqlalchemy>=2.0.23
# Video Extraction (auto-updated)
yt-dlp>=2023.12.30
# Scraping
aiohttp>=3.9.0
beautifulsoup4>=4.12.0
lxml>=5.0.0
requests>=2.31.0
# Browser Automation (auto-updated)
playwright>=1.40.0
# Caching (optional)
redis>=5.0.0
# HTTP Client
httpx>=0.25.0
# Multipart uploads
python-multipart>=0.0.6

650
backend/rophim_scraper.py Normal file
View file

@ -0,0 +1,650 @@
"""
PhimMoiChill Scraper - Extracts movie catalog and video sources
Updated for phimmoichill.network
"""
import asyncio
import aiohttp
import ssl
import re
from bs4 import BeautifulSoup
from dataclasses import dataclass
from typing import List, Optional, Dict, Any
from urllib.parse import urljoin, urlparse
import json
BASE_URL = "https://phimmoichill.network"
@dataclass
class RophimMovie:
id: str
title: str
original_title: Optional[str]
slug: str
thumbnail: str
backdrop: Optional[str]
year: Optional[int]
rating: Optional[str]
duration: Optional[int] # in minutes
quality: Optional[str]
genre: Optional[str]
description: Optional[str]
category: str # movies, series, anime, etc
cast: Optional[List[str]] = None
director: Optional[str] = None
country: Optional[str] = None
episodes: Optional[List[Dict]] = None
class RophimScraper:
"""Scraper for PhimMoiChill video catalog"""
def __init__(self):
self.session: Optional[aiohttp.ClientSession] = None
self.headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'vi-VN,vi;q=0.9,en-US;q=0.8,en;q=0.7',
'Referer': BASE_URL
}
async def _get_session(self) -> aiohttp.ClientSession:
if not self.session:
# Disable SSL verification for macOS compatibility
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
connector = aiohttp.TCPConnector(ssl=ssl_context)
self.session = aiohttp.ClientSession(headers=self.headers, connector=connector)
return self.session
async def close(self):
if self.session:
await self.session.close()
self.session = None
async def _fetch_html(self, url: str) -> str:
"""Fetch HTML content from URL"""
session = await self._get_session()
async with session.get(url) as response:
if response.status == 200:
return await response.text()
raise Exception(f"Failed to fetch {url}: {response.status}")
async def _fetch_json(self, url: str) -> Dict:
"""Fetch JSON from URL"""
session = await self._get_session()
async with session.get(url) as response:
if response.status == 200:
return await response.json()
raise Exception(f"Failed to fetch JSON {url}: {response.status}")
async def get_homepage_movies(self, page: int = 1, limit: int = 24) -> List[RophimMovie]:
"""Extract movies from homepage/feed
Uses /danh-sach/phim-le endpoint for PhimMoiChill
Pagination uses /page/N format (not ?page=N query param)
"""
if page == 1:
url = f"{BASE_URL}/danh-sach/phim-le"
else:
url = f"{BASE_URL}/danh-sach/phim-le/page/{page}"
html = await self._fetch_html(url)
return self._parse_movie_grid(html, limit)
async def get_category(self, category: str, page: int = 1, limit: int = 24) -> List[RophimMovie]:
"""Get movies by category with parallel page fetching"""
# Determine how many pages we need to fetch to satisfy the limit (average ~40 items per page)
# We'll fetch 2 pages in parallel if limit is high
num_pages = 2 if limit > 40 else 1
async def fetch_page(p):
try:
if p == 1:
url = f"{BASE_URL}/{category}"
else:
url = f"{BASE_URL}/{category}/page/{p}"
html = await self._fetch_html(url)
return self._parse_movie_grid(html, 100)
except Exception:
return []
# Start concurrent fetches
page_tasks = [fetch_page(p) for p in range(page, page + num_pages)]
results = await asyncio.gather(*page_tasks)
# Combine results and remove duplicates
movies = []
seen_slugs = set()
for batch in results:
for m in batch:
if m.slug not in seen_slugs:
movies.append(m)
seen_slugs.add(m.slug)
return movies[:limit]
async def search(self, query: str, limit: int = 20) -> List[RophimMovie]:
"""Search for movies"""
url = f"{BASE_URL}/tim-kiem?keyword={query}"
html = await self._fetch_html(url)
return self._parse_movie_grid(html, limit)
async def get_movie_detail(self, slug: str) -> Optional[RophimMovie]:
"""Get detailed movie info including episodes"""
url = f"{BASE_URL}/phim/{slug}"
html = await self._fetch_html(url)
return self._parse_movie_detail(html, slug)
async def get_video_source(self, movie_slug: str, episode: int = 1) -> Optional[str]:
"""Extract video source URL for playback
Returns direct m3u8 or MP4 URL
"""
# Try to get the player page
player_url = f"{BASE_URL}/xem-phim/{movie_slug}/tap-{episode}"
html = await self._fetch_html(player_url)
# Look for embedded video sources
sources = self._extract_video_sources(html)
if sources:
return sources[0] # Return best quality source
return None
def _parse_movie_grid(self, html: str, limit: int) -> List[RophimMovie]:
"""Parse movie cards from HTML grid using BeautifulSoup"""
movies = []
soup = BeautifulSoup(html, 'lxml')
# PhimMoiChill uses .myui-vodlist__box for each movie item
movie_items = soup.select('.myui-vodlist__box')
for item in movie_items[:limit]:
try:
# Find the main link with class myui-vodlist__thumb
link = item.select_one('a.myui-vodlist__thumb')
if not link:
link = item.select_one('a[href*="/phim/"]')
if not link:
continue
href = link.get('href', '')
slug = self._extract_slug(href)
if not slug:
continue
# Get title from link title attribute or h4.title
title = link.get('title', '')
if not title:
title_elem = item.select_one('h4.title a, h4 a, .title a')
if title_elem:
title = title_elem.get_text(strip=True)
else:
title = slug.replace('-', ' ').title()
# Get thumbnail from background-image style
thumbnail = ''
style = link.get('style', '')
bg_match = re.search(r'url\(([^)]+)\)', style)
if bg_match:
thumbnail = bg_match.group(1).strip('"\'')
else:
# Fallback to img tag
img = item.select_one('img')
if img:
thumbnail = img.get('src', '') or img.get('data-src', '')
# Get quality badge (.pic-tag)
quality_elem = item.select_one('.pic-tag, .quality, .label')
quality = quality_elem.get_text(strip=True) if quality_elem else 'HD'
# Get English title from description
eng_title_elem = item.select_one('.text-muted, .myui-vodlist__detail p')
original_title = eng_title_elem.get_text(strip=True) if eng_title_elem else None
# Determine category from quality badge or episode count
category = "movies"
if quality and ('tập' in quality.lower() or 'ep' in quality.lower()):
category = "series"
# Extract year from original title
year = None
if original_title:
year_match = re.search(r'\((\d{4})\)', original_title)
if year_match:
year = int(year_match.group(1))
movie = RophimMovie(
id=slug,
title=title,
original_title=original_title,
slug=slug,
thumbnail=self._normalize_url(thumbnail),
backdrop=None,
year=year,
rating=None,
duration=None,
quality=quality or 'HD',
genre=None,
description=None,
category=category
)
movies.append(movie)
except Exception as e:
# Skip problematic items
continue
return movies
def _parse_movie_detail(self, html: str, slug: str) -> Optional[RophimMovie]:
"""Parse detailed movie page"""
soup = BeautifulSoup(html, 'lxml')
# Get title
title_elem = soup.select_one('h1.movie-title, h1, .title')
title = title_elem.get_text(strip=True) if title_elem else slug.replace('-', ' ').title()
# Get description from meta tags (better quality)
description = None
meta_desc = soup.select_one('meta[name="description"], meta[property="og:description"]')
if meta_desc:
description = meta_desc.get('content', '').strip()
# Fallback to page content if no meta description
if not description:
desc_elem = soup.select_one('.description, .content, .film-description, .entry-content')
description = desc_elem.get_text(strip=True) if desc_elem else None
# Get poster from meta og:image (high quality)
poster = ''
poster_meta = soup.select_one('meta[property="og:image"]')
if poster_meta:
poster = poster_meta.get('content', '')
else:
# Fallback to img tag
poster_elem = soup.select_one('.movie-l-img img, .thumb img, img.img-responsive')
poster = poster_elem.get('src', '') if poster_elem else ''
# Get metadata from info sections
director = None
cast = []
country = None
genres = []
year = None
rating = None
episodes_count = None
# PhimMoiChill uses <li> tags with labels
info_items = soup.select('.movie-info li, .film-info li, .movie-details li, ul li')
for item in info_items:
item_text = item.get_text()
# Year (Năm phát hành)
if 'Năm' in item_text:
year_match = re.search(r'(\d{4})', item_text)
if year_match:
year = int(year_match.group(1))
# Episodes (Số tập)
elif 'Số tập' in item_text:
ep_match = re.search(r'(\d+)', item_text)
if ep_match:
episodes_count = int(ep_match.group(1))
# Country (Quốc gia)
elif 'Quốc gia' in item_text:
country_links = item.select('a')
if country_links:
country = ', '.join([a.get_text(strip=True) for a in country_links])
else:
country = item_text.replace('Quốc gia:', '').strip()
# Genres (Thể loại)
elif 'Thể loại' in item_text:
genre_links = item.select('a')
if genre_links:
genres = [a.get_text(strip=True) for a in genre_links]
else:
genre_text = item_text.replace('Thể loại:', '').strip()
genres = [g.strip() for g in genre_text.split(',') if g.strip()]
# Director (Đạo diễn)
elif 'Đạo diễn' in item_text:
director_links = item.select('a')
if director_links:
director = ', '.join([a.get_text(strip=True) for a in director_links])
else:
director = item_text.replace('Đạo diễn:', '').strip()
# Cast (Diễn viên)
elif 'Diễn viên' in item_text:
cast_links = item.select('a')
if cast_links:
cast = [a.get_text(strip=True) for a in cast_links]
else:
cast_text = item_text.replace('Diễn viên:', '').strip()
cast = [c.strip() for c in cast_text.split(',') if c.strip()]
# Rating
elif 'Đánh giá' in item_text or 'IMDb' in item_text:
rating_match = re.search(r'(\d+\.?\d*)/10', item_text)
if rating_match:
rating = rating_match.group(1)
# Get episodes
episodes = self._parse_episodes(soup)
category = "series" if episodes or (episodes_count and episodes_count > 1) else "movies"
return RophimMovie(
id=slug,
title=title,
original_title=None,
slug=slug,
thumbnail=self._normalize_url(poster),
backdrop=None,
year=year,
rating=rating,
duration=self._extract_duration(html),
quality=self._extract_quality(html),
genre=', '.join(genres) if genres else None,
description=description, # Now has real description!
category=category,
cast=cast if cast else None,
director=director,
country=country,
episodes=episodes
)
def _parse_episodes(self, soup) -> Optional[List[Dict]]:
"""Extract episode list from movie detail page"""
episodes = []
# Find episode links
ep_links = soup.select('a[href*="/tap-"], a[href*="episode"], .episode-list a')
for link in ep_links:
href = link.get('href', '')
text = link.get_text(strip=True)
# Extract episode number
ep_match = re.search(r'tap-(\d+)', href) or re.search(r'(\d+)', text)
if ep_match:
number = int(ep_match.group(1))
episodes.append({
'number': number,
'title': text or f"Tập {number}",
'url': self._normalize_url(href)
})
# Remove duplicates and sort
seen = set()
unique_episodes = []
for ep in sorted(episodes, key=lambda x: x['number']):
if ep['number'] not in seen:
seen.add(ep['number'])
unique_episodes.append(ep)
return unique_episodes if unique_episodes else None
def _extract_video_sources(self, html: str) -> List[str]:
"""Extract video source URLs from player page"""
sources = []
# Look for m3u8 sources
m3u8_pattern = r'(https?://[^"\'\>\s]+\.m3u8[^"\'\>\s]*)'
m3u8_matches = re.findall(m3u8_pattern, html)
sources.extend(m3u8_matches)
# Look for MP4 sources
mp4_pattern = r'(https?://[^"\'\>\s]+\.mp4[^"\'\>\s]*)'
mp4_matches = re.findall(mp4_pattern, html)
sources.extend(mp4_matches)
# Look for iframe sources (embedded players)
iframe_pattern = r'<iframe[^>]*src="([^"]+)"'
iframe_matches = re.findall(iframe_pattern, html)
# Check for common video hostings in iframe
for iframe_src in iframe_matches:
if any(host in iframe_src for host in ['streamtape', 'doodstream', 'mixdrop', 'fembed', 'player', 'embed']):
sources.append(iframe_src)
return sources
def _extract_slug(self, url: str) -> Optional[str]:
"""Extract movie slug from URL"""
match = re.search(r'/phim/([^/?#]+)', url)
if match:
return match.group(1)
match = re.search(r'/([^/?#]+)(?:\?|$)', url)
return match.group(1) if match else None
def _normalize_url(self, url: str) -> str:
"""Normalize relative URLs to absolute"""
if not url:
return ""
if url.startswith('//'):
return 'https:' + url
if url.startswith('/'):
return urljoin(BASE_URL, url)
return url
def _extract_year(self, text: str) -> Optional[int]:
"""Extract year from text"""
match = re.search(r'\b(19|20)\d{2}\b', text)
return int(match.group()) if match else None
def _extract_quality(self, text: str) -> Optional[str]:
"""Extract video quality from text"""
patterns = ['4K', '2160p', '1080p', 'FullHD', '720p', 'HD', '480p', 'SD', 'Full']
for p in patterns:
if re.search(rf'\b{p}\b', text, re.IGNORECASE):
return p.replace('FullHD', '1080p').upper()
return None
def _extract_rating(self, text: str) -> Optional[str]:
"""Extract rating (IMDb, TV-MA, etc)"""
match = re.search(r'(\d+\.?\d*)/10', text)
if match:
return match.group()
return None
def _extract_duration(self, text: str) -> Optional[int]:
"""Extract duration in minutes"""
match = re.search(r'(\d+)\s*(?:phút|min|minutes?)', text, re.IGNORECASE)
return int(match.group(1)) if match else None
def _extract_genre(self, text: str) -> Optional[str]:
"""Extract genre tags"""
genres = []
genre_patterns = [
r'Hành Động', r'Kinh Dị', r'Tình Cảm', r'Hài', r'Viễn Tưởng',
r'Hoạt Hình', r'Phiêu Lưu', r'Bí Ẩn', r'Võ Thuật', r'Chiến Tranh',
r'Action', r'Horror', r'Romance', r'Comedy', r'Sci-Fi',
r'Animation', r'Adventure', r'Mystery', r'Martial Arts', r'War'
]
for pattern in genre_patterns:
if re.search(pattern, text, re.IGNORECASE):
genres.append(pattern)
return ', '.join(genres[:3]) if genres else None
# Singleton instance
scraper = RophimScraper()
# Async helpers for non-async contexts
def get_homepage_sync(limit: int = 24) -> List[RophimMovie]:
"""Synchronous wrapper for getting homepage movies from page 1"""
return asyncio.run(scraper.get_homepage_movies(1, limit))
def get_movies(page: int = 1, limit: int = 24) -> List[Dict]:
"""Compatible wrapper for get_homepage_movies returning dicts"""
async def _fetch():
local_scraper = RophimScraper()
try:
movies = await local_scraper.get_homepage_movies(page, limit)
await local_scraper.close()
return movies
except Exception:
await local_scraper.close()
raise
movies = asyncio.run(_fetch())
return [m.__dict__ for m in movies]
def search_sync(query: str, limit: int = 20) -> List[RophimMovie]:
"""Synchronous wrapper for searching"""
return asyncio.run(scraper.search(query, limit))
async def get_video_stream(slug: str, episode: int = 1, server: int = 0) -> Optional[str]:
"""Get video stream URL from ophim API
Uses ophim1.com V1 API which provides direct m3u8 links.
"""
import aiohttp
import ssl
ssl_ctx = ssl.create_default_context()
ssl_ctx.check_hostname = False
ssl_ctx.verify_mode = ssl.CERT_NONE
try:
# ophim V1 API endpoint is more reliable
api_url = f"https://ophim1.com/v1/api/phim/{slug}"
print(f"DEBUG: Fetching stream from ophim V1 API: {api_url}")
async with aiohttp.ClientSession(connector=aiohttp.TCPConnector(ssl=ssl_ctx)) as session:
async with session.get(api_url, timeout=15) as response:
if response.status != 200:
print(f"DEBUG: API returned status {response.status}")
return None
json_response = await response.json()
# Handle the v1 structure: data.item.episodes
data_block = json_response.get('data', {})
item = data_block.get('item', {})
episodes = item.get('episodes', [])
if not episodes:
# Fallback for old API structure: episodes
episodes = json_response.get('episodes', [])
if not episodes:
print(f"DEBUG: No episodes found for slug: {slug}")
return None
# Get the requested server (default to first)
server_idx = min(server, len(episodes) - 1)
server_data = episodes[server_idx].get('server_data', [])
if not server_data:
print(f"DEBUG: No server data found for slug: {slug}")
return None
# Get the requested episode
episode_idx = episode - 1
if episode_idx >= len(server_data):
# If specifically requested episode 1 but it's empty, use whatever is first
episode_idx = 0
if episode_idx < 0:
episode_idx = 0
ep_data = server_data[episode_idx]
# Prefer m3u8 link, fallback to embed
stream_url = ep_data.get('link_m3u8') or ep_data.get('link_embed')
if stream_url:
print(f"DEBUG: ✓ Found stream URL")
return stream_url
else:
print(f"DEBUG: Links are empty in API response for {slug}")
return None
except Exception as e:
print(f"ERROR: Exception in get_video_stream: {e}")
# Fallback to scraping phimmoichill directly if API logic fails
print(f"⚠ API logic failed, falling back to scraper for {slug}")
try:
from rophim_scraper import RophimScraper
local_scraper = RophimScraper()
url = await local_scraper.get_video_source(slug, episode)
await local_scraper.close()
return url
except Exception as e:
print(f"DEBUG: Scraper fallback also failed: {e}")
return None
def get_movie_details(slug: str) -> Optional[Dict]:
"""Get movie details with episodes from ophim API"""
import requests
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# First try ophim API which has more complete data including episodes
try:
api_url = f"https://ophim1.com/phim/{slug}"
response = requests.get(api_url, verify=False, timeout=15)
if response.status_code == 200:
data = response.json()
movie = data.get('movie', {})
if movie:
# Extract category/genre info
categories = movie.get('category', [])
genres = [c.get('name', '') for c in categories if c.get('name')]
# Build episodes list
episodes = data.get('episodes', [])
return {
'id': movie.get('slug', slug),
'title': movie.get('name', ''),
'original_title': movie.get('origin_name'),
'slug': movie.get('slug', slug),
'thumbnail': movie.get('poster_url') or movie.get('thumb_url'),
'backdrop': movie.get('thumb_url'),
'year': movie.get('year'),
'rating': movie.get('tmdb', {}).get('vote_average') if movie.get('tmdb') else None,
'duration': movie.get('time'),
'quality': movie.get('quality', 'HD'),
'genre': ', '.join(genres) if genres else None,
'genres': genres,
'description': movie.get('content', '').replace('<p>', '').replace('</p>', ''),
'category': movie.get('type', 'movies'),
'cast': movie.get('actor', []),
'director': movie.get('director', [''])[0] if movie.get('director') else '',
'country': movie.get('country', [{}])[0].get('name', '') if movie.get('country') else '',
'episodes': episodes, # Include full episodes data with streaming links
'source_url': f"https://phimmoichill.network/phim/{slug}"
}
except Exception as e:
print(f"ophim API error: {e}")
# Fallback to scraper
async def _fetch():
local_scraper = RophimScraper()
try:
movie = await local_scraper.get_movie_detail(slug)
await local_scraper.close()
if movie:
return movie.__dict__
return None
except Exception:
await local_scraper.close()
return None
return asyncio.run(_fetch())

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

232
backend/static/history.html Normal file
View file

@ -0,0 +1,232 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Watch History - KV-Stream</title>
<!-- Preconnect for fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@500;600;700;900&display=swap"
rel="stylesheet">
<link rel="stylesheet" href="/styles/index.css">
<style>
/* History Specific Styles */
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 40px;
margin-top: 20px;
}
.history-title {
font-size: 2rem;
font-weight: 700;
}
.clear-btn {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
font-family: inherit;
transition: all 0.2s;
}
.clear-btn:hover {
background: rgba(255, 0, 0, 0.2);
border-color: rgba(255, 0, 0, 0.4);
}
.history-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
padding: 0 40px 40px;
}
.history-card {
position: relative;
border-radius: 12px;
overflow: hidden;
background: #1a1a1a;
transition: transform 0.3s ease;
text-decoration: none;
color: white;
}
.history-card:hover {
transform: scale(1.05);
z-index: 2;
}
.history-thumb {
width: 100%;
aspect-ratio: 2/3;
object-fit: cover;
}
.history-info {
padding: 12px;
}
.history-progress-bar {
height: 3px;
background: rgba(255, 255, 255, 0.2);
width: 100%;
position: absolute;
bottom: 0;
left: 0;
}
.history-progress-fill {
height: 100%;
background: var(--color-primary);
}
.history-meta {
font-size: 0.85rem;
color: #aaa;
margin-top: 4px;
}
</style>
</head>
<body>
<div id="app" class="app-layout">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar__logo">
<a href="/" style="color:white;">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M4 8H2v12c0 1.1.9 2 2 2h12v-2H4V8z" />
<path
d="M20 2H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z" />
</svg>
</a>
</div>
<nav class="sidebar__nav">
<a href="/" class="sidebar__nav-item" title="Home">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
</svg>
</a>
<!-- History Link (Active) -->
<a href="/history.html" class="sidebar__nav-item active" title="History">
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-7 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z" />
</svg>
</a>
</nav>
</aside>
<!-- Main Content -->
<main class="main-content" style="margin-left: 80px; width: calc(100% - 80px);">
<div class="history-header">
<div style="display:flex; gap:20px; align-items:center;">
<h1 class="history-title" style="margin:0; cursor:pointer;" id="tabHistory">Watch History</h1>
<h1 class="history-title" style="margin:0; opacity:0.5; cursor:pointer;" id="tabMyList">My List</h1>
</div>
<button class="clear-btn" id="clearHistoryBtn">Clear History</button>
</div>
<div class="history-grid" id="historyGrid">
<!-- Items -->
</div>
<div id="emptyState" style="text-align:center; display:none; padding:50px;">
<h2 id="emptyTitle">No history yet</h2>
<p style="color:#aaa;" id="emptyMsg">Movies you watch will appear here.</p>
<a href="/" class="action-btn action-btn--primary" style="margin-top:20px; display:inline-block;">Browse
Movies</a>
</div>
</main>
</div>
<script src="/js/history-service.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const grid = document.getElementById('historyGrid');
const emptyState = document.getElementById('emptyState');
const clearBtn = document.getElementById('clearHistoryBtn');
const tabHistory = document.getElementById('tabHistory');
const tabMyList = document.getElementById('tabMyList');
const emptyTitle = document.getElementById('emptyTitle');
const emptyMsg = document.getElementById('emptyMsg');
let currentView = 'history'; // history or mylist
function updateTabs() {
if (currentView === 'history') {
tabHistory.style.opacity = '1';
tabMyList.style.opacity = '0.5';
clearBtn.style.display = 'block';
} else {
tabHistory.style.opacity = '0.5';
tabMyList.style.opacity = '1';
clearBtn.style.display = 'none'; // Don't allow clearing My List via this button
}
render();
}
tabHistory.addEventListener('click', () => { currentView = 'history'; updateTabs(); });
tabMyList.addEventListener('click', () => { currentView = 'mylist'; updateTabs(); });
function render() {
let items = [];
if (currentView === 'history') {
items = window.historyService.getHistory();
emptyTitle.textContent = "No history yet";
emptyMsg.textContent = "Movies you watch will appear here.";
} else {
items = window.historyService.getFavorites();
emptyTitle.textContent = "My List is empty";
emptyMsg.textContent = "Add movies to your list to watch later.";
}
if (items.length === 0) {
grid.style.display = 'none';
emptyState.style.display = 'block';
return;
}
grid.style.display = 'grid';
emptyState.style.display = 'none';
grid.innerHTML = items.map(item => `
<a href="/watch.html?id=${item.id}&slug=${item.slug}&ep=${item.progress?.episode || 1}" class="history-card">
<img src="${item.thumbnail}" class="history-thumb" loading="lazy" alt="${item.title}">
<div class="history-info">
<h3 style="font-size:1rem; margin-bottom:4px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${item.title}</h3>
<div class="history-meta">${currentView === 'history' ? `Episode ${item.progress.episode}` : (item.year || 'Movie')}</div>
<div class="history-meta" style="font-size:0.75rem; margin-top:2px;">
${currentView === 'history' ? new Date(item.timestamp).toLocaleDateString() : 'Added ' + new Date(item.addedAt || Date.now()).toLocaleDateString()}
</div>
</div>
${currentView === 'history' ? `
<div class="history-progress-bar">
<div class="history-progress-fill" style="width: ${item.progress.percentage || 0}%"></div>
</div>` : ''}
</a>
`).join('');
}
clearBtn.addEventListener('click', () => {
if (confirm('Are you sure you want to clear your watch history?')) {
window.historyService.clearHistory();
render();
}
});
updateTabs();
});
</script>
</body>
</html>

341
backend/static/index.html Normal file
View file

@ -0,0 +1,341 @@
<!DOCTYPE html>
<html class="dark" lang="en">
<head>
<meta charset="utf-8" />
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
<title>StreamFlix - Homepage</title>
<meta name="description" content="StreamFlix - Premium Movie Streaming">
<meta name="theme-color" content="#141414">
<meta name="referrer" content="no-referrer">
<!-- Fonts -->
<link href="https://fonts.googleapis.com" rel="preconnect" />
<link crossorigin="" href="https://fonts.gstatic.com" rel="preconnect" />
<link href="https://fonts.googleapis.com/css2?family=Spline+Sans:wght@300;400;500;600;700&display=swap"
rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap"
rel="stylesheet" />
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<!-- Theme Config -->
<script>
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"primary": "#ea2a33",
"background-light": "#f8f6f6",
"background-dark": "#141414",
"surface-dark": "#181818",
},
fontFamily: {
"display": ["Spline Sans", "sans-serif"]
},
borderRadius: { "DEFAULT": "0.25rem", "lg": "0.5rem", "xl": "0.75rem", "full": "9999px" },
},
},
}
</script>
<style>
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none;
/* IE and Edge */
scrollbar-width: none;
/* Firefox */
}
.hero-gradient {
background: linear-gradient(0deg, #141414 0%, rgba(20, 20, 20, 0.5) 50%, rgba(20, 20, 20, 0.4) 100%);
}
.card-hover:hover {
transform: scale(1.05);
transition: transform 0.3s ease-in-out;
z-index: 10;
}
/* Loading spinner */
.loading-spinner {
border: 3px solid rgba(255, 255, 255, 0.1);
border-top-color: #ea2a33;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Search modal styling */
.search-modal {
position: fixed;
inset: 0;
z-index: 100;
display: none;
align-items: flex-start;
justify-content: center;
padding-top: 80px;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(8px);
}
.search-modal.active {
display: flex;
}
</style>
<!-- PWA Meta Tags -->
<link rel="manifest" href="/manifest.json">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-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="StreamFlix">
<link rel="icon" type="image/png" href="/icons/icon-512.png">
<link rel="apple-touch-icon" href="/icons/icon-512.png">
<script type="module" crossorigin src="/assets/main-B16Z87Li.js"></script>
<link rel="modulepreload" crossorigin href="/assets/Toast-BwR22KmJ.js">
</head>
<body class="bg-background-light dark:bg-background-dark text-white font-display overflow-x-hidden antialiased">
<div class="relative flex min-h-screen flex-col">
<!-- Navigation -->
<header
class="fixed top-0 left-0 right-0 z-50 transition-colors duration-300 bg-gradient-to-b from-black/90 via-black/60 to-transparent pb-2 md:pb-10"
id="mainHeader">
<div class="px-4 md:px-12 py-2 md:py-4 flex items-center justify-between">
<div class="flex items-center gap-8">
<!-- Logo -->
<a class="flex items-center gap-2 text-primary hover:opacity-90 transition-opacity" href="/">
<span class="material-symbols-outlined text-4xl text-primary"
style="font-variation-settings: 'FILL' 1;">movie</span>
<span class="text-2xl font-bold tracking-tighter text-white hidden sm:block">StreamFlix</span>
</a>
<!-- Desktop Nav Links -->
<nav class="hidden md:flex items-center gap-6" id="mainNav">
<a class="text-sm font-medium text-white hover:text-gray-300 transition-colors nav-link active"
href="#" data-view="home">Home</a>
<a class="text-sm font-medium text-gray-300 hover:text-white transition-colors nav-link"
href="#" data-view="series">TV Shows</a>
<a class="text-sm font-medium text-gray-300 hover:text-white transition-colors nav-link"
href="#" data-view="movies">Movies</a>
<a class="text-sm font-medium text-gray-300 hover:text-white transition-colors nav-link"
href="#" data-view="cinema">New & Popular</a>
<a class="text-sm font-medium text-gray-300 hover:text-white transition-colors nav-link"
href="#" data-view="history">My List</a>
</nav>
</div>
<div class="flex items-center gap-4 md:gap-6">
<!-- Search (Modal Trigger) -->
<button class="text-white hover:text-gray-300 transition-colors hidden sm:block"
id="headerSearchBtn">
<span class="material-symbols-outlined text-2xl">search</span>
</button>
<!-- Mobile Search Icon -->
<button class="sm:hidden text-white" id="mobileSearchBtn">
<span class="material-symbols-outlined">search</span>
</button>
<button class="text-white hover:text-gray-300 transition-colors relative">
<span class="material-symbols-outlined">notifications</span>
<span class="absolute top-0 right-0 h-2 w-2 bg-primary rounded-full"></span>
</button>
<div class="flex items-center gap-2 cursor-pointer group">
<div class="h-8 w-8 rounded overflow-hidden border border-transparent group-hover:border-white transition-all bg-cover bg-center"
style="background-image: url('https://wallpapers.com/images/hd/netflix-profile-pictures-1000-x-1000-qo9h82134t9nv0j0.jpg');">
</div>
<span
class="material-symbols-outlined text-white transition-transform group-hover:rotate-180 text-sm">arrow_drop_down</span>
</div>
</div>
</div>
</header>
<!-- Hero Section -->
<div class="relative w-full h-[85vh] min-h-[600px]" id="heroContainer">
<!-- Hero Background Image -->
<div class="absolute inset-0 bg-cover bg-center bg-no-repeat" id="heroBg"
style="background-image: url('');">
<!-- Overlay Gradient -->
<div
class="absolute inset-0 bg-gradient-to-r from-background-dark/90 via-background-dark/40 to-transparent">
</div>
<div class="absolute inset-0 bg-gradient-to-t from-background-dark via-transparent to-transparent">
</div>
</div>
<!-- Hero Content -->
<div class="relative h-full flex items-center px-4 md:px-12 pt-20">
<div class="max-w-2xl flex flex-col gap-6 animate-fade-in-up" id="heroContent">
<div class="flex flex-col gap-4">
<div id="heroTagContainer"
class="hidden flex items-center gap-2 text-primary font-bold tracking-widest text-sm uppercase">
<span class="material-symbols-outlined text-lg">local_fire_department</span>
<span id="heroTag"></span>
</div>
<h1 class="text-5xl md:text-7xl font-black leading-tight tracking-tight text-white drop-shadow-lg"
id="heroTitle">
</h1>
<p class="text-base md:text-lg text-gray-200 font-medium leading-relaxed drop-shadow-md max-w-xl line-clamp-3"
id="heroDescription">
Loading...
</p>
</div>
<div class="flex flex-wrap gap-4 mt-2">
<button
class="flex items-center justify-center gap-2 bg-white text-black hover:bg-white/90 px-8 py-3 rounded-md font-bold text-lg transition-colors"
id="heroPlayBtn">
<span class="material-symbols-outlined fill-current"
style="font-variation-settings: 'FILL' 1;">play_arrow</span>
Play
</button>
<button
class="flex items-center justify-center gap-2 bg-gray-500/40 hover:bg-gray-500/50 backdrop-blur-sm text-white px-8 py-3 rounded-md font-bold text-lg transition-colors"
id="heroInfoBtn">
<span class="material-symbols-outlined">info</span>
More Info
</button>
</div>
</div>
</div>
</div>
<!-- Main Content Area -->
<main class="relative z-10 -mt-24 md:-mt-32 pb-20 space-y-12 pl-4 md:pl-12" id="mainContent">
<!-- Dynamic Video Grid -->
<div id="videoGrid" class="flex flex-col gap-12">
<!-- Loading Indicator -->
<div class="flex items-center justify-center py-20" id="loading">
<div class="flex flex-col items-center gap-4">
<div class="loading-spinner"></div>
<span class="text-gray-400">Loading movies...</span>
</div>
</div>
</div>
</main>
<!-- Footer - Hidden on mobile -->
<footer class="hidden md:block w-full bg-black/40 py-16 px-4 md:px-12 mt-auto border-t border-gray-800">
<div class="max-w-5xl mx-auto">
<div class="flex gap-4 mb-6 text-gray-400">
<a class="hover:text-white" href="#"><svg class="w-6 h-6 fill-current" viewBox="0 0 24 24">
<path
d="M24 4.557c-.883.392-1.832.656-2.828.775 1.017-.609 1.798-1.574 2.165-2.724-.951.564-2.005.974-3.127 1.195-.897-.957-2.178-1.555-3.594-1.555-3.179 0-5.515 2.966-4.797 6.045-4.091-.205-7.719-2.165-10.148-5.144-1.29 2.213-.669 5.108 1.523 6.574-.806-.026-1.566-.247-2.229-.616-.054 2.281 1.581 4.415 3.949 4.89-.693.188-1.452.232-2.224.084.626 1.956 2.444 3.379 4.6 3.419-2.07 1.623-4.678 2.348-7.29 2.04 2.179 1.397 4.768 2.212 7.548 2.212 9.142 0 14.307-7.721 13.995-14.646.962-.695 1.797-1.562 2.457-2.549z">
</path>
</svg></a>
<a class="hover:text-white" href="#"><svg class="w-6 h-6 fill-current" viewBox="0 0 24 24">
<path
d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.85-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z">
</path>
</svg></a>
<a class="hover:text-white" href="#"><svg class="w-6 h-6 fill-current" viewBox="0 0 24 24">
<path
d="M19.615 3.184c-3.604-.246-11.631-.245-15.23 0-3.897.266-4.356 2.62-4.385 8.816.029 6.185.484 8.549 4.385 8.816 3.6.245 11.626.246 15.23 0 3.897-.266 4.356-2.62 4.385-8.816-.029-6.185-.484-8.549-4.385-8.816zm-10.615 12.816v-8l8 3.993-8 4.007z">
</path>
</svg></a>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-xs text-gray-500 mb-6">
<div class="flex flex-col gap-3">
<a class="hover:underline" href="#">Audio Description</a>
<a class="hover:underline" href="#">Investor Relations</a>
<a class="hover:underline" href="#">Legal Notices</a>
</div>
<div class="flex flex-col gap-3">
<a class="hover:underline" href="#">Help Center</a>
<a class="hover:underline" href="#">Jobs</a>
<a class="hover:underline" href="#">Cookie Preferences</a>
</div>
<div class="flex flex-col gap-3">
<a class="hover:underline" href="#">Gift Cards</a>
<a class="hover:underline" href="#">Terms of Use</a>
<a class="hover:underline" href="#">Corporate Information</a>
</div>
<div class="flex flex-col gap-3">
<a class="hover:underline" href="#">Media Center</a>
<a class="hover:underline" href="#">Privacy</a>
<a class="hover:underline" href="#">Contact Us</a>
</div>
</div>
<div class="mb-4">
<button
class="border border-gray-500 text-gray-500 px-4 py-1 text-sm hover:text-white hover:border-white transition-colors">
Service Code
</button>
</div>
<p class="text-[10px] text-gray-500">
© 1997-2024 StreamFlix, Inc.
</p>
</div>
</footer>
</div>
<!-- Mobile Bottom Navigation -->
<nav class="fixed bottom-0 left-0 right-0 z-50 bg-[#121212]/95 backdrop-blur-lg border-t border-white/5 pb-5 pt-3 px-6 md:hidden"
id="mobileBottomNav">
<div class="flex justify-between items-center max-w-lg mx-auto">
<button class="flex flex-col items-center gap-1 text-white cursor-pointer group nav-item active"
data-view="home">
<span class="material-symbols-outlined text-2xl" style="font-variation-settings: 'FILL' 1;">home</span>
<span class="text-[10px] font-medium">Home</span>
</button>
<button
class="flex flex-col items-center gap-1 text-gray-400 hover:text-white transition-colors cursor-pointer group nav-item"
data-view="cinema">
<span class="material-symbols-outlined text-2xl">video_library</span>
<span class="text-[10px] font-medium">New & Hot</span>
</button>
<button
class="flex flex-col items-center gap-1 text-gray-400 hover:text-white transition-colors cursor-pointer group nav-item"
data-view="mylist">
<span class="material-symbols-outlined text-2xl">playlist_play</span>
<span class="text-[10px] font-medium">My List</span>
</button>
<button
class="flex flex-col items-center gap-1 text-gray-400 hover:text-white transition-colors cursor-pointer group nav-item"
data-view="search">
<span class="material-symbols-outlined text-2xl">search</span>
<span class="text-[10px] font-medium">Search</span>
</button>
</div>
</nav>
<!-- Search Modal -->
<div class="search-modal" id="searchModal">
<div class="w-full max-w-3xl mx-4">
<div class="flex items-center gap-4 mb-6">
<span class="material-symbols-outlined text-white/50 text-3xl">search</span>
<input type="text" id="searchInput"
class="flex-1 bg-transparent border-none text-white text-2xl placeholder-white/30 focus:ring-0 focus:outline-none"
placeholder="Titles, people, genres" autocomplete="off">
<button class="text-white/50 hover:text-white" id="closeSearch">
<span class="material-symbols-outlined text-3xl">close</span>
</button>
</div>
<div id="searchResults" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4"></div>
<div id="searchLoading" class="hidden flex-col items-center justify-center py-20">
<div class="loading-spinner"></div>
<span class="text-gray-400 mt-4">Searching...</span>
</div>
</div>
</div>
<!-- Scripts -->
<script src="/js/history-service.js"></script>
</body>
</html>

305
backend/static/info.html Normal file
View file

@ -0,0 +1,305 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Movie Details - KV-Stream</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@500;600;700;900&display=swap"
rel="stylesheet">
<link rel="stylesheet" href="/styles/index.css">
<style>
/* Info Page Specific Styles */
.info-page-container {
position: relative;
min-height: 100vh;
width: 100%;
}
/* Backdrop */
.info-backdrop {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 70vh;
background-size: cover;
background-position: center;
mask-image: linear-gradient(to bottom, black 0%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, black 0%, transparent 100%);
z-index: 0;
opacity: 0.6;
}
.info-content-wrapper {
position: relative;
z-index: 2;
padding: 100px 40px 40px;
max-width: 1400px;
margin: 0 auto;
display: flex;
gap: 50px;
}
/* Left Column: Poster */
.info-poster-col {
flex-shrink: 0;
width: 300px;
}
.info-poster {
width: 100%;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
aspect-ratio: 2/3;
object-fit: cover;
}
/* Right Column: Details */
.info-details-col {
flex-grow: 1;
padding-top: 20px;
}
.info-title {
font-size: 3rem;
font-weight: 900;
margin-bottom: 10px;
line-height: 1.1;
}
.info-original-title {
font-size: 1.2rem;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 20px;
}
.info-rating {
display: flex;
align-items: center;
gap: 5px;
margin-bottom: 20px;
color: #f5c518;
font-weight: 600;
}
/* Metadata Grid */
.meta-box {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 24px;
margin-bottom: 30px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.meta-row {
display: flex;
margin-bottom: 12px;
line-height: 1.5;
}
.meta-label {
width: 150px;
color: rgba(255, 255, 255, 0.6);
flex-shrink: 0;
}
.meta-value {
color: #fff;
flex-grow: 1;
}
.meta-tag {
display: inline-block;
color: var(--color-primary);
}
/* Actions */
.info-actions {
display: flex;
gap: 16px;
margin-bottom: 40px;
}
/* Description */
.section-header {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding-bottom: 10px;
}
.info-desc {
font-size: 1rem;
line-height: 1.6;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 40px;
max-width: 800px;
}
/* Recommendations */
.rec-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 16px;
}
.rec-card {
text-decoration: none;
color: white;
transition: transform 0.2s;
}
.rec-card:hover {
transform: scale(1.05);
}
.rec-img {
width: 100%;
border-radius: 8px;
aspect-ratio: 2/3;
object-fit: cover;
margin-bottom: 8px;
}
@media (max-width: 900px) {
.info-content-wrapper {
flex-direction: column;
align-items: center;
padding-top: 40px;
}
.info-poster-col {
width: 200px;
}
.info-details-col {
text-align: center;
}
.meta-row {
text-align: left;
}
.info-title {
font-size: 2rem;
}
}
</style>
</head>
<body>
<div id="app" class="app-layout">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar__logo">
<a href="/" style="color:white;">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M4 8H2v12c0 1.1.9 2 2 2h12v-2H4V8z" />
<path
d="M20 2H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z" />
</svg>
</a>
</div>
<nav class="sidebar__nav">
<a href="/" class="sidebar__nav-item" title="Home">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
</svg>
</a>
<a href="/history.html" class="sidebar__nav-item" title="History">
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-7 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z" />
</svg>
</a>
</nav>
</aside>
<main class="main-content" style="margin-left: 80px; width: calc(100% - 80px);">
<div class="info-page-container">
<!-- Backdrop -->
<div class="info-backdrop" id="backdrop"></div>
<div class="info-content-wrapper">
<!-- Poster -->
<div class="info-poster-col">
<img src="" alt="Poster" class="info-poster" id="poster">
</div>
<!-- Details -->
<div class="info-details-col">
<h1 class="info-title" id="title">Loading...</h1>
<div class="info-original-title" id="originalTitle"></div>
<div class="info-rating" id="rating">
<!-- e.g. ⭐⭐⭐⭐⭐ (0 votes) -->
</div>
<!-- Metadata Box -->
<div class="meta-box">
<div class="meta-row">
<span class="meta-label">Trạng thái:</span>
<span class="meta-value" id="status">...</span>
</div>
<div class="meta-row">
<span class="meta-label">Năm phát hành:</span>
<span class="meta-value" id="year">...</span>
</div>
<div class="meta-row">
<span class="meta-label">Số tập:</span>
<span class="meta-value" id="episodes">...</span>
</div>
<div class="meta-row">
<span class="meta-label">Quốc gia:</span>
<span class="meta-value" id="country">...</span>
</div>
<div class="meta-row">
<span class="meta-label">Thể loại:</span>
<span class="meta-value" id="genre">...</span>
</div>
<div class="meta-row">
<span class="meta-label">Đạo diễn:</span>
<span class="meta-value" id="director">...</span>
</div>
<div class="meta-row">
<span class="meta-label">Diễn viên:</span>
<span class="meta-value" id="cast">...</span>
</div>
</div>
<!-- Actions -->
<div class="info-actions">
<a href="#" class="action-btn action-btn--primary" id="btnWatch">
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
<path d="M8 5v14l11-7z" />
</svg>
Xem Phim
</a>
<!-- My List Button can go here -->
</div>
<!-- Description -->
<div class="section-header">Nội dung chi tiết</div>
<div class="info-desc" id="description"></div>
<!-- Tags/Keywords -->
<div style="margin-bottom: 40px; display:flex; gap:10px; flex-wrap:wrap;" id="tags"></div>
<!-- Recommendations -->
<div class="section-header">Có thể bạn sẽ thích (Top phim hay)</div>
<div class="rec-grid" id="recommendations"></div>
</div>
</div>
</div>
</main>
</div>
<script type="module" src="/scripts/info.js"></script>
</body>
</html>

View file

@ -0,0 +1,141 @@
/**
* HistoryService - Manages watch history using localStorage
* Allows users to save progress without logging in.
*/
if (!window.HistoryService) {
window.HistoryService = class HistoryService {
constructor() {
this.STORAGE_KEY = 'kv_watch_history';
this.MAX_ITEMS = 100; // Limit history size
}
/**
* Get all history items
* @returns {Array} List of history items sorted by timestamp (newest first)
*/
getHistory() {
try {
const history = localStorage.getItem(this.STORAGE_KEY);
return history ? JSON.parse(history) : [];
} catch (e) {
console.error('Error reading history:', e);
return [];
}
}
/**
* Add or update a movie/episode in history
* @param {Object} movie - Movie object
* @param {Object} progress - Progress info (optional)
*/
addToHistory(movie, progress = {}) {
const history = this.getHistory();
// Remove existing entry for this item if it exists
// Identify by slug
const existingIndex = history.findIndex(item => item.slug === movie.slug);
if (existingIndex !== -1) {
history.splice(existingIndex, 1);
}
// Create new entry
const entry = {
id: movie.id || movie.slug,
slug: movie.slug,
title: movie.title,
thumbnail: movie.thumbnail,
backdrop: movie.backdrop,
description: movie.description,
timestamp: Date.now(),
progress: {
currentTime: progress.currentTime || 0,
duration: progress.duration || 0,
percentage: progress.percentage || 0,
episode: progress.episode || 1
},
...movie // Store other metadata
};
// Add to front
history.unshift(entry);
// Trim size
if (history.length > this.MAX_ITEMS) {
history.pop();
}
this.saveHistory(history);
}
// --- Favorites (My List) Methods ---
getFavorites() {
try {
const list = localStorage.getItem('myList');
return list ? JSON.parse(list) : [];
} catch (e) { return []; }
}
toggleFavorite(movie) {
let list = this.getFavorites();
const exists = list.some(item => item.slug === movie.slug);
if (exists) {
list = list.filter(item => item.slug !== movie.slug);
} else {
list.push({
id: movie.id || movie.slug,
slug: movie.slug,
title: movie.title,
thumbnail: movie.thumbnail,
addedAt: Date.now()
});
}
localStorage.setItem('myList', JSON.stringify(list));
return !exists; // Return true if added, false if removed
}
isFavorite(slug) {
return this.getFavorites().some(item => item.slug === slug);
}
/**
* Remove an item from history
* @param {String} slug
*/
removeFromHistory(slug) {
let history = this.getHistory();
history = history.filter(item => item.slug !== slug);
this.saveHistory(history);
}
/**
* Clear all history
*/
clearHistory() {
localStorage.removeItem(this.STORAGE_KEY);
}
saveHistory(history) {
try {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(history));
// Dispatch event for UI updates
window.dispatchEvent(new CustomEvent('history-updated', { detail: history }));
} catch (e) {
console.error('Error saving history:', e);
}
}
/**
* Check if a movie is in history
*/
isInHistory(slug) {
return this.getHistory().some(item => item.slug === slug);
}
}
// Export singleton
window.historyService = new window.HistoryService();
}

View file

@ -0,0 +1,17 @@
{
"name": "KV-Stream",
"short_name": "KV-Stream",
"description": "Premium Movie Streaming with Liquid Glass Design",
"start_url": "/",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#000000",
"icons": [
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

20
backend/static/sw.js Normal file
View file

@ -0,0 +1,20 @@
const CACHE_NAME = 'kv-stream-v1';
const ASSETS = [
'/',
'/index.html',
'/watch.html',
'/styles/index.css',
'/icons/icon-512.png'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS))
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((response) => response || fetch(event.request))
);
});

401
backend/static/watch.html Normal file
View file

@ -0,0 +1,401 @@
<!DOCTYPE html>
<html class="dark" lang="en">
<head>
<meta charset="utf-8" />
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
<title>StreamFlix - Movie Details</title>
<meta name="description" content="StreamFlix - Watch Movies Online">
<meta name="theme-color" content="#141414">
<meta name="referrer" content="no-referrer">
<!-- Fonts -->
<link href="https://fonts.googleapis.com" rel="preconnect" />
<link crossorigin="" href="https://fonts.gstatic.com" rel="preconnect" />
<link href="https://fonts.googleapis.com/css2?family=Spline+Sans:wght@300;400;500;600;700&display=swap"
rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap"
rel="stylesheet" />
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<!-- Theme Config -->
<script>
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"primary": "#ea2a33",
"background-light": "#f8f6f6",
"background-dark": "#141414",
"surface-dark": "#181818",
},
fontFamily: {
"display": ["Spline Sans", "sans-serif"]
},
borderRadius: { "DEFAULT": "0.25rem", "lg": "0.5rem", "xl": "0.75rem", "full": "9999px" },
},
},
}
</script>
<style>
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #141414;
}
::-webkit-scrollbar-thumb {
background: #333;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* Loading spinner */
.loading-spinner {
border: 3px solid rgba(255, 255, 255, 0.1);
border-top-color: #ea2a33;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
<!-- PWA Meta Tags -->
<link rel="manifest" href="/manifest.json">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-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="StreamFlix">
<link rel="icon" type="image/png" href="/icons/icon-512.png">
<link rel="apple-touch-icon" href="/icons/icon-512.png">
<script type="module" crossorigin src="/assets/watch-Baf19X1S.js"></script>
<link rel="modulepreload" crossorigin href="/assets/Toast-BwR22KmJ.js">
</head>
<body class="bg-background-light dark:bg-background-dark text-slate-900 dark:text-white font-display overflow-x-hidden">
<!-- Navigation -->
<nav class="fixed top-0 w-full z-50 transition-all duration-300 bg-gradient-to-b from-black/80 to-transparent pointer-events-none"
id="watchHeader">
<div class="px-4 md:px-12 py-4 pt-6 flex items-center justify-between">
<!-- Back / Close Button -->
<button
class="pointer-events-auto text-white flex items-center justify-center p-2 rounded-full hover:bg-white/10 transition-colors"
id="watchBackBtn">
<span class="material-symbols-outlined text-3xl shadow-md">arrow_back</span>
</button>
<!-- Right Actions -->
<div class="flex items-center gap-4 pointer-events-auto">
<button
class="text-white flex items-center justify-center p-2 rounded-full hover:bg-white/10 transition-colors">
<span class="material-symbols-outlined text-2xl">cast</span>
</button>
<button
class="text-white flex items-center justify-center p-2 rounded-full hover:bg-white/10 transition-colors"
id="searchBtn">
<span class="material-symbols-outlined text-2xl">search</span>
</button>
<!-- Desktop Profile (Hidden on mobile) -->
<div class="hidden md:block size-8 rounded-md bg-cover bg-center cursor-pointer border border-white/20 hover:border-white/60 transition-all"
style="background-image: url('https://wallpapers.com/images/hd/netflix-profile-pictures-1000-x-1000-qo9h82134t9nv0j0.jpg');">
</div>
</div>
</div>
</nav>
<!-- Hero Section (Movie Background) -->
<div class="relative w-full aspect-[4/5] md:aspect-auto md:h-[85vh] md:min-h-[600px] overflow-hidden group/hero"
id="heroSection">
<!-- Hero Background -->
<div class="absolute inset-0 bg-cover bg-center bg-no-repeat transition-transform duration-[10s] ease-out group-hover/hero:scale-105"
id="heroBg" style="background-image: url('');"></div>
<!-- Gradients -->
<div class="absolute inset-0 bg-gradient-to-t from-background-dark via-background-dark/40 to-transparent"></div>
<div
class="absolute inset-0 bg-gradient-to-r from-background-dark/90 via-background-dark/30 to-transparent hidden md:block">
</div>
<!-- Play Button (Centered Mobile) -->
<div class="absolute inset-0 flex items-center justify-center pointer-events-none md:hidden z-20">
<div class="size-16 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center pointer-events-auto cursor-pointer hover:scale-105 transition-transform border border-white/30"
id="mobilePlayBtn">
<span class="material-symbols-outlined text-white text-4xl fill-1"
style="font-variation-settings: 'FILL' 1;">play_arrow</span>
</div>
</div>
<!-- Hero Content (Desktop) -->
<div
class="absolute bottom-0 left-0 w-full px-4 md:px-12 pb-12 md:pb-20 z-10 flex flex-col justify-end h-full hidden md:flex">
<div class="max-w-2xl">
<!-- Movie Title -->
<h1 class="text-5xl md:text-7xl font-black text-white tracking-tight leading-[0.9] mb-4 drop-shadow-lg"
id="movieTitleDesktop">Loading...</h1>
<!-- Meta Data -->
<div class="flex items-center flex-wrap gap-4 text-white/90 text-sm md:text-base font-medium mb-6">
<span class="text-[#46d369] font-bold" id="movieMatchDesktop">98% Match</span>
<span class="text-gray-300" id="movieYearDesktop">2024</span>
<span class="border border-white/40 px-1.5 py-0.5 text-xs rounded text-gray-300"
id="movieRatingDesktop">PG-13</span>
<span class="flex items-center gap-1 text-gray-300"><span
class="material-symbols-outlined text-sm">hd</span> <span
id="movieQualityDesktop">HD</span></span>
</div>
<!-- Synopsis -->
<p class="text-white/80 text-lg md:text-xl leading-relaxed font-medium mb-8 drop-shadow-md line-clamp-3"
id="movieDescriptionDesktop">Loading description...</p>
<!-- Actions -->
<div class="flex items-center gap-4">
<button
class="flex items-center gap-2 bg-white text-black px-8 py-3 rounded hover:bg-white/90 transition-colors font-bold text-lg min-w-[140px] justify-center"
id="playBtnDesktop">
<span class="material-symbols-outlined text-3xl"
style="font-variation-settings: 'FILL' 1;">play_arrow</span>
Play
</button>
<button
class="flex items-center gap-2 bg-gray-500/40 hover:bg-gray-500/50 backdrop-blur-sm text-white px-6 py-3 rounded transition-colors font-bold text-lg min-w-[160px] justify-center"
id="addListBtnDesktop">
<span class="material-symbols-outlined text-2xl">add</span>
<span>My List</span>
</button>
</div>
</div>
</div>
</div>
<!-- Mobile Content (Title, Meta, Actions) -->
<div class="relative px-5 -mt-20 z-10 flex flex-col gap-5 md:hidden text-white">
<!-- Title and Badges -->
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2 mb-1">
<span class="material-symbols-outlined text-primary text-2xl"
style="font-variation-settings: 'FILL' 1;">movie</span>
<span class="text-xs font-bold tracking-[0.2em] text-gray-300 uppercase">Series</span>
</div>
<h1 class="text-4xl font-bold tracking-tight text-white leading-none" id="movieTitleMobile">Loading...</h1>
<!-- Metadata Row -->
<div class="flex items-center flex-wrap gap-x-3 gap-y-2 mt-1 text-sm text-gray-300 font-medium">
<span class="text-[#46d369] font-bold" id="movieMatchMobile">98% Match</span>
<span id="movieYearMobile">2024</span>
<span class="bg-gray-700/60 px-1.5 py-0.5 rounded text-xs border border-gray-600"
id="movieRatingMobile">TV-MA</span>
<span id="movieDurationMobile">2h 30m</span>
<span
class="border border-gray-500 rounded px-1 text-[10px] font-bold h-4 flex items-center leading-none"
id="movieQualityMobile">HD</span>
</div>
</div>
<!-- Action Buttons -->
<div class="flex flex-col gap-3 mt-2">
<button
class="flex items-center justify-center gap-2 w-full bg-primary hover:bg-red-700 text-white font-bold py-3 px-4 rounded transition-colors active:scale-[0.98]"
id="playBtnMobile">
<span class="material-symbols-outlined fill-1"
style="font-variation-settings: 'FILL' 1;">play_arrow</span>
<span>Resume</span>
</button>
<div class="flex items-center gap-3 w-full">
<button
class="flex items-center justify-center gap-2 flex-1 bg-[#2b2b2b] hover:bg-[#383838] text-white font-semibold py-3 px-4 rounded transition-colors active:scale-[0.98]"
id="addListBtnMobile">
<span class="material-symbols-outlined">add</span>
<span>My List</span>
</button>
<button
class="flex flex-none items-center justify-center w-12 h-12 bg-[#2b2b2b] hover:bg-[#383838] text-white rounded transition-colors active:scale-[0.98]"
id="shareBtnMobile">
<span class="material-symbols-outlined">share</span>
</button>
</div>
</div>
<!-- Synopsis -->
<div class="mb-4">
<p class="text-sm text-gray-300 leading-relaxed line-clamp-3" id="movieDescriptionMobile">Loading...</p>
</div>
</div>
<!-- Video Player (Hidden by default, shown when playing) -->
<div class="fixed inset-0 z-[100] bg-black hidden" id="videoPlayerContainer">
<button class="absolute top-4 right-4 z-10 text-white hover:text-gray-300" id="closePlayer">
<span class="material-symbols-outlined text-3xl">close</span>
</button>
<div class="w-full h-full" id="videoPlayer">
<div class="w-full h-full flex items-center justify-center" id="playerLoading">
<div class="flex flex-col items-center gap-4">
<div class="loading-spinner"></div>
<span class="text-gray-400">Loading stream...</span>
</div>
</div>
</div>
</div>
<!-- Main Content Container (Removed negative margin on mobile to prevent overlap) -->
<div class="relative z-20 md:-mt-10 px-4 md:px-12 pb-20" id="mainContent">
<!-- Details Layout (Column) -->
<div class="flex flex-col gap-12">
<!-- Top Section: Details & Episodes (Full Width) -->
<div class="space-y-10 w-full">
<!-- Stats & Tags -->
<div class="flex flex-wrap gap-x-8 gap-y-4 text-sm text-gray-400 border-t border-white/10 pt-6"
id="movieTags">
<!-- Genre tags will be injected here -->
</div>
<!-- Tab Navigation -->
<div class="flex items-center gap-8 border-b border-white/10" id="tabNav">
<button class="text-lg font-bold text-white border-b-4 border-primary pb-3 tab-btn active"
data-tab="episodes">Episodes</button>
<button class="text-lg font-medium text-gray-400 pb-3 hover:text-white transition-colors tab-btn"
data-tab="details">Details</button>
</div>
<!-- Episodes Panel -->
<div class="tab-panel" id="episodesPanel">
<!-- Season Select -->
<div class="flex items-center gap-4 mb-6" id="seasonSelectContainer">
<select
class="bg-surface-dark border border-white/20 text-white px-4 py-2 rounded text-sm focus:border-primary focus:ring-0"
id="seasonSelect">
<option value="1">Season 1</option>
</select>
<span class="text-gray-400 text-sm" id="episodeCount">Episodes</span>
</div>
<!-- Episode Grid -->
<div class="space-y-4" id="episodesGrid">
<!-- Episode cards will be injected here -->
<div class="text-gray-400 text-center py-8" id="episodesLoading">
<div class="loading-spinner mx-auto mb-4"></div>
Loading episodes...
</div>
</div>
</div>
<!-- Trailers Panel -->
<div class="tab-panel hidden" id="trailersPanel">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6" id="trailersGrid">
<!-- Trailers will be injected here -->
<p class="text-gray-400 col-span-full text-center py-8">No trailers available</p>
</div>
</div>
<!-- Details Panel -->
<div class="tab-panel hidden" id="detailsPanel">
<!-- Cast Carousel -->
<div class="space-y-4 mb-8">
<h3 class="text-xl font-bold text-white">Top Cast</h3>
<div class="flex gap-4 overflow-x-auto pb-4 no-scrollbar snap-x" id="castCarousel">
<!-- Cast will be injected here -->
</div>
</div>
<!-- Additional Details -->
<div class="bg-surface-dark p-6 rounded-xl border border-white/5" id="additionalDetails">
<h3 class="text-xl font-bold text-white mb-4">About this movie</h3>
<div class="space-y-3 text-gray-400" id="detailsList">
<!-- Details will be injected here -->
</div>
</div>
</div>
</div>
<!-- Bottom Section: More Like This (Smaller Cards) -->
<div class="space-y-6 pt-8 border-t border-white/10">
<!-- Recommendations Section -->
<div class="space-y-8" id="recommendationsContainer">
<!-- Dynamic sections will be injected here -->
</div>
</div>
</div>
</div>
<!-- Mobile Bottom Navigation -->
<nav class="fixed bottom-0 left-0 right-0 z-50 bg-[#121212]/95 backdrop-blur-lg border-t border-white/5 pb-5 pt-3 px-6 md:hidden"
id="mobileBottomNav">
<div class="flex justify-between items-center max-w-lg mx-auto">
<button class="flex flex-col items-center gap-1 text-white cursor-pointer group nav-item" data-view="home">
<span class="material-symbols-outlined text-2xl">home</span>
<span class="text-[10px] font-medium">Home</span>
</button>
<button
class="flex flex-col items-center gap-1 text-gray-400 hover:text-white transition-colors cursor-pointer group nav-item"
data-view="cinema">
<span class="material-symbols-outlined text-2xl">video_library</span>
<span class="text-[10px] font-medium">New & Hot</span>
</button>
<button
class="flex flex-col items-center gap-1 text-gray-400 hover:text-white transition-colors cursor-pointer group nav-item"
data-view="mylist">
<span class="material-symbols-outlined text-2xl">playlist_play</span>
<span class="text-[10px] font-medium">My List</span>
</button>
<button
class="flex flex-col items-center gap-1 text-gray-400 hover:text-white transition-colors cursor-pointer group nav-item"
data-view="search">
<span class="material-symbols-outlined text-2xl">search</span>
<span class="text-[10px] font-medium">Search</span>
</button>
</div>
</nav>
<!-- Search Modal -->
<div class="fixed inset-0 z-[150] bg-black/90 backdrop-blur-sm hidden" id="searchModal">
<div class="w-full max-w-3xl mx-auto px-4 pt-20">
<div class="flex items-center gap-4 mb-6">
<span class="material-symbols-outlined text-white/50 text-3xl">search</span>
<input type="text" id="searchInput"
class="flex-1 bg-transparent border-none text-white text-2xl placeholder-white/30 focus:ring-0 focus:outline-none"
placeholder="Titles, people, genres" autocomplete="off">
<button class="text-white/50 hover:text-white" id="closeSearch">
<span class="material-symbols-outlined text-3xl">close</span>
</button>
</div>
<div id="searchResults" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
<!-- Search results will be injected here -->
</div>
</div>
</div>
<!-- Toast Container -->
<div class="fixed bottom-4 right-4 z-[200] flex flex-col gap-2" id="toastContainer"></div>
<!-- Video Player Dependencies -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.7/hls.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/artplayer/5.3.0/artplayer.js"></script>
<!-- App Scripts -->
<script src="/js/history-service.js"></script>
</body>
</html>

BIN
backend/streamflow.db Normal file

Binary file not shown.

210
backend/tmdb_service.py Normal file
View file

@ -0,0 +1,210 @@
"""
TMDB (The Movie Database) Service
Provides rich movie metadata from open-source movie database
"""
import aiohttp
import os
import asyncio
from typing import Optional, Dict, List
TMDB_API_KEY = os.getenv('TMDB_API_KEY', '')
TMDB_BASE = 'https://api.themoviedb.org/3'
TMDB_IMAGE_BASE = 'https://image.tmdb.org/t/p'
class TMDBService:
"""Service to fetch movie data from The Movie Database"""
def __init__(self, api_key: str = None):
self.api_key = api_key or TMDB_API_KEY
self.session: Optional[aiohttp.ClientSession] = None
async def _get_session(self) -> aiohttp.ClientSession:
if not self.session:
self.session = aiohttp.ClientSession()
return self.session
async def close(self):
if self.session:
await self.session.close()
self.session = None
async def search_movie(self, title: str, year: Optional[int] = None) -> Optional[Dict]:
"""
Search for a movie by title and optional year
Returns the best match or None
"""
if not self.api_key:
print("⚠ TMDB_API_KEY not set, skipping TMDB enrichment")
return None
try:
session = await self._get_session()
params = {
'api_key': self.api_key,
'query': title,
'language': 'en-US'
}
if year:
params['year'] = year
async with session.get(f'{TMDB_BASE}/search/movie', params=params) as response:
if response.status == 200:
data = await response.json()
results = data.get('results', [])
if results:
# Return first result (best match)
return results[0]
return None
except Exception as e:
print(f"TMDB search error: {e}")
return None
async def get_movie_details(self, tmdb_id: int) -> Optional[Dict]:
"""
Get detailed movie information including cast and crew
"""
if not self.api_key:
return None
try:
session = await self._get_session()
params = {
'api_key': self.api_key,
'append_to_response': 'credits',
'language': 'en-US'
}
async with session.get(f'{TMDB_BASE}/movie/{tmdb_id}', params=params) as response:
if response.status == 200:
return await response.json()
return None
except Exception as e:
print(f"TMDB details error: {e}")
return None
def get_poster_url(self, poster_path: str, size: str = 'w500') -> str:
"""Get full poster URL from TMDB path"""
if not poster_path:
return ''
return f'{TMDB_IMAGE_BASE}/{size}{poster_path}'
def get_profile_url(self, profile_path: str, size: str = 'w185') -> str:
"""Get full profile photo URL from TMDB path"""
if not profile_path:
return ''
return f'{TMDB_IMAGE_BASE}/{size}{profile_path}'
async def enrich_movie_data(self, movie: Dict) -> Dict:
"""
Enrich movie data with TMDB information
Merges TMDB data into existing movie object
"""
if not self.api_key:
return movie
try:
title = movie.get('title') or movie.get('name', '')
year = movie.get('year')
if not title:
return movie
# Search for movie
search_result = await self.search_movie(title, year)
if not search_result:
return movie
tmdb_id = search_result.get('id')
# Get detailed info
details = await self.get_movie_details(tmdb_id)
if not details:
return movie
# Merge data (TMDB takes precedence for certain fields)
enriched = movie.copy()
# Enhanced description
if details.get('overview'):
enriched['tmdb_description'] = details['overview']
# Runtime in minutes
if details.get('runtime'):
enriched['runtime_minutes'] = details['runtime']
# Budget and revenue
if details.get('budget'):
enriched['budget'] = details['budget']
if details.get('revenue'):
enriched['revenue'] = details['revenue']
# Tagline
if details.get('tagline'):
enriched['tagline'] = details['tagline']
# Better rating
if details.get('vote_average'):
enriched['tmdb_rating'] = details['vote_average']
# Enhanced poster
if details.get('poster_path'):
enriched['tmdb_poster'] = self.get_poster_url(details['poster_path'])
if details.get('backdrop_path'):
enriched['tmdb_backdrop'] = self.get_poster_url(details['backdrop_path'], 'w1280')
# Cast with photos
credits = details.get('credits', {})
cast_list = credits.get('cast', [])[:10] # Top 10 cast
enriched['tmdb_cast'] = [
{
'name': person['name'],
'character': person.get('character', ''),
'profile_photo': self.get_profile_url(person.get('profile_path'))
}
for person in cast_list
]
# Director
crew_list = credits.get('crew', [])
directors = [person['name'] for person in crew_list if person.get('job') == 'Director']
if directors:
enriched['tmdb_director'] = directors[0]
print(f"✓ Enriched '{title}' with TMDB data")
return enriched
except Exception as e:
print(f"Error enriching movie: {e}")
return movie
# Singleton instance
tmdb_service = TMDBService()
# Sync wrapper
def enrich_movie_sync(movie: Dict) -> Dict:
"""Synchronous wrapper for enriching movie data"""
return asyncio.run(tmdb_service.enrich_movie_data(movie))
if __name__ == "__main__":
# Test
import asyncio
async def test():
service = TMDBService()
# Test search
result = await service.search_movie("Junior", 1994)
print(f"Search result: {result.get('title') if result else 'None'}")
if result:
details = await service.get_movie_details(result['id'])
print(f"Runtime: {details.get('runtime')} min")
print(f"Cast: {len(details.get('credits', {}).get('cast', []))} actors")
await service.close()
asyncio.run(test())

150
backend/video_extractor.py Normal file
View file

@ -0,0 +1,150 @@
"""
Video Extractor - yt-dlp wrapper for stream URL extraction
"""
import asyncio
from typing import Optional
from dataclasses import dataclass
import yt_dlp
@dataclass
class VideoInfo:
"""Extracted video information"""
title: str
thumbnail: str
duration: int
stream_url: str
format_id: str
resolution: str
ext: str
source_url: str
class VideoExtractor:
"""
yt-dlp wrapper for extracting direct stream URLs.
Supports quality negotiation up to 4K.
"""
# Quality preference order (highest to lowest)
QUALITY_PREFERENCE = [
'2160p', '1440p', '1080p', '720p', '480p', '360p', '240p'
]
def __init__(self):
self.base_opts = {
'quiet': True,
'no_warnings': True,
'extract_flat': False,
'noplaylist': True,
}
def _get_format_selector(self, preferred_quality: Optional[str] = None) -> str:
"""Build format selector string for yt-dlp"""
if preferred_quality:
# User requested specific quality
height = preferred_quality.replace('p', '')
return f'bestvideo[height<={height}]+bestaudio/best[height<={height}]/best'
# Default: best available
return 'bestvideo+bestaudio/best'
async def extract(
self,
url: str,
preferred_quality: Optional[str] = None,
cookies_file: Optional[str] = None
) -> VideoInfo:
"""
Extract video information and stream URL.
Args:
url: Source video URL
preferred_quality: Optional quality preference (e.g., '1080p', '720p')
cookies_file: Optional path to cookies file for authenticated sources
Returns:
VideoInfo with stream URL and metadata
Raises:
Exception if extraction fails
"""
opts = {
**self.base_opts,
'format': self._get_format_selector(preferred_quality),
}
if cookies_file:
opts['cookiefile'] = cookies_file
# Run yt-dlp in thread pool to avoid blocking
loop = asyncio.get_event_loop()
info = await loop.run_in_executor(None, self._extract_sync, url, opts)
return info
def _extract_sync(self, url: str, opts: dict) -> VideoInfo:
"""Synchronous extraction (runs in thread pool)"""
with yt_dlp.YoutubeDL(opts) as ydl:
info = ydl.extract_info(url, download=False)
# Get the best format's URL
stream_url = info.get('url')
# If no direct URL, check requested_formats (for merged streams)
if not stream_url and 'requested_formats' in info:
# Prefer video+audio manifest or video stream
for fmt in info['requested_formats']:
if fmt.get('url'):
stream_url = fmt['url']
break
# Fallback to formats list
if not stream_url and 'formats' in info:
for fmt in reversed(info['formats']):
if fmt.get('url'):
stream_url = fmt['url']
break
if not stream_url:
raise Exception("Could not extract stream URL")
# Determine resolution
height = info.get('height', 0)
resolution = f"{height}p" if height else 'unknown'
return VideoInfo(
title=info.get('title', 'Unknown'),
thumbnail=info.get('thumbnail', ''),
duration=info.get('duration', 0),
stream_url=stream_url,
format_id=info.get('format_id', ''),
resolution=resolution,
ext=info.get('ext', 'mp4'),
source_url=url
)
async def get_available_qualities(self, url: str) -> list[str]:
"""Get list of available quality options for a video"""
opts = {
**self.base_opts,
'listformats': False,
}
loop = asyncio.get_event_loop()
def extract_formats():
with yt_dlp.YoutubeDL(opts) as ydl:
info = ydl.extract_info(url, download=False)
formats = info.get('formats', [])
qualities = set()
for fmt in formats:
height = fmt.get('height')
if height:
qualities.add(f"{height}p")
return sorted(qualities, key=lambda x: int(x.replace('p', '')), reverse=True)
return await loop.run_in_executor(None, extract_formats)
# Singleton instance
extractor = VideoExtractor()

35
docker-compose.yml Normal file
View file

@ -0,0 +1,35 @@
version: '3.8'
services:
# StreamFlow Unified (Backend + Frontend)
app:
image: vndangkhoa/streamflow:latest
platform: linux/amd64
ports:
- "3478:8000"
environment:
- REDIS_URL=redis://redis:6379
- DATABASE_URL=sqlite:///./data/streamflow.db
- PYTHONUNBUFFERED=1
volumes:
- ./backend/data:/app/data
depends_on:
redis:
condition: service_healthy
restart: unless-stopped
# Redis Cache
redis:
image: redis:7-alpine
platform: linux/amd64
ports:
- "6379:6379"
volumes:
- ./redis-data:/data
restart: unless-stopped
command: redis-server --appendonly yes
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 5s
timeout: 3s
retries: 5

18
frontend/Dockerfile Normal file
View file

@ -0,0 +1,18 @@
FROM node:20-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy source code
COPY . .
# Expose port
EXPOSE 3000
# Run development server
CMD ["npm", "run", "dev", "--", "--host"]

342
frontend/index.html Normal file
View file

@ -0,0 +1,342 @@
<!DOCTYPE html>
<html class="dark" lang="en">
<head>
<meta charset="utf-8" />
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
<title>StreamFlix - Homepage</title>
<meta name="description" content="StreamFlix - Premium Movie Streaming">
<meta name="theme-color" content="#141414">
<meta name="referrer" content="no-referrer">
<!-- Fonts -->
<link href="https://fonts.googleapis.com" rel="preconnect" />
<link crossorigin="" href="https://fonts.gstatic.com" rel="preconnect" />
<link href="https://fonts.googleapis.com/css2?family=Spline+Sans:wght@300;400;500;600;700&display=swap"
rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap"
rel="stylesheet" />
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<!-- Theme Config -->
<script>
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"primary": "#ea2a33",
"background-light": "#f8f6f6",
"background-dark": "#141414",
"surface-dark": "#181818",
},
fontFamily: {
"display": ["Spline Sans", "sans-serif"]
},
borderRadius: { "DEFAULT": "0.25rem", "lg": "0.5rem", "xl": "0.75rem", "full": "9999px" },
},
},
}
</script>
<style>
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none;
/* IE and Edge */
scrollbar-width: none;
/* Firefox */
}
.hero-gradient {
background: linear-gradient(0deg, #141414 0%, rgba(20, 20, 20, 0.5) 50%, rgba(20, 20, 20, 0.4) 100%);
}
.card-hover:hover {
transform: scale(1.05);
transition: transform 0.3s ease-in-out;
z-index: 10;
}
/* Loading spinner */
.loading-spinner {
border: 3px solid rgba(255, 255, 255, 0.1);
border-top-color: #ea2a33;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Search modal styling */
.search-modal {
position: fixed;
inset: 0;
z-index: 100;
display: none;
align-items: flex-start;
justify-content: center;
padding-top: 80px;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(8px);
}
.search-modal.active {
display: flex;
}
</style>
<!-- PWA Meta Tags -->
<link rel="manifest" href="/manifest.json">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-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="StreamFlix">
<link rel="icon" type="image/png" href="/icons/icon-512.png">
<link rel="apple-touch-icon" href="/icons/icon-512.png">
</head>
<body class="bg-background-light dark:bg-background-dark text-white font-display overflow-x-hidden antialiased">
<div class="relative flex min-h-screen flex-col">
<!-- Navigation -->
<header
class="fixed top-0 left-0 right-0 z-50 transition-colors duration-300 bg-gradient-to-b from-black/90 via-black/60 to-transparent pb-2 md:pb-10"
id="mainHeader">
<div class="px-4 md:px-12 py-2 md:py-4 flex items-center justify-between">
<div class="flex items-center gap-8">
<!-- Logo -->
<a class="flex items-center gap-2 text-primary hover:opacity-90 transition-opacity" href="/">
<span class="material-symbols-outlined text-4xl text-primary"
style="font-variation-settings: 'FILL' 1;">movie</span>
<span class="text-2xl font-bold tracking-tighter text-white hidden sm:block">StreamFlix</span>
</a>
<!-- Desktop Nav Links -->
<nav class="hidden md:flex items-center gap-6" id="mainNav">
<a class="text-sm font-medium text-white hover:text-gray-300 transition-colors nav-link active"
href="#" data-view="home">Home</a>
<a class="text-sm font-medium text-gray-300 hover:text-white transition-colors nav-link"
href="#" data-view="series">TV Shows</a>
<a class="text-sm font-medium text-gray-300 hover:text-white transition-colors nav-link"
href="#" data-view="movies">Movies</a>
<a class="text-sm font-medium text-gray-300 hover:text-white transition-colors nav-link"
href="#" data-view="cinema">New & Popular</a>
<a class="text-sm font-medium text-gray-300 hover:text-white transition-colors nav-link"
href="#" data-view="history">My List</a>
</nav>
</div>
<div class="flex items-center gap-4 md:gap-6">
<!-- Search (Modal Trigger) -->
<button class="text-white hover:text-gray-300 transition-colors hidden sm:block"
id="headerSearchBtn">
<span class="material-symbols-outlined text-2xl">search</span>
</button>
<!-- Mobile Search Icon -->
<button class="sm:hidden text-white" id="mobileSearchBtn">
<span class="material-symbols-outlined">search</span>
</button>
<button class="text-white hover:text-gray-300 transition-colors relative">
<span class="material-symbols-outlined">notifications</span>
<span class="absolute top-0 right-0 h-2 w-2 bg-primary rounded-full"></span>
</button>
<div class="flex items-center gap-2 cursor-pointer group">
<div class="h-8 w-8 rounded overflow-hidden border border-transparent group-hover:border-white transition-all bg-cover bg-center"
style="background-image: url('https://wallpapers.com/images/hd/netflix-profile-pictures-1000-x-1000-qo9h82134t9nv0j0.jpg');">
</div>
<span
class="material-symbols-outlined text-white transition-transform group-hover:rotate-180 text-sm">arrow_drop_down</span>
</div>
</div>
</div>
</header>
<!-- Hero Section -->
<div class="relative w-full h-[85vh] min-h-[600px]" id="heroContainer">
<!-- Hero Background Image -->
<div class="absolute inset-0 bg-cover bg-center bg-no-repeat" id="heroBg"
style="background-image: url('');">
<!-- Overlay Gradient -->
<div
class="absolute inset-0 bg-gradient-to-r from-background-dark/90 via-background-dark/40 to-transparent">
</div>
<div class="absolute inset-0 bg-gradient-to-t from-background-dark via-transparent to-transparent">
</div>
</div>
<!-- Hero Content -->
<div class="relative h-full flex items-center px-4 md:px-12 pt-20">
<div class="max-w-2xl flex flex-col gap-6 animate-fade-in-up" id="heroContent">
<div class="flex flex-col gap-4">
<div id="heroTagContainer"
class="hidden flex items-center gap-2 text-primary font-bold tracking-widest text-sm uppercase">
<span class="material-symbols-outlined text-lg">local_fire_department</span>
<span id="heroTag"></span>
</div>
<h1 class="text-5xl md:text-7xl font-black leading-tight tracking-tight text-white drop-shadow-lg"
id="heroTitle">
</h1>
<p class="text-base md:text-lg text-gray-200 font-medium leading-relaxed drop-shadow-md max-w-xl line-clamp-3"
id="heroDescription">
Loading...
</p>
</div>
<div class="flex flex-wrap gap-4 mt-2">
<button
class="flex items-center justify-center gap-2 bg-white text-black hover:bg-white/90 px-8 py-3 rounded-md font-bold text-lg transition-colors"
id="heroPlayBtn">
<span class="material-symbols-outlined fill-current"
style="font-variation-settings: 'FILL' 1;">play_arrow</span>
Play
</button>
<button
class="flex items-center justify-center gap-2 bg-gray-500/40 hover:bg-gray-500/50 backdrop-blur-sm text-white px-8 py-3 rounded-md font-bold text-lg transition-colors"
id="heroInfoBtn">
<span class="material-symbols-outlined">info</span>
More Info
</button>
</div>
</div>
</div>
</div>
<!-- Main Content Area -->
<main class="relative z-10 -mt-24 md:-mt-32 pb-20 space-y-12 pl-4 md:pl-12" id="mainContent">
<!-- Dynamic Video Grid -->
<div id="videoGrid" class="flex flex-col gap-12">
<!-- Loading Indicator -->
<div class="flex items-center justify-center py-20" id="loading">
<div class="flex flex-col items-center gap-4">
<div class="loading-spinner"></div>
<span class="text-gray-400">Loading movies...</span>
</div>
</div>
</div>
</main>
<!-- Footer - Hidden on mobile -->
<footer class="hidden md:block w-full bg-black/40 py-16 px-4 md:px-12 mt-auto border-t border-gray-800">
<div class="max-w-5xl mx-auto">
<div class="flex gap-4 mb-6 text-gray-400">
<a class="hover:text-white" href="#"><svg class="w-6 h-6 fill-current" viewBox="0 0 24 24">
<path
d="M24 4.557c-.883.392-1.832.656-2.828.775 1.017-.609 1.798-1.574 2.165-2.724-.951.564-2.005.974-3.127 1.195-.897-.957-2.178-1.555-3.594-1.555-3.179 0-5.515 2.966-4.797 6.045-4.091-.205-7.719-2.165-10.148-5.144-1.29 2.213-.669 5.108 1.523 6.574-.806-.026-1.566-.247-2.229-.616-.054 2.281 1.581 4.415 3.949 4.89-.693.188-1.452.232-2.224.084.626 1.956 2.444 3.379 4.6 3.419-2.07 1.623-4.678 2.348-7.29 2.04 2.179 1.397 4.768 2.212 7.548 2.212 9.142 0 14.307-7.721 13.995-14.646.962-.695 1.797-1.562 2.457-2.549z">
</path>
</svg></a>
<a class="hover:text-white" href="#"><svg class="w-6 h-6 fill-current" viewBox="0 0 24 24">
<path
d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.85-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z">
</path>
</svg></a>
<a class="hover:text-white" href="#"><svg class="w-6 h-6 fill-current" viewBox="0 0 24 24">
<path
d="M19.615 3.184c-3.604-.246-11.631-.245-15.23 0-3.897.266-4.356 2.62-4.385 8.816.029 6.185.484 8.549 4.385 8.816 3.6.245 11.626.246 15.23 0 3.897-.266 4.356-2.62 4.385-8.816-.029-6.185-.484-8.549-4.385-8.816zm-10.615 12.816v-8l8 3.993-8 4.007z">
</path>
</svg></a>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-xs text-gray-500 mb-6">
<div class="flex flex-col gap-3">
<a class="hover:underline" href="#">Audio Description</a>
<a class="hover:underline" href="#">Investor Relations</a>
<a class="hover:underline" href="#">Legal Notices</a>
</div>
<div class="flex flex-col gap-3">
<a class="hover:underline" href="#">Help Center</a>
<a class="hover:underline" href="#">Jobs</a>
<a class="hover:underline" href="#">Cookie Preferences</a>
</div>
<div class="flex flex-col gap-3">
<a class="hover:underline" href="#">Gift Cards</a>
<a class="hover:underline" href="#">Terms of Use</a>
<a class="hover:underline" href="#">Corporate Information</a>
</div>
<div class="flex flex-col gap-3">
<a class="hover:underline" href="#">Media Center</a>
<a class="hover:underline" href="#">Privacy</a>
<a class="hover:underline" href="#">Contact Us</a>
</div>
</div>
<div class="mb-4">
<button
class="border border-gray-500 text-gray-500 px-4 py-1 text-sm hover:text-white hover:border-white transition-colors">
Service Code
</button>
</div>
<p class="text-[10px] text-gray-500">
© 1997-2024 StreamFlix, Inc.
</p>
</div>
</footer>
</div>
<!-- Mobile Bottom Navigation -->
<nav class="fixed bottom-0 left-0 right-0 z-50 bg-[#121212]/95 backdrop-blur-lg border-t border-white/5 pb-5 pt-3 px-6 md:hidden"
id="mobileBottomNav">
<div class="flex justify-between items-center max-w-lg mx-auto">
<button class="flex flex-col items-center gap-1 text-white cursor-pointer group nav-item active"
data-view="home">
<span class="material-symbols-outlined text-2xl" style="font-variation-settings: 'FILL' 1;">home</span>
<span class="text-[10px] font-medium">Home</span>
</button>
<button
class="flex flex-col items-center gap-1 text-gray-400 hover:text-white transition-colors cursor-pointer group nav-item"
data-view="cinema">
<span class="material-symbols-outlined text-2xl">video_library</span>
<span class="text-[10px] font-medium">New & Hot</span>
</button>
<button
class="flex flex-col items-center gap-1 text-gray-400 hover:text-white transition-colors cursor-pointer group nav-item"
data-view="mylist">
<span class="material-symbols-outlined text-2xl">playlist_play</span>
<span class="text-[10px] font-medium">My List</span>
</button>
<button
class="flex flex-col items-center gap-1 text-gray-400 hover:text-white transition-colors cursor-pointer group nav-item"
data-view="search">
<span class="material-symbols-outlined text-2xl">search</span>
<span class="text-[10px] font-medium">Search</span>
</button>
</div>
</nav>
<!-- Search Modal -->
<div class="search-modal" id="searchModal">
<div class="w-full max-w-3xl mx-4">
<div class="flex items-center gap-4 mb-6">
<span class="material-symbols-outlined text-white/50 text-3xl">search</span>
<input type="text" id="searchInput"
class="flex-1 bg-transparent border-none text-white text-2xl placeholder-white/30 focus:ring-0 focus:outline-none"
placeholder="Titles, people, genres" autocomplete="off">
<button class="text-white/50 hover:text-white" id="closeSearch">
<span class="material-symbols-outlined text-3xl">close</span>
</button>
</div>
<div id="searchResults" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4"></div>
<div id="searchLoading" class="hidden flex-col items-center justify-center py-20">
<div class="loading-spinner"></div>
<span class="text-gray-400 mt-4">Searching...</span>
</div>
</div>
</div>
<!-- Scripts -->
<script src="/js/history-service.js"></script>
<script type="module" src="/scripts/search.js"></script>
<script type="module" src="/scripts/category-system.js"></script>
<script type="module" src="/scripts/main.js"></script>
</body>
</html>

972
frontend/package-lock.json generated Normal file
View file

@ -0,0 +1,972 @@
{
"name": "streamflow-frontend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "streamflow-frontend",
"version": "1.0.0",
"dependencies": {
"artplayer": "^5.1.1"
},
"devDependencies": {
"vite": "^5.0.8"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz",
"integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz",
"integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz",
"integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz",
"integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz",
"integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz",
"integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz",
"integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz",
"integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz",
"integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz",
"integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz",
"integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz",
"integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz",
"integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz",
"integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz",
"integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz",
"integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz",
"integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz",
"integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz",
"integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz",
"integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz",
"integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz",
"integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
"node_modules/artplayer": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/artplayer/-/artplayer-5.3.0.tgz",
"integrity": "sha512-yExO39MpEg4P+bxgChxx1eJfiUPE4q1QQRLCmqGhlsj+ANuaoEkR8hF93LdI5ZyrAcIbJkuEndxEiUoKobifDw==",
"license": "MIT",
"dependencies": {
"option-validator": "^2.0.6"
}
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.21.5",
"@esbuild/android-arm": "0.21.5",
"@esbuild/android-arm64": "0.21.5",
"@esbuild/android-x64": "0.21.5",
"@esbuild/darwin-arm64": "0.21.5",
"@esbuild/darwin-x64": "0.21.5",
"@esbuild/freebsd-arm64": "0.21.5",
"@esbuild/freebsd-x64": "0.21.5",
"@esbuild/linux-arm": "0.21.5",
"@esbuild/linux-arm64": "0.21.5",
"@esbuild/linux-ia32": "0.21.5",
"@esbuild/linux-loong64": "0.21.5",
"@esbuild/linux-mips64el": "0.21.5",
"@esbuild/linux-ppc64": "0.21.5",
"@esbuild/linux-riscv64": "0.21.5",
"@esbuild/linux-s390x": "0.21.5",
"@esbuild/linux-x64": "0.21.5",
"@esbuild/netbsd-x64": "0.21.5",
"@esbuild/openbsd-x64": "0.21.5",
"@esbuild/sunos-x64": "0.21.5",
"@esbuild/win32-arm64": "0.21.5",
"@esbuild/win32-ia32": "0.21.5",
"@esbuild/win32-x64": "0.21.5"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/option-validator": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/option-validator/-/option-validator-2.0.6.tgz",
"integrity": "sha512-tmZDan2LRIRQyhUGvkff68/O0R8UmF+Btmiiz0SmSw2ng3CfPZB9wJlIjHpe/MKUZqyIZkVIXCrwr1tIN+0Dzg==",
"license": "MIT",
"dependencies": {
"kind-of": "^6.0.3"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/rollup": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
},
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.54.0",
"@rollup/rollup-android-arm64": "4.54.0",
"@rollup/rollup-darwin-arm64": "4.54.0",
"@rollup/rollup-darwin-x64": "4.54.0",
"@rollup/rollup-freebsd-arm64": "4.54.0",
"@rollup/rollup-freebsd-x64": "4.54.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.54.0",
"@rollup/rollup-linux-arm-musleabihf": "4.54.0",
"@rollup/rollup-linux-arm64-gnu": "4.54.0",
"@rollup/rollup-linux-arm64-musl": "4.54.0",
"@rollup/rollup-linux-loong64-gnu": "4.54.0",
"@rollup/rollup-linux-ppc64-gnu": "4.54.0",
"@rollup/rollup-linux-riscv64-gnu": "4.54.0",
"@rollup/rollup-linux-riscv64-musl": "4.54.0",
"@rollup/rollup-linux-s390x-gnu": "4.54.0",
"@rollup/rollup-linux-x64-gnu": "4.54.0",
"@rollup/rollup-linux-x64-musl": "4.54.0",
"@rollup/rollup-openharmony-arm64": "4.54.0",
"@rollup/rollup-win32-arm64-msvc": "4.54.0",
"@rollup/rollup-win32-ia32-msvc": "4.54.0",
"@rollup/rollup-win32-x64-gnu": "4.54.0",
"@rollup/rollup-win32-x64-msvc": "4.54.0",
"fsevents": "~2.3.2"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
"rollup": "^4.20.0"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^18.0.0 || >=20.0.0",
"less": "*",
"lightningcss": "^1.21.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.4.0"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"less": {
"optional": true
},
"lightningcss": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
}
}
}
}
}

17
frontend/package.json Normal file
View file

@ -0,0 +1,17 @@
{
"name": "streamflow-frontend",
"version": "1.0.0",
"description": "StreamFlow - Ad-free video streaming",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"artplayer": "^5.1.1"
},
"devDependencies": {
"vite": "^5.0.8"
}
}

View file

@ -0,0 +1,232 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Watch History - KV-Stream</title>
<!-- Preconnect for fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@500;600;700;900&display=swap"
rel="stylesheet">
<link rel="stylesheet" href="/styles/index.css">
<style>
/* History Specific Styles */
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 40px;
margin-top: 20px;
}
.history-title {
font-size: 2rem;
font-weight: 700;
}
.clear-btn {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
font-family: inherit;
transition: all 0.2s;
}
.clear-btn:hover {
background: rgba(255, 0, 0, 0.2);
border-color: rgba(255, 0, 0, 0.4);
}
.history-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
padding: 0 40px 40px;
}
.history-card {
position: relative;
border-radius: 12px;
overflow: hidden;
background: #1a1a1a;
transition: transform 0.3s ease;
text-decoration: none;
color: white;
}
.history-card:hover {
transform: scale(1.05);
z-index: 2;
}
.history-thumb {
width: 100%;
aspect-ratio: 2/3;
object-fit: cover;
}
.history-info {
padding: 12px;
}
.history-progress-bar {
height: 3px;
background: rgba(255, 255, 255, 0.2);
width: 100%;
position: absolute;
bottom: 0;
left: 0;
}
.history-progress-fill {
height: 100%;
background: var(--color-primary);
}
.history-meta {
font-size: 0.85rem;
color: #aaa;
margin-top: 4px;
}
</style>
</head>
<body>
<div id="app" class="app-layout">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar__logo">
<a href="/" style="color:white;">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M4 8H2v12c0 1.1.9 2 2 2h12v-2H4V8z" />
<path
d="M20 2H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z" />
</svg>
</a>
</div>
<nav class="sidebar__nav">
<a href="/" class="sidebar__nav-item" title="Home">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
</svg>
</a>
<!-- History Link (Active) -->
<a href="/history.html" class="sidebar__nav-item active" title="History">
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-7 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z" />
</svg>
</a>
</nav>
</aside>
<!-- Main Content -->
<main class="main-content" style="margin-left: 80px; width: calc(100% - 80px);">
<div class="history-header">
<div style="display:flex; gap:20px; align-items:center;">
<h1 class="history-title" style="margin:0; cursor:pointer;" id="tabHistory">Watch History</h1>
<h1 class="history-title" style="margin:0; opacity:0.5; cursor:pointer;" id="tabMyList">My List</h1>
</div>
<button class="clear-btn" id="clearHistoryBtn">Clear History</button>
</div>
<div class="history-grid" id="historyGrid">
<!-- Items -->
</div>
<div id="emptyState" style="text-align:center; display:none; padding:50px;">
<h2 id="emptyTitle">No history yet</h2>
<p style="color:#aaa;" id="emptyMsg">Movies you watch will appear here.</p>
<a href="/" class="action-btn action-btn--primary" style="margin-top:20px; display:inline-block;">Browse
Movies</a>
</div>
</main>
</div>
<script src="/js/history-service.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const grid = document.getElementById('historyGrid');
const emptyState = document.getElementById('emptyState');
const clearBtn = document.getElementById('clearHistoryBtn');
const tabHistory = document.getElementById('tabHistory');
const tabMyList = document.getElementById('tabMyList');
const emptyTitle = document.getElementById('emptyTitle');
const emptyMsg = document.getElementById('emptyMsg');
let currentView = 'history'; // history or mylist
function updateTabs() {
if (currentView === 'history') {
tabHistory.style.opacity = '1';
tabMyList.style.opacity = '0.5';
clearBtn.style.display = 'block';
} else {
tabHistory.style.opacity = '0.5';
tabMyList.style.opacity = '1';
clearBtn.style.display = 'none'; // Don't allow clearing My List via this button
}
render();
}
tabHistory.addEventListener('click', () => { currentView = 'history'; updateTabs(); });
tabMyList.addEventListener('click', () => { currentView = 'mylist'; updateTabs(); });
function render() {
let items = [];
if (currentView === 'history') {
items = window.historyService.getHistory();
emptyTitle.textContent = "No history yet";
emptyMsg.textContent = "Movies you watch will appear here.";
} else {
items = window.historyService.getFavorites();
emptyTitle.textContent = "My List is empty";
emptyMsg.textContent = "Add movies to your list to watch later.";
}
if (items.length === 0) {
grid.style.display = 'none';
emptyState.style.display = 'block';
return;
}
grid.style.display = 'grid';
emptyState.style.display = 'none';
grid.innerHTML = items.map(item => `
<a href="/watch.html?id=${item.id}&slug=${item.slug}&ep=${item.progress?.episode || 1}" class="history-card">
<img src="${item.thumbnail}" class="history-thumb" loading="lazy" alt="${item.title}">
<div class="history-info">
<h3 style="font-size:1rem; margin-bottom:4px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${item.title}</h3>
<div class="history-meta">${currentView === 'history' ? `Episode ${item.progress.episode}` : (item.year || 'Movie')}</div>
<div class="history-meta" style="font-size:0.75rem; margin-top:2px;">
${currentView === 'history' ? new Date(item.timestamp).toLocaleDateString() : 'Added ' + new Date(item.addedAt || Date.now()).toLocaleDateString()}
</div>
</div>
${currentView === 'history' ? `
<div class="history-progress-bar">
<div class="history-progress-fill" style="width: ${item.progress.percentage || 0}%"></div>
</div>` : ''}
</a>
`).join('');
}
clearBtn.addEventListener('click', () => {
if (confirm('Are you sure you want to clear your watch history?')) {
window.historyService.clearHistory();
render();
}
});
updateTabs();
});
</script>
</body>
</html>

305
frontend/public/info.html Normal file
View file

@ -0,0 +1,305 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Movie Details - KV-Stream</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@500;600;700;900&display=swap"
rel="stylesheet">
<link rel="stylesheet" href="/styles/index.css">
<style>
/* Info Page Specific Styles */
.info-page-container {
position: relative;
min-height: 100vh;
width: 100%;
}
/* Backdrop */
.info-backdrop {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 70vh;
background-size: cover;
background-position: center;
mask-image: linear-gradient(to bottom, black 0%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, black 0%, transparent 100%);
z-index: 0;
opacity: 0.6;
}
.info-content-wrapper {
position: relative;
z-index: 2;
padding: 100px 40px 40px;
max-width: 1400px;
margin: 0 auto;
display: flex;
gap: 50px;
}
/* Left Column: Poster */
.info-poster-col {
flex-shrink: 0;
width: 300px;
}
.info-poster {
width: 100%;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
aspect-ratio: 2/3;
object-fit: cover;
}
/* Right Column: Details */
.info-details-col {
flex-grow: 1;
padding-top: 20px;
}
.info-title {
font-size: 3rem;
font-weight: 900;
margin-bottom: 10px;
line-height: 1.1;
}
.info-original-title {
font-size: 1.2rem;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 20px;
}
.info-rating {
display: flex;
align-items: center;
gap: 5px;
margin-bottom: 20px;
color: #f5c518;
font-weight: 600;
}
/* Metadata Grid */
.meta-box {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 24px;
margin-bottom: 30px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.meta-row {
display: flex;
margin-bottom: 12px;
line-height: 1.5;
}
.meta-label {
width: 150px;
color: rgba(255, 255, 255, 0.6);
flex-shrink: 0;
}
.meta-value {
color: #fff;
flex-grow: 1;
}
.meta-tag {
display: inline-block;
color: var(--color-primary);
}
/* Actions */
.info-actions {
display: flex;
gap: 16px;
margin-bottom: 40px;
}
/* Description */
.section-header {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding-bottom: 10px;
}
.info-desc {
font-size: 1rem;
line-height: 1.6;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 40px;
max-width: 800px;
}
/* Recommendations */
.rec-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 16px;
}
.rec-card {
text-decoration: none;
color: white;
transition: transform 0.2s;
}
.rec-card:hover {
transform: scale(1.05);
}
.rec-img {
width: 100%;
border-radius: 8px;
aspect-ratio: 2/3;
object-fit: cover;
margin-bottom: 8px;
}
@media (max-width: 900px) {
.info-content-wrapper {
flex-direction: column;
align-items: center;
padding-top: 40px;
}
.info-poster-col {
width: 200px;
}
.info-details-col {
text-align: center;
}
.meta-row {
text-align: left;
}
.info-title {
font-size: 2rem;
}
}
</style>
</head>
<body>
<div id="app" class="app-layout">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar__logo">
<a href="/" style="color:white;">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M4 8H2v12c0 1.1.9 2 2 2h12v-2H4V8z" />
<path
d="M20 2H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z" />
</svg>
</a>
</div>
<nav class="sidebar__nav">
<a href="/" class="sidebar__nav-item" title="Home">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
</svg>
</a>
<a href="/history.html" class="sidebar__nav-item" title="History">
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-7 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z" />
</svg>
</a>
</nav>
</aside>
<main class="main-content" style="margin-left: 80px; width: calc(100% - 80px);">
<div class="info-page-container">
<!-- Backdrop -->
<div class="info-backdrop" id="backdrop"></div>
<div class="info-content-wrapper">
<!-- Poster -->
<div class="info-poster-col">
<img src="" alt="Poster" class="info-poster" id="poster">
</div>
<!-- Details -->
<div class="info-details-col">
<h1 class="info-title" id="title">Loading...</h1>
<div class="info-original-title" id="originalTitle"></div>
<div class="info-rating" id="rating">
<!-- e.g. ⭐⭐⭐⭐⭐ (0 votes) -->
</div>
<!-- Metadata Box -->
<div class="meta-box">
<div class="meta-row">
<span class="meta-label">Trạng thái:</span>
<span class="meta-value" id="status">...</span>
</div>
<div class="meta-row">
<span class="meta-label">Năm phát hành:</span>
<span class="meta-value" id="year">...</span>
</div>
<div class="meta-row">
<span class="meta-label">Số tập:</span>
<span class="meta-value" id="episodes">...</span>
</div>
<div class="meta-row">
<span class="meta-label">Quốc gia:</span>
<span class="meta-value" id="country">...</span>
</div>
<div class="meta-row">
<span class="meta-label">Thể loại:</span>
<span class="meta-value" id="genre">...</span>
</div>
<div class="meta-row">
<span class="meta-label">Đạo diễn:</span>
<span class="meta-value" id="director">...</span>
</div>
<div class="meta-row">
<span class="meta-label">Diễn viên:</span>
<span class="meta-value" id="cast">...</span>
</div>
</div>
<!-- Actions -->
<div class="info-actions">
<a href="#" class="action-btn action-btn--primary" id="btnWatch">
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
<path d="M8 5v14l11-7z" />
</svg>
Xem Phim
</a>
<!-- My List Button can go here -->
</div>
<!-- Description -->
<div class="section-header">Nội dung chi tiết</div>
<div class="info-desc" id="description"></div>
<!-- Tags/Keywords -->
<div style="margin-bottom: 40px; display:flex; gap:10px; flex-wrap:wrap;" id="tags"></div>
<!-- Recommendations -->
<div class="section-header">Có thể bạn sẽ thích (Top phim hay)</div>
<div class="rec-grid" id="recommendations"></div>
</div>
</div>
</div>
</main>
</div>
<script type="module" src="/scripts/info.js"></script>
</body>
</html>

View file

@ -0,0 +1,141 @@
/**
* HistoryService - Manages watch history using localStorage
* Allows users to save progress without logging in.
*/
if (!window.HistoryService) {
window.HistoryService = class HistoryService {
constructor() {
this.STORAGE_KEY = 'kv_watch_history';
this.MAX_ITEMS = 100; // Limit history size
}
/**
* Get all history items
* @returns {Array} List of history items sorted by timestamp (newest first)
*/
getHistory() {
try {
const history = localStorage.getItem(this.STORAGE_KEY);
return history ? JSON.parse(history) : [];
} catch (e) {
console.error('Error reading history:', e);
return [];
}
}
/**
* Add or update a movie/episode in history
* @param {Object} movie - Movie object
* @param {Object} progress - Progress info (optional)
*/
addToHistory(movie, progress = {}) {
const history = this.getHistory();
// Remove existing entry for this item if it exists
// Identify by slug
const existingIndex = history.findIndex(item => item.slug === movie.slug);
if (existingIndex !== -1) {
history.splice(existingIndex, 1);
}
// Create new entry
const entry = {
id: movie.id || movie.slug,
slug: movie.slug,
title: movie.title,
thumbnail: movie.thumbnail,
backdrop: movie.backdrop,
description: movie.description,
timestamp: Date.now(),
progress: {
currentTime: progress.currentTime || 0,
duration: progress.duration || 0,
percentage: progress.percentage || 0,
episode: progress.episode || 1
},
...movie // Store other metadata
};
// Add to front
history.unshift(entry);
// Trim size
if (history.length > this.MAX_ITEMS) {
history.pop();
}
this.saveHistory(history);
}
// --- Favorites (My List) Methods ---
getFavorites() {
try {
const list = localStorage.getItem('myList');
return list ? JSON.parse(list) : [];
} catch (e) { return []; }
}
toggleFavorite(movie) {
let list = this.getFavorites();
const exists = list.some(item => item.slug === movie.slug);
if (exists) {
list = list.filter(item => item.slug !== movie.slug);
} else {
list.push({
id: movie.id || movie.slug,
slug: movie.slug,
title: movie.title,
thumbnail: movie.thumbnail,
addedAt: Date.now()
});
}
localStorage.setItem('myList', JSON.stringify(list));
return !exists; // Return true if added, false if removed
}
isFavorite(slug) {
return this.getFavorites().some(item => item.slug === slug);
}
/**
* Remove an item from history
* @param {String} slug
*/
removeFromHistory(slug) {
let history = this.getHistory();
history = history.filter(item => item.slug !== slug);
this.saveHistory(history);
}
/**
* Clear all history
*/
clearHistory() {
localStorage.removeItem(this.STORAGE_KEY);
}
saveHistory(history) {
try {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(history));
// Dispatch event for UI updates
window.dispatchEvent(new CustomEvent('history-updated', { detail: history }));
} catch (e) {
console.error('Error saving history:', e);
}
}
/**
* Check if a movie is in history
*/
isInHistory(slug) {
return this.getHistory().some(item => item.slug === slug);
}
}
// Export singleton
window.historyService = new window.HistoryService();
}

View file

@ -0,0 +1,17 @@
{
"name": "KV-Stream",
"short_name": "KV-Stream",
"description": "Premium Movie Streaming with Liquid Glass Design",
"start_url": "/",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#000000",
"icons": [
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

20
frontend/public/sw.js Normal file
View file

@ -0,0 +1,20 @@
const CACHE_NAME = 'kv-stream-v1';
const ASSETS = [
'/',
'/index.html',
'/watch.html',
'/styles/index.css',
'/icons/icon-512.png'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS))
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((response) => response || fetch(event.request))
);
});

252
frontend/scripts/api.js Normal file
View file

@ -0,0 +1,252 @@
/**
* StreamFlow - API Client
* Handles all communication with the backend
*/
const API_BASE = '/api';
class ApiClient {
/**
* Extract video stream URL
* @param {string} url - Source video URL
* @param {string} quality - Optional quality preference (e.g., "1080p")
* @returns {Promise<Object>} Extraction result with stream URL
*/
async extractVideo(url, quality = null) {
const response = await fetch(`${API_BASE}/extract`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, quality })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Extraction failed');
}
return response.json();
}
/**
* Get available quality options for a video
* @param {string} url - Source video URL
* @returns {Promise<string[]>} List of available qualities
*/
async getQualities(url) {
const response = await fetch(`${API_BASE}/qualities?url=${encodeURIComponent(url)}`);
if (!response.ok) {
throw new Error('Failed to get qualities');
}
const data = await response.json();
return data.qualities;
}
/**
* List all videos
* @param {Object} options - Query options
* @returns {Promise<Array>} List of videos
*/
async listVideos({ skip = 0, limit = 50, category = null } = {}) {
let url = `${API_BASE}/videos?skip=${skip}&limit=${limit}`;
if (category && category !== 'all') {
url += `&category=${encodeURIComponent(category)}`;
}
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to fetch videos');
}
return response.json();
}
/**
* Add a video to the library
* @param {Object} video - Video data
* @returns {Promise<Object>} Created video
*/
async addVideo(video) {
const response = await fetch(`${API_BASE}/videos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(video)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to add video');
}
return response.json();
}
/**
* Delete a video from the library
* @param {number} id - Video ID
*/
async deleteVideo(id) {
const response = await fetch(`${API_BASE}/videos/${id}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete video');
}
}
/**
* Search videos by title
* @param {string} query - Search query
* @param {number} limit - Max results
* @returns {Promise<Array>} Search results
*/
async searchVideos(query, limit = 20) {
const response = await fetch(
`${API_BASE}/search?q=${encodeURIComponent(query)}&limit=${limit}`
);
if (!response.ok) {
throw new Error('Search failed');
}
return response.json();
}
/**
* Check API health
* @returns {Promise<Object>} Health status
*/
async health() {
const response = await fetch(`${API_BASE}/health`);
return response.json();
}
// ============================================
// RoPhim Integration Methods
// ============================================
/**
* Get RoPhim movie catalog
* @param {Object} options - Query options
* @returns {Promise<Object>} Catalog with movies
*/
async getRophimCatalog({ category = null, country = null, genre = null, page = 1, limit = 24, sort = 'modified' } = {}) {
let url = `${API_BASE}/rophim/catalog?page=${page}&limit=${limit}&sort=${sort}`;
if (category) url += `&category=${encodeURIComponent(category)}`;
if (country) url += `&country=${encodeURIComponent(country)}`;
if (genre) url += `&genre=${encodeURIComponent(genre)}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to fetch RoPhim catalog');
}
return response.json();
}
/**
* Get curated homepage sections (Top Rated, New Releases, by Genre)
* @returns {Promise<Object>} Sections with movies sorted by rating
*/
async getCuratedSections() {
const response = await fetch(`${API_BASE}/rophim/home/curated`);
if (!response.ok) {
throw new Error('Failed to fetch curated sections');
}
return response.json();
}
/**
* Search movies on RoPhim
* @param {string} query - Search query
* @param {number} limit - Max results
* @returns {Promise<Object>} Search results
*/
async searchRophim(query, limit = 20) {
const response = await fetch(
`${API_BASE}/rophim/search?q=${encodeURIComponent(query)}&limit=${limit}`
);
if (!response.ok) {
throw new Error('RoPhim search failed');
}
return response.json();
}
/**
* Get dynamic homepage sections (Genres/Countries)
* @param {number} page - Page number
* @returns {Promise<Object>} Sections
*/
async getHomeSections(page = 2, view = 'home') {
const response = await fetch(`${API_BASE}/rophim/home/sections?page=${page}&view=${view}`);
if (!response.ok) throw new Error('Failed to fetch home sections');
return response.json();
}
/**
* Get movie details from RoPhim
* @param {string} slug - Movie slug
* @returns {Promise<Object>} Movie details
*/
async getRophimMovie(slug) {
const response = await fetch(`${API_BASE}/rophim/movie/${encodeURIComponent(slug)}`);
if (!response.ok) {
throw new Error('Failed to fetch movie details');
}
return response.json();
}
/**
* Get video stream URL from RoPhim
* @param {string} slug - Movie slug
* @param {number} episode - Episode number (default: 1)
* @returns {Promise<Object>} Stream URL
*/
async getRophimStream(slug, episode = 1) {
const response = await fetch(
`${API_BASE}/rophim/stream/${encodeURIComponent(slug)}?episode=${episode}`
);
if (!response.ok) {
throw new Error('Failed to get stream');
}
return response.json();
}
/**
* Get video stream URL from PhimMoiChill using source URL or slug
* This method extracts direct m3u8 from JWPlayer
* @param {string} sourceUrl - Full source URL (e.g., https://royalcanalbikehire.ie/phim/movie-name)
* @param {string} slug - Movie slug (optional, extracted from URL if not provided)
* @param {number} episode - Episode number (default: 1)
* @param {number} server - Server index (0=VIP1 m3u8, 1=VIP2 embed)
* @returns {Promise<Object>} Stream URL
*/
async getRophimStreamByUrl(sourceUrl, slug = '', episode = 1, server = 0) {
const response = await fetch(`${API_BASE}/rophim/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_url: sourceUrl, slug: slug || '', episode, server })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to get stream');
}
return response.json();
}
}
export const api = new ApiClient();

View file

@ -0,0 +1,101 @@
/**
* Category System for PhimMoiChill Themed Sections
* Provides functionality to load and render categorized content
*/
/**
* Load themed category sections from PhimMoiChill
*/
async function loadCategories() {
try {
console.log('📂 Loading themed categories...');
const response = await fetch('/api/rophim/categories/all');
const data = await response.json();
if (data && data.categories) {
console.log(`✓ Loaded ${Object.keys(data.categories).length} category sections`);
return data.categories;
}
return null;
} catch (error) {
console.error('Error loading categories:', error);
return null;
}
}
/**
* Create ranking badge for Top 10
*/
function createRankingBadge(rank) {
const badge = document.createElement('div');
badge.className = 'video-card__ranking';
// Add specific class for top 3 (gold, silver, bronze)
if (rank <= 3) {
badge.classList.add(`video-card__ranking--${rank}`);
}
badge.textContent = `#${rank}`;
return badge;
}
/**
* Create quality/category badge (NEW, HOT, CINEMA, etc.)
*/
function createQualityBadge(badgeText) {
if (!badgeText) return null;
const badge = document.createElement('div');
badge.className = 'video-card__badge';
// Determine badge style based on text
const text = badgeText.toUpperCase();
if (text.includes('HOT')) {
badge.classList.add('video-card__badge--hot');
} else if (text.includes('NEW')) {
badge.classList.add('video-card__badge--new');
} else if (text.includes('CINEMA')) {
badge.classList.add('video-card__badge--cinema');
} else if (text.includes('FULL')) {
badge.classList.add('video-card__badge--full');
}
badge.textContent = text;
return badge;
}
/**
* Enhance video card with badges
*/
function enhanceVideoCardWithBadges(card, video) {
if (!card) return card;
const container = card.querySelector('.video-card__container');
if (!container) return card;
// Add quality badge if present
if (video.badge) {
const badge = createQualityBadge(video.badge);
if (badge) {
container.appendChild(badge);
}
}
// Add ranking badge if present (for Top 10)
if (video.ranking) {
const rankBadge = createRankingBadge(video.ranking);
container.appendChild(rankBadge);
}
return card;
}
// Export functions for use in main.js
if (typeof window !== 'undefined') {
window.categorySystem = {
loadCategories,
createRankingBadge,
createQualityBadge,
enhanceVideoCardWithBadges
};
}

View file

@ -0,0 +1,399 @@
/**
* KV-Stream - Hero Billboard Component
* Modern Apple TV+ / Netflix inspired hero section
*/
/**
* Create a modern hero section with featured content
* @param {Object|Array<Object>} featuredItems - Featured video object or array
* @param {Function} onPlay - Callback when play is clicked
* @param {Function} onInfo - Callback when more info is clicked
* @param {string} modifier - Optional CSS class for variants
* @returns {HTMLElement} Hero section element
*/
export function createHeroSection(featuredItems, onPlay, onInfo, modifier = '') {
const hero = document.createElement('section');
hero.className = `hero-billboard ${modifier}`;
hero.id = 'heroSection';
// Normalize input to array
const items = Array.isArray(featuredItems) ? featuredItems : [featuredItems];
if (items.length === 0 || !items[0]) {
return hero;
}
// Get first featured item for display
const featured = items[0];
const backdropUrl = featured.backdrop || featured.thumbnail || featured.poster_url || '';
const year = featured.year || new Date().getFullYear();
const rating = featured.rating ? `${featured.rating}` : '';
const quality = featured.resolution || featured.quality || 'HD';
const genre = featured.genre || featured.category || '';
const duration = featured.duration || '';
// Build meta items
const metaItems = [quality, year, genre, duration, rating].filter(Boolean);
hero.innerHTML = `
<div class="hero-billboard__backdrop">
<img src="${backdropUrl}" alt="${featured.title}" loading="eager" />
<div class="hero-billboard__gradient"></div>
</div>
<div class="hero-billboard__content">
<div class="hero-billboard__info">
<h1 class="hero-billboard__title">${featured.title}</h1>
<div class="hero-billboard__meta">
${metaItems.map((item, i) => `
<span class="hero-billboard__meta-item">${item}</span>
${i < metaItems.length - 1 ? '<span class="hero-billboard__meta-dot">•</span>' : ''}
`).join('')}
</div>
<p class="hero-billboard__description">${featured.description || ''}</p>
<div class="hero-billboard__actions">
<button class="hero-billboard__btn hero-billboard__btn--primary" data-action="play" data-id="${featured.id}">
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
<path d="M8 5v14l11-7z"/>
</svg>
<span>Watch Now</span>
</button>
<button class="hero-billboard__btn hero-billboard__btn--secondary" data-action="info" data-id="${featured.id}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="24" height="24">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 16v-4"></path>
<path d="M12 8h.01"></path>
</svg>
<span>More Info</span>
</button>
</div>
</div>
</div>
${items.length > 1 ? createSliderDots(items) : ''}
`;
// Add styles
addHeroStyles();
// Setup slider if multiple items
if (items.length > 1) {
setupSlider(hero, items, onPlay, onInfo);
} else {
// Single item events
setupSingleItemEvents(hero, featured, onPlay, onInfo);
}
return hero;
}
function createSliderDots(items) {
return `
<div class="hero-billboard__dots">
${items.map((_, i) => `
<button class="hero-billboard__dot ${i === 0 ? 'active' : ''}" data-index="${i}"></button>
`).join('')}
</div>
`;
}
function setupSingleItemEvents(hero, featured, onPlay, onInfo) {
const playBtn = hero.querySelector('[data-action="play"]');
const infoBtn = hero.querySelector('[data-action="info"]');
if (playBtn) {
playBtn.addEventListener('click', () => onPlay && onPlay(featured));
}
if (infoBtn) {
infoBtn.addEventListener('click', () => onInfo && onInfo(featured));
}
}
function setupSlider(hero, items, onPlay, onInfo) {
let currentIndex = 0;
let interval;
const dots = hero.querySelectorAll('.hero-billboard__dot');
const showSlide = (index) => {
if (index < 0) index = items.length - 1;
if (index >= items.length) index = 0;
currentIndex = index;
const featured = items[index];
const backdropUrl = featured.backdrop || featured.thumbnail || featured.poster_url || '';
// Update content
const backdrop = hero.querySelector('.hero-billboard__backdrop img');
const title = hero.querySelector('.hero-billboard__title');
const description = hero.querySelector('.hero-billboard__description');
const playBtn = hero.querySelector('[data-action="play"]');
const infoBtn = hero.querySelector('[data-action="info"]');
if (backdrop) backdrop.src = backdropUrl;
if (title) title.textContent = featured.title;
if (description) description.textContent = featured.description || '';
if (playBtn) playBtn.dataset.id = featured.id;
if (infoBtn) infoBtn.dataset.id = featured.id;
// Update dots
dots.forEach((dot, i) => dot.classList.toggle('active', i === index));
};
const startAutoPlay = () => {
if (interval) clearInterval(interval);
interval = setInterval(() => showSlide(currentIndex + 1), 8000);
};
startAutoPlay();
// Dot click events
dots.forEach(dot => {
dot.addEventListener('click', () => {
const idx = parseInt(dot.dataset.index);
showSlide(idx);
startAutoPlay();
});
});
// Button events
hero.addEventListener('click', (e) => {
const playBtn = e.target.closest('[data-action="play"]');
const infoBtn = e.target.closest('[data-action="info"]');
if (playBtn && onPlay) {
onPlay(items[currentIndex]);
} else if (infoBtn && onInfo) {
onInfo(items[currentIndex]);
}
});
}
function addHeroStyles() {
if (document.getElementById('hero-billboard-styles')) return;
const styles = document.createElement('style');
styles.id = 'hero-billboard-styles';
styles.textContent = `
.hero-billboard {
position: relative;
width: 100%;
/* Fluid height: scales with viewport, min 300px, max 85vh */
height: clamp(300px, 60vh, 85vh);
overflow: hidden;
margin-bottom: clamp(10px, 2vw, 30px);
}
.hero-billboard__backdrop {
position: absolute;
inset: 0;
}
.hero-billboard__backdrop img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center 20%;
}
.hero-billboard__gradient {
position: absolute;
inset: 0;
background: linear-gradient(
to right,
rgba(0, 0, 0, 0.95) 0%,
rgba(0, 0, 0, 0.7) 25%,
rgba(0, 0, 0, 0.3) 50%,
transparent 75%
),
linear-gradient(
to top,
rgba(0, 0, 0, 1) 0%,
rgba(0, 0, 0, 0.6) 15%,
transparent 50%
);
}
.hero-billboard__content {
position: absolute;
inset: 0;
display: flex;
align-items: center;
/* Fluid padding: scales with viewport */
padding: 0 clamp(20px, 5vw, 80px);
}
.hero-billboard__info {
/* Fluid max-width: scales between 280px and 600px based on viewport */
max-width: clamp(280px, 40vw, 600px);
z-index: 2;
}
.hero-billboard__title {
/* Fluid font-size: scales dynamically with screen */
font-size: clamp(1.5rem, 4vw, 3.5rem);
font-weight: 700;
color: #fff;
margin: 0 0 clamp(8px, 1.5vw, 20px) 0;
line-height: 1.15;
text-shadow: 0 2px 10px rgba(0,0,0,0.6);
}
.hero-billboard__meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: clamp(4px, 0.8vw, 10px);
margin-bottom: clamp(8px, 1.5vw, 20px);
}
.hero-billboard__meta-item {
font-size: clamp(0.7rem, 1vw, 1rem);
color: rgba(255,255,255,0.9);
font-weight: 500;
}
.hero-billboard__meta-item:first-child {
background: #0071e3;
padding: clamp(2px, 0.4vw, 6px) clamp(6px, 0.8vw, 12px);
border-radius: 4px;
font-size: clamp(0.65rem, 0.9vw, 0.85rem);
font-weight: 600;
}
.hero-billboard__meta-dot {
color: rgba(255,255,255,0.5);
font-size: clamp(0.6rem, 0.8vw, 0.85rem);
}
.hero-billboard__description {
font-size: clamp(0.85rem, 1.1vw, 1.1rem);
color: rgba(255,255,255,0.8);
line-height: 1.5;
margin: 0 0 clamp(12px, 2vw, 28px) 0;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.hero-billboard__actions {
display: flex;
gap: clamp(8px, 1vw, 14px);
flex-wrap: wrap;
}
.hero-billboard__btn {
display: inline-flex;
align-items: center;
gap: clamp(6px, 0.6vw, 10px);
padding: clamp(10px, 1.2vw, 16px) clamp(16px, 2vw, 32px);
border: none;
border-radius: clamp(6px, 0.6vw, 10px);
font-size: clamp(0.85rem, 1vw, 1.1rem);
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.hero-billboard__btn--primary {
background: #fff;
color: #000;
}
.hero-billboard__btn--primary:hover {
background: rgba(255,255,255,0.9);
transform: scale(1.02);
}
.hero-billboard__btn--secondary {
background: rgba(255,255,255,0.15);
color: #fff;
backdrop-filter: blur(10px);
}
.hero-billboard__btn--secondary:hover {
background: rgba(255,255,255,0.25);
}
.hero-billboard__btn svg {
flex-shrink: 0;
width: clamp(18px, 1.5vw, 24px);
height: clamp(18px, 1.5vw, 24px);
}
.hero-billboard__dots {
position: absolute;
bottom: clamp(15px, 3vh, 35px);
left: 50%;
transform: translateX(-50%);
display: flex;
gap: clamp(5px, 0.6vw, 10px);
z-index: 10;
}
.hero-billboard__dot {
width: clamp(6px, 0.6vw, 10px);
height: clamp(6px, 0.6vw, 10px);
border-radius: 50%;
border: none;
background: rgba(255,255,255,0.4);
cursor: pointer;
transition: all 0.3s ease;
padding: 0;
}
.hero-billboard__dot.active {
background: #fff;
width: clamp(16px, 2vw, 28px);
border-radius: 4px;
}
.hero-billboard__dot:hover {
background: rgba(255,255,255,0.7);
}
/* Small variant for category pages */
.hero-billboard.hero--small {
height: clamp(200px, 35vh, 400px);
}
/* Mobile-specific adjustments (small screens need content at bottom) */
@media (max-width: 600px) {
.hero-billboard__content {
align-items: flex-end;
padding-bottom: clamp(50px, 10vh, 80px);
}
.hero-billboard__info {
max-width: 100%;
}
.hero-billboard__description {
-webkit-line-clamp: 2;
}
.hero-billboard__actions {
width: 100%;
}
.hero-billboard__btn {
flex: 1;
justify-content: center;
}
}
`;
document.head.appendChild(styles);
}
export function initHeroCarousel(container, featuredVideos, onPlay, onInfo) {
if (!featuredVideos || featuredVideos.length === 0) return;
const hero = createHeroSection(featuredVideos, onPlay, onInfo);
container.innerHTML = '';
container.appendChild(hero);
}

View file

@ -0,0 +1,195 @@
/**
* Netflix 2025 Info Modal Component
* Premium, cinematic modal with video preview and rich metadata
*/
export function createInfoModal(video, onClose, onPlay, recommendations = []) {
const modal = document.createElement('div');
modal.className = 'modal modal--info active';
modal.id = `modal-${video.id}`;
const backdropUrl = video.backdrop || video.thumbnail;
const isSeries = video.type === 'series' || video.category?.toLowerCase() === 'series';
modal.innerHTML = `
<div class="modal__backdrop"></div>
<div class="modal__container">
<button class="modal__close" aria-label="Close">
<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
</button>
<div class="modal__header">
<div class="modal__header-video">
<img src="${backdropUrl}" alt="${video.title}" class="modal__header-img">
${video.preview_url ? `
<video class="modal__header-preview" muted playsinline loop>
<source src="${video.preview_url}" type="video/mp4">
</video>
` : ''}
</div>
<div class="modal__header-vignette"></div>
<div class="modal__header-content">
<h2 class="modal__title">${video.title}</h2>
<div class="modal__actions">
<button class="modal__btn modal__btn--primary" data-action="play">
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M8 5v14l11-7z"/></svg>
<span>Play</span>
</button>
<button class="modal__btn modal__btn--round" data-action="add" title="Add to My List">
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" width="24" height="24"><path d="M12 5v14m-7-7h14" stroke-width="2" stroke-linecap="round"/></svg>
</button>
<button class="modal__btn modal__btn--round" data-action="like" title="I like this">
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" width="24" height="24"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3" stroke-width="2" stroke-linecap="round"/></svg>
</button>
</div>
</div>
</div>
<div class="modal__body">
<div class="modal__info-grid">
<div class="modal__info-main">
<div class="modal__metadata">
<span class="modal__match">${video.matchScore || 95}% Match</span>
<span class="modal__year">${video.releaseYear || video.year || 2024}</span>
<span class="modal__age">${video.maturityRating || '13+'}</span>
<span class="modal__duration">${video.duration ? Math.floor(video.duration / 3600) + 'h ' + Math.floor((video.duration % 3600) / 60) + 'm' : '2h 15m'}</span>
<span class="modal__quality">${video.quality || 'HD'}</span>
</div>
<p class="modal__description">${video.description || 'No description available for this title.'}</p>
</div>
<div class="modal__info-side">
${video.cast && video.cast.length && video.cast[0] !== 'Unknown' ? `
<div class="modal__tags">
<span class="modal__label">Cast:</span>
<span class="modal__value">${video.cast.join(', ')}</span>
</div>
` : ''}
<div class="modal__tags">
<span class="modal__label">Genres:</span>
<span class="modal__value">${video.genres ? video.genres.join(', ') : video.category || 'Movies'}</span>
</div>
${video.director && video.director !== 'Unknown' ? `
<div class="modal__tags">
<span class="modal__label">Director:</span>
<span class="modal__value">${video.director}</span>
</div>
` : ''}
${video.country && video.country !== 'International' ? `
<div class="modal__tags">
<span class="modal__label">Country:</span>
<span class="modal__value">${video.country}</span>
</div>
` : ''}
</div>
</div>
${isSeries && video.episodes && video.episodes.length > 0 ? `
<div class="modal__episodes">
<div class="modal__section-header">
<h3 class="modal__section-title">Episodes</h3>
<span class="modal__episode-count">${video.episodes.length} Episodes</span>
</div>
<div class="modal__episodes-list">
${video.episodes.map(ep => `
<div class="episode-row" data-episode-url="${ep.url}">
<div class="episode-row__number">${ep.number}</div>
<div class="episode-row__img">
<img src="${video.backdrop || video.thumbnail}" alt="Episode ${ep.number}">
<div class="episode-row__play-icon">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</div>
</div>
<div class="episode-row__info">
<div class="episode-row__header">
<span class="episode-row__title">${ep.title || `Episode ${ep.number}`}</span>
<span class="episode-row__duration">${Math.floor(Math.random() * 20 + 40)}m</span>
</div>
<p class="episode-row__desc">${ep.description || (video.description || '').substring(0, 60)}...</p>
</div>
</div>
`).join('')}
</div>
</div>
` : ''}
${recommendations.length > 0 ? `
<div class="modal__recommendations">
<h3 class="modal__section-title">More Like This</h3>
<div class="recommendations-grid">
${recommendations.map(rec => `
<div class="recommendation-card" data-video-id="${rec.id}">
<div class="recommendation-card__img-wrapper">
<img src="${rec.thumbnail}" alt="${rec.title}">
<div class="recommendation-card__play">
<svg viewBox="0 0 24 24" fill="currentColor" width="32" height="32"><path d="M8 5v14l11-7z"/></svg>
</div>
</div>
<div class="recommendation-card__content">
<h4 class="recommendation-card__title">${rec.title}</h4>
<div class="recommendation-card__meta">
<span class="modal__match">${rec.matchScore || 90}% Match</span>
<span class="modal__age">${rec.maturityRating || '13+'}</span>
<span class="modal__year">${rec.year || 2024}</span>
</div>
<p class="recommendation-card__desc">${(rec.description || 'No description').substring(0, 80)}${rec.description && rec.description.length > 80 ? '...' : ''}</p>
</div>
</div>
`).join('')}
</div>
</div>
` : ''}
</div>
</div>
`;
// Event Listeners
modal.querySelector('.modal__close').addEventListener('click', () => onClose(modal));
modal.querySelector('.modal__backdrop').addEventListener('click', () => onClose(modal));
modal.querySelector('[data-action="play"]').addEventListener('click', () => onPlay(video));
// Autoplay header video
const headerVideo = modal.querySelector('.modal__header-preview');
const headerImg = modal.querySelector('.modal__header-img');
if (headerVideo) {
setTimeout(() => {
headerVideo.play().then(() => {
headerImg.style.opacity = '0';
headerVideo.style.opacity = '1';
}).catch(e => console.log('Autoplay failed', e));
}, 1000);
}
// Recommendation card clicks
modal.querySelectorAll('.recommendation-card').forEach(card => {
card.addEventListener('click', () => {
const vidId = card.dataset.videoId;
const targetVid = recommendations.find(r => r.id == vidId);
if (targetVid) {
// In a real app, we might navigate or open another modal
onPlay(targetVid);
}
});
});
// Episode row clicks
modal.querySelectorAll('.episode-row').forEach(row => {
row.addEventListener('click', () => {
const url = row.dataset.episodeUrl;
if (url) {
// Create a temporary video object for the episode
const episodeTitle = row.querySelector('.episode-row__title').textContent;
const episodeVideo = {
...video,
source_url: url,
title: `${video.title}: ${episodeTitle}`,
isEpisode: true
};
onPlay(episodeVideo);
}
});
});
return modal;
}

View file

@ -0,0 +1,72 @@
/**
* Netflix 2025 "New & Hot" Feed Component
* Optimized for mobile vertical scrolling
*/
export function createNewAndHotItem(video) {
const item = document.createElement('div');
item.className = 'new-hot-item';
// Random date for demo
const months = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
const month = months[Math.floor(Math.random() * 12)];
const day = Math.floor(Math.random() * 28) + 1;
item.innerHTML = `
<div class="new-hot-item__sidebar">
<span class="new-hot-item__month">${month}</span>
<span class="new-hot-item__day">${day}</span>
</div>
<div class="new-hot-item__content">
<div class="new-hot-item__card">
<div class="new-hot-item__img-wrapper">
<img src="${video.backdrop || video.thumbnail}" alt="${video.title}">
<div class="new-hot-item__play">
<svg viewBox="0 0 24 24" fill="currentColor" width="40" height="40"><path d="M8 5v14l11-7z"/></svg>
</div>
</div>
<div class="new-hot-item__details">
<div class="new-hot-item__header">
<h2 class="new-hot-item__title">${video.title}</h2>
<div class="new-hot-item__actions">
<button class="new-hot-item__btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" width="24" height="24"><path d="M15 10l5 5-5 5M4 4v7a4 4 0 0 0 4 4h12" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span>Remind Me</span>
</button>
<button class="new-hot-item__btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" width="24" height="24"><path d="M12 17h2v-6h-2v6zm1-15C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zM11 9h2V7h-2v2z" stroke-width="1"/></svg>
<span>Info</span>
</button>
</div>
</div>
<p class="new-hot-item__desc">${video.description || 'Watch now on Netflix.'}</p>
<div class="new-hot-item__tags">
${(video.genres || ['Exciting', 'Action', 'Netflix Original']).map(t => `<span class="new-hot-item__tag">${t}</span>`).join('')}
</div>
</div>
</div>
</div>
`;
return item;
}
export function renderNewAndHotView(container, videos) {
container.innerHTML = `
<div class="new-hot-view">
<div class="new-hot-header">
<div class="new-hot-tabs">
<button class="new-hot-tab active">🍿 Coming Soon</button>
<button class="new-hot-tab">🔥 Everyone's Watching</button>
</div>
</div>
<div class="new-hot-feed">
<!-- Items will be injected here -->
</div>
</div>
`;
const feed = container.querySelector('.new-hot-feed');
videos.forEach(video => {
feed.appendChild(createNewAndHotItem(video));
});
}

View file

@ -0,0 +1,146 @@
/**
* StreamFlow - Search Component
* Real-time search with 300ms debouncing
*/
import { api } from '../api.js';
/**
* Create a debounced function
* @param {function} func - Function to debounce
* @param {number} wait - Wait time in ms
* @returns {function} Debounced function
*/
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
/**
* Initialize search functionality
* @param {HTMLInputElement} inputEl - Search input element
* @param {HTMLElement} resultsEl - Search results container
* @param {function} onSelect - Callback when result is selected
*/
export function initSearch(inputEl, resultsEl, onSelect) {
if (!inputEl || !resultsEl) return;
const DEBOUNCE_DELAY = 300;
let currentQuery = '';
/**
* Perform search and update results
* @param {string} query - Search query
*/
async function performSearch(query) {
currentQuery = query;
if (!query || query.length < 2) {
resultsEl.classList.remove('active');
resultsEl.innerHTML = '';
return;
}
try {
// Use RoPhim search API instead of local database
const response = await api.searchRophim(query);
const results = response?.movies || [];
// Only update if query hasn't changed
if (query !== currentQuery) return;
if (results.length === 0) {
resultsEl.innerHTML = `
<div class="search__result" style="opacity: 0.5;">
<span>No results found for "${escapeHtml(query)}"</span>
</div>
`;
} else {
resultsEl.innerHTML = results.map(video => `
<div class="search__result" data-video-slug="${video.slug}">
<img
src="${video.poster_url || video.thumb_url || video.thumbnail || 'data:image/svg+xml,%3Csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 80 45\" fill=\"%231a1a1a\"%3E%3Crect width=\"80\" height=\"45\"/%3E%3C/svg%3E'}"
alt="${escapeHtml(video.name || video.title)}"
class="search__result-thumb"
loading="lazy"
>
<div class="search__result-info">
<div class="search__result-title">${escapeHtml(video.name || video.title)}</div>
<div class="search__result-meta">
${video.quality ? `${video.quality}` : ''}
${video.year || ''}
</div>
</div>
</div>
`).join('');
// Add click handlers - navigate to watch page
resultsEl.querySelectorAll('.search__result[data-video-slug]').forEach(el => {
el.addEventListener('click', () => {
const slug = el.dataset.videoSlug;
window.location.href = `/watch.html?id=${slug}&slug=${slug}`;
});
});
}
resultsEl.classList.add('active');
} catch (error) {
console.error('Search error:', error);
resultsEl.innerHTML = `
<div class="search__result" style="color: var(--color-error);">
<span>Search failed. Please try again.</span>
</div>
`;
resultsEl.classList.add('active');
}
}
// Debounced search handler
const debouncedSearch = debounce(performSearch, DEBOUNCE_DELAY);
// Input event handler
inputEl.addEventListener('input', (e) => {
debouncedSearch(e.target.value.trim());
});
// Close results on click outside
document.addEventListener('click', (e) => {
if (inputEl && resultsEl && !inputEl.contains(e.target) && !resultsEl.contains(e.target)) {
resultsEl.classList.remove('active');
}
});
// Close on escape
inputEl.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
inputEl.blur();
resultsEl.classList.remove('active');
}
});
// Reopen on focus if there's a query
inputEl.addEventListener('focus', () => {
if (inputEl.value.trim().length >= 2) {
resultsEl.classList.add('active');
}
});
}
/**
* Escape HTML special characters
* @param {string} str - Input string
* @returns {string} Escaped string
*/
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}

View file

@ -0,0 +1,60 @@
/**
* StreamFlow - Toast Notification Component
*/
const TOAST_DURATION = 4000;
/**
* Show a toast notification
* @param {string} message - Toast message
* @param {string} type - Toast type: 'success', 'error', 'info'
*/
export function showToast(message, type = 'info') {
const container = document.getElementById('toastContainer');
if (!container) return;
const toast = document.createElement('div');
toast.className = `toast toast--${type}`;
toast.innerHTML = `
<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
${getToastIcon(type)}
</svg>
<span>${escapeHtml(message)}</span>
`;
container.appendChild(toast);
// Auto-remove after duration
setTimeout(() => {
toast.style.animation = 'slideIn 0.3s ease reverse';
setTimeout(() => toast.remove(), 300);
}, TOAST_DURATION);
}
/**
* Get icon SVG path for toast type
* @param {string} type - Toast type
* @returns {string} SVG path
*/
function getToastIcon(type) {
switch (type) {
case 'success':
return '<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>';
case 'error':
return '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>';
default:
return '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/>';
}
}
/**
* Escape HTML special characters
* @param {string} str - Input string
* @returns {string} Escaped string
*/
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}

View file

@ -0,0 +1,241 @@
/**
* PhimMoi UI - Video Card Component
* Poster-style cards with episode badges and hover effects
*/
import { imageCache } from '../services/imageCache.js';
/**
* Detect if movie is newly released (within last 30 days or current year)
*/
function isNewRelease(video) {
const currentYear = new Date().getFullYear();
// Check if released this year
if (video.year === currentYear) return true;
// Check quality badge for "Mới" or "New" indicators
const quality = (video.quality || '').toLowerCase();
if (quality.includes('mới') || quality.includes('new')) return true;
// Check if movie was recently added (within 7 days)
if (video.modified?.time) {
const modifiedDate = new Date(video.modified.time);
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
if (modifiedDate > sevenDaysAgo) return true;
}
return false;
}
/**
* Detect movie type based on episode count and quality
*/
function getMovieType(video) {
const quality = (video.quality || '').toLowerCase();
const episodeCount = video.episodes?.length || 0;
const category = (video.category || video.type || '').toLowerCase();
// Check for trailer
if (quality.includes('trailer') || category.includes('trailer')) {
return 'trailer';
}
// Check for series (has episodes or is marked as series)
if (episodeCount > 1 || category.includes('series') || category.includes('phim-bo') ||
quality.includes('tập') || quality.includes('ep')) {
return 'series';
}
// Check for animation
if (category.includes('hoathinh') || category.includes('animation') || category.includes('anime')) {
return 'animation';
}
// Default to full movie
return 'movie';
}
/**
* Get episode count text
*/
function getEpisodeText(video) {
const quality = video.quality || '';
// Check if quality contains episode info like "Tập 12" or "12/24"
const epMatch = quality.match(/(?:tập\s*)?(\d+)(?:\s*\/\s*(\d+))?/i);
if (epMatch) {
return quality; // Return as-is, it already contains episode info
}
const episodeCount = video.episodes?.length || 0;
if (episodeCount > 1) {
return `${episodeCount} Tập`;
}
return null;
}
/**
* Create a video card element - PhimMoi Style
* @param {Object} video - Video data
* @param {function} onPlay - Callback when play is clicked
* @param {function} onInfo - Callback when more info is clicked
* @returns {HTMLElement} Video card element
*/
export function createVideoCard(video, onPlay, onInfo) {
const card = document.createElement('div');
card.className = 'video-card';
card.dataset.videoId = video.id;
const thumbnail = video.thumbnail || '';
const year = video.year || new Date().getFullYear();
// Smart badge detection
const isNew = isNewRelease(video);
const movieType = getMovieType(video);
const episodeText = getEpisodeText(video);
// Quality badge (HD, FHD, 4K, CAM, etc.)
let qualityBadge = video.quality || 'HD';
// Clean up quality text - remove episode info if it exists
qualityBadge = qualityBadge.replace(/(?:tập\s*)?\d+(?:\s*\/\s*\d+)?/gi, '').trim() || 'HD';
if (qualityBadge.length > 6) qualityBadge = 'HD'; // Fallback if too long
// Numeric rating badge
const rating = parseFloat(video.rating || 0);
const isFresh = rating >= 7.0;
const ratingPercent = Math.round(rating * 10);
let numericRatingHTML = '';
if (rating > 0) {
numericRatingHTML = `
<div class="numeric-rating">
<span class="numeric-rating__score">${rating.toFixed(1)}</span>
</div>
`;
}
// Build rating badge HTML (Rotten Tomatoes style)
let tomatoBadgeHTML = '';
if (rating > 0) {
const tomatoIcon = isFresh ? '🍅' : '🥀';
tomatoBadgeHTML = `
<div class="tomato-badge ${isFresh ? 'tomato-badge--fresh' : 'tomato-badge--rotten'}">
<span class="tomato-badge__icon">${tomatoIcon}</span>
<span class="tomato-badge__score">${ratingPercent}%</span>
</div>
`;
}
// Placeholder for loading state
const placeholderSvg = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 450"%3E%3Crect width="300" height="450" fill="%2314141c"/%3E%3C/svg%3E';
// Build tags HTML
let tagsHTML = '';
// NEW tag (top left)
if (isNew) {
tagsHTML += `<span class="video-tag video-tag--new">MỚI</span>`;
}
// Type tag (SERIES / PHIM LẺ)
if (movieType === 'trailer') {
tagsHTML += `<span class="video-tag video-tag--trailer">TRAILER</span>`;
} else if (movieType === 'series') {
tagsHTML += `<span class="video-tag video-tag--series">PHIM BỘ</span>`;
} else if (movieType === 'animation') {
tagsHTML += `<span class="video-tag video-tag--animation">HOẠT HÌNH</span>`;
}
card.innerHTML = `
<div class="video-card__container">
<div class="video-card__poster">
<img src="${placeholderSvg}" data-src="${thumbnail}" alt="${escapeHtml(video.title)}" loading="lazy" referrerpolicy="no-referrer" class="video-card__img" onerror="this.onerror=null;this.src='https://placehold.co/400x600/14141c/e5c07b?text=Movie'">
<!-- Top Left Tags -->
<div class="video-tags">
${tagsHTML}
</div>
<!-- Bottom Right Info (Ratings & Quality) -->
<div class="card-meta-bottom-right">
${tomatoBadgeHTML}
${numericRatingHTML}
<span class="poster-badge">${qualityBadge}</span>
</div>
<!-- Bottom Left Info (Year & Episodes) -->
<div class="card-meta-bottom-left">
<span class="year-badge">${year}</span>
${episodeText ? `<span class="episode-badge">${episodeText}</span>` : ''}
</div>
<!-- Watch Progress Bar -->
${video.progress && video.progress.percentage > 0 ? `
<div class="video-card__progress">
<div class="video-card__progress-fill" style="width: ${video.progress.percentage}%"></div>
</div>
` : ''}
<!-- Play overlay on hover -->
<div class="video-card__overlay">
<button class="video-card__play-btn" data-action="play" aria-label="Play">
<svg viewBox="0 0 24 24" fill="currentColor" width="40" height="40">
<path d="M8 5v14l11-7z"/>
</svg>
</button>
</div>
</div>
</div>
<!-- Movie Title -->
<div class="video-card__title">
<span class="video-card__name">${escapeHtml(video.title)}</span>
</div>
`;
// Lazy load image from cache when visible
const img = card.querySelector('.video-card__img');
if (img && thumbnail) {
// Use IntersectionObserver for lazy loading
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Load from cache
imageCache.getCachedImage(thumbnail).then(cachedUrl => {
img.src = cachedUrl;
img.classList.add('loaded');
}).catch(() => {
// Fallback to direct load
img.src = thumbnail;
img.onload = () => img.classList.add('loaded');
img.onerror = () => img.classList.add('loaded'); // Show placeholder if fails
});
observer.unobserve(img);
}
});
}, {
rootMargin: '800px', // Start loading 800px before visible
threshold: 0
});
observer.observe(img);
}
// Event Listeners
card.querySelector('[data-action="play"]')?.addEventListener('click', (e) => {
e.stopPropagation();
onPlay?.(video);
});
// Default click behavior - play on any click
card.addEventListener('click', () => {
onPlay?.(video);
});
return card;
}
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}

View file

@ -0,0 +1,241 @@
/**
* StreamFlow - Video Player Component
* ArtPlayer.js integration with custom skin
*/
import Artplayer from 'artplayer';
// Player instance reference
let currentPlayer = null;
/**
* Format duration for display
* @param {number} seconds - Duration in seconds
* @returns {string} Formatted duration
*/
function formatDuration(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
return `${minutes}:${String(secs).padStart(2, '0')}`;
}
/**
* Initialize video player
* @param {HTMLElement} container - Container element
* @param {Object} options - Player options
* @returns {Artplayer} Player instance
*/
export function initPlayer(container, options = {}) {
// Destroy existing player if any
destroyPlayer();
const {
url,
poster,
title,
autoplay = false,
qualities = []
} = options;
// Build player config with enhanced buffering
const playerConfig = {
container,
url,
poster,
title,
volume: 0.7,
autoplay,
autoSize: false,
autoMini: true,
loop: false,
flip: true,
playbackRate: true,
aspectRatio: true,
screenshot: true,
setting: true,
hotkey: true,
pip: true,
mutex: true,
fullscreen: true,
fullscreenWeb: true,
miniProgressBar: true,
playsInline: true,
autoPlayback: true,
theme: '#f5c518', // Golden-yellow accent
lang: 'en',
moreVideoAttr: {
crossOrigin: 'anonymous',
preload: 'auto',
},
airplay: true,
// HLS custom configuration for better buffering
customType: {
m3u8: function playM3u8(video, url, art) {
if (Hls.isSupported()) {
if (art.hls) {
art.hls.destroy();
}
const hls = new Hls({
// Buffer configuration for faster start
maxBufferLength: 30, // Max buffer in seconds
maxMaxBufferLength: 60, // Max buffer ceiling
maxBufferSize: 60 * 1000 * 1000, // Max buffer size (60MB)
maxBufferHole: 0.5, // Max gap in buffer
lowLatencyMode: false, // Disable low latency for stability
startLevel: -1, // Auto select quality
// Faster loading
enableWorker: true,
startFragPrefetch: true, // Prefetch next fragment
testBandwidth: true
});
hls.loadSource(url);
hls.attachMedia(video);
art.hls = hls;
art.on('destroy', () => hls.destroy());
// Handle HLS errors
hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
console.warn('HLS network error, trying to recover...');
hls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
console.warn('HLS media error, trying to recover...');
hls.recoverMediaError();
break;
default:
console.error('Fatal HLS error');
break;
}
}
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Native HLS support (Safari)
video.src = url;
}
}
},
settings: [
{
html: 'Speed',
selector: [
{ html: '0.5x', value: 0.5 },
{ html: '0.75x', value: 0.75 },
{ html: 'Normal', value: 1, default: true },
{ html: '1.25x', value: 1.25 },
{ html: '1.5x', value: 1.5 },
{ html: '2x', value: 2 }
],
onSelect(item) {
if (currentPlayer) {
currentPlayer.playbackRate = item.value;
}
return item.html;
}
}
],
icons: {
loading: `<div class="loading__spinner"></div>`,
state: `<svg viewBox="0 0 24 24" fill="currentColor" width="64" height="64"><path d="M8 5v14l11-7z"/></svg>`
},
cssVar: {
'--art-theme': '#f5c518',
'--art-background-color': '#0f0f0f',
'--art-progress-color': '#f5c518',
'--art-control-background-color': 'rgba(0, 0, 0, 0.8)',
'--art-control-height': '48px',
'--art-bottom-gap': '12px'
}
};
// Only add quality if available (ArtPlayer requires array, not undefined)
if (qualities.length > 0) {
playerConfig.quality = qualities.map((q, i) => ({
default: i === 0,
html: q,
url: url
}));
}
// Initialize ArtPlayer
currentPlayer = new Artplayer(playerConfig);
// Event handling
currentPlayer.on('ready', () => {
console.log('Player ready');
if (currentPlayer.video) {
currentPlayer.video.preload = 'auto';
}
});
currentPlayer.on('video:waiting', () => {
console.log('Buffering...');
});
currentPlayer.on('video:canplay', () => {
console.log('Can play');
});
currentPlayer.on('error', (error) => {
console.error('Player error:', error);
});
return currentPlayer;
}
/**
* Destroy current player instance
*/
export function destroyPlayer() {
if (currentPlayer) {
currentPlayer.destroy();
currentPlayer = null;
}
}
/**
* Get current player instance
* @returns {Artplayer|null} Current player or null
*/
export function getPlayer() {
return currentPlayer;
}
/**
* Create a lazy-load placeholder with play button
* @param {Object} options - Placeholder options
* @returns {HTMLElement} Placeholder element
*/
export function createPlayerPlaceholder(options = {}) {
const { poster, onClick } = options;
const placeholder = document.createElement('div');
placeholder.className = 'player-skeleton';
if (poster) {
placeholder.style.backgroundImage = `url(${poster})`;
placeholder.style.backgroundSize = 'cover';
placeholder.style.backgroundPosition = 'center';
}
placeholder.innerHTML = `
<div class="player-skeleton__play">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z"/>
</svg>
</div>
`;
if (onClick) {
placeholder.addEventListener('click', onClick);
}
return placeholder;
}

145
frontend/scripts/info.js Normal file
View file

@ -0,0 +1,145 @@
import { api } from './api.js';
// DOM Elements
const elements = {
poster: document.getElementById('poster'),
backdrop: document.getElementById('backdrop'),
title: document.getElementById('title'),
originalTitle: document.getElementById('originalTitle'),
rating: document.getElementById('rating'),
status: document.getElementById('status'),
year: document.getElementById('year'),
episodes: document.getElementById('episodes'),
country: document.getElementById('country'),
genre: document.getElementById('genre'),
director: document.getElementById('director'),
cast: document.getElementById('cast'),
description: document.getElementById('description'),
btnWatch: document.getElementById('btnWatch'),
tags: document.getElementById('tags'),
recommendations: document.getElementById('recommendations')
};
async function init() {
const params = new URLSearchParams(window.location.search);
const id = params.get('id');
const slug = params.get('slug');
if (!id && !slug) {
window.location.href = '/';
return;
}
try {
const movieSlug = slug || id;
const data = await api.getRophimMovie(movieSlug);
if (data) {
renderInfo(data.movie || data, data.episodes || []);
loadRecommendations();
}
} catch (e) {
console.error('Error loading info:', e);
// Fallback or error state
}
}
function renderInfo(movie, episodes) {
document.title = `${movie.name || movie.title} - KV-Stream`;
// Images
const posterUrl = movie.poster_url || movie.thumb_url || movie.thumbnail || 'https://via.placeholder.com/300x450?text=No+Poster';
const backdropUrl = movie.backdrop_url || posterUrl;
if (elements.poster) {
elements.poster.src = posterUrl;
elements.poster.onerror = () => { elements.poster.src = 'https://via.placeholder.com/300x450?text=No+Poster'; };
}
if (elements.backdrop) elements.backdrop.style.backgroundImage = `url('${backdropUrl}')`;
// Titles
if (elements.title) elements.title.textContent = movie.name || movie.title;
if (elements.originalTitle) elements.originalTitle.textContent = movie.origin_name || movie.original_title || '';
// Metadata
if (elements.status) {
// Infer status
let status = 'Đang chiếu'; // Default
if (movie.status === 'completed' || (episodes.length > 0 && movie.episode_current === 'Full')) status = 'Hoàn tất';
elements.status.innerHTML = `<span style="background:#2ecc71; padding:2px 8px; border-radius:4px; font-size:0.9em; color:#000; font-weight:bold;">${status}</span>`;
}
if (elements.year) elements.year.textContent = movie.year || 'N/A';
// Episodes Count
if (elements.episodes) {
const epCount = episodes[0]?.server_data?.length || 1;
const currentEp = movie.episode_current || epCount;
const totalEp = movie.episode_total || '?';
elements.episodes.textContent = `${epCount}`;
}
// Country
if (elements.country) {
const countries = Array.isArray(movie.country) ? movie.country.map(c => c.name) : [movie.country];
elements.country.textContent = countries.filter(Boolean).join(', ') || 'Đang cập nhật';
}
// Genre
if (elements.genre) {
const genres = Array.isArray(movie.category) ? movie.category.map(c => c.name) : (movie.genre ? movie.genre.split(',') : []);
elements.genre.textContent = genres.map(g => g.trim()).join(', ') || 'Đang cập nhật';
}
// Director
if (elements.director) {
const director = Array.isArray(movie.director) ? movie.director.join(', ') : movie.director;
elements.director.textContent = director || 'Đang cập nhật';
}
// Cast
if (elements.cast) {
const cast = Array.isArray(movie.actor) ? movie.actor.join(', ') : (movie.cast ? (Array.isArray(movie.cast) ? movie.cast.join(', ') : movie.cast) : '');
elements.cast.textContent = cast || 'Đang cập nhật';
}
// Description
if (elements.description) {
elements.description.innerHTML = movie.content || movie.description || 'Chưa có mô tả.';
}
// Watch Link
if (elements.btnWatch) {
elements.btnWatch.href = `/watch.html?id=${movie.slug}&slug=${movie.slug}`;
}
// Tags (Keywords)
if (elements.tags) {
// Just use Title and English title as tags for now
const tags = [movie.name, movie.origin_name].filter(Boolean);
elements.tags.innerHTML = tags.map(t =>
`<a href="#" class="action-btn action-btn--glass" style="font-size:0.8rem; padding:4px 12px;">${t}</a>`
).join('');
}
}
async function loadRecommendations() {
if (!elements.recommendations) return;
try {
const res = await api.getRophimCatalog({ page: 1, limit: 24 });
const recs = res.movies || [];
elements.recommendations.innerHTML = recs.map(v => `
<a href="/info.html?id=${v.slug}&slug=${v.slug}" class="rec-card">
<img src="${v.thumbnail}" class="rec-img" loading="lazy">
<div style="font-weight:600; font-size:0.9rem; margin-bottom:4px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${v.title}</div>
<div style="font-size:0.8rem; color:#aaa;">${v.year || ''}</div>
</a>
`).join('');
} catch (e) {
console.warn('Failed to load recs', e);
}
}
init();

View file

@ -0,0 +1,200 @@
/**
* TV-Style Keyboard Navigation
* Handles Arrow keys to navigate horizontally through sliders and vertically between rows.
*/
export class KeyboardNavigation {
constructor() {
this.currentFocus = null;
this.isEnabled = false;
// Selectors for focusable items
this.selectors = [
'.video-card',
'.hero__btn',
'.slider-btn',
'#topSearchBtn'
];
}
init() {
this.isEnabled = true;
document.addEventListener('keydown', this.handleKey.bind(this));
document.addEventListener('mousemove', this.handleMouseMove.bind(this));
// Initial focus?
// Usually wait for user to press a key to enter "Keyboard Mode"
// so we don't show focus rings to mouse users.
}
handleMouseMove() {
// If mouse moves, likely user is using mouse.
// Optional: clear focus to avoid conflict?
// For now, let's keep them separate or just let hover take precedence.
if (this.currentFocus) {
this.currentFocus.blur();
this.currentFocus.classList.remove('keyboard-focused');
this.currentFocus = null;
}
}
handleKey(e) {
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
e.preventDefault(); // Prevent default page scroll
if (!this.currentFocus) {
this.focusFirstVisible();
return;
}
let nextTarget = null;
switch (e.key) {
case 'ArrowRight':
nextTarget = this.moveHorizontal(1);
break;
case 'ArrowLeft':
nextTarget = this.moveHorizontal(-1);
break;
case 'ArrowUp':
nextTarget = this.moveVertical(-1);
break;
case 'ArrowDown':
nextTarget = this.moveVertical(1);
break;
}
if (nextTarget) {
this.setFocus(nextTarget);
}
} else if (e.key === 'Enter') {
if (this.currentFocus) {
this.currentFocus.click();
}
}
}
focusFirstVisible() {
// Find first video card in viewport
const candidates = document.querySelectorAll('.video-card');
if (candidates.length > 0) {
this.setFocus(candidates[0]);
}
}
setFocus(el) {
if (this.currentFocus) {
this.currentFocus.classList.remove('keyboard-focused');
// Trigger mouseleave logic if needed to reset z-index?
}
this.currentFocus = el;
el.classList.add('keyboard-focused');
el.focus({ preventScroll: true }); // Native focus
// Smooth scroll into view
el.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center'
});
}
moveHorizontal(direction) {
// 1. Try siblings first (if in a list)
// If direction is 1 (Right), look for nextElementSibling
if (!this.currentFocus) return null;
const allFocusable = Array.from(document.querySelectorAll(this.selectors.join(',')));
const currentIndex = allFocusable.indexOf(this.currentFocus);
if (currentIndex === -1) return null;
const nextIndex = currentIndex + direction;
if (nextIndex >= 0 && nextIndex < allFocusable.length) {
// Simple DOM order check
// BUT for sliders, DOM order matches visual order usually.
// Check if they are in the same container?
// If dragging across rows, Horizontal arrow shouldn't jump rows if possible?
// But flattening functionality is easier: just go to next DOM element.
// Refinement: If next element is in a DIFFERENT slider row, only jump if it's logically close?
// Ideally Right Arrow should stay in row.
const currentRect = this.currentFocus.getBoundingClientRect();
const nextEl = allFocusable[nextIndex];
const nextRect = nextEl.getBoundingClientRect();
// Heuristic: If vertical distance is large, it's a new row.
// If delta Y > height/2, maybe block horizontal nav?
const verticalDist = Math.abs(currentRect.top - nextRect.top);
if (verticalDist > currentRect.height * 0.5) {
// New row. Should arrow keys wrap?
// User said "scrollable to the right". Usually means stay in row or wrap.
// Let's allow wrapping for now, or strict row logic?
// Strict Row Logic is better for TV.
// If I am at end of row, right arrow does nothing or goes to "Next" button?
// Let's rely on simple DOM order for now as "good enough" for v1
// except if the user specifically requested "scrollable right".
// If I press Right at end of row, and it jumps to next row, that's okay.
}
return nextEl;
}
return null;
}
moveVertical(direction) {
// Find closest element in the visual direction
if (!this.currentFocus) return null;
const currentRect = this.currentFocus.getBoundingClientRect();
const centerX = currentRect.left + currentRect.width / 2;
const allFocusable = Array.from(document.querySelectorAll(this.selectors.join(',')));
// Filter elements that are strictly Above/Below
const candidates = allFocusable.filter(el => {
if (el === this.currentFocus) return false;
const rect = el.getBoundingClientRect();
if (direction === 1) { // Down
return rect.top >= currentRect.bottom - (currentRect.height * 0.2); // permit slight overlap
} else { // Up
return rect.bottom <= currentRect.top + (currentRect.height * 0.2);
}
});
if (candidates.length === 0) return null;
// Find the one with minimum distance
// Distance = Vertical Diff + Horizontal Diff penalty
let bestCandidate = null;
let minDistance = Infinity;
candidates.forEach(el => {
const rect = el.getBoundingClientRect();
const elCenterX = rect.left + rect.width / 2;
const elCenterY = rect.top + rect.height / 2;
// Vertical distance (primary)
const vDist = Math.abs(rect.top - currentRect.top);
// Horizontal alignment penalty
const hDist = Math.abs(elCenterX - centerX);
// Weighted distance: Vertical matter, but horizontally closest is best within that band.
// Actually, we usually want the "row immediately below".
// So sort by Vertical distance first.
// Simple Euclidean distance?
const dist = Math.sqrt(Math.pow(vDist, 2) + Math.pow(hDist, 2));
if (dist < minDistance) {
minDistance = dist;
bestCandidate = el;
}
});
return bestCandidate;
}
}

3087
frontend/scripts/main.js Normal file

File diff suppressed because it is too large Load diff

196
frontend/scripts/search.js Normal file
View file

@ -0,0 +1,196 @@
/**
* Search Modal Functionality
*/
import { api } from './api.js';
// Search state
let searchTimeout = null;
const SEARCH_DEBOUNCE_MS = 300;
// Elements
const searchModal = document.getElementById('searchModal');
const searchBackdrop = document.getElementById('searchBackdrop');
const searchInput = document.getElementById('searchInput');
const closeSearch = document.getElementById('closeSearch');
const searchLoading = document.getElementById('searchLoading');
const searchGrid = document.getElementById('searchGrid');
// Search button in sidebar
const searchNavButton = document.querySelector('[data-view="search"]');
/**
* Open search modal
*/
function openSearchModal() {
searchModal.classList.add('active');
setTimeout(() => searchInput.focus(), 100);
}
/**
* Close search modal
*/
function closeSearchModal() {
searchModal.classList.remove('active');
searchInput.value = '';
searchGrid.innerHTML = '';
searchLoading.style.display = 'none';
}
/**
* Perform search
*/
async function performSearch(query) {
if (!query || query.trim().length < 2) {
searchGrid.innerHTML = '';
searchLoading.style.display = 'none';
return;
}
// Show loading
searchLoading.style.display = 'flex';
try {
// Search in the API
const response = await api.searchRophim(query);
searchLoading.style.display = 'none';
if (response && response.movies && response.movies.length > 0) {
// Display results
searchGrid.innerHTML = response.movies.map(movie => {
return `
<div class="video-card" data-id="${movie.slug}" onclick="window.location.href='/watch.html?id=${movie.slug}&slug=${movie.slug}'">
<div class="video-card__container">
<div class="video-card__thumbnail">
<img src="${movie.thumbnail || 'https://via.placeholder.com/300x450?text=No+Image'}" alt="${movie.title}" loading="lazy">
</div>
<div class="video-card__overlay">
<div class="video-card__info">
<h3 class="video-card__title">${movie.title}</h3>
<div class="video-card__meta">
<span>${movie.year || ''}</span>
${movie.quality ? `<span>${movie.quality}</span>` : ''}
</div>
</div>
</div>
</div>
</div>
`;
}).join('');
} else {
// No results
searchGrid.innerHTML = `
<div style="grid-column: 1/-1; text-align: center; padding: 60px 20px; color: var(--apple-text-tertiary);">
<svg viewBox="0 0 24 24" fill="currentColor" width="48" height="48" style="opacity: 0.5; margin-bottom: 16px;">
<path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
</svg>
<p>No results found for "${query}"</p>
</div>
`;
}
} catch (error) {
console.error('Search failed:', error);
searchLoading.style.display = 'none';
searchGrid.innerHTML = `
<div style="grid-column: 1/-1; text-align: center; padding: 60px 20px; color: var(--apple-error);">
<p>Search failed. Please try again.</p>
</div>
`;
}
}
/**
* Setup search event listeners
*/
export function initSearch() {
// Collect all possible search triggers
const triggers = [
document.getElementById('headerSearchBtn'),
document.getElementById('mobileSearchBtn'),
document.querySelector('[data-view="search"]'),
document.querySelector('button[data-view="search"]') // Mobile bottom nav
];
triggers.forEach(btn => {
if (btn) {
// Remove old listeners by cloning (simple way) or just add new one
// Since we are shifting logic, just add listener
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation(); // Stop bubbling
openSearchModal();
});
}
});
// Close button
if (closeSearch) {
closeSearch.addEventListener('click', closeSearchModal);
}
// Backdrop click
if (searchBackdrop) {
searchBackdrop.addEventListener('click', closeSearchModal);
}
// Search input with debouncing
if (searchInput) {
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
const query = e.target.value;
searchTimeout = setTimeout(() => {
performSearch(query);
}, SEARCH_DEBOUNCE_MS);
});
// Enter key to search immediately
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
clearTimeout(searchTimeout);
performSearch(e.target.value);
}
});
}
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
// Cmd/Ctrl + K to open search
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
openSearchModal();
}
// Escape to close
if (e.key === 'Escape' && searchModal.classList.contains('active')) {
closeSearchModal();
}
});
// Check for ?search= URL parameter and auto-perform search
const urlParams = new URLSearchParams(window.location.search);
const searchQuery = urlParams.get('search');
if (searchQuery && searchQuery.trim()) {
// Open modal and perform search
setTimeout(() => {
openSearchModal();
if (searchInput) {
searchInput.value = searchQuery;
}
performSearch(searchQuery);
// Clean up the URL without refreshing
const newUrl = window.location.pathname;
window.history.replaceState({}, '', newUrl);
}, 300);
}
}
// Initialize on load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initSearch);
} else {
initSearch();
}

View file

@ -0,0 +1,203 @@
/**
* Image Cache Service
* Caches movie posters and thumbnails for faster loading
*/
const IMAGE_CACHE_NAME = 'kvstream-images-v1';
const IMAGE_CACHE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
const IMAGE_CACHE_MAX_ITEMS = 500;
class ImageCacheService {
constructor() {
this.memoryCache = new Map();
this.cacheEnabled = 'caches' in window;
this.pendingRequests = new Map();
}
/**
* Get cached image or fetch and cache it
* @param {string} url - Image URL
* @returns {Promise<string>} - Blob URL for the image
*/
async getCachedImage(url) {
if (!url || !this.cacheEnabled) return url;
// Check memory cache first (fastest)
if (this.memoryCache.has(url)) {
return this.memoryCache.get(url);
}
// Deduplicate pending requests
if (this.pendingRequests.has(url)) {
return this.pendingRequests.get(url);
}
const fetchPromise = this._fetchAndCache(url);
this.pendingRequests.set(url, fetchPromise);
try {
const result = await fetchPromise;
return result;
} finally {
this.pendingRequests.delete(url);
}
}
async _fetchAndCache(url) {
try {
const cache = await caches.open(IMAGE_CACHE_NAME);
// Check cache first
const cachedResponse = await cache.match(url);
if (cachedResponse) {
const blob = await cachedResponse.blob();
const blobUrl = URL.createObjectURL(blob);
this.memoryCache.set(url, blobUrl);
return blobUrl;
}
// Fetch and cache
const response = await fetch(url, { mode: 'cors', credentials: 'omit' });
if (response.ok) {
const responseClone = response.clone();
cache.put(url, responseClone);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
this.memoryCache.set(url, blobUrl);
// Cleanup old cache entries periodically
this._cleanupCache(cache);
return blobUrl;
}
} catch (error) {
// Silent fail - return original URL
console.warn('Image cache failed:', url);
}
return url;
}
/**
* Preload images for faster display
* @param {string[]} urls - Array of image URLs to preload
*/
async preloadImages(urls) {
if (!urls || urls.length === 0) return;
// Batch preload with limited concurrency
const batchSize = 6;
for (let i = 0; i < urls.length; i += batchSize) {
const batch = urls.slice(i, i + batchSize);
await Promise.allSettled(batch.map(url => this.getCachedImage(url)));
}
}
/**
* Create optimized image element with lazy loading and caching
* @param {string} url - Image source URL
* @param {string} alt - Alt text
* @param {string} className - CSS class
* @returns {HTMLImageElement}
*/
createCachedImage(url, alt = '', className = '') {
const img = document.createElement('img');
img.alt = alt;
img.className = className;
img.loading = 'lazy';
img.decoding = 'async';
// Set placeholder first
img.src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 450"%3E%3Crect fill="%23222"%3E%3C/rect%3E%3C/svg%3E';
// Then load cached image
if (url) {
this.getCachedImage(url).then(cachedUrl => {
img.src = cachedUrl;
});
}
return img;
}
/**
* Cleanup old cache entries
*/
async _cleanupCache(cache) {
try {
const keys = await cache.keys();
if (keys.length > IMAGE_CACHE_MAX_ITEMS) {
// Remove oldest 20% of entries
const toRemove = Math.floor(keys.length * 0.2);
for (let i = 0; i < toRemove; i++) {
await cache.delete(keys[i]);
}
}
} catch (error) {
// Ignore cleanup errors
}
}
/**
* Clear all cached images
*/
async clearCache() {
this.memoryCache.clear();
if (this.cacheEnabled) {
await caches.delete(IMAGE_CACHE_NAME);
}
}
/**
* Get cache statistics
*/
async getCacheStats() {
const stats = {
memoryItems: this.memoryCache.size,
cacheItems: 0,
cacheSize: 0
};
if (this.cacheEnabled) {
try {
const cache = await caches.open(IMAGE_CACHE_NAME);
const keys = await cache.keys();
stats.cacheItems = keys.length;
} catch (e) { }
}
return stats;
}
}
// Export singleton instance
export const imageCache = new ImageCacheService();
// Auto-preload visible images on scroll
let preloadObserver = null;
export function setupImagePreloading() {
if (preloadObserver) return;
preloadObserver = new IntersectionObserver((entries) => {
const urls = entries
.filter(e => e.isIntersecting)
.map(e => e.target.dataset.src || e.target.src)
.filter(Boolean);
if (urls.length > 0) {
imageCache.preloadImages(urls);
}
}, {
rootMargin: '200px',
threshold: 0
});
// Observe all images with data-src or src
document.querySelectorAll('img[data-src], img[src]').forEach(img => {
preloadObserver.observe(img);
});
}
export default imageCache;

1076
frontend/scripts/watch.js Normal file

File diff suppressed because it is too large Load diff

196
frontend/styles/base.css Normal file
View file

@ -0,0 +1,196 @@
/* ============================================
KV-Stream - Base Styles
PIXEL-PERFECT NETFLIX BASE STYLES
============================================ */
/* ============================================
RESET & FOUNDATION
============================================ */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
scroll-behavior: smooth;
scrollbar-width: none;
-ms-overflow-style: none;
}
html::-webkit-scrollbar {
display: none;
}
body {
font-family: var(--font-family);
font-size: var(--font-size-base);
font-weight: var(--font-weight-regular);
line-height: var(--line-height-normal);
color: var(--netflix-text);
background-color: var(--netflix-bg);
min-height: 100vh;
overflow-x: hidden;
}
/* ============================================
SCROLLBAR HIDING
============================================ */
::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
background: transparent;
}
/* ============================================
SELECTION
============================================ */
::selection {
background: var(--netflix-red);
color: var(--netflix-text);
}
/* ============================================
LINKS
============================================ */
a {
color: inherit;
text-decoration: none;
}
a:hover {
color: var(--netflix-text-secondary);
}
/* ============================================
IMAGES
============================================ */
img {
max-width: 100%;
height: auto;
display: block;
}
/* ============================================
NETFLIX SHIMMER ANIMATION
============================================ */
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.shimmer {
background: linear-gradient(90deg,
var(--netflix-bg-card) 25%,
var(--netflix-bg-elevated) 50%,
var(--netflix-bg-card) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
/* ============================================
LOADING STATES
============================================ */
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
gap: 16px;
color: var(--netflix-text-secondary);
}
.loading__spinner {
width: 48px;
height: 48px;
border: 3px solid var(--netflix-bg-elevated);
border-top-color: var(--netflix-red);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ============================================
EMPTY STATES
============================================ */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
text-align: center;
color: var(--netflix-text-secondary);
}
.empty-state svg {
opacity: 0.3;
margin-bottom: 16px;
}
.empty-state h2 {
font-size: var(--font-size-xl);
color: var(--netflix-text);
margin-bottom: 8px;
}
.empty-state p {
font-size: var(--font-size-base);
}
/* ============================================
UTILITY CLASSES
============================================ */
.text-match {
color: var(--netflix-green) !important;
}
.text-muted {
color: var(--netflix-text-secondary) !important;
}
.text-red {
color: var(--netflix-red) !important;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* ============================================
FOCUS STYLES (Accessibility)
============================================ */
:focus-visible {
outline: 2px solid var(--netflix-red);
outline-offset: 2px;
}
button:focus:not(:focus-visible),
a:focus:not(:focus-visible) {
outline: none;
}

View file

@ -0,0 +1,112 @@
/* ============================================
KV-Stream - Button Components
PIXEL-PERFECT NETFLIX BUTTONS
============================================ */
/* ============================================
BASE BUTTON
============================================ */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: var(--btn-height);
padding: var(--btn-padding);
font-family: inherit;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
border-radius: var(--btn-radius);
border: none;
cursor: pointer;
transition: all var(--transition-base);
white-space: nowrap;
}
.btn svg {
width: 20px;
height: 20px;
}
/* ============================================
NETFLIX PRIMARY (White)
============================================ */
.btn--primary {
background: var(--netflix-text);
color: var(--netflix-bg);
}
.btn--primary:hover {
background: rgba(255, 255, 255, 0.85);
}
/* ============================================
NETFLIX SECONDARY (Gray)
============================================ */
.btn--secondary {
background: rgba(109, 109, 110, 0.7);
color: var(--netflix-text);
}
.btn--secondary:hover {
background: rgba(109, 109, 110, 0.5);
}
/* ============================================
NETFLIX RED
============================================ */
.btn--red {
background: var(--netflix-red);
color: var(--netflix-text);
}
.btn--red:hover {
background: var(--netflix-red-hover);
}
/* ============================================
GHOST (Outline)
============================================ */
.btn--ghost {
background: transparent;
color: var(--netflix-text);
border: 1px solid var(--netflix-text-muted);
}
.btn--ghost:hover {
border-color: var(--netflix-text);
background: rgba(255, 255, 255, 0.1);
}
/* ============================================
ICON BUTTON (Circle)
============================================ */
.btn--icon {
width: 40px;
height: 40px;
padding: 0;
border-radius: 50%;
background: rgba(42, 42, 42, 0.6);
border: 2px solid rgba(255, 255, 255, 0.5);
color: var(--netflix-text);
}
.btn--icon:hover {
background: rgba(42, 42, 42, 0.9);
border-color: var(--netflix-text);
transform: scale(1.1);
}
/* ============================================
SMALL VARIANT
============================================ */
.btn--sm {
height: var(--btn-height-sm);
padding: 0 16px;
font-size: var(--font-size-sm);
}
.btn--sm svg {
width: 16px;
height: 16px;
}

View file

@ -0,0 +1,502 @@
/* ============================================
KV-Stream - Video Card Components
PIXEL-PERFECT NETFLIX CARDS
============================================ */
/* ============================================
NETFLIX VIDEO CARD - Base Styles
============================================ */
.video-card {
position: relative;
flex: 0 0 var(--card-width-desktop);
width: var(--card-width-desktop);
aspect-ratio: var(--card-aspect-ratio);
cursor: pointer;
z-index: var(--z-card);
transition: z-index 0s var(--transition-card);
scroll-snap-align: start;
}
.video-card:hover {
z-index: var(--z-card-hover);
transition: z-index 0s 0s;
}
.video-card__container {
width: 100%;
height: 100%;
position: relative;
border-radius: var(--card-radius);
overflow: visible;
background: var(--netflix-bg-card);
transition: transform var(--transition-card), box-shadow var(--transition-card);
}
/* ============================================
NETFLIX HOVER EXPANSION EFFECT
============================================ */
.video-card:hover .video-card__container {
transform: scale(var(--card-hover-scale));
box-shadow: var(--shadow-card-hover);
border-radius: var(--card-radius) var(--card-radius) 0 0;
}
/* First card in row: scale from left edge */
.video-card:first-child:hover .video-card__container {
transform: scale(var(--card-hover-scale));
transform-origin: left center;
}
/* Last card in row: scale from right edge */
.video-card:last-child:hover .video-card__container {
transform: scale(var(--card-hover-scale));
transform-origin: right center;
}
/* ============================================
CARD THUMBNAIL / POSTER
============================================ */
.video-card__thumbnail {
width: 100%;
height: 100%;
overflow: hidden;
border-radius: var(--card-radius);
transition: border-radius var(--transition-card);
}
.video-card:hover .video-card__thumbnail {
border-radius: var(--card-radius) var(--card-radius) 0 0;
}
.video-card__poster {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.video-card__poster img,
.video-card__img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.4s ease;
}
.video-card:hover .video-card__poster img,
.video-card:hover .video-card__img {
transform: scale(1.05);
}
/* ============================================
NETFLIX INFO BAR (Appears on Hover)
============================================ */
.video-card__info {
position: absolute;
top: 100%;
left: 0;
right: 0;
padding: 12px;
background: var(--netflix-bg-card);
border-radius: 0 0 var(--card-radius) var(--card-radius);
box-shadow: var(--shadow-dropdown);
opacity: 0;
pointer-events: none;
transform: translateY(-10px);
transition: all var(--transition-card);
z-index: 51;
}
.video-card:hover .video-card__info {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
/* ============================================
INFO BAR CONTROLS
============================================ */
.video-card__controls {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 6px;
margin-bottom: 8px;
}
.video-card__controls-left {
display: flex;
align-items: center;
gap: 6px;
}
/* Netflix Circle Buttons */
.circle-btn {
width: 32px;
height: 32px;
min-width: 32px;
min-height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all var(--transition-fast);
border: 2px solid rgba(255, 255, 255, 0.5);
background: rgba(42, 42, 42, 0.6);
color: var(--netflix-text);
}
.circle-btn--primary {
background: var(--netflix-text);
color: var(--netflix-bg);
border-color: var(--netflix-text);
}
.circle-btn--primary:hover {
background: rgba(255, 255, 255, 0.85);
}
.circle-btn--outline:hover {
border-color: var(--netflix-text);
background: rgba(42, 42, 42, 0.9);
}
.circle-btn svg {
width: 16px;
height: 16px;
}
/* More Info (Expand) Button */
.circle-btn--expand {
margin-left: auto;
}
/* ============================================
METADATA ROW
============================================ */
.video-card__metadata {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-bold);
margin-bottom: 6px;
}
.video-card__metadata .match {
color: var(--netflix-green);
}
.video-card__metadata .age,
.video-card__metadata .hd {
border: 1px solid rgba(255, 255, 255, 0.4);
padding: 0 4px;
font-size: 9px;
border-radius: 2px;
}
.video-card__metadata .hd {
border-color: rgba(255, 255, 255, 0.5);
}
/* ============================================
GENRES / TAGS
============================================ */
.video-card__genres {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
font-size: var(--font-size-xs);
color: var(--netflix-text-secondary);
}
.video-card__genres span::after {
content: '•';
margin-left: 4px;
color: var(--netflix-text-muted);
}
.video-card__genres span:last-child::after {
content: none;
}
/* ============================================
VIDEO PREVIEW (Optional)
============================================ */
.video-card__video-wrapper {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #000;
opacity: 0;
transition: opacity 0.3s ease;
border-radius: var(--card-radius);
overflow: hidden;
}
.video-card:hover .video-card__video-wrapper {
opacity: 1;
}
.video-card__preview-video {
width: 100%;
height: 100%;
object-fit: cover;
}
/* ============================================
PLAY BUTTON OVERLAY
============================================ */
.video-card__overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
opacity: 0;
transition: opacity var(--transition-base);
border-radius: var(--card-radius);
}
.video-card:hover .video-card__overlay {
opacity: 1;
}
.video-card__play-btn {
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #000;
transition: transform var(--transition-fast);
box-shadow: var(--shadow-card);
}
.video-card__play-btn:hover {
transform: scale(1.1);
}
.video-card__play-btn svg {
width: 20px;
height: 20px;
margin-left: 3px;
}
/* ============================================
PROGRESS BAR (Watch History)
============================================ */
.video-card__progress {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.2);
z-index: 15;
border-radius: 0 0 var(--card-radius) var(--card-radius);
}
.video-card__progress-fill {
height: 100%;
background: var(--netflix-red);
}
/* ============================================
VIDEO TAGS (Top Left Badges)
============================================ */
.video-tags {
position: absolute;
top: 6px;
left: 6px;
display: flex;
flex-direction: column;
gap: 4px;
z-index: 10;
}
.video-tag {
padding: 2px 6px;
border-radius: 2px;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-bold);
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--netflix-text);
}
.video-tag--new {
background: var(--netflix-red);
}
.video-tag--series {
background: #00a8e1;
}
.video-tag--trailer {
background: #ff9500;
color: #000;
}
/* ============================================
QUALITY BADGE
============================================ */
.poster-badge {
padding: 2px 6px;
background: rgba(0, 0, 0, 0.75);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 2px;
font-size: 10px;
font-weight: var(--font-weight-bold);
color: var(--netflix-text);
text-transform: uppercase;
}
/* ============================================
EPISODE BADGE
============================================ */
.episode-badge {
padding: 2px 6px;
background: rgba(0, 0, 0, 0.8);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 2px;
font-size: 10px;
font-weight: var(--font-weight-semibold);
color: var(--netflix-green);
}
/* ============================================
YEAR BADGE
============================================ */
.year-badge {
padding: 2px 6px;
background: rgba(0, 0, 0, 0.75);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 2px;
font-size: 10px;
font-weight: var(--font-weight-bold);
color: var(--netflix-text);
}
/* ============================================
RATING BADGES
============================================ */
.tomato-badge {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
border-radius: 2px;
font-size: 10px;
font-weight: var(--font-weight-bold);
}
.tomato-badge--fresh {
background: #e50914;
/* Netflix Red */
color: #fff;
}
.tomato-badge--rotten {
background: #333;
color: #fff;
}
.numeric-rating {
padding: 2px 6px;
background: rgba(255, 255, 255, 0.9);
color: #000;
border-radius: 2px;
font-size: 10px;
font-weight: var(--font-weight-black);
}
/* ============================================
META CONTAINERS (Positional Clusters)
============================================ */
.card-meta-bottom-right {
position: absolute;
bottom: 6px;
right: 6px;
display: flex;
align-items: center;
gap: 4px;
z-index: 10;
}
.card-meta-bottom-left {
position: absolute;
bottom: 6px;
left: 6px;
display: flex;
flex-direction: column;
gap: 4px;
z-index: 10;
}
/* ============================================
CARD TITLE & META
============================================ */
.video-card__title {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
line-height: 1.2;
margin-bottom: 4px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
color: var(--netflix-text);
}
.video-card__meta {
font-size: var(--font-size-xs);
color: var(--netflix-text-secondary);
display: flex;
align-items: center;
gap: 6px;
}
.video-card__duration {
position: absolute;
bottom: 6px;
right: 6px;
padding: 2px 6px;
background: rgba(0, 0, 0, 0.8);
border-radius: 2px;
font-size: var(--font-size-xs);
}
.video-card__resolution {
position: absolute;
top: 6px;
left: 6px;
padding: 2px 6px;
background: var(--netflix-red);
border-radius: 2px;
font-size: 9px;
font-weight: var(--font-weight-bold);
color: var(--netflix-text);
text-transform: uppercase;
}
/* Keyboard Navigation */
.video-card.keyboard-focused {
z-index: var(--z-card-hover);
}
.video-card.keyboard-focused .video-card__container {
transform: scale(1.05);
box-shadow: 0 0 0 3px var(--netflix-red), var(--shadow-card-hover);
}

View file

@ -0,0 +1,132 @@
/* ============================================
KV-Stream - Form Components
Search, Categories, Inputs
============================================ */
/* Search */
.search {
flex: 1;
max-width: 600px;
position: relative;
}
.search__input {
width: 100%;
height: 44px;
padding: 0 var(--spacing-lg);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
color: var(--color-text-primary);
font-size: var(--font-size-base);
transition: all var(--transition-base);
}
.search__input::placeholder {
color: var(--color-text-tertiary);
}
.search__input:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: var(--shadow-glow);
}
.search__results {
position: absolute;
top: calc(100% + 8px);
left: 0;
right: 0;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
max-height: 400px;
overflow-y: auto;
display: none;
z-index: var(--z-elevated);
}
.search__results.active {
display: block;
}
.search__result {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-md);
cursor: pointer;
transition: background var(--transition-fast);
}
.search__result:hover {
background: var(--color-surface-hover);
}
.search__result-thumb {
width: 80px;
height: 45px;
border-radius: var(--radius-sm);
object-fit: cover;
background: var(--color-bg-tertiary);
}
.search__result-info {
flex: 1;
min-width: 0;
}
.search__result-title {
font-weight: var(--font-weight-medium);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.search__result-meta {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
/* Categories */
.categories {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-xl);
overflow-x: auto;
padding-bottom: var(--spacing-sm);
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.categories::-webkit-scrollbar {
display: none;
}
.category {
padding: 8px 16px;
background: transparent;
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: all var(--transition-base);
white-space: nowrap;
}
.category:hover {
background: var(--color-surface-hover);
color: var(--color-text-primary);
border-color: var(--color-border-hover);
}
.category--active {
background: var(--color-accent);
border-color: var(--color-accent);
color: #fff;
font-weight: var(--font-weight-semibold);
box-shadow: 0 0 15px var(--color-accent-glow);
}

View file

@ -0,0 +1,50 @@
/* ============================================
KV-Stream - Loading States
Spinners, Skeletons, Empty States
============================================ */
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-md);
padding: var(--spacing-3xl);
color: var(--color-text-secondary);
}
.loading__spinner {
width: 40px;
height: 40px;
border: 3px solid var(--color-border);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-md);
padding: var(--spacing-3xl);
text-align: center;
color: var(--color-text-secondary);
}
.empty-state svg {
color: var(--color-text-tertiary);
}
.empty-state h2 {
font-size: var(--font-size-xl);
color: var(--color-text-primary);
}

View file

@ -0,0 +1,413 @@
/* ============================================
KV-Stream - Modal Components
PIXEL-PERFECT NETFLIX MODALS
============================================ */
/* ============================================
PLAYER MODAL
============================================ */
.player-modal {
position: fixed;
inset: 0;
z-index: var(--z-modal);
display: none;
}
.player-modal.active {
display: flex;
align-items: center;
justify-content: center;
}
.player-modal__backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.9);
animation: fadeIn 0.2s ease;
}
.player-modal__content {
position: relative;
width: 100%;
max-width: 1100px;
max-height: 90vh;
margin: 40px;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
background: var(--netflix-bg-card);
border-radius: 6px;
animation: slideUp 0.3s ease;
scrollbar-width: none;
}
.player-modal__content::-webkit-scrollbar {
display: none;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.player-modal__close {
position: absolute;
top: 16px;
right: 16px;
width: 36px;
height: 36px;
background: var(--netflix-bg);
border: none;
color: var(--netflix-text);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
z-index: 10;
transition: all var(--transition-base);
}
.player-modal__close:hover {
background: var(--netflix-text);
color: var(--netflix-bg);
}
.player-modal__close svg {
width: 18px;
height: 18px;
}
/* ============================================
MODAL INFO SECTION
============================================ */
.player-modal__info {
padding: 20px 24px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
background: linear-gradient(to top, var(--netflix-bg-card), transparent);
}
.player-modal__title {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
margin-bottom: 4px;
color: var(--netflix-text);
}
.player-modal__meta {
font-size: var(--font-size-sm);
color: var(--netflix-text-secondary);
display: flex;
align-items: center;
gap: 8px;
}
.player-modal__meta span::after {
content: '•';
margin-left: 8px;
color: var(--netflix-text-muted);
}
.player-modal__meta span:last-child::after {
content: none;
}
/* ============================================
QUALITY SELECTOR
============================================ */
.player-modal__quality {
display: flex;
gap: 6px;
}
.quality-btn {
padding: 6px 14px;
background: rgba(42, 42, 42, 0.8);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--btn-radius);
color: var(--netflix-text-secondary);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: all var(--transition-fast);
}
.quality-btn:hover {
background: rgba(60, 60, 60, 0.9);
border-color: rgba(255, 255, 255, 0.2);
color: var(--netflix-text);
}
.quality-btn.active {
background: var(--netflix-red);
border-color: var(--netflix-red);
color: var(--netflix-text);
}
/* ============================================
PLAYER CONTAINER
============================================ */
.player-container {
aspect-ratio: 16 / 9;
background: #000;
overflow: hidden;
}
/* ============================================
EPISODE LIST
============================================ */
.player-modal__episodes {
margin-top: 0;
padding: 20px 24px;
background: var(--netflix-bg-card);
}
.player-modal__episodes-title {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
margin-bottom: 16px;
color: var(--netflix-text);
display: flex;
align-items: center;
gap: 8px;
}
.player-modal__episodes-title::before {
content: '';
width: 3px;
height: 16px;
background: var(--netflix-red);
border-radius: 2px;
}
.episode-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(50px, 1fr));
gap: 8px;
}
.episode-btn {
padding: 10px 8px;
background: var(--netflix-bg-elevated);
border: 1px solid var(--netflix-border);
border-radius: var(--btn-radius);
color: var(--netflix-text-secondary);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: all var(--transition-fast);
text-align: center;
}
.episode-btn:hover {
background: rgba(255, 255, 255, 0.1);
border-color: var(--netflix-red);
color: var(--netflix-text);
}
.episode-btn.active {
background: var(--netflix-red);
border-color: var(--netflix-red);
color: var(--netflix-text);
font-weight: var(--font-weight-bold);
}
/* ============================================
GENERIC MODAL
============================================ */
.modal {
position: fixed;
inset: 0;
z-index: var(--z-modal);
display: none;
}
.modal.active {
display: flex;
align-items: center;
justify-content: center;
}
.modal__backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.85);
}
.modal__content {
position: relative;
width: 100%;
max-width: 450px;
margin: 24px;
padding: 24px;
background: var(--netflix-bg-card);
border-radius: 6px;
animation: slideUp 0.3s ease;
}
.modal__title {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
margin-bottom: 20px;
color: var(--netflix-text);
}
.modal__actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}
/* ============================================
FORM ELEMENTS
============================================ */
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--netflix-text-secondary);
margin-bottom: 8px;
}
.input {
width: 100%;
padding: 12px 16px;
background: var(--netflix-bg-elevated);
border: 1px solid var(--netflix-border);
border-radius: var(--btn-radius);
color: var(--netflix-text);
font-family: inherit;
font-size: var(--font-size-base);
transition: all var(--transition-fast);
}
.input::placeholder {
color: var(--netflix-text-muted);
}
.input:focus {
outline: none;
border-color: var(--netflix-text);
background: var(--netflix-bg);
}
select.input {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%238c8c8c' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10l-5 5z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 14px center;
padding-right: 40px;
}
/* ============================================
TOAST NOTIFICATIONS
============================================ */
.toast-container {
position: fixed;
bottom: 80px;
right: 24px;
z-index: calc(var(--z-modal) + 100);
display: flex;
flex-direction: column;
gap: 10px;
}
.toast {
padding: 14px 20px;
background: var(--netflix-bg-card);
border-radius: var(--btn-radius);
box-shadow: var(--shadow-dropdown);
display: flex;
align-items: center;
gap: 12px;
animation: slideIn 0.3s ease;
min-width: 260px;
color: var(--netflix-text);
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.toast--success {
border-left: 3px solid var(--netflix-green);
}
.toast--error {
border-left: 3px solid var(--netflix-red);
}
.toast--info {
border-left: 3px solid #00a8e1;
}
/* ============================================
PLAYER SKELETON
============================================ */
.player-skeleton {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--netflix-bg-elevated);
position: relative;
}
.player-skeleton__play {
width: 70px;
height: 70px;
background: var(--netflix-text);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all var(--transition-base);
box-shadow: var(--shadow-card);
}
.player-skeleton__play:hover {
transform: scale(1.1);
}
.player-skeleton__play svg {
width: 28px;
height: 28px;
color: var(--netflix-bg);
margin-left: 4px;
}

View file

@ -0,0 +1,45 @@
/* Base Video Grid Definition (Desktop) */
.video-grid {
display: grid !important;
/* Larger cards for better visibility */
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)) !important;
gap: var(--spacing-lg) !important;
padding: var(--spacing-lg) 4%;
width: 100%;
}
/* Ensure cards inside grid take full width */
.video-grid .video-card {
width: 100%;
/* Override fixed width if any */
flex: none;
/* Override flex */
aspect-ratio: 2/3;
min-width: 160px;
}
/* Medium screens - slightly smaller cards */
@media (max-width: 1200px) {
.video-grid {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 20px;
}
}
/* Tablet */
@media (max-width: 768px) {
.video-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 16px;
padding: var(--spacing-md) 3%;
}
}
/* Mobile - 2 columns */
@media (max-width: 480px) {
.video-grid {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
padding: var(--spacing-sm) 16px;
}
}

41
frontend/styles/index.css Normal file
View file

@ -0,0 +1,41 @@
/* ============================================
KV-Stream - Main Stylesheet
Modular CSS Architecture
============================================ */
/*
* This file imports all CSS modules.
* The styles are split into logical modules for easier maintenance:
*
* - variables.css: Design tokens (colors, spacing, typography)
* - base.css: Reset, global styles
* - layout.css: Sidebar, header, app structure
* - components/: Reusable UI components
* - sections/: Page-specific sections
* - responsive.css: All media queries
*/
/* === Core === */
@import 'variables.css';
@import 'base.css';
@import 'layout.css';
/* === Components === */
@import 'components/buttons.css';
@import 'components/cards.css';
@import 'components/forms.css';
@import 'components/loading.css';
@import 'components/modals.css';
/* === Sections === */
@import 'sections/hero.css';
@import 'sections/sliders.css';
@import 'sections/feed.css';
/* === Responsive (must be last to override) === */
@import 'responsive.css';
/* === Patches (for quick fixes) === */
@import 'grid-patch.css';
@import 'responsive-patch.css';
@import 'search-modal.css';

266
frontend/styles/layout.css Normal file
View file

@ -0,0 +1,266 @@
/* ============================================
KV-Stream - Layout Styles
PIXEL-PERFECT NETFLIX LAYOUT
============================================ */
/* ============================================
NETFLIX TOP HEADER
============================================ */
.netflix-header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: var(--header-height);
background: var(--netflix-bg-header);
z-index: var(--z-header);
display: flex;
align-items: center;
padding: 0 var(--row-padding);
transition: background 0.4s ease;
}
.netflix-header.scrolled {
background: var(--netflix-bg-header-scrolled);
box-shadow: var(--shadow-header);
}
.netflix-header__logo {
display: flex;
align-items: center;
margin-right: 40px;
}
.netflix-header__logo svg,
.netflix-header__logo img {
height: 28px;
width: auto;
}
.netflix-header__nav {
display: flex;
align-items: center;
gap: 20px;
flex: 1;
}
.netflix-header__nav-link {
color: var(--netflix-text-secondary);
font-size: var(--font-size-base);
font-weight: var(--font-weight-regular);
transition: color var(--transition-fast);
white-space: nowrap;
text-decoration: none;
}
.netflix-header__nav-link:hover {
color: var(--netflix-text-muted);
}
.netflix-header__nav-link.active {
color: var(--netflix-text);
font-weight: var(--font-weight-bold);
}
.netflix-header__right {
display: flex;
align-items: center;
gap: 20px;
margin-left: auto;
}
.netflix-header__search {
background: none;
border: none;
color: var(--netflix-text);
cursor: pointer;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.netflix-header__search svg {
width: 24px;
height: 24px;
}
.netflix-header__profile {
width: 32px;
height: 32px;
border-radius: var(--card-radius);
overflow: hidden;
cursor: pointer;
transition: border-color var(--transition-fast);
border: 1px solid transparent;
}
.netflix-header__profile:hover {
border-color: var(--netflix-text);
}
.netflix-header__profile img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* ============================================
LEGACY SIDEBAR (Hidden on Desktop with Header)
============================================ */
.sidebar {
display: none;
}
/* ============================================
MAIN CONTENT AREA
============================================ */
.app-layout {
display: flex;
flex-direction: column;
min-height: 100vh;
background: var(--netflix-bg);
}
.main-content {
flex: 1;
padding-top: var(--header-height);
background: var(--netflix-bg);
min-height: 100vh;
}
.main {
padding: 0;
max-width: 100%;
}
/* ============================================
NETFLIX ROW SECTIONS
============================================ */
.netflix-row-section {
position: relative;
margin: var(--row-margin) 0;
z-index: var(--z-row);
}
.netflix-row-section:hover {
z-index: calc(var(--z-row) + 1);
}
.netflix-row-title {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-medium);
color: var(--netflix-text-secondary);
margin: 0 0 12px var(--row-padding);
transition: color var(--transition-fast);
}
.netflix-row-section:hover .netflix-row-title {
color: var(--netflix-text);
}
/* ============================================
VIEW TABS
============================================ */
.view-tabs {
display: flex;
gap: 16px;
margin-bottom: 24px;
padding: 0 var(--row-padding);
}
.view-tab {
background: transparent;
border: 1px solid var(--netflix-text-muted);
color: var(--netflix-text-secondary);
padding: 8px 20px;
border-radius: var(--btn-radius);
font-family: inherit;
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: all var(--transition-fast);
}
.view-tab:hover {
border-color: var(--netflix-text);
color: var(--netflix-text);
}
.view-tab.active {
background: var(--netflix-text);
color: var(--netflix-bg);
border-color: var(--netflix-text);
}
/* ============================================
FLOATING SEARCH BUTTON
============================================ */
.floating-search-btn {
position: fixed;
top: 20px;
right: 20px;
width: 48px;
height: 48px;
background: var(--netflix-red);
border: none;
border-radius: 50%;
color: var(--netflix-text);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: calc(var(--z-header) + 1);
box-shadow: var(--shadow-card);
transition: all var(--transition-base);
}
.floating-search-btn:hover {
transform: scale(1.1);
background: var(--netflix-red-hover);
}
.floating-search-btn svg {
width: 20px;
height: 20px;
}
/* ============================================
BACK TO TOP BUTTON
============================================ */
.back-to-top {
position: fixed;
bottom: 80px;
right: 20px;
width: 48px;
height: 48px;
background: var(--netflix-bg-card);
border: 1px solid var(--netflix-border);
border-radius: 50%;
color: var(--netflix-text);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
visibility: hidden;
transform: translateY(20px);
transition: all var(--transition-base);
z-index: 99;
}
.back-to-top.visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.back-to-top:hover {
background: var(--netflix-red);
border-color: var(--netflix-red);
}
.back-to-top svg {
width: 24px;
height: 24px;
}

View file

@ -0,0 +1,33 @@
/* ============================================
RESPONSIVE GRID OVERRIDES (Final Layout)
============================================ */
/* Mobile (Portrait/Small) - Force 2 Columns for best readability */
@media (max-width: 600px) {
.video-grid {
grid-template-columns: repeat(2, 1fr) !important;
gap: 12px !important;
padding: 16px 12px !important;
}
.video-grid .video-card {
aspect-ratio: 2/3 !important;
}
}
/* Tablet / Landscape Mobile - Balance density */
@media (min-width: 601px) and (max-width: 1024px) {
.video-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)) !important;
gap: 16px !important;
padding: 20px 16px !important;
}
}
/* Desktop - Premium Large Cards (Apple TV+ Style) */
@media (min-width: 1025px) {
.video-grid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)) !important;
gap: 24px !important;
}
}

View file

@ -0,0 +1,513 @@
/* ============================================
KV-Stream - Responsive Styles
PIXEL-PERFECT NETFLIX RESPONSIVENESS
============================================ */
/* ============================================
DESKTOP LARGE (1400px+)
============================================ */
@media (min-width: 1400px) {
:root {
--card-width-desktop: 220px;
}
.video-card {
flex: 0 0 var(--card-width-desktop);
width: var(--card-width-desktop);
}
}
/* ============================================
DESKTOP (1200px - 1400px)
============================================ */
@media (min-width: 1200px) and (max-width: 1399px) {
:root {
--card-width-desktop: 200px;
}
}
/* ============================================
LAPTOP (1024px - 1199px)
============================================ */
@media (min-width: 1024px) and (max-width: 1199px) {
:root {
--card-width-desktop: 180px;
--card-hover-scale: 1.25;
}
.hero__content {
max-width: 50%;
}
}
/* ============================================
TABLET (768px - 1023px)
============================================ */
@media (min-width: 768px) and (max-width: 1023px) {
:root {
--card-width-desktop: 160px;
--header-height: 56px;
--card-hover-scale: 1.2;
}
.hero {
height: 70vh;
}
.hero__content {
max-width: 60%;
bottom: 25%;
}
.hero__title {
font-size: clamp(1.8rem, 4vw, 2.5rem);
}
.hero__description {
-webkit-line-clamp: 2;
}
.netflix-header__nav {
display: none;
}
.hero__poster-float {
display: none !important;
}
.slider-btn {
width: 45px;
}
}
/* ============================================
MOBILE (max-width: 767px)
============================================ */
@media (max-width: 767px) {
:root {
--card-width-desktop: 110px;
--card-gap: 6px;
--row-padding: 3%;
--row-margin: 20px;
--header-height: 48px;
--card-hover-scale: 1;
}
/* ============================================
MOBILE LAYOUT
============================================ */
.app-layout {
padding-bottom: var(--mobile-nav-height);
}
.main-content {
padding-top: 0;
margin-bottom: calc(var(--mobile-nav-height) + env(safe-area-inset-bottom));
}
/* ============================================
NETFLIX MOBILE HEADER
============================================ */
.netflix-header {
height: var(--header-height);
background: linear-gradient(180deg, rgba(0, 0, 0, 0.8) 0%, transparent 100%);
}
.netflix-header.scrolled {
background: rgba(20, 20, 20, 0.98);
}
.netflix-header__logo svg,
.netflix-header__logo img {
height: 22px;
}
.netflix-header__nav {
display: none;
}
.netflix-header__right {
gap: 12px;
}
/* Hide floating search on mobile (use header) */
.floating-search-btn {
display: none;
}
/* ============================================
MOBILE SIDEBAR BOTTOM NAV
============================================ */
.sidebar {
display: flex !important;
position: fixed;
top: auto;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: var(--mobile-nav-height);
flex-direction: row;
justify-content: space-around;
align-items: center;
padding: 0 8px;
padding-bottom: env(safe-area-inset-bottom);
background: #121212;
border-top: 1px solid rgba(51, 51, 51, 0.8);
border-right: none;
z-index: var(--z-mobile-nav);
}
.sidebar__logo {
display: none;
}
.sidebar__nav {
display: flex;
flex-direction: row;
flex: 1;
justify-content: space-around;
align-items: center;
gap: 0;
}
.sidebar__nav-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
width: auto;
height: auto;
padding: 6px 12px;
border-radius: 0;
color: var(--netflix-text-muted);
}
.sidebar__nav-item svg {
width: 20px;
height: 20px;
}
.sidebar__nav-item.active {
color: var(--netflix-text);
background: transparent;
}
.sidebar__nav-item.active::before {
display: none;
}
.sidebar__profile {
display: none;
}
/* ============================================
MOBILE HERO
============================================ */
.hero {
height: 75vh;
min-height: 450px;
margin-bottom: -60px;
}
.hero__gradient-overlay {
background:
linear-gradient(to top, #141414 0%, rgba(20, 20, 20, 0.6) 30%, transparent 60%);
}
.hero__content {
max-width: 100%;
bottom: 15%;
left: var(--row-padding);
right: var(--row-padding);
text-align: center;
align-items: center;
}
.hero__title {
font-size: clamp(1.5rem, 6vw, 2rem);
text-align: center;
}
.hero__description {
font-size: var(--font-size-base);
-webkit-line-clamp: 2;
text-align: center;
}
.hero__metadata {
justify-content: center;
}
.hero__actions {
flex-direction: row;
width: 100%;
justify-content: center;
gap: 8px;
}
.hero__btn {
flex: 1;
max-width: 160px;
padding: 10px 16px;
font-size: var(--font-size-base);
}
.hero__btn svg {
width: 20px;
height: 20px;
}
.hero-controls {
bottom: 10%;
right: 50%;
transform: translateX(50%);
}
.hero-arrow {
display: none;
}
.hero__poster-float {
display: none !important;
}
/* ============================================
MOBILE CARDS - NO HOVER EXPANSION
============================================ */
.video-card {
flex: 0 0 var(--card-width-desktop);
width: var(--card-width-desktop);
}
.video-card:hover .video-card__container,
.video-card:focus .video-card__container {
transform: none;
box-shadow: none;
border-radius: var(--card-radius);
}
.video-card__info {
display: none !important;
}
.video-card__overlay {
opacity: 0 !important;
}
.video-card__play-btn {
width: 40px;
height: 40px;
}
/* ============================================
MOBILE SLIDERS
============================================ */
.slider-section {
margin: var(--row-margin) 0;
}
.slider-section__title {
font-size: var(--font-size-base);
margin-bottom: 10px;
}
.slider-section__title::after {
display: none;
}
.slider-track {
gap: var(--card-gap);
padding-bottom: 10px;
margin-bottom: -10px;
}
.slider-btn {
display: none;
}
/* ============================================
MOBILE VIDEO GRID
============================================ */
.video-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
padding: 0 var(--row-padding);
}
.video-grid .video-card {
flex: auto;
width: 100%;
}
/* ============================================
MOBILE MODALS
============================================ */
.modal {
align-items: flex-end;
}
.modal__container {
width: 100%;
max-width: none;
border-radius: 12px 12px 0 0;
max-height: 90vh;
}
.player-modal__content {
margin: 0;
max-height: 100vh;
border-radius: 0;
}
/* ============================================
MOBILE MISC
============================================ */
.section-banner {
height: 140px;
margin: 16px var(--row-padding);
}
.section-banner__title {
font-size: var(--font-size-lg);
}
.shortcut-card {
min-width: 160px;
height: 100px;
padding: 16px;
}
.shortcut-card h3 {
font-size: var(--font-size-base);
}
.view-tabs {
padding: 0 var(--row-padding);
gap: 8px;
}
.view-tab {
padding: 6px 16px;
font-size: var(--font-size-sm);
}
.back-to-top {
bottom: calc(var(--mobile-nav-height) + 20px);
right: 16px;
width: 40px;
height: 40px;
}
}
/* ============================================
EXTRA SMALL MOBILE (max-width: 480px)
============================================ */
@media (max-width: 480px) {
:root {
--card-width-desktop: 100px;
}
.video-grid {
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.hero__title {
font-size: 1.5rem;
}
.hero__btn {
padding: 8px 12px;
font-size: var(--font-size-sm);
}
.sidebar__nav-item {
padding: 6px 8px;
}
.sidebar__nav-item svg {
width: 18px;
height: 18px;
}
}
/* ============================================
LANDSCAPE MOBILE
============================================ */
@media (max-width: 767px) and (orientation: landscape) {
.hero {
height: 90vh;
min-height: 280px;
}
.hero__content {
bottom: 10%;
}
.hero__title {
font-size: 1.5rem;
}
.hero__description {
display: none;
}
}
/* ============================================
DESKTOP HOVER INTERACTIONS
============================================ */
@media (hover: hover) and (pointer: fine) {
.video-card:hover .video-card__container {
transform: scale(var(--card-hover-scale));
}
.video-card:hover .video-card__info {
opacity: 1;
transform: translateY(0);
}
.video-card:hover .video-card__overlay {
opacity: 1;
}
}
/* ============================================
TOUCH DEVICES - NO HOVER
============================================ */
@media (hover: none) {
.video-card:hover .video-card__container,
.video-card:active .video-card__container {
transform: none;
}
.video-card__info {
display: none !important;
}
.slider-btn {
opacity: 1;
}
}
/* ============================================
PRINT STYLES
============================================ */
@media print {
.netflix-header,
.sidebar,
.hero,
.floating-search-btn,
.back-to-top {
display: none !important;
}
.main-content {
padding: 0;
margin: 0;
}
}

View file

@ -0,0 +1,149 @@
/* ============================================
Search Modal
============================================ */
.search-modal {
position: fixed;
inset: 0;
z-index: var(--z-modal);
display: none;
align-items: flex-start;
justify-content: center;
padding-top: 80px;
}
.search-modal.active {
display: flex;
}
.search-modal__backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.search-modal__content {
position: relative;
width: 90%;
max-width: 900px;
background: var(--apple-bg-secondary);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
border: 1px solid var(--apple-border);
max-height: 80vh;
display: flex;
flex-direction: column;
}
.search-modal__header {
display: flex;
align-items: center;
gap: 16px;
padding: 24px;
border-bottom: 1px solid var(--apple-border);
}
.search-modal__input {
flex: 1;
background: var(--apple-bg-tertiary);
border: 1px solid var(--apple-border);
border-radius: var(--radius-md);
padding: 14px 20px;
font-size: 17px;
color: var(--apple-text-primary);
outline: none;
transition: all var(--transition-base);
}
.search-modal__input:focus {
border-color: var(--apple-accent);
box-shadow: 0 0 0 3px var(--apple-accent-glow);
}
.search-modal__input::placeholder {
color: var(--apple-text-tertiary);
}
.search-modal__close {
background: transparent;
border: none;
color: var(--apple-text-secondary);
cursor: pointer;
padding: 8px;
border-radius: var(--radius-sm);
transition: all var(--transition-base);
}
.search-modal__close:hover {
background: var(--apple-bg-elevated);
color: var(--apple-text-primary);
}
.search-modal__results {
flex: 1;
overflow-y: auto;
padding: 24px;
}
.search-empty,
.search-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--apple-text-tertiary);
}
.search-empty svg,
.search-loading svg {
margin-bottom: 16px;
opacity: 0.5;
}
.search-empty p {
font-size: 15px;
}
.search-loading .loading__spinner {
width: 40px;
height: 40px;
border: 3px solid var(--apple-bg-elevated);
border-top-color: var(--apple-accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.search-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
}
.search-grid .video-card {
flex: 1;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.search-modal {
padding-top: 20px;
}
.search-modal__content {
width: 95%;
max-height: 90vh;
}
.search-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
}
}

View file

@ -0,0 +1,514 @@
/* ============================================
KV-Stream - Feed Styles
New & Hot, Category Views, Navigation
============================================ */
/* New & Hot Feed (Netflix 2025 Specification) */
.new-hot-view {
padding: 20px 0 100px 0;
}
.new-hot-header {
position: sticky;
top: 0;
background: var(--color-bg-primary);
z-index: 100;
padding: 10px 0;
margin-bottom: 20px;
}
.new-hot-tabs {
display: flex;
gap: 12px;
padding: 0 4%;
overflow-x: auto;
scrollbar-width: none;
}
.new-hot-tabs::-webkit-scrollbar {
display: none;
}
.new-hot-tab {
background: #232323;
color: #bcbcbc;
border: none;
padding: 8px 16px;
border-radius: 20px;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
font-size: 14px;
}
.new-hot-tab.active {
background: white;
color: black;
}
.new-hot-feed {
padding: 0 4%;
max-width: 800px;
margin: 0 auto;
}
.new-hot-item {
display: flex;
gap: 15px;
margin-bottom: 40px;
}
.new-hot-item__sidebar {
display: flex;
flex-direction: column;
align-items: center;
width: 45px;
flex-shrink: 0;
}
.new-hot-item__month {
font-size: 12px;
font-weight: 800;
color: #bcbcbc;
}
.new-hot-item__day {
font-size: 24px;
font-weight: 900;
color: white;
}
.new-hot-item__content {
flex: 1;
}
.new-hot-item__card {
background: #181818;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
}
.new-hot-item__img-wrapper {
position: relative;
aspect-ratio: 16 / 9;
}
.new-hot-item__img-wrapper img {
width: 100%;
height: 100%;
object-fit: cover;
}
.new-hot-item__play {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.5);
border: 1px solid white;
border-radius: 50%;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.new-hot-item__details {
padding: 20px;
}
.new-hot-item__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.new-hot-item__title {
font-size: 1.4rem;
font-weight: 800;
color: white;
font-family: 'Outfit', sans-serif;
}
.new-hot-item__actions {
display: flex;
gap: 15px;
}
.new-hot-item__btn {
display: flex;
flex-direction: column;
align-items: center;
background: none;
border: none;
color: white;
font-size: 10px;
font-weight: 700;
cursor: pointer;
gap: 4px;
}
.new-hot-item__desc {
color: #bcbcbc;
font-size: 0.95rem;
line-height: 1.4;
margin-bottom: 15px;
}
.new-hot-item__tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.new-hot-item__tag {
font-size: 11px;
color: white;
font-weight: 700;
}
.new-hot-item__tag:not(:last-child)::after {
content: '•';
margin-left: 8px;
color: #e50914;
}
/* Side Navigation Menu */
.side-menu {
position: fixed;
inset: 0;
z-index: var(--z-modal);
visibility: hidden;
opacity: 0;
transition: var(--transition-base);
}
.side-menu.active {
visibility: visible;
opacity: 1;
}
.side-menu__backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.7);
}
.side-menu__content {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 280px;
max-width: 80vw;
background: var(--color-bg-secondary);
transform: translateX(-100%);
transition: var(--transition-base);
overflow-y: auto;
}
.side-menu.active .side-menu__content {
transform: translateX(0);
}
.side-menu__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-lg);
border-bottom: 1px solid var(--color-border);
}
.side-menu__title {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.side-menu__close {
background: none;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
padding: var(--spacing-sm);
border-radius: var(--radius-md);
}
.side-menu__item {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-md) var(--spacing-lg);
color: var(--color-text-primary);
text-decoration: none;
border-bottom: 1px solid var(--color-border);
transition: var(--transition-fast);
}
.side-menu__item:hover {
background: var(--color-bg-hover);
color: var(--color-accent);
}
.badge--new {
background: var(--color-error);
color: white;
font-size: 10px;
padding: 2px 6px;
border-radius: var(--radius-full);
font-weight: var(--font-weight-bold);
}
/* Search Overlay */
.search-overlay {
position: fixed;
inset: 0;
z-index: var(--z-modal);
background: var(--color-bg-primary);
visibility: hidden;
opacity: 0;
transition: var(--transition-base);
padding: var(--spacing-lg);
}
.search-overlay.active {
visibility: visible;
opacity: 1;
}
.search-overlay__container {
display: flex;
align-items: center;
gap: var(--spacing-md);
max-width: 600px;
margin: 0 auto;
}
.search-overlay__input {
flex: 1;
padding: var(--spacing-md) var(--spacing-lg);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
color: var(--color-text-primary);
font-size: var(--font-size-lg);
}
.search-overlay__input:focus {
outline: none;
border-color: var(--color-accent);
}
.search-overlay__close {
background: none;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
padding: var(--spacing-sm);
}
/* Footer - PhimMoiChill Style */
.footer {
background: var(--color-bg-secondary);
border-top: 1px solid var(--color-border);
padding: var(--spacing-3xl) 0 var(--spacing-lg);
margin-top: var(--spacing-3xl);
}
.footer__container {
max-width: var(--container-max);
margin: 0 auto;
padding: 0 var(--spacing-xl);
display: grid;
grid-template-columns: 1fr 2fr;
gap: var(--spacing-3xl);
}
.footer__brand {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.footer__logo {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.footer__logo-icon {
font-size: 32px;
}
.footer__logo-text {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
}
.footer__logo-accent {
color: var(--color-accent);
}
.footer__tagline {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
line-height: var(--line-height-relaxed);
}
.footer__social {
display: flex;
gap: var(--spacing-sm);
margin-top: var(--spacing-md);
}
.footer__social-link {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-hover);
border-radius: var(--radius-full);
color: var(--color-text-secondary);
transition: var(--transition-base);
}
.footer__social-link:hover {
background: var(--color-accent);
color: white;
}
/* Recommendations Grid */
.recommendations-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.recommendation-card {
background: #2f2f2f;
border-radius: 5px;
overflow: hidden;
cursor: pointer;
transition: background 0.2s, transform 0.2s;
}
.recommendation-card:hover {
background: #3a3a3a;
transform: translateY(-5px);
}
.recommendation-card__img-wrapper {
position: relative;
aspect-ratio: 16 / 9;
}
.recommendation-card img {
width: 100%;
height: 100%;
object-fit: cover;
}
.recommendation-card__play {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
opacity: 0;
transition: opacity 0.3s;
background: rgba(30, 30, 30, 0.5);
border-radius: 50%;
width: 50px;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.recommendation-card:hover .recommendation-card__play {
opacity: 1;
}
.recommendation-card__content {
padding: 16px;
}
.recommendation-card__meta {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 10px;
margin-bottom: 12px;
font-size: 0.9rem;
}
.recommendation-card__desc {
font-size: 0.85rem;
color: #d2d2d2;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 4;
line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Episodes Section - Netflix 2025 */
.modal__episodes {
margin-top: 3rem;
border-top: 1px solid #404040;
padding-top: 2rem;
}
.modal__section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
/* Header - RoPhim Mobile Style */
.header__menu-btn {
background: none;
border: none;
color: var(--color-text-primary);
cursor: pointer;
padding: var(--spacing-sm);
border-radius: var(--radius-md);
transition: var(--transition-fast);
}
.header__menu-btn:hover {
background: var(--color-bg-hover);
}
.header__search-btn {
background: none;
border: none;
color: var(--color-text-primary);
cursor: pointer;
padding: var(--spacing-sm);
border-radius: var(--radius-md);
transition: var(--transition-fast);
}
.header__search-btn:hover {
background: var(--color-bg-hover);
}
.header__logo-accent {
color: var(--color-accent);
}
.header__tagline {
display: block;
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
font-weight: var(--font-weight-regular);
}

View file

@ -0,0 +1,464 @@
/* ============================================
KV-Stream - Hero Section
PIXEL-PERFECT NETFLIX BILLBOARD
============================================ */
/* ============================================
NETFLIX HERO BILLBOARD
============================================ */
.hero-container {
margin-bottom: -100px;
/* Overlap with rows */
}
.hero {
position: relative;
width: 100%;
height: 80vh;
min-height: 500px;
max-height: 800px;
background: var(--netflix-bg);
overflow: hidden;
}
.hero__video-container {
position: absolute;
inset: 0;
}
.hero__backdrop {
width: 100%;
height: 100%;
background-size: cover;
background-position: center 20%;
}
.hero__backdrop img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center 20%;
}
/* ============================================
NETFLIX VIGNETTE GRADIENTS
============================================ */
.hero__gradient-overlay {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 1;
/* Netflix's signature left-to-right + bottom vignette */
background:
linear-gradient(to right, rgba(20, 20, 20, 0.9) 0%, rgba(20, 20, 20, 0.5) 30%, transparent 60%),
linear-gradient(to top, #141414 0%, rgba(20, 20, 20, 0.7) 15%, transparent 40%);
}
.hero__vignette {
position: absolute;
left: 0;
right: 0;
pointer-events: none;
z-index: 1;
}
.hero__vignette--top {
top: 0;
height: 150px;
background: linear-gradient(180deg, rgba(20, 20, 20, 0.5) 0%, transparent 100%);
}
.hero__vignette--bottom {
bottom: 0;
height: 50%;
background: linear-gradient(to top, #141414 0%, rgba(20, 20, 20, 0.8) 20%, transparent 100%);
}
/* ============================================
HERO CONTENT
============================================ */
.hero__content {
position: absolute;
bottom: 30%;
left: var(--row-padding);
z-index: 2;
max-width: 45%;
display: flex;
flex-direction: column;
gap: 16px;
}
.hero__info-layer {
display: flex;
flex-direction: column;
gap: 16px;
animation: fadeSlideUp 0.8s ease-out;
}
@keyframes fadeSlideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Netflix Title */
.hero__title {
font-family: var(--font-heading);
font-size: var(--font-size-hero);
font-weight: var(--font-weight-bold);
line-height: 1.1;
letter-spacing: -0.02em;
color: var(--netflix-text);
text-shadow: 2px 2px 8px rgba(0, 0, 0, 0.8);
margin: 0;
}
/* Metadata (Match %, Year, Rating) */
.hero__metadata {
display: flex;
align-items: center;
gap: 10px;
font-size: var(--font-size-base);
font-weight: var(--font-weight-bold);
}
.hero__match {
color: var(--netflix-green);
}
.hero__age,
.hero__quality {
border: 1px solid rgba(255, 255, 255, 0.4);
padding: 0 4px;
font-size: var(--font-size-xs);
border-radius: 2px;
}
/* Description */
.hero__description {
font-size: var(--font-size-lg);
line-height: 1.4;
color: var(--netflix-text);
text-shadow: 1px 1px 4px rgba(0, 0, 0, 0.7);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* ============================================
NETFLIX HERO BUTTONS
============================================ */
.hero__actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
}
.hero__btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 28px;
border-radius: var(--btn-radius);
border: none;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
cursor: pointer;
transition: all var(--transition-base);
white-space: nowrap;
}
.hero__btn svg {
width: 24px;
height: 24px;
}
/* Play Button - White */
.hero__btn--primary {
background: var(--netflix-text);
color: var(--netflix-bg);
}
.hero__btn--primary:hover {
background: rgba(255, 255, 255, 0.85);
}
/* More Info Button - Gray */
.hero__btn--secondary {
background: rgba(109, 109, 110, 0.7);
color: var(--netflix-text);
}
.hero__btn--secondary:hover {
background: rgba(109, 109, 110, 0.5);
}
/* ============================================
HERO SLIDER CONTROLS
============================================ */
.hero-slider-track {
position: absolute;
inset: 0;
z-index: 0;
}
.hero-slide {
position: absolute;
inset: 0;
}
.hero-controls {
position: absolute;
bottom: 15%;
right: var(--row-padding);
display: flex;
gap: 4px;
z-index: 10;
}
.hero-indicator {
width: 12px;
height: 2px;
border-radius: 0;
background: rgba(255, 255, 255, 0.3);
border: none;
padding: 0;
cursor: pointer;
transition: all var(--transition-base);
}
.hero-indicator.active {
background: var(--netflix-text);
width: 20px;
}
.hero-indicator:hover {
background: rgba(255, 255, 255, 0.6);
}
.hero-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 50px;
height: 100px;
background: rgba(0, 0, 0, 0.3);
border: none;
color: var(--netflix-text);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10;
opacity: 0;
transition: all var(--transition-base);
}
.hero:hover .hero-arrow {
opacity: 1;
}
.hero-arrow:hover {
background: rgba(0, 0, 0, 0.6);
}
.hero-arrow svg {
width: 32px;
height: 32px;
}
.hero-arrow--prev {
left: 0;
}
.hero-arrow--next {
right: 0;
}
/* ============================================
SECTION BANNERS
============================================ */
.section-banner {
position: relative;
height: 180px;
margin: 24px var(--row-padding);
border-radius: var(--card-radius);
background: var(--netflix-bg-card);
overflow: hidden;
display: flex;
align-items: flex-end;
cursor: pointer;
transition: transform var(--transition-base);
}
.section-banner:hover {
transform: scale(1.01);
}
.section-banner__bg {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
transition: transform 0.6s ease;
}
.section-banner:hover .section-banner__bg {
transform: scale(1.05);
}
.section-banner__overlay {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.85), transparent);
}
.section-banner__content {
position: relative;
z-index: 2;
padding: 20px 24px;
width: 100%;
}
.section-banner__title {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
margin: 0;
color: var(--netflix-text);
}
.section-banner__subtitle {
font-size: var(--font-size-sm);
color: var(--netflix-text-secondary);
text-transform: uppercase;
letter-spacing: 1px;
}
/* ============================================
CATEGORY SHORTCUTS
============================================ */
.category-shortcuts-section {
width: 100%;
margin-bottom: 24px;
overflow-x: auto;
display: flex;
scrollbar-width: none;
}
.category-shortcuts-section::-webkit-scrollbar {
display: none;
}
.category-shortcuts-track {
display: inline-flex;
gap: 16px;
padding: 0 var(--row-padding);
}
.shortcut-card {
min-width: 240px;
height: 130px;
background: linear-gradient(135deg, #2a2a2a, #1a1a1a);
border-radius: var(--card-radius);
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 20px;
cursor: pointer;
transition: transform var(--transition-base), background var(--transition-base);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.shortcut-card:hover {
transform: translateY(-4px);
background: linear-gradient(135deg, #333, #222);
border-color: rgba(255, 255, 255, 0.15);
}
.shortcut-card h3 {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--netflix-text);
margin: 0 0 4px;
}
.shortcut-card span {
font-size: var(--font-size-sm);
color: var(--netflix-text-muted);
text-transform: uppercase;
letter-spacing: 1px;
}
.shortcut-icon {
position: absolute;
top: 16px;
right: 16px;
font-size: 20px;
color: rgba(255, 255, 255, 0.2);
transition: all var(--transition-base);
}
.shortcut-card:hover .shortcut-icon {
transform: translateX(4px);
color: var(--netflix-red);
}
/* ============================================
SMALL HERO (Category Pages)
============================================ */
.hero--small {
height: 50vh !important;
min-height: 350px !important;
max-height: 450px !important;
}
/* ============================================
POSTER FLOAT (Portrait Mode)
============================================ */
.hero__poster-float {
position: absolute;
right: 8%;
bottom: 15%;
height: 65%;
aspect-ratio: 2/3;
z-index: 5;
display: none;
animation: posterFloat 1s ease-out;
}
@keyframes posterFloat {
from {
opacity: 0;
transform: translateY(40px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.hero__poster-float img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: var(--card-radius);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.7);
}
.hero--portrait-mode .hero__poster-float {
display: block;
}
.hero--portrait-mode .hero__content {
max-width: 40%;
}

View file

@ -0,0 +1,305 @@
/* ============================================
KV-Stream - Content Sliders
PIXEL-PERFECT NETFLIX HORIZONTAL ROWS
============================================ */
/* ============================================
NETFLIX ROW CONTAINER
============================================ */
.slider-section {
position: relative;
margin: var(--row-margin) 0;
z-index: var(--z-row);
}
.slider-section:hover {
z-index: calc(var(--z-row) + 5);
}
.slider-section__title {
font-family: var(--font-heading);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-medium);
color: var(--netflix-text-secondary);
margin: 0 0 12px var(--row-padding);
transition: color var(--transition-fast);
display: flex;
align-items: center;
}
.slider-section:hover .slider-section__title {
color: var(--netflix-text);
}
/* "Explore All" Link After Title */
.slider-section__title::after {
content: 'Explore All ';
font-size: var(--font-size-xs);
color: var(--netflix-red);
margin-left: 12px;
opacity: 0;
transform: translateX(-10px);
transition: all var(--transition-base);
}
.slider-section:hover .slider-section__title::after {
opacity: 1;
transform: translateX(0);
}
.slider-container {
position: relative;
}
/* ============================================
NETFLIX HORIZONTAL SCROLL TRACK
============================================ */
.slider-track {
display: flex;
gap: var(--card-gap);
padding: 0 var(--row-padding);
padding-bottom: 40px;
/* Space for hover expansion */
margin-bottom: -40px;
overflow-x: auto;
overflow-y: visible;
scroll-behavior: smooth;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
-ms-overflow-style: none;
}
.slider-track::-webkit-scrollbar {
display: none;
}
/* ============================================
NETFLIX SCROLL BUTTONS
============================================ */
.slider-btn {
position: absolute;
top: 0;
bottom: 40px;
width: 55px;
background: rgba(20, 20, 20, 0.5);
border: none;
color: var(--netflix-text);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 30;
opacity: 0;
transition: all var(--transition-base);
}
.slider-container:hover .slider-btn {
opacity: 1;
}
.slider-btn:hover {
background: rgba(20, 20, 20, 0.9);
}
.slider-btn svg {
width: 40px;
height: 40px;
transition: transform var(--transition-fast);
}
.slider-btn:hover svg {
transform: scale(1.25);
}
.slider-btn--left {
left: 0;
border-radius: 0 var(--card-radius) var(--card-radius) 0;
}
.slider-btn--right {
right: 0;
border-radius: var(--card-radius) 0 0 var(--card-radius);
}
/* ============================================
SECTION HEADER
============================================ */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
padding: 0 var(--row-padding);
}
.section-title {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--netflix-text);
display: flex;
align-items: center;
gap: 8px;
}
.section-title::before {
content: '';
width: 4px;
height: 20px;
background: var(--netflix-red);
border-radius: 2px;
}
.section-link {
font-size: var(--font-size-sm);
color: var(--netflix-text-secondary);
transition: color var(--transition-fast);
text-decoration: none;
}
.section-link:hover {
color: var(--netflix-text);
}
/* ============================================
VIDEO GRID (For Search/Categories)
============================================ */
.video-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--card-width-desktop), 1fr));
gap: var(--card-gap);
padding: 0 var(--row-padding);
}
/* ============================================
INTEREST CARDS (Quick Category Filters)
============================================ */
.interest-section {
padding: 24px var(--row-padding);
}
.interest-cards {
display: flex;
gap: 12px;
overflow-x: auto;
scrollbar-width: none;
}
.interest-cards::-webkit-scrollbar {
display: none;
}
.interest-card {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 20px;
border: 1px solid var(--netflix-border);
border-radius: 20px;
background: transparent;
color: var(--netflix-text);
cursor: pointer;
transition: all var(--transition-base);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
}
.interest-card:hover {
background: var(--netflix-text);
color: var(--netflix-bg);
border-color: var(--netflix-text);
}
.interest-card__icon {
font-size: 18px;
}
/* ============================================
NETFLIX TOP 10 SECTION
============================================ */
.top10-section {
margin: var(--row-margin) 0;
position: relative;
}
.top10-track {
display: flex;
gap: 12px;
padding: 0 var(--row-padding);
padding-bottom: 40px;
margin-bottom: -40px;
overflow-x: auto;
scrollbar-width: none;
}
.top10-track::-webkit-scrollbar {
display: none;
}
.top10-item {
position: relative;
flex: 0 0 auto;
display: flex;
align-items: flex-end;
}
.top10-number {
font-size: 120px;
font-weight: 900;
line-height: 0.8;
color: var(--netflix-bg);
-webkit-text-stroke: 3px var(--netflix-text-muted);
margin-right: -30px;
z-index: 0;
user-select: none;
}
.top10-item .video-card {
z-index: 1;
}
/* ============================================
SECTION TITLE STYLES
============================================ */
.section-title-apple {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--netflix-text);
margin-bottom: 16px;
padding-left: var(--row-padding);
}
.section-more {
color: var(--netflix-text-secondary);
text-decoration: none;
font-size: var(--font-size-sm);
transition: color var(--transition-fast);
}
.section-more:hover {
color: var(--netflix-text);
}
.movie-section {
padding: 0 var(--row-padding) 24px;
}
.movie-row {
display: flex;
gap: var(--card-gap);
overflow-x: auto;
padding: 16px var(--row-padding);
scroll-behavior: smooth;
scrollbar-width: none;
}
.movie-row::-webkit-scrollbar {
display: none;
}
.movie-row .video-card {
flex: 0 0 auto;
width: var(--card-width-desktop);
}

View file

@ -0,0 +1,109 @@
/* ============================================
KV-Stream - CSS Variables
PIXEL-PERFECT NETFLIX DESIGN TOKENS
============================================ */
:root {
/* === Netflix Exact Color Palette === */
--netflix-bg: #141414;
--netflix-bg-card: #181818;
--netflix-bg-elevated: #232323;
--netflix-bg-header: rgba(20, 20, 20, 0);
--netflix-bg-header-scrolled: rgba(20, 20, 20, 0.95);
--netflix-red: #e50914;
--netflix-red-hover: #f40612;
--netflix-red-dark: #b20710;
--netflix-text: #ffffff;
--netflix-text-secondary: #b3b3b3;
--netflix-text-muted: #8c8c8c;
--netflix-text-dim: #666666;
--netflix-green: #46d369;
--netflix-border: rgba(255, 255, 255, 0.1);
/* Legacy compatibility aliases */
--color-bg-primary: var(--netflix-bg);
--color-bg-secondary: var(--netflix-bg-card);
--color-bg-tertiary: var(--netflix-bg-elevated);
--color-bg-elevated: var(--netflix-bg-elevated);
--color-bg-card: var(--netflix-bg-card);
--color-text-primary: var(--netflix-text);
--color-text-secondary: var(--netflix-text-secondary);
--color-text-tertiary: var(--netflix-text-muted);
--color-accent: var(--netflix-red);
--color-border: var(--netflix-border);
--apple-bg-primary: var(--netflix-bg);
--apple-text-primary: var(--netflix-text);
--apple-accent: var(--netflix-red);
/* === Netflix Card Specifications === */
--card-width-desktop: 200px;
--card-width-tablet: 160px;
--card-width-mobile: 110px;
--card-aspect-ratio: 2 / 3;
/* Portrait posters */
--card-gap: 8px;
--card-radius: 4px;
--card-hover-scale: 1.3;
--card-hover-delay: 300ms;
/* === Netflix Layout Specifications === */
--header-height: 68px;
--header-height-mobile: 48px;
--row-padding: 4%;
--row-margin: 3vw;
--mobile-nav-height: 56px;
/* === Netflix Typography (Netflix Sans fallback) === */
--font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
--font-heading: 'Helvetica Neue', Helvetica, Arial, sans-serif;
--font-size-xs: 11px;
--font-size-sm: 13px;
--font-size-base: 14px;
--font-size-lg: 16px;
--font-size-xl: 18px;
--font-size-2xl: 20px;
--font-size-3xl: 24px;
--font-size-4xl: 32px;
--font-size-hero: clamp(2rem, 4vw, 3.5rem);
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--line-height-tight: 1.1;
--line-height-normal: 1.4;
--line-height-relaxed: 1.6;
/* === Netflix Button Specs === */
--btn-height: 42px;
--btn-height-sm: 32px;
--btn-radius: 4px;
--btn-padding: 0 24px;
/* === Netflix Shadows === */
--shadow-card: 0 4px 16px rgba(0, 0, 0, 0.5);
--shadow-card-hover: 0 8px 32px rgba(0, 0, 0, 0.7);
--shadow-dropdown: 0 2px 10px rgba(0, 0, 0, 0.8);
--shadow-header: 0 0 10px rgba(0, 0, 0, 0.5);
/* === Netflix Transitions === */
--transition-fast: 150ms ease;
--transition-base: 250ms ease;
--transition-card: 300ms cubic-bezier(0.2, 0, 0.2, 1);
--transition-hover-delay: 300ms;
/* === Z-Index Scale === */
--z-base: 0;
--z-card: 10;
--z-card-hover: 50;
--z-row: 20;
--z-header: 100;
--z-dropdown: 150;
--z-modal: 1000;
--z-mobile-nav: 200;
}

1060
frontend/styles/watch.css Normal file

File diff suppressed because it is too large Load diff

25
frontend/vite.config.js Normal file
View file

@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import { resolve } from 'path'
export default defineConfig({
root: '.',
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true
}
}
},
build: {
outDir: 'dist',
assetsDir: 'assets',
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html'),
watch: resolve(__dirname, 'watch.html')
}
}
}
})

401
frontend/watch.html Normal file
View file

@ -0,0 +1,401 @@
<!DOCTYPE html>
<html class="dark" lang="en">
<head>
<meta charset="utf-8" />
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
<title>StreamFlix - Movie Details</title>
<meta name="description" content="StreamFlix - Watch Movies Online">
<meta name="theme-color" content="#141414">
<meta name="referrer" content="no-referrer">
<!-- Fonts -->
<link href="https://fonts.googleapis.com" rel="preconnect" />
<link crossorigin="" href="https://fonts.gstatic.com" rel="preconnect" />
<link href="https://fonts.googleapis.com/css2?family=Spline+Sans:wght@300;400;500;600;700&display=swap"
rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap"
rel="stylesheet" />
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<!-- Theme Config -->
<script>
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"primary": "#ea2a33",
"background-light": "#f8f6f6",
"background-dark": "#141414",
"surface-dark": "#181818",
},
fontFamily: {
"display": ["Spline Sans", "sans-serif"]
},
borderRadius: { "DEFAULT": "0.25rem", "lg": "0.5rem", "xl": "0.75rem", "full": "9999px" },
},
},
}
</script>
<style>
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #141414;
}
::-webkit-scrollbar-thumb {
background: #333;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* Loading spinner */
.loading-spinner {
border: 3px solid rgba(255, 255, 255, 0.1);
border-top-color: #ea2a33;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
<!-- PWA Meta Tags -->
<link rel="manifest" href="/manifest.json">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-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="StreamFlix">
<link rel="icon" type="image/png" href="/icons/icon-512.png">
<link rel="apple-touch-icon" href="/icons/icon-512.png">
</head>
<body class="bg-background-light dark:bg-background-dark text-slate-900 dark:text-white font-display overflow-x-hidden">
<!-- Navigation -->
<nav class="fixed top-0 w-full z-50 transition-all duration-300 bg-gradient-to-b from-black/80 to-transparent pointer-events-none"
id="watchHeader">
<div class="px-4 md:px-12 py-4 pt-6 flex items-center justify-between">
<!-- Back / Close Button -->
<button
class="pointer-events-auto text-white flex items-center justify-center p-2 rounded-full hover:bg-white/10 transition-colors"
id="watchBackBtn">
<span class="material-symbols-outlined text-3xl shadow-md">arrow_back</span>
</button>
<!-- Right Actions -->
<div class="flex items-center gap-4 pointer-events-auto">
<button
class="text-white flex items-center justify-center p-2 rounded-full hover:bg-white/10 transition-colors">
<span class="material-symbols-outlined text-2xl">cast</span>
</button>
<button
class="text-white flex items-center justify-center p-2 rounded-full hover:bg-white/10 transition-colors"
id="searchBtn">
<span class="material-symbols-outlined text-2xl">search</span>
</button>
<!-- Desktop Profile (Hidden on mobile) -->
<div class="hidden md:block size-8 rounded-md bg-cover bg-center cursor-pointer border border-white/20 hover:border-white/60 transition-all"
style="background-image: url('https://wallpapers.com/images/hd/netflix-profile-pictures-1000-x-1000-qo9h82134t9nv0j0.jpg');">
</div>
</div>
</div>
</nav>
<!-- Hero Section (Movie Background) -->
<div class="relative w-full aspect-[4/5] md:aspect-auto md:h-[85vh] md:min-h-[600px] overflow-hidden group/hero"
id="heroSection">
<!-- Hero Background -->
<div class="absolute inset-0 bg-cover bg-center bg-no-repeat transition-transform duration-[10s] ease-out group-hover/hero:scale-105"
id="heroBg" style="background-image: url('');"></div>
<!-- Gradients -->
<div class="absolute inset-0 bg-gradient-to-t from-background-dark via-background-dark/40 to-transparent"></div>
<div
class="absolute inset-0 bg-gradient-to-r from-background-dark/90 via-background-dark/30 to-transparent hidden md:block">
</div>
<!-- Play Button (Centered Mobile) -->
<div class="absolute inset-0 flex items-center justify-center pointer-events-none md:hidden z-20">
<div class="size-16 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center pointer-events-auto cursor-pointer hover:scale-105 transition-transform border border-white/30"
id="mobilePlayBtn">
<span class="material-symbols-outlined text-white text-4xl fill-1"
style="font-variation-settings: 'FILL' 1;">play_arrow</span>
</div>
</div>
<!-- Hero Content (Desktop) -->
<div
class="absolute bottom-0 left-0 w-full px-4 md:px-12 pb-12 md:pb-20 z-10 flex flex-col justify-end h-full hidden md:flex">
<div class="max-w-2xl">
<!-- Movie Title -->
<h1 class="text-5xl md:text-7xl font-black text-white tracking-tight leading-[0.9] mb-4 drop-shadow-lg"
id="movieTitleDesktop">Loading...</h1>
<!-- Meta Data -->
<div class="flex items-center flex-wrap gap-4 text-white/90 text-sm md:text-base font-medium mb-6">
<span class="text-[#46d369] font-bold" id="movieMatchDesktop">98% Match</span>
<span class="text-gray-300" id="movieYearDesktop">2024</span>
<span class="border border-white/40 px-1.5 py-0.5 text-xs rounded text-gray-300"
id="movieRatingDesktop">PG-13</span>
<span class="flex items-center gap-1 text-gray-300"><span
class="material-symbols-outlined text-sm">hd</span> <span
id="movieQualityDesktop">HD</span></span>
</div>
<!-- Synopsis -->
<p class="text-white/80 text-lg md:text-xl leading-relaxed font-medium mb-8 drop-shadow-md line-clamp-3"
id="movieDescriptionDesktop">Loading description...</p>
<!-- Actions -->
<div class="flex items-center gap-4">
<button
class="flex items-center gap-2 bg-white text-black px-8 py-3 rounded hover:bg-white/90 transition-colors font-bold text-lg min-w-[140px] justify-center"
id="playBtnDesktop">
<span class="material-symbols-outlined text-3xl"
style="font-variation-settings: 'FILL' 1;">play_arrow</span>
Play
</button>
<button
class="flex items-center gap-2 bg-gray-500/40 hover:bg-gray-500/50 backdrop-blur-sm text-white px-6 py-3 rounded transition-colors font-bold text-lg min-w-[160px] justify-center"
id="addListBtnDesktop">
<span class="material-symbols-outlined text-2xl">add</span>
<span>My List</span>
</button>
</div>
</div>
</div>
</div>
<!-- Mobile Content (Title, Meta, Actions) -->
<div class="relative px-5 -mt-20 z-10 flex flex-col gap-5 md:hidden text-white">
<!-- Title and Badges -->
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2 mb-1">
<span class="material-symbols-outlined text-primary text-2xl"
style="font-variation-settings: 'FILL' 1;">movie</span>
<span class="text-xs font-bold tracking-[0.2em] text-gray-300 uppercase">Series</span>
</div>
<h1 class="text-4xl font-bold tracking-tight text-white leading-none" id="movieTitleMobile">Loading...</h1>
<!-- Metadata Row -->
<div class="flex items-center flex-wrap gap-x-3 gap-y-2 mt-1 text-sm text-gray-300 font-medium">
<span class="text-[#46d369] font-bold" id="movieMatchMobile">98% Match</span>
<span id="movieYearMobile">2024</span>
<span class="bg-gray-700/60 px-1.5 py-0.5 rounded text-xs border border-gray-600"
id="movieRatingMobile">TV-MA</span>
<span id="movieDurationMobile">2h 30m</span>
<span
class="border border-gray-500 rounded px-1 text-[10px] font-bold h-4 flex items-center leading-none"
id="movieQualityMobile">HD</span>
</div>
</div>
<!-- Action Buttons -->
<div class="flex flex-col gap-3 mt-2">
<button
class="flex items-center justify-center gap-2 w-full bg-primary hover:bg-red-700 text-white font-bold py-3 px-4 rounded transition-colors active:scale-[0.98]"
id="playBtnMobile">
<span class="material-symbols-outlined fill-1"
style="font-variation-settings: 'FILL' 1;">play_arrow</span>
<span>Resume</span>
</button>
<div class="flex items-center gap-3 w-full">
<button
class="flex items-center justify-center gap-2 flex-1 bg-[#2b2b2b] hover:bg-[#383838] text-white font-semibold py-3 px-4 rounded transition-colors active:scale-[0.98]"
id="addListBtnMobile">
<span class="material-symbols-outlined">add</span>
<span>My List</span>
</button>
<button
class="flex flex-none items-center justify-center w-12 h-12 bg-[#2b2b2b] hover:bg-[#383838] text-white rounded transition-colors active:scale-[0.98]"
id="shareBtnMobile">
<span class="material-symbols-outlined">share</span>
</button>
</div>
</div>
<!-- Synopsis -->
<div class="mb-4">
<p class="text-sm text-gray-300 leading-relaxed line-clamp-3" id="movieDescriptionMobile">Loading...</p>
</div>
</div>
<!-- Video Player (Hidden by default, shown when playing) -->
<div class="fixed inset-0 z-[100] bg-black hidden" id="videoPlayerContainer">
<button class="absolute top-4 right-4 z-10 text-white hover:text-gray-300" id="closePlayer">
<span class="material-symbols-outlined text-3xl">close</span>
</button>
<div class="w-full h-full" id="videoPlayer">
<div class="w-full h-full flex items-center justify-center" id="playerLoading">
<div class="flex flex-col items-center gap-4">
<div class="loading-spinner"></div>
<span class="text-gray-400">Loading stream...</span>
</div>
</div>
</div>
</div>
<!-- Main Content Container (Removed negative margin on mobile to prevent overlap) -->
<div class="relative z-20 md:-mt-10 px-4 md:px-12 pb-20" id="mainContent">
<!-- Details Layout (Column) -->
<div class="flex flex-col gap-12">
<!-- Top Section: Details & Episodes (Full Width) -->
<div class="space-y-10 w-full">
<!-- Stats & Tags -->
<div class="flex flex-wrap gap-x-8 gap-y-4 text-sm text-gray-400 border-t border-white/10 pt-6"
id="movieTags">
<!-- Genre tags will be injected here -->
</div>
<!-- Tab Navigation -->
<div class="flex items-center gap-8 border-b border-white/10" id="tabNav">
<button class="text-lg font-bold text-white border-b-4 border-primary pb-3 tab-btn active"
data-tab="episodes">Episodes</button>
<button class="text-lg font-medium text-gray-400 pb-3 hover:text-white transition-colors tab-btn"
data-tab="details">Details</button>
</div>
<!-- Episodes Panel -->
<div class="tab-panel" id="episodesPanel">
<!-- Season Select -->
<div class="flex items-center gap-4 mb-6" id="seasonSelectContainer">
<select
class="bg-surface-dark border border-white/20 text-white px-4 py-2 rounded text-sm focus:border-primary focus:ring-0"
id="seasonSelect">
<option value="1">Season 1</option>
</select>
<span class="text-gray-400 text-sm" id="episodeCount">Episodes</span>
</div>
<!-- Episode Grid -->
<div class="space-y-4" id="episodesGrid">
<!-- Episode cards will be injected here -->
<div class="text-gray-400 text-center py-8" id="episodesLoading">
<div class="loading-spinner mx-auto mb-4"></div>
Loading episodes...
</div>
</div>
</div>
<!-- Trailers Panel -->
<div class="tab-panel hidden" id="trailersPanel">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6" id="trailersGrid">
<!-- Trailers will be injected here -->
<p class="text-gray-400 col-span-full text-center py-8">No trailers available</p>
</div>
</div>
<!-- Details Panel -->
<div class="tab-panel hidden" id="detailsPanel">
<!-- Cast Carousel -->
<div class="space-y-4 mb-8">
<h3 class="text-xl font-bold text-white">Top Cast</h3>
<div class="flex gap-4 overflow-x-auto pb-4 no-scrollbar snap-x" id="castCarousel">
<!-- Cast will be injected here -->
</div>
</div>
<!-- Additional Details -->
<div class="bg-surface-dark p-6 rounded-xl border border-white/5" id="additionalDetails">
<h3 class="text-xl font-bold text-white mb-4">About this movie</h3>
<div class="space-y-3 text-gray-400" id="detailsList">
<!-- Details will be injected here -->
</div>
</div>
</div>
</div>
<!-- Bottom Section: More Like This (Smaller Cards) -->
<div class="space-y-6 pt-8 border-t border-white/10">
<!-- Recommendations Section -->
<div class="space-y-8" id="recommendationsContainer">
<!-- Dynamic sections will be injected here -->
</div>
</div>
</div>
</div>
<!-- Mobile Bottom Navigation -->
<nav class="fixed bottom-0 left-0 right-0 z-50 bg-[#121212]/95 backdrop-blur-lg border-t border-white/5 pb-5 pt-3 px-6 md:hidden"
id="mobileBottomNav">
<div class="flex justify-between items-center max-w-lg mx-auto">
<button class="flex flex-col items-center gap-1 text-white cursor-pointer group nav-item" data-view="home">
<span class="material-symbols-outlined text-2xl">home</span>
<span class="text-[10px] font-medium">Home</span>
</button>
<button
class="flex flex-col items-center gap-1 text-gray-400 hover:text-white transition-colors cursor-pointer group nav-item"
data-view="cinema">
<span class="material-symbols-outlined text-2xl">video_library</span>
<span class="text-[10px] font-medium">New & Hot</span>
</button>
<button
class="flex flex-col items-center gap-1 text-gray-400 hover:text-white transition-colors cursor-pointer group nav-item"
data-view="mylist">
<span class="material-symbols-outlined text-2xl">playlist_play</span>
<span class="text-[10px] font-medium">My List</span>
</button>
<button
class="flex flex-col items-center gap-1 text-gray-400 hover:text-white transition-colors cursor-pointer group nav-item"
data-view="search">
<span class="material-symbols-outlined text-2xl">search</span>
<span class="text-[10px] font-medium">Search</span>
</button>
</div>
</nav>
<!-- Search Modal -->
<div class="fixed inset-0 z-[150] bg-black/90 backdrop-blur-sm hidden" id="searchModal">
<div class="w-full max-w-3xl mx-auto px-4 pt-20">
<div class="flex items-center gap-4 mb-6">
<span class="material-symbols-outlined text-white/50 text-3xl">search</span>
<input type="text" id="searchInput"
class="flex-1 bg-transparent border-none text-white text-2xl placeholder-white/30 focus:ring-0 focus:outline-none"
placeholder="Titles, people, genres" autocomplete="off">
<button class="text-white/50 hover:text-white" id="closeSearch">
<span class="material-symbols-outlined text-3xl">close</span>
</button>
</div>
<div id="searchResults" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
<!-- Search results will be injected here -->
</div>
</div>
</div>
<!-- Toast Container -->
<div class="fixed bottom-4 right-4 z-[200] flex flex-col gap-2" id="toastContainer"></div>
<!-- Video Player Dependencies -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.7/hls.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/artplayer/5.3.0/artplayer.js"></script>
<!-- App Scripts -->
<script src="/js/history-service.js"></script>
<script type="module" src="/scripts/search.js"></script>
<script type="module" src="/scripts/watch.js"></script>
</body>
</html>