chore: initial commit with premium Liquid Glass UI and performance optimizations
This commit is contained in:
commit
6ca4a168be
69 changed files with 19323 additions and 0 deletions
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal 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
52
Dockerfile
Normal 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
96
README.md
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
# StreamFlow - Premium Cinema Experience 🎬
|
||||
|
||||
[](https://hub.docker.com/r/vndangkhoa/streamflow)
|
||||
[](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
42
backend/Dockerfile
Normal 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
159
backend/auto_updater.py
Normal 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
92
backend/cache.py
Normal 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()
|
||||
328
backend/category_discovery.py
Normal file
328
backend/category_discovery.py
Normal 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
234
backend/category_scraper.py
Normal 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
98
backend/database.py
Normal 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
1118
backend/main.py
Normal file
File diff suppressed because it is too large
Load diff
30
backend/requirements.txt
Normal file
30
backend/requirements.txt
Normal 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
650
backend/rophim_scraper.py
Normal 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())
|
||||
|
||||
47
backend/static/assets/Toast-BwR22KmJ.js
Normal file
47
backend/static/assets/Toast-BwR22KmJ.js
Normal file
File diff suppressed because one or more lines are too long
392
backend/static/assets/main-B16Z87Li.js
Normal file
392
backend/static/assets/main-B16Z87Li.js
Normal file
File diff suppressed because one or more lines are too long
104
backend/static/assets/watch-Baf19X1S.js
Normal file
104
backend/static/assets/watch-Baf19X1S.js
Normal file
File diff suppressed because one or more lines are too long
232
backend/static/history.html
Normal file
232
backend/static/history.html
Normal 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
341
backend/static/index.html
Normal 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
305
backend/static/info.html
Normal 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>
|
||||
141
backend/static/js/history-service.js
Normal file
141
backend/static/js/history-service.js
Normal 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();
|
||||
}
|
||||
17
backend/static/manifest.json
Normal file
17
backend/static/manifest.json
Normal 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
20
backend/static/sw.js
Normal 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
401
backend/static/watch.html
Normal 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
BIN
backend/streamflow.db
Normal file
Binary file not shown.
210
backend/tmdb_service.py
Normal file
210
backend/tmdb_service.py
Normal 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
150
backend/video_extractor.py
Normal 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
35
docker-compose.yml
Normal 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
18
frontend/Dockerfile
Normal 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
342
frontend/index.html
Normal 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
972
frontend/package-lock.json
generated
Normal 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
17
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
232
frontend/public/history.html
Normal file
232
frontend/public/history.html
Normal 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
305
frontend/public/info.html
Normal 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>
|
||||
141
frontend/public/js/history-service.js
Normal file
141
frontend/public/js/history-service.js
Normal 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();
|
||||
}
|
||||
17
frontend/public/manifest.json
Normal file
17
frontend/public/manifest.json
Normal 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
20
frontend/public/sw.js
Normal 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
252
frontend/scripts/api.js
Normal 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();
|
||||
101
frontend/scripts/category-system.js
Normal file
101
frontend/scripts/category-system.js
Normal 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
|
||||
};
|
||||
}
|
||||
399
frontend/scripts/components/HeroSection.js
Normal file
399
frontend/scripts/components/HeroSection.js
Normal 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);
|
||||
}
|
||||
|
||||
195
frontend/scripts/components/InfoModal.js
Normal file
195
frontend/scripts/components/InfoModal.js
Normal 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;
|
||||
}
|
||||
72
frontend/scripts/components/NewAndHot.js
Normal file
72
frontend/scripts/components/NewAndHot.js
Normal 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));
|
||||
});
|
||||
}
|
||||
146
frontend/scripts/components/SearchBar.js
Normal file
146
frontend/scripts/components/SearchBar.js
Normal 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;
|
||||
}
|
||||
60
frontend/scripts/components/Toast.js
Normal file
60
frontend/scripts/components/Toast.js
Normal 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;
|
||||
}
|
||||
241
frontend/scripts/components/VideoCard.js
Normal file
241
frontend/scripts/components/VideoCard.js
Normal 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;
|
||||
}
|
||||
241
frontend/scripts/components/VideoPlayer.js
Normal file
241
frontend/scripts/components/VideoPlayer.js
Normal 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
145
frontend/scripts/info.js
Normal 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();
|
||||
200
frontend/scripts/keyboard-nav.js
Normal file
200
frontend/scripts/keyboard-nav.js
Normal 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
3087
frontend/scripts/main.js
Normal file
File diff suppressed because it is too large
Load diff
196
frontend/scripts/search.js
Normal file
196
frontend/scripts/search.js
Normal 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();
|
||||
}
|
||||
203
frontend/scripts/services/imageCache.js
Normal file
203
frontend/scripts/services/imageCache.js
Normal 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
1076
frontend/scripts/watch.js
Normal file
File diff suppressed because it is too large
Load diff
196
frontend/styles/base.css
Normal file
196
frontend/styles/base.css
Normal 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;
|
||||
}
|
||||
112
frontend/styles/components/buttons.css
Normal file
112
frontend/styles/components/buttons.css
Normal 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;
|
||||
}
|
||||
502
frontend/styles/components/cards.css
Normal file
502
frontend/styles/components/cards.css
Normal 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);
|
||||
}
|
||||
132
frontend/styles/components/forms.css
Normal file
132
frontend/styles/components/forms.css
Normal 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);
|
||||
}
|
||||
50
frontend/styles/components/loading.css
Normal file
50
frontend/styles/components/loading.css
Normal 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);
|
||||
}
|
||||
413
frontend/styles/components/modals.css
Normal file
413
frontend/styles/components/modals.css
Normal 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;
|
||||
}
|
||||
45
frontend/styles/grid-patch.css
Normal file
45
frontend/styles/grid-patch.css
Normal 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
41
frontend/styles/index.css
Normal 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
266
frontend/styles/layout.css
Normal 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;
|
||||
}
|
||||
33
frontend/styles/responsive-patch.css
Normal file
33
frontend/styles/responsive-patch.css
Normal 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;
|
||||
}
|
||||
}
|
||||
513
frontend/styles/responsive.css
Normal file
513
frontend/styles/responsive.css
Normal 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;
|
||||
}
|
||||
}
|
||||
149
frontend/styles/search-modal.css
Normal file
149
frontend/styles/search-modal.css
Normal 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;
|
||||
}
|
||||
}
|
||||
514
frontend/styles/sections/feed.css
Normal file
514
frontend/styles/sections/feed.css
Normal 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);
|
||||
}
|
||||
464
frontend/styles/sections/hero.css
Normal file
464
frontend/styles/sections/hero.css
Normal 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%;
|
||||
}
|
||||
305
frontend/styles/sections/sliders.css
Normal file
305
frontend/styles/sections/sliders.css
Normal 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);
|
||||
}
|
||||
109
frontend/styles/variables.css
Normal file
109
frontend/styles/variables.css
Normal 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
1060
frontend/styles/watch.css
Normal file
File diff suppressed because it is too large
Load diff
25
frontend/vite.config.js
Normal file
25
frontend/vite.config.js
Normal 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
401
frontend/watch.html
Normal 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>
|
||||
Loading…
Reference in a new issue