commit 6ca4a168be139f079a112ba94ede82aa82995d29 Author: Khoa.vo Date: Tue Dec 23 18:30:09 2025 +0700 chore: initial commit with premium Liquid Glass UI and performance optimizations diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b38920 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0d52bc3 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e6bbe67 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# StreamFlow - Premium Cinema Experience 🎬 + +[![Docker Image](https://img.shields.io/docker/v/vndangkhoa/streamflow?label=DockerHub&logo=docker)](https://hub.docker.com/r/vndangkhoa/streamflow) +[![GitHub](https://img.shields.io/github/v/release/vndangkhoa/Streamflow?label=GitHub&logo=github)](https://github.com/vndangkhoa/Streamflow) + +StreamFlow is a high-fidelity movie streaming application designed for NAS enthusiasts and home cinema lovers. It combines a premium **Apple TV+ inspired aesthetic** with a lightweight, high-performance backend, now consolidated into a **single Docker image** for effortless deployment. + +--- + +## 💎 Premium Features + +### 🧊 Liquid Glass UI +- **Immersive Design**: Deep frosted-glass effects (40px+ blur) with Apple-style deep occlusion. +- **Micro-interactions**: 1px translucent borders, 3D card scaling, and smooth state transitions. +- **Cinematic Hero**: Dynamic full-screen backdrops that change based on featured content. +- **Dark Mode Perfected**: A custom OLED-friendly palette optimized for theater viewing. + +### 📱 Native PWA Experience +- **Installable**: Full Progressive Web App (PWA) support. Add to Home Screen on iOS and Android. +- **Native Feel**: Runs in standalone mode without browser chrome for a truly native app experience. +- **Custom Icons**: High-resolution 'Liquid Glass' app icons for your home screen. + +### 🐳 Unified NAS Architecture +- **Single-Container Deployment**: Backend and Frontend are bundled into one efficient image. +- **Low Overhead**: Zero-bypass streaming shifts heavy video load 100% to the client side. +- **NAS-Optimized**: Designed to run smoothly on Synology, QNAP, and Unraid (linux/amd64). + +### 🍅 Rich Metadata +- **Rotten Tomatoes Ratings**: Real-time integration of "Fresh" and "Rotten" score badges. +- **Smart Catalog**: Automatically categories Phim Lẻ, Phim Bộ, Hoạt Hình, and Cinema releases. +- **Watch History**: Cross-device history and "My List" bookmarks saved to Redis. + +--- + +## � 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. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..4dd7ff8 --- /dev/null +++ b/backend/Dockerfile @@ -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"] + diff --git a/backend/auto_updater.py b/backend/auto_updater.py new file mode 100644 index 0000000..1640b77 --- /dev/null +++ b/backend/auto_updater.py @@ -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}") diff --git a/backend/cache.py b/backend/cache.py new file mode 100644 index 0000000..c4cdf50 --- /dev/null +++ b/backend/cache.py @@ -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() diff --git a/backend/category_discovery.py b/backend/category_discovery.py new file mode 100644 index 0000000..ab46436 --- /dev/null +++ b/backend/category_discovery.py @@ -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) diff --git a/backend/category_scraper.py b/backend/category_scraper.py new file mode 100644 index 0000000..869abe1 --- /dev/null +++ b/backend/category_scraper.py @@ -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': [] + } diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..8844b1c --- /dev/null +++ b/backend/database.py @@ -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 diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..f7a2963 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,1118 @@ +""" +StreamFlow Backend - FastAPI Application +High-performance video streaming with yt-dlp integration +""" +from fastapi import FastAPI, HTTPException, Depends, Query +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel, HttpUrl +from typing import Optional, Dict, List +import time +import os +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse, JSONResponse + +from cache import cache +from video_extractor import extractor, VideoInfo +from database import init_db, get_db, VideoRepository, Video + +# Initialize FastAPI app +app = FastAPI( + title="KV-Netflix API", + description="Ad-free video streaming with movie catalog", + version="1.0.0" +) + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# Request/Response models +class ExtractRequest(BaseModel): + url: str + quality: Optional[str] = None # e.g., "1080p", "720p" + + +class ExtractResponse(BaseModel): + title: str + thumbnail: str + duration: int + stream_url: str + resolution: str + cached: bool + extraction_time_ms: int + + +class VideoCreate(BaseModel): + title: str + source_url: str + description: Optional[str] = None + thumbnail: Optional[str] = None + category: Optional[str] = None + + +class VideoResponse(BaseModel): + id: int + title: str + source_url: str + thumbnail: Optional[str] + duration: int + resolution: Optional[str] + category: Optional[str] + + class Config: + from_attributes = True + + +# Startup event +@app.on_event("startup") +async def startup(): + init_db() + print("✓ KV-Netflix Database initialized") + + # Auto-update check disabled on startup (can cause hangs) + # Use POST /api/admin/update to manually trigger updates + print("ℹ Use /api/admin/update to update dependencies") + + +# Health check +@app.get("/api/health") +async def health_check(): + return { + "status": "healthy", + "cache_type": "redis" if cache.is_redis else "memory", + "version": "1.0.0" + } + + +# ============================================ +# Admin Endpoints - Version & Updates +# ============================================ + +@app.get("/api/admin/version") +async def get_versions(): + """Get versions of all managed dependencies""" + from auto_updater import get_all_versions + import asyncio + + loop = asyncio.get_event_loop() + versions = await loop.run_in_executor(None, get_all_versions) + + return { + "status": "ok", + "versions": versions + } + + +@app.post("/api/admin/update") +async def trigger_update(package: str = None): + """Trigger manual update of dependencies + + Args: + package: Specific package to update (yt-dlp, playwright, all) + If not specified, updates all packages + """ + from auto_updater import update_yt_dlp, update_playwright, update_all_dependencies + import asyncio + + loop = asyncio.get_event_loop() + + if package == "yt-dlp": + success, msg = await loop.run_in_executor(None, update_yt_dlp) + return {"package": "yt-dlp", "success": success, "message": msg} + + elif package == "playwright": + success, msg = await loop.run_in_executor(None, update_playwright) + return {"package": "playwright", "success": success, "message": msg} + + else: + # Update all + results = await loop.run_in_executor(None, update_all_dependencies) + return { + "status": "completed", + "results": {pkg: {"success": s, "message": m} for pkg, (s, m) in results.items()} + } + + +# Video extraction endpoint +@app.post("/api/extract", response_model=ExtractResponse) +async def extract_video(request: ExtractRequest): + """ + Extract video stream URL from source. + Uses cache-aside pattern with 3-hour TTL. + """ + start_time = time.time() + + # Check cache first + cached_data = cache.get(f"video:{request.url}") + if cached_data: + extraction_time = int((time.time() - start_time) * 1000) + return ExtractResponse( + title=cached_data['title'], + thumbnail=cached_data['thumbnail'], + duration=cached_data['duration'], + stream_url=cached_data['stream_url'], + resolution=cached_data['resolution'], + cached=True, + extraction_time_ms=extraction_time + ) + + # Cache miss - extract with yt-dlp + try: + video_info = await extractor.extract(request.url, request.quality) + + # Cache the result + cache.set(f"video:{request.url}", { + 'title': video_info.title, + 'thumbnail': video_info.thumbnail, + 'duration': video_info.duration, + 'stream_url': video_info.stream_url, + 'resolution': video_info.resolution, + }) + + extraction_time = int((time.time() - start_time) * 1000) + + return ExtractResponse( + title=video_info.title, + thumbnail=video_info.thumbnail, + duration=video_info.duration, + stream_url=video_info.stream_url, + resolution=video_info.resolution, + cached=False, + extraction_time_ms=extraction_time + ) + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Extraction failed: {str(e)}") + + +# Get available qualities +@app.get("/api/qualities") +async def get_qualities(url: str): + """Get available quality options for a video""" + try: + qualities = await extractor.get_available_qualities(url) + return {"qualities": qualities} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# Video CRUD endpoints +@app.post("/api/videos", response_model=VideoResponse) +async def create_video(video: VideoCreate, db=Depends(get_db)): + """Add a video to the library""" + repo = VideoRepository(db) + + # Check if already exists + existing = repo.get_by_url(video.source_url) + if existing: + raise HTTPException(status_code=400, detail="Video already exists") + + new_video = repo.create(**video.dict()) + return new_video + + +@app.get("/api/videos", response_model=list[VideoResponse]) +async def list_videos( + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + category: Optional[str] = None, + db=Depends(get_db) +): + """List all videos with pagination""" + repo = VideoRepository(db) + if category: + return repo.get_by_category(category, limit) + return repo.get_all(skip, limit) + + +@app.get("/api/videos/{video_id}", response_model=VideoResponse) +async def get_video(video_id: int, db=Depends(get_db)): + """Get video by ID""" + repo = VideoRepository(db) + video = repo.get_by_id(video_id) + if not video: + raise HTTPException(status_code=404, detail="Video not found") + return video + + +@app.delete("/api/videos/{video_id}") +async def delete_video(video_id: int, db=Depends(get_db)): + """Delete video from library""" + repo = VideoRepository(db) + if repo.delete(video_id): + return {"message": "Video deleted"} + raise HTTPException(status_code=404, detail="Video not found") + + +# Search endpoint +@app.get("/api/search", response_model=list[VideoResponse]) +async def search_videos( + q: str = Query(..., min_length=1), + limit: int = Query(20, ge=1, le=50), + db=Depends(get_db) +): + """Search videos by title""" + repo = VideoRepository(db) + return repo.search(q, limit) + + +# ============================================ +# PhimMoiChill Integration Endpoints (using Playwright crawler) +# ============================================ + +@app.get("/api/rophim/catalog") +async def get_phimmoichill_catalog( + category: Optional[str] = None, + page: int = Query(1, ge=1), + limit: int = Query(24, ge=1, le=50), + sort: str = Query("modified", description="Sort by: modified, year, rating") +): + """ + Get movie catalog from ophim API with sorting support. + """ + # Check cache first + cache_key = f"catalog:{category}:{page}:{limit}:{sort}" + cached = cache.get(cache_key) + if cached: + return cached + + import aiohttp + import ssl + + # Map categories to ophim slugs + category_map = { + # Main categories + 'movies': 'danh-sach/phim-le', + 'series': 'danh-sach/phim-bo', + 'tv-shows': 'danh-sach/phim-bo', + 'animation': 'danh-sach/hoat-hinh', + 'cinema': 'danh-sach/phim-chieu-rap', + # Vietnamese slugs (passthrough) + 'phim-le': 'danh-sach/phim-le', + 'phim-bo': 'danh-sach/phim-bo', + 'phim-moi': 'danh-sach/phim-moi-cap-nhat', # Updated to distinct + 'phim-moi-cap-nhat': 'danh-sach/phim-moi-cap-nhat', + 'hoat-hinh': 'danh-sach/hoat-hinh', + 'phim-chieu-rap': 'danh-sach/phim-chieu-rap', + # New/trending/popular + 'trending': 'danh-sach/phim-moi-cap-nhat', # Distinct + 'new': 'danh-sach/phim-le', # Default to movies + 'popular': 'danh-sach/phim-le', + 'all': 'danh-sach/phim-le', + # Genre categories + 'action': 'the-loai/hanh-dong', + 'comedy': 'the-loai/hai-huoc', + 'drama': 'the-loai/chinh-kich', + 'horror': 'the-loai/kinh-di', + 'romance': 'the-loai/tinh-cam', + 'scifi': 'the-loai/vien-tuong', + # Country categories + 'korean': 'quoc-gia/han-quoc', + 'han-quoc': 'quoc-gia/han-quoc', + 'usa': 'quoc-gia/au-my', + 'au-my': 'quoc-gia/au-my', + 'china': 'quoc-gia/trung-quoc', + 'trung-quoc': 'quoc-gia/trung-quoc', + 'japan': 'quoc-gia/nhat-ban', + 'nhat-ban': 'quoc-gia/nhat-ban', + 'thailand': 'quoc-gia/thai-lan', + 'thai-lan': 'quoc-gia/thai-lan', + 'vietnam': 'quoc-gia/viet-nam', + 'viet-nam': 'quoc-gia/viet-nam', + 'my': 'quoc-gia/au-my', + 'hong-kong': 'quoc-gia/hong-kong', + 'dai-loan': 'quoc-gia/dai-loan', + 'an-do': 'quoc-gia/an-do', + + # Additional mappings for main.js categories + 'hanh-dong': 'the-loai/hanh-dong', + 'kinh-di': 'the-loai/kinh-di', + 'tinh-cam': 'the-loai/tinh-cam', + 'vien-tuong': 'the-loai/vien-tuong', + 'hai-huoc': 'the-loai/hai-huoc', + 'han-quoc-hits': 'quoc-gia/han-quoc', + 'phieu-luu': 'the-loai/phieu-luu', + 'vo-thuat': 'the-loai/vo-thuat', + 'hinh-su': 'the-loai/hinh-su', + 'tai-lieu': 'the-loai/tai-lieu', + 'gia-dinh': 'the-loai/gia-dinh', + 'co-trang': 'the-loai/co-trang', + 'hoc-duong': 'the-loai/hoc-duong', + 'tam-ly': 'the-loai/tam-ly', + 'than-thoai': 'the-loai/than-thoai', + 'chien-tranh': 'the-loai/chien-tranh', + 'the-thao': 'the-loai/the-thao', + 'am-nhac': 'the-loai/am-nhac', + 'than-thoai': 'the-loai/than-thoai', + 'hoc-duong': 'the-loai/hoc-duong', + } + + # Use mapped slug or fallback to input as-is (for advanced users) + slug = category_map.get(category, f'danh-sach/{category}') if category else 'danh-sach/phim-le' + # If category starts with known prefixes, use as-is + if category and (category.startswith('danh-sach/') or category.startswith('the-loai/') or category.startswith('quoc-gia/')): + slug = category + + try: + ssl_ctx = ssl.create_default_context() + ssl_ctx.check_hostname = False + ssl_ctx.verify_mode = ssl.CERT_NONE + connector = aiohttp.TCPConnector(ssl=ssl_ctx) + + async with aiohttp.ClientSession(connector=connector) as session: + # Use ophim JSON API + api_url = f"https://ophim1.com/v1/api/{slug}?page={page}" + + async with session.get(api_url, timeout=aiohttp.ClientTimeout(total=15)) as resp: + if resp.status != 200: + # Fallback to general movies if specific slug fails + print(f"Warning: slug {slug} failed ({resp.status}), falling back...") + api_url = f"https://ophim1.com/v1/api/danh-sach/phim-le?page={page}" + async with session.get(api_url) as fallback_resp: + data = await fallback_resp.json() + else: + data = await resp.json() + + items = data.get('data', {}).get('items', []) + + # Parse movies with full metadata including ratings + movies = [] + for item in items: + tmdb_data = item.get('tmdb', {}) + imdb_data = item.get('imdb', {}) + + # Get the best available rating + tmdb_rating = tmdb_data.get('vote_average', 0) or 0 + imdb_rating = imdb_data.get('vote_average', 0) or 0 + best_rating = max(tmdb_rating, imdb_rating) + + movies.append({ + 'id': item.get('slug', ''), + 'title': item.get('name', ''), + 'original_title': item.get('origin_name'), + 'slug': item.get('slug', ''), + 'thumbnail': f"https://img.ophim.live/uploads/movies/{item.get('thumb_url', '')}", + 'poster_url': f"https://img.ophim.live/uploads/movies/{item.get('poster_url', '')}", + 'year': item.get('year'), + 'quality': item.get('quality', 'HD'), + 'duration': item.get('time'), + 'category': item.get('type', 'single'), + 'tmdb_rating': tmdb_rating, + 'imdb_rating': imdb_rating, + 'rating': best_rating, + 'vote_count': tmdb_data.get('vote_count', 0), + 'genres': [cat.get('name') for cat in item.get('category', [])], + 'country': [c.get('name') for c in item.get('country', [])], + 'modified': item.get('modified', {}).get('time'), + 'episode_current': item.get('episode_current'), + 'lang': item.get('lang'), + }) + + # Apply sorting + if sort == 'year': + movies.sort(key=lambda x: x.get('year') or 0, reverse=True) + elif sort == 'rating': + movies.sort(key=lambda x: x.get('rating') or 0, reverse=True) + # 'modified' is already the default sort from API + + result = { + "movies": movies[:limit], + "page": page, + "category": category or 'movies', + "sort": sort, + "total": len(movies) + } + + # Cache for 1 hour (3600s) + cache.set(cache_key, result, ttl=3600) + return result + + except aiohttp.ClientError as e: + raise HTTPException(status_code=500, detail=f"Failed to fetch catalog: {str(e)}") + + +@app.get("/api/rophim/search") +async def search_phimmoichill( + q: str = Query(..., min_length=1), + limit: int = Query(20, ge=1, le=50) +): + """Search movies by title AND actors using ophim API""" + import aiohttp + import ssl + + movies = [] + seen_slugs = set() + + ssl_ctx = ssl.create_default_context() + ssl_ctx.check_hostname = False + ssl_ctx.verify_mode = ssl.CERT_NONE + connector = aiohttp.TCPConnector(ssl=ssl_ctx) + + def add_movie(item): + """Helper to add movie avoiding duplicates""" + slug = item.get('slug', '') + if slug and slug not in seen_slugs: + seen_slugs.add(slug) + movies.append({ + 'id': slug, + 'title': item.get('name', ''), + 'original_title': item.get('origin_name'), + 'slug': slug, + 'thumbnail': f"https://img.ophim.live/uploads/movies/{item.get('thumb_url', '')}", + 'backdrop': f"https://img.ophim.live/uploads/movies/{item.get('poster_url', '')}", + 'year': item.get('year'), + 'rating': None, + 'duration': None, + 'quality': item.get('quality', 'HD'), + 'genre': None, + 'description': None, + 'category': item.get('type', 'movies') + }) + + async with aiohttp.ClientSession(connector=connector) as session: + # 1. Search by movie title (primary) + try: + api_url = f"https://ophim1.com/v1/api/tim-kiem?keyword={q}&limit={limit}" + async with session.get(api_url, timeout=aiohttp.ClientTimeout(total=10)) as resp: + if resp.status == 200: + data = await resp.json() + items = data.get('data', {}).get('items', []) + for item in items: + add_movie(item) + except Exception as e: + print(f"Title search failed: {e}") + + # 2. Search by actor name (secondary) + if len(movies) < limit: + try: + # ophim actor search endpoint + actor_slug = q.lower().replace(' ', '-') + actor_url = f"https://ophim1.com/v1/api/danh-sach/dien-vien/{actor_slug}" + async with session.get(actor_url, timeout=aiohttp.ClientTimeout(total=10)) as resp: + if resp.status == 200: + data = await resp.json() + items = data.get('data', {}).get('items', []) + for item in items: + if len(movies) >= limit: + break + add_movie(item) + except Exception as e: + print(f"Actor search failed: {e}") + + # Fallback to phimmoichill scraper if no results + if not movies: + from rophim_scraper import RophimScraper + try: + scraper = RophimScraper() + try: + results = await scraper.search(q, limit) + movies = [movie.__dict__ for movie in results] + finally: + await scraper.close() + except Exception as e: + print(f"Scraper search failed: {e}") + + return { + "movies": movies[:limit], + "total": len(movies) + } + + + + +@app.get("/api/rophim/categories/discover") +async def discover_categories(): + """ + Discover all available categories from PhimMoiChill + Returns types, genres, countries, and years + """ + from category_discovery import get_categories + + try: + categories = await get_categories() + + # Count total movies per category type + totals = { + cat_type: len(cat_list) + for cat_type, cat_list in categories.items() + } + + return { + "categories": categories, + "totals": totals, + "total_categories": sum(totals.values()) + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to discover categories: {str(e)}") + + +@app.get("/api/rophim/category") +async def get_movies_by_category( + slug: str = Query(..., description="Category slug (e.g., 'the-loai/hanh-dong', 'danh-sach/phim-le')"), + page: int = Query(1, ge=1), + limit: int = Query(24, ge=1, le=50) +): + """ + Get movies for a specific category + Examples: ?slug=phim-le, ?slug=the-loai/hanh-dong, ?slug=quoc-gia/han-quoc + """ + from rophim_scraper import RophimScraper + + try: + scraper = RophimScraper() + try: + # Use the get_category method which supports all category types + results = await scraper.get_category(slug, page, limit) + movies = [movie.__dict__ for movie in results] + + return { + "movies": movies, + "category": slug, + "page": page, + "total": len(movies) + } + finally: + await scraper.close() + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to fetch category: {str(e)}") + + + + + +@app.get("/api/rophim/home/curated") +async def get_curated_homepage_sections(): + """ + Get curated homepage sections with TOP RATED, NEW RELEASES, and popular genres. + This provides a Rotten Tomatoes / Moviewiser style layout. + """ + # Check cache + cache_key = "home:curated_v2" + cached = cache.get(cache_key) + if cached: + return cached + + import aiohttp + import ssl + + sections = [] + + ssl_ctx = ssl.create_default_context() + ssl_ctx.check_hostname = False + ssl_ctx.verify_mode = ssl.CERT_NONE + connector = aiohttp.TCPConnector(ssl=ssl_ctx) + + async def fetch_section(session, title: str, slug: str, sort_key: str = None, limit: int = 15): + """Fetch a single section""" + try: + api_url = f"https://ophim1.com/v1/api/{slug}?page=1" + async with session.get(api_url, timeout=aiohttp.ClientTimeout(total=10)) as resp: + if resp.status != 200: + return None + data = await resp.json() + items = data.get('data', {}).get('items', []) + + movies = [] + for item in items[:30]: # Get more to allow better sorting + tmdb_data = item.get('tmdb', {}) + imdb_data = item.get('imdb', {}) + tmdb_rating = tmdb_data.get('vote_average', 0) or 0 + imdb_rating = imdb_data.get('vote_average', 0) or 0 + + movies.append({ + 'id': item.get('slug', ''), + 'title': item.get('name', ''), + 'original_title': item.get('origin_name'), + 'slug': item.get('slug', ''), + 'thumbnail': f"https://img.ophim.live/uploads/movies/{item.get('thumb_url', '')}", + 'poster_url': f"https://img.ophim.live/uploads/movies/{item.get('poster_url', '')}", + 'year': item.get('year'), + 'quality': item.get('quality', 'HD'), + 'rating': max(tmdb_rating, imdb_rating), + 'tmdb_rating': tmdb_rating, + 'vote_count': tmdb_data.get('vote_count', 0), + 'category': item.get('type', 'single'), + 'genres': [cat.get('name') for cat in item.get('category', [])], + }) + + # Apply sorting + if sort_key == 'rating': + movies.sort(key=lambda x: (x.get('rating') or 0, x.get('vote_count') or 0), reverse=True) + elif sort_key == 'year': + movies.sort(key=lambda x: x.get('year') or 0, reverse=True) + + return { + 'title': title, + 'key': slug, + 'movies': movies[:limit] + } + except Exception as e: + print(f"Error fetching {title}: {e}") + return None + + try: + async with aiohttp.ClientSession(connector=connector) as session: + import asyncio + + # Define curated sections + section_configs = [ + ("🏆 Top Rated Movies", "danh-sach/phim-le", "rating"), + ("🎬 New Releases", "danh-sach/phim-le", "year"), + ("📺 Top Rated Series", "danh-sach/phim-bo", "rating"), + ("💥 Action & Adventure", "the-loai/hanh-dong", "rating"), + ("😱 Horror & Thriller", "the-loai/kinh-di", "rating"), + ("❤️ Romance", "the-loai/tinh-cam", "rating"), + ("🎭 Drama", "the-loai/chinh-kich", "rating"), + ("😂 Comedy", "the-loai/hai-huoc", "rating"), + ("🌟 Sci-Fi & Fantasy", "the-loai/vien-tuong", "rating"), + ("🎌 Animation & Anime", "danh-sach/hoat-hinh", "rating"), + ("🇰🇷 Korean Movies", "quoc-gia/han-quoc", "rating"), + ("🇺🇸 Western Movies", "quoc-gia/au-my", "rating"), + ] + + tasks = [fetch_section(session, title, slug, sort_key) for title, slug, sort_key in section_configs] + results = await asyncio.gather(*tasks) + + sections = [r for r in results if r and r.get('movies')] + + result = {"sections": sections, "total": len(sections)} + # Cache for 6 hours (21600s) + cache.set(cache_key, result, ttl=21600) + return result + + except Exception as e: + print(f"Error fetching curated sections: {e}") + return {"sections": [], "error": str(e)} + + + +@app.get("/api/rophim/stream/{slug}") +async def get_rophim_stream(slug: str, episode: int = 1): + """ + Get video stream URL from ophim API for a specific slug and episode. + """ + from rophim_scraper import get_video_stream + from fastapi.responses import JSONResponse + + try: + print(f"DEBUG: Processing stream request for {slug} ep {episode}") + stream_url = await get_video_stream(slug, episode=episode) + + if not stream_url: + print(f"DEBUG: Stream not found for {slug}") + return JSONResponse(status_code=404, content={"detail": "Stream not found"}) + + print(f"DEBUG: Success! Returning stream URL for {slug}") + return {"stream_url": stream_url} + except Exception as e: + print(f"ERROR in get_rophim_stream: {e}") + return JSONResponse(status_code=500, content={"detail": str(e)}) + +@app.post("/api/rophim/stream") +async def get_rophim_stream_post(data: dict): + """ + Get video stream URL (POST) - supports source_url if needed + """ + import traceback + from fastapi.responses import JSONResponse + from rophim_scraper import get_video_stream + + try: + slug = data.get('slug') + episode = int(data.get('episode', 1)) + + if not slug: + raise HTTPException(status_code=400, detail="Slug required") + + stream_url = await get_video_stream(slug, episode=episode) + + if not stream_url: + raise HTTPException(status_code=404, detail="Stream not found") + + return JSONResponse(content={"stream_url": stream_url}) + except HTTPException: + raise + except Exception as e: + print(f"CRITICAL ERROR in get_rophim_stream_post: {e}") + traceback.print_exc() + return JSONResponse( + status_code=500, + content={"detail": str(e)} + ) + + +@app.get("/api/rophim/home/sections") +async def get_home_more_sections(page: int = Query(1, ge=1), view: str = Query('home')): + """ + Get paginated sections for homepage OR specific views (infinite scroll). + Returns dynamic sections (Genres, Countries, etc.) or View specific sections. + """ + from category_scraper import PhimMoiChillCategoryScraper + + scraper = PhimMoiChillCategoryScraper() + try: + if view == 'home': + # Home logic (Page 2+ usually) + # If page < 2, get_mixed_sections might return empty or negative index logic? + # My logic: idx_start = (page - 2) * 5. If page=1 => -5. + # But Main Page uses get_all_sections for Page 1. + # So this endpoint is only for Page 2+ on Home. + if page < 2: + results = [] + else: + results = await scraper.get_mixed_sections(page) + else: + # Category Views using get_view_sections + results = await scraper.get_view_sections(view, page) + + return {"sections": results, "page": page} + except Exception as e: + print(f"Error fetching more sections: {e}") + return {"sections": [], "page": page} + finally: + await scraper.close() + +def clean_movie_description(movie: Dict) -> Dict: + """Remove messy metadata from description field""" + desc = movie.get('description', '') + if desc and ('Trạng thái' in desc or 'Năm phát hành' in desc): + # Description contains concatenated metadata - clear it + movie['description'] = None + return movie + + +@app.get("/api/rophim/movie/{slug}") +async def get_phimmoichill_movie(slug: str): + """Get detailed movie info from PhimMoiChill with optional TMDB enrichment""" + import asyncio + from rophim_scraper import get_movie_details + + try: + loop = asyncio.get_event_loop() + movie = await loop.run_in_executor( + None, + lambda: get_movie_details(slug) + ) + if not movie: + raise HTTPException(status_code=404, detail="Movie not found") + + # Clean up description field + movie = clean_movie_description(movie) + + # Try to enrich with TMDB data + try: + from tmdb_service import tmdb_service + enriched = await tmdb_service.enrich_movie_data(movie) + return enriched + except Exception as tmdb_error: + print(f"TMDB enrichment failed: {tmdb_error}") + # Return base movie data if TMDB fails + return movie + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to fetch movie: {str(e)}") + + +@app.get("/api/rophim/stream/{slug}") +async def get_phimmoichill_stream( + slug: str, + episode: int = Query(1, ge=1), + server: int = Query(0, ge=0, le=2) +): + """Get video stream URL for a movie/episode using ophim API""" + import asyncio + from rophim_scraper import get_video_stream + + try: + # Run sync scraper in thread pool + loop = asyncio.get_event_loop() + stream_url = await loop.run_in_executor( + None, + lambda: get_video_stream(slug, episode, server) + ) + + if not stream_url: + raise HTTPException(status_code=404, detail="Stream not found - video source extraction failed") + + return { + "stream_url": stream_url, + "episode": episode, + "slug": slug + } + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to get stream: {str(e)}") + + +class PhimMoiChillStreamRequest(BaseModel): + source_url: str + slug: str = "" + episode: int = 1 + server: int = 0 + + +@app.post("/api/rophim/stream") +async def get_phimmoichill_stream_by_url(request: PhimMoiChillStreamRequest): + """Get video stream URL using slug from source_url - uses ophim API""" + import asyncio + import re + from rophim_scraper import get_video_stream + + try: + # Extract slug from source_url + slug = request.slug + if not slug and request.source_url: + # e.g., https://phimmoichill.network/phim/slug-name + match = re.search(r'/phim/([^/\?]+)', request.source_url) + if match: + slug = match.group(1) + + if not slug: + raise HTTPException(status_code=400, detail="Could not extract slug from URL") + + loop = asyncio.get_event_loop() + stream_url = await loop.run_in_executor( + None, + lambda: get_video_stream(slug, request.episode, request.server) + ) + + if not stream_url: + raise HTTPException(status_code=404, detail="Stream not found - video source extraction failed") + + return { + "stream_url": stream_url, + "episode": request.episode, + "slug": slug + } + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to get stream: {str(e)}") + + +# ============================================ +# Scheduled Crawl Endpoint +# ============================================ + +@app.post("/api/crawl/trigger") +async def trigger_crawl( + page: int = Query(1, ge=1), + limit: int = Query(50, ge=1, le=100) +): + """ + Trigger a movie catalog crawl. + Can be called manually or by external scheduler (cron, Docker healthcheck). + Returns the number of movies crawled. + """ + import asyncio + from rophim_scraper import get_movies + + try: + loop = asyncio.get_event_loop() + movies = await loop.run_in_executor( + None, + lambda: get_movies(page, limit) + ) + + return { + "success": True, + "crawled_count": len(movies), + "page": page, + "message": f"Successfully crawled {len(movies)} movies" + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Crawl failed: {str(e)}") + + + + +@app.get("/api/crawl/status") +async def crawl_status(): + """Get the last crawl status and timestamp""" + return { + "status": "ready", + "message": "Use POST /api/crawl/trigger to start a crawl" + } + + +# ============================================ +# Category Endpoints - PhimMoiChill Themed Sections +# ============================================ + +@app.get("/api/rophim/categories/all") +async def get_all_categories(): + """Get all themed category sections in one call""" + import asyncio + from category_scraper import get_categories_sync + + try: + loop = asyncio.get_event_loop() + categories = await loop.run_in_executor(None, get_categories_sync) + + return { + "categories": categories, + "total_sections": len(categories) + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to fetch categories: {str(e)}") + + +@app.get("/api/rophim/categories/hot") +async def get_hot_category(limit: int = Query(24, ge=1, le=50)): + """Get Hot Movies category""" + import asyncio + from category_scraper import PhimMoiChillCategoryScraper + + try: + async def _fetch(): + scraper = PhimMoiChillCategoryScraper() + try: + movies = await scraper.get_hot_movies(limit) + await scraper.close() + return movies + except: + await scraper.close() + raise + + loop = asyncio.get_event_loop() + movies = await loop.run_in_executor(None, lambda: asyncio.run(_fetch())) + + return {"movies": movies, "category": "hot", "total": len(movies)} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to fetch hot movies: {str(e)}") + + +@app.get("/api/rophim/categories/new-releases") +async def get_new_releases_category(limit: int = Query(24, ge=1, le=50)): + """Get New Releases category""" + import asyncio + from category_scraper import PhimMoiChillCategoryScraper + + try: + async def _fetch(): + scraper = PhimMoiChillCategoryScraper() + try: + movies = await scraper.get_new_releases(limit) + await scraper.close() + return movies + except: + await scraper.close() + raise + + loop = asyncio.get_event_loop() + movies = await loop.run_in_executor(None, lambda: asyncio.run(_fetch())) + + return {"movies": movies, "category": "new_releases", "total": len(movies)} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to fetch new releases: {str(e)}") + + +@app.get("/api/rophim/categories/top10") +async def get_top10_category(): + """Get Top 10 Most Watched""" + import asyncio + from category_scraper import PhimMoiChillCategoryScraper + + try: + async def _fetch(): + scraper = PhimMoiChillCategoryScraper() + try: + movies = await scraper.get_top_10() + await scraper.close() + return movies + except: + await scraper.close() + raise + + loop = asyncio.get_event_loop() + movies = await loop.run_in_executor(None, lambda: asyncio.run(_fetch())) + + return {"movies": movies, "category": "top10", "total": len(movies)} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to fetch top 10: {str(e)}") + + +@app.get("/api/rophim/categories/cinema") +async def get_cinema_category(limit: int = Query(24, ge=1, le=50)): + """Get Cinema Releases category""" + import asyncio + from category_scraper import PhimMoiChillCategoryScraper + + try: + async def _fetch(): + scraper = PhimMoiChillCategoryScraper() + try: + movies = await scraper.get_cinema_releases(limit) + await scraper.close() + return movies + except: + await scraper.close() + raise + + loop = asyncio.get_event_loop() + movies = await loop.run_in_executor(None, lambda: asyncio.run(_fetch())) + + return {"movies": movies, "category": "cinema", "total": len(movies)} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to fetch cinema releases: {str(e)}") + + +# ============================================ +# Static Files Serving (Production) +# ============================================ + +# Mount static files from the 'static' directory +# In Docker, the built frontend will be copied here +frontend_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "static")) +print(f"🔍 DEBUG: Resolved frontend_path to: {frontend_path}") +print(f"🔍 DEBUG: Path exists: {os.path.exists(frontend_path)}") + +if os.path.exists(frontend_path): + print(f"✓ Serving frontend from {frontend_path}") + + # Mount directories only if they exist (Vite production builds often flatten these) + for folder in ["assets", "icons", "scripts", "styles", "js"]: + folder_path = os.path.join(frontend_path, folder) + if os.path.exists(folder_path): + app.mount(f"/{folder}", StaticFiles(directory=folder_path), name=folder) + print(f" - Mounted /{folder}") + + @app.get("/") + async def serve_index(): + return FileResponse(os.path.join(frontend_path, "index.html")) + + @app.get("/watch") + @app.get("/watch.html") + async def serve_watch(): + return FileResponse(os.path.join(frontend_path, "watch.html")) + + @app.get("/manifest.json") + async def serve_manifest(): + return FileResponse(os.path.join(frontend_path, "manifest.json")) + + @app.get("/sw.js") + async def serve_sw(): + return FileResponse(os.path.join(frontend_path, "sw.js")) + +# Catch-all for any other routes (SPA support) +@app.exception_handler(404) +async def custom_404_handler(request, exc): + if not request.url.path.startswith("/api"): + if os.path.exists(os.path.join(frontend_path, "index.html")): + return FileResponse(os.path.join(frontend_path, "index.html")) + return JSONResponse(status_code=404, content={"detail": "Not found"}) + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) + diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..7fe1ef5 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/rophim_scraper.py b/backend/rophim_scraper.py new file mode 100644 index 0000000..bac31cd --- /dev/null +++ b/backend/rophim_scraper.py @@ -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
  • 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']*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('

    ', '').replace('

    ', ''), + '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()) + diff --git a/backend/static/assets/Toast-BwR22KmJ.js b/backend/static/assets/Toast-BwR22KmJ.js new file mode 100644 index 0000000..5d82b89 --- /dev/null +++ b/backend/static/assets/Toast-BwR22KmJ.js @@ -0,0 +1,47 @@ +(function(){const h=document.createElement("link").relList;if(h&&h.supports&&h.supports("modulepreload"))return;for(const e of document.querySelectorAll('link[rel="modulepreload"]'))m(e);new MutationObserver(e=>{for(const t of e)if(t.type==="childList")for(const u of t.addedNodes)u.tagName==="LINK"&&u.rel==="modulepreload"&&m(u)}).observe(document,{childList:!0,subtree:!0});function o(e){const t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin==="use-credentials"?t.credentials="include":e.crossOrigin==="anonymous"?t.credentials="omit":t.credentials="same-origin",t}function m(e){if(e.ep)return;e.ep=!0;const t=o(e);fetch(e.href,t)}})();const z="/api";class Bt{async extractVideo(h,o=null){const m=await fetch(`${z}/extract`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({url:h,quality:o})});if(!m.ok){const e=await m.json();throw new Error(e.detail||"Extraction failed")}return m.json()}async getQualities(h){const o=await fetch(`${z}/qualities?url=${encodeURIComponent(h)}`);if(!o.ok)throw new Error("Failed to get qualities");return(await o.json()).qualities}async listVideos({skip:h=0,limit:o=50,category:m=null}={}){let e=`${z}/videos?skip=${h}&limit=${o}`;m&&m!=="all"&&(e+=`&category=${encodeURIComponent(m)}`);const t=await fetch(e);if(!t.ok)throw new Error("Failed to fetch videos");return t.json()}async addVideo(h){const o=await fetch(`${z}/videos`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(h)});if(!o.ok){const m=await o.json();throw new Error(m.detail||"Failed to add video")}return o.json()}async deleteVideo(h){if(!(await fetch(`${z}/videos/${h}`,{method:"DELETE"})).ok)throw new Error("Failed to delete video")}async searchVideos(h,o=20){const m=await fetch(`${z}/search?q=${encodeURIComponent(h)}&limit=${o}`);if(!m.ok)throw new Error("Search failed");return m.json()}async health(){return(await fetch(`${z}/health`)).json()}async getRophimCatalog({category:h=null,country:o=null,genre:m=null,page:e=1,limit:t=24,sort:u="modified"}={}){let r=`${z}/rophim/catalog?page=${e}&limit=${t}&sort=${u}`;h&&(r+=`&category=${encodeURIComponent(h)}`),o&&(r+=`&country=${encodeURIComponent(o)}`),m&&(r+=`&genre=${encodeURIComponent(m)}`);const i=await fetch(r);if(!i.ok)throw new Error("Failed to fetch RoPhim catalog");return i.json()}async getCuratedSections(){const h=await fetch(`${z}/rophim/home/curated`);if(!h.ok)throw new Error("Failed to fetch curated sections");return h.json()}async searchRophim(h,o=20){const m=await fetch(`${z}/rophim/search?q=${encodeURIComponent(h)}&limit=${o}`);if(!m.ok)throw new Error("RoPhim search failed");return m.json()}async getHomeSections(h=2,o="home"){const m=await fetch(`${z}/rophim/home/sections?page=${h}&view=${o}`);if(!m.ok)throw new Error("Failed to fetch home sections");return m.json()}async getRophimMovie(h){const o=await fetch(`${z}/rophim/movie/${encodeURIComponent(h)}`);if(!o.ok)throw new Error("Failed to fetch movie details");return o.json()}async getRophimStream(h,o=1){const m=await fetch(`${z}/rophim/stream/${encodeURIComponent(h)}?episode=${o}`);if(!m.ok)throw new Error("Failed to get stream");return m.json()}async getRophimStreamByUrl(h,o="",m=1,e=0){const t=await fetch(`${z}/rophim/stream`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({source_url:h,slug:o||"",episode:m,server:e})});if(!t.ok){const u=await t.json();throw new Error(u.detail||"Failed to get stream")}return t.json()}}const Dt=new Bt;let mt=null;const At=300,bt=document.getElementById("searchModal"),xt=document.getElementById("searchBackdrop"),K=document.getElementById("searchInput"),wt=document.getElementById("closeSearch"),dt=document.getElementById("searchLoading"),pt=document.getElementById("searchGrid");document.querySelector('[data-view="search"]');function gt(){bt.classList.add("active"),setTimeout(()=>K.focus(),100)}function vt(){bt.classList.remove("active"),K.value="",pt.innerHTML="",dt.style.display="none"}async function yt(a){if(!a||a.trim().length<2){pt.innerHTML="",dt.style.display="none";return}dt.style.display="flex";try{const h=await Dt.searchRophim(a);dt.style.display="none",h&&h.movies&&h.movies.length>0?pt.innerHTML=h.movies.map(o=>` +
    +
    +
    + ${o.title} +
    +
    +
    +

    ${o.title}

    +
    + ${o.year||""} + ${o.quality?`${o.quality}`:""} +
    +
    +
    +
    +
    + `).join(""):pt.innerHTML=` +
    + + + +

    No results found for "${a}"

    +
    + `}catch(h){console.error("Search failed:",h),dt.style.display="none",pt.innerHTML=` +
    +

    Search failed. Please try again.

    +
    + `}}function jt(){[document.getElementById("headerSearchBtn"),document.getElementById("mobileSearchBtn"),document.querySelector('[data-view="search"]'),document.querySelector('button[data-view="search"]')].forEach(m=>{m&&m.addEventListener("click",e=>{e.preventDefault(),e.stopPropagation(),gt()})}),wt&&wt.addEventListener("click",vt),xt&&xt.addEventListener("click",vt),K&&(K.addEventListener("input",m=>{clearTimeout(mt);const e=m.target.value;mt=setTimeout(()=>{yt(e)},At)}),K.addEventListener("keydown",m=>{m.key==="Enter"&&(clearTimeout(mt),yt(m.target.value))})),document.addEventListener("keydown",m=>{(m.metaKey||m.ctrlKey)&&m.key==="k"&&(m.preventDefault(),gt()),m.key==="Escape"&&bt.classList.contains("active")&&vt()});const o=new URLSearchParams(window.location.search).get("search");o&&o.trim()&&setTimeout(()=>{gt(),K&&(K.value=o),yt(o);const m=window.location.pathname;window.history.replaceState({},"",m)},300)}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",jt):jt();/*! + * artplayer.js v5.3.0 + * Github: https://github.com/zhw2590582/ArtPlayer + * (c) 2017-2025 Harvey Zack + * Released under the MIT License. + */(function(a,h,o,m,e,t,u,r){var i=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{},n=typeof i[m]=="function"&&i[m],s=n.i||{},l=n.cache||{},c=typeof module<"u"&&typeof module.require=="function"&&module.require.bind(module);function p(y,x){if(!l[y]){if(!a[y]){if(e[y])return e[y];var f=typeof i[m]=="function"&&i[m];if(!x&&f)return f(y,!0);if(n)return n(y,!0);if(c&&typeof y=="string")return c(y);var v=Error("Cannot find module '"+y+"'");throw v.code="MODULE_NOT_FOUND",v}j.resolve=function(E){var S=a[y][1][E];return S??E},j.cache={};var w=l[y]=new p.Module(y);a[y][0].call(w.exports,j,w,w.exports,i)}return l[y].exports;function j(E){var S=j.resolve(E);return S===!1?{}:p(S)}}p.isParcelRequire=!0,p.Module=function(y){this.id=y,this.bundle=p,this.require=c,this.exports={}},p.modules=a,p.cache=l,p.parent=n,p.distDir=void 0,p.publicUrl=void 0,p.devServer=void 0,p.i=s,p.register=function(y,x){a[y]=[function(f,v){v.exports=x},{}]},Object.defineProperty(p,"root",{get:function(){return i[m]}}),i[m]=p;for(var d=0;dQ.call(this,this)),R.DEBUG){let et=W=>console.log(`[ART.${this.id}] -> ${W}`);et(`Version@${R.version}`);for(let W=0;Wet(`Event@${ct.type}`))}Y.push(this)}static get instances(){return Y}static get version(){return n.version}static get config(){return l.default}static get utils(){return V}static get scheme(){return H.default}static get Emitter(){return J.default}static get validator(){return i.default}static get kindOf(){return i.default.kindOf}static get html(){return G.default.html}static get option(){return{id:"",container:"#artplayer",url:"",poster:"",type:"",theme:"#f00",volume:.7,isLive:!1,muted:!1,autoplay:!1,autoSize:!1,autoMini:!1,loop:!1,flip:!1,playbackRate:!1,aspectRatio:!1,screenshot:!1,setting:!1,hotkey:!0,pip:!1,mutex:!0,backdrop:!0,fullscreen:!1,fullscreenWeb:!1,subtitleOffset:!1,miniProgressBar:!1,useSSR:!1,playsInline:!0,lock:!1,gesture:!0,fastForward:!1,autoPlayback:!1,autoOrientation:!1,airplay:!1,proxy:void 0,layers:[],contextmenu:[],controls:[],settings:[],quality:[],highlight:[],plugins:[],thumbnails:{url:"",number:60,column:10,width:0,height:0,scale:1},subtitle:{url:"",type:"",style:{},name:"",escape:!0,encoding:"utf-8",onVttLoad:X=>X},moreVideoAttr:{controls:!1,preload:V.isSafari?"auto":"metadata"},i18n:{},icons:{},cssVar:{},customType:{},lang:navigator==null?void 0:navigator.language.toLowerCase()}}get proxy(){return this.events.proxy}get query(){return this.template.query}get video(){return this.template.$video}destroy(X=!0){R.REMOVE_SRC_WHEN_DESTROY&&this.video.removeAttribute("src"),this.events.destroy(),this.template.destroy(X),Y.splice(Y.indexOf(this),1),this.isDestroy=!0,this.emit("destroy")}}o.default=R,R.STYLE=u.default,R.DEBUG=!1,R.CONTEXTMENU=!0,R.NOTICE_TIME=2e3,R.SETTING_WIDTH=250,R.SETTING_ITEM_WIDTH=200,R.SETTING_ITEM_HEIGHT=35,R.RESIZE_TIME=200,R.SCROLL_TIME=200,R.SCROLL_GAP=50,R.AUTO_PLAYBACK_MAX=10,R.AUTO_PLAYBACK_MIN=5,R.AUTO_PLAYBACK_TIMEOUT=3e3,R.RECONNECT_TIME_MAX=5,R.RECONNECT_SLEEP_TIME=1e3,R.CONTROL_HIDE_TIME=3e3,R.DBCLICK_TIME=300,R.DBCLICK_FULLSCREEN=!0,R.MOBILE_DBCLICK_PLAY=!0,R.MOBILE_CLICK_PLAY=!1,R.AUTO_ORIENTATION_TIME=200,R.INFO_LOOP_TIME=1e3,R.FAST_FORWARD_VALUE=3,R.FAST_FORWARD_TIME=1e3,R.TOUCH_MOVE_RATIO=.5,R.VOLUME_STEP=.1,R.SEEK_STEP=5,R.PLAYBACK_RATE=[.5,.75,1,1.25,1.5,2],R.ASPECT_RATIO=["default","4:3","16:9"],R.FLIP=["normal","horizontal","vertical"],R.FULLSCREEN_WEB_IN_BODY=!1,R.LOG_VERSION=!0,R.USE_RAF=!1,R.REMOVE_SRC_WHEN_DESTROY=!0,V.isBrowser&&(window.Artplayer=R,V.setStyleText("artplayer-style",u.default),setTimeout(()=>{R.LOG_VERSION&&console.log(`%c ArtPlayer %c ${R.version} %c https://artplayer.org`,"color: #fff; background: #5f5f5f","color: #fff; background: #4bc729","")},100))},{"bundle-text:./style/index.less":"2wh8D","option-validator":"g7VGh","../package.json":"lh3R5","./config":"eJfh8","./contextmenu":"9zso8","./control":"dp1yk","./events":"jmVSD","./hotkey":"dswts","./i18n":"d9ktO","./icons":"fFHY0","./info":"kZ0F8","./layer":"j9lbi","./loading":"bMjWd","./mask":"k1nkQ","./notice":"fPVaU","./player":"uR0Sw","./plugins":"cjxJL","./scheme":"biLjm","./setting":"bwLGT","./storage":"kwqbK","./subtitle":"k5613","./template":"fwOA1","./utils":"aBlEo","./utils/emitter":"4NM7P","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],"2wh8D":[function(a,h,o,m){h.exports='.art-video-player{--art-theme:red;--art-font-color:#fff;--art-background-color:#000;--art-text-shadow-color:#00000080;--art-transition-duration:.2s;--art-padding:10px;--art-border-radius:3px;--art-progress-height:6px;--art-progress-color:#ffffff40;--art-hover-color:#ffffff40;--art-loaded-color:#ffffff40;--art-state-size:80px;--art-state-opacity:.8;--art-bottom-height:100px;--art-bottom-offset:20px;--art-bottom-gap:5px;--art-highlight-width:8px;--art-highlight-color:#ffffff80;--art-control-height:46px;--art-control-opacity:.75;--art-control-icon-size:36px;--art-control-icon-scale:1.1;--art-volume-height:120px;--art-volume-handle-size:14px;--art-lock-size:36px;--art-indicator-scale:0;--art-indicator-size:16px;--art-fullscreen-web-index:9999;--art-settings-icon-size:24px;--art-settings-max-height:300px;--art-selector-max-height:300px;--art-contextmenus-min-width:250px;--art-subtitle-font-size:20px;--art-subtitle-gap:5px;--art-subtitle-bottom:15px;--art-subtitle-border:#000;--art-widget-background:#000000d9;--art-tip-background:#000000b3;--art-scrollbar-size:4px;--art-scrollbar-background:#ffffff40;--art-scrollbar-background-hover:#ffffff80;--art-mini-progress-height:2px}.art-bg-cover{background-position:50%;background-repeat:no-repeat;background-size:cover}.art-bottom-gradient{background-image:linear-gradient(#0000,#0006,#000);background-position:bottom;background-repeat:repeat-x}.art-backdrop-filter{backdrop-filter:saturate(180%)blur(20px);background-color:#000000bf!important}.art-truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.art-video-player{zoom:1;text-align:left;user-select:none;box-sizing:border-box;width:100%;height:100%;color:var(--art-font-color);background-color:var(--art-background-color);text-shadow:0 0 2px var(--art-text-shadow-color);-webkit-tap-highlight-color:#0000;-ms-touch-action:manipulation;touch-action:manipulation;-ms-high-contrast-adjust:none;direction:ltr;outline:0;margin:0 auto;padding:0;font-family:PingFang SC,Helvetica Neue,Microsoft YaHei,Roboto,Arial,sans-serif;font-size:14px;line-height:1.3;position:relative}.art-video-player *,.art-video-player :before,.art-video-player :after{box-sizing:border-box}.art-video-player ::-webkit-scrollbar{width:var(--art-scrollbar-size);height:var(--art-scrollbar-size)}.art-video-player ::-webkit-scrollbar-thumb{background-color:var(--art-scrollbar-background)}.art-video-player ::-webkit-scrollbar-thumb:hover{background-color:var(--art-scrollbar-background-hover)}.art-video-player img{vertical-align:top;max-width:100%}.art-video-player svg{fill:var(--art-font-color)}.art-video-player a{color:var(--art-font-color);text-decoration:none}.art-icon{justify-content:center;align-items:center;line-height:1;display:flex}.art-video-player.art-backdrop .art-contextmenus,.art-video-player.art-backdrop .art-info,.art-video-player.art-backdrop .art-settings,.art-video-player.art-backdrop .art-layer-auto-playback,.art-video-player.art-backdrop .art-selector-list,.art-video-player.art-backdrop .art-volume-inner{backdrop-filter:saturate(180%)blur(20px);background-color:#000000bf!important}.art-video{z-index:10;cursor:pointer;width:100%;height:100%;position:absolute;inset:0}.art-poster{z-index:11;pointer-events:none;background-position:50%;background-repeat:no-repeat;background-size:cover;width:100%;height:100%;position:absolute;inset:0}.art-video-player .art-subtitle{z-index:20;text-align:center;pointer-events:none;justify-content:center;align-items:center;gap:var(--art-subtitle-gap);width:100%;bottom:var(--art-subtitle-bottom);font-size:var(--art-subtitle-font-size);transition:bottom var(--art-transition-duration)ease;text-shadow:var(--art-subtitle-border)1px 0 1px,var(--art-subtitle-border)0 1px 1px,var(--art-subtitle-border)-1px 0 1px,var(--art-subtitle-border)0 -1px 1px,var(--art-subtitle-border)1px 1px 1px,var(--art-subtitle-border)-1px -1px 1px,var(--art-subtitle-border)1px -1px 1px,var(--art-subtitle-border)-1px 1px 1px;flex-direction:column;padding:0 5%;display:none;position:absolute}.art-video-player.art-subtitle-show .art-subtitle{display:flex}.art-video-player.art-control-show .art-subtitle{bottom:calc(var(--art-control-height) + var(--art-subtitle-bottom))}.art-danmuku{z-index:30;pointer-events:none;width:100%;height:100%;position:absolute;inset:0;overflow:hidden}.art-video-player .art-layers{z-index:40;pointer-events:none;width:100%;height:100%;display:none;position:absolute;inset:0}.art-video-player .art-layers .art-layer{pointer-events:auto}.art-video-player.art-layer-show .art-layers{display:flex}.art-video-player .art-mask{z-index:50;pointer-events:none;justify-content:center;align-items:center;width:100%;height:100%;display:flex;position:absolute;inset:0}.art-video-player .art-mask .art-state{opacity:0;width:var(--art-state-size);height:var(--art-state-size);transition:all var(--art-transition-duration)ease;justify-content:center;align-items:center;display:flex;transform:scale(2)}.art-video-player.art-mask-show .art-state{cursor:pointer;pointer-events:auto;opacity:var(--art-state-opacity);transform:scale(1)}.art-video-player.art-loading-show .art-state{display:none}.art-video-player .art-loading{z-index:70;pointer-events:none;justify-content:center;align-items:center;width:100%;height:100%;display:none;position:absolute;inset:0}.art-video-player.art-loading-show .art-loading{display:flex}.art-video-player .art-bottom{z-index:60;opacity:0;pointer-events:none;width:100%;height:100%;padding:0 var(--art-padding);transition:all var(--art-transition-duration)ease;background-size:100% var(--art-bottom-height);background-image:linear-gradient(#0000,#0006,#000);background-position:bottom;background-repeat:repeat-x;flex-direction:column;justify-content:flex-end;display:flex;position:absolute;inset:0;overflow:hidden}.art-video-player .art-bottom .art-controls,.art-video-player .art-bottom .art-progress{transform:translateY(var(--art-bottom-offset));transition:transform var(--art-transition-duration)ease}.art-video-player.art-control-show .art-bottom,.art-video-player.art-hover .art-bottom{opacity:1}.art-video-player.art-control-show .art-bottom .art-controls,.art-video-player.art-hover .art-bottom .art-controls,.art-video-player.art-control-show .art-bottom .art-progress,.art-video-player.art-hover .art-bottom .art-progress{transform:translateY(0)}.art-bottom .art-progress{z-index:0;pointer-events:auto;padding-bottom:var(--art-bottom-gap);position:relative}.art-bottom .art-progress .art-control-progress{cursor:pointer;height:var(--art-progress-height);justify-content:center;align-items:center;display:flex;position:relative}.art-bottom .art-progress .art-control-progress .art-control-progress-inner{width:100%;height:50%;transition:height var(--art-transition-duration)ease;background-color:var(--art-progress-color);align-items:center;display:flex;position:relative}.art-bottom .art-progress .art-control-progress .art-control-progress-inner .art-progress-hover{z-index:0;background-color:var(--art-hover-color);width:0%;height:100%;position:absolute;inset:0}.art-bottom .art-progress .art-control-progress .art-control-progress-inner .art-progress-loaded{z-index:10;background-color:var(--art-loaded-color);width:0%;height:100%;position:absolute;inset:0}.art-bottom .art-progress .art-control-progress .art-control-progress-inner .art-progress-played{z-index:20;background-color:var(--art-theme);width:0%;height:100%;position:absolute;inset:0}.art-bottom .art-progress .art-control-progress .art-control-progress-inner .art-progress-highlight{z-index:30;pointer-events:none;width:100%;height:100%;position:absolute;inset:0}.art-bottom .art-progress .art-control-progress .art-control-progress-inner .art-progress-highlight span{z-index:0;pointer-events:auto;width:100%;height:100%;transform:translateX(calc(var(--art-highlight-width)/-2));background-color:var(--art-highlight-color);position:absolute;inset:0 auto 0 0;width:var(--art-highlight-width)!important}.art-bottom .art-progress .art-control-progress .art-control-progress-inner .art-progress-indicator{z-index:40;width:var(--art-indicator-size);height:var(--art-indicator-size);transform:scale(var(--art-indicator-scale));margin-left:calc(var(--art-indicator-size)/-2);transition:transform var(--art-transition-duration)ease;border-radius:50%;justify-content:center;align-items:center;display:flex;position:absolute;left:0}.art-bottom .art-progress .art-control-progress .art-control-progress-inner .art-progress-indicator .art-icon{pointer-events:none;width:100%;height:100%}.art-bottom .art-progress .art-control-progress .art-control-progress-inner .art-progress-indicator:hover{transform:scale(1.2)!important}.art-bottom .art-progress .art-control-progress .art-control-progress-inner .art-progress-indicator:active{transform:scale(1)!important}.art-bottom .art-progress .art-control-progress .art-control-progress-inner .art-progress-tip{z-index:50;border-radius:var(--art-border-radius);white-space:nowrap;background-color:var(--art-tip-background);padding:3px 5px;font-size:12px;line-height:1;display:none;position:absolute;top:-25px;left:0}.art-bottom .art-progress .art-control-progress:hover .art-control-progress-inner{height:100%}.art-bottom .art-progress .art-control-thumbnails{bottom:calc(var(--art-bottom-gap) + 10px);border-radius:var(--art-border-radius);pointer-events:none;background-color:var(--art-widget-background);display:none;position:absolute;left:0;box-shadow:0 1px 3px #0003,0 1px 2px -1px #0003}.art-bottom:hover .art-progress .art-control-progress .art-control-progress-inner .art-progress-indicator{transform:scale(1)}.art-controls{z-index:10;pointer-events:auto;height:var(--art-control-height);justify-content:space-between;align-items:center;display:flex;position:relative}.art-controls .art-controls-left,.art-controls .art-controls-right{height:100%;display:flex}.art-controls .art-controls-center{flex:1;justify-content:center;align-items:center;height:100%;padding:0 10px;display:none}.art-controls .art-controls-right{justify-content:flex-end}.art-controls .art-control{cursor:pointer;white-space:nowrap;opacity:var(--art-control-opacity);min-height:var(--art-control-height);min-width:var(--art-control-height);transition:opacity var(--art-transition-duration)ease;flex-shrink:0;justify-content:center;align-items:center;display:flex}.art-controls .art-control .art-icon{height:var(--art-control-icon-size);width:var(--art-control-icon-size);transform:scale(var(--art-control-icon-scale));transition:transform var(--art-transition-duration)ease}.art-controls .art-control .art-icon:active{transform:scale(calc(var(--art-control-icon-scale)*.8))}.art-controls .art-control:hover{opacity:1}.art-control-volume{position:relative}.art-control-volume .art-volume-panel{text-align:center;cursor:default;opacity:0;pointer-events:none;left:0;right:0;bottom:var(--art-control-height);width:var(--art-control-height);height:var(--art-volume-height);transition:all var(--art-transition-duration)ease;justify-content:center;align-items:center;padding:0 5px;font-size:12px;display:flex;position:absolute;transform:translateY(10px)}.art-control-volume .art-volume-panel .art-volume-inner{border-radius:var(--art-border-radius);background-color:var(--art-widget-background);flex-direction:column;align-items:center;gap:10px;width:100%;height:100%;padding:10px 0 12px;display:flex}.art-control-volume .art-volume-panel .art-volume-inner .art-volume-slider{cursor:pointer;flex:1;justify-content:center;width:100%;display:flex;position:relative}.art-control-volume .art-volume-panel .art-volume-inner .art-volume-slider .art-volume-handle{border-radius:var(--art-border-radius);background-color:#ffffff40;justify-content:center;width:2px;display:flex;position:relative;overflow:hidden}.art-control-volume .art-volume-panel .art-volume-inner .art-volume-slider .art-volume-handle .art-volume-loaded{z-index:0;background-color:var(--art-theme);width:100%;height:100%;position:absolute;inset:0}.art-control-volume .art-volume-panel .art-volume-inner .art-volume-slider .art-volume-indicator{width:var(--art-volume-handle-size);height:var(--art-volume-handle-size);margin-top:calc(var(--art-volume-handle-size)/-2);background-color:var(--art-theme);transition:transform var(--art-transition-duration)ease;border-radius:100%;flex-shrink:0;position:absolute;transform:scale(1)}.art-control-volume .art-volume-panel .art-volume-inner .art-volume-slider:active .art-volume-indicator{transform:scale(.9)}.art-control-volume:hover .art-volume-panel{opacity:1;pointer-events:auto;transform:translateY(0)}.art-video-player .art-notice{z-index:80;width:100%;height:auto;padding:var(--art-padding);pointer-events:none;display:none;position:absolute;inset:0 0 auto}.art-video-player .art-notice .art-notice-inner{border-radius:var(--art-border-radius);background-color:var(--art-tip-background);padding:5px;line-height:1;display:inline-flex}.art-video-player.art-notice-show .art-notice{display:flex}.art-video-player .art-contextmenus{z-index:120;border-radius:var(--art-border-radius);background-color:var(--art-widget-background);min-width:var(--art-contextmenus-min-width);flex-direction:column;padding:5px 0;font-size:12px;display:none;position:absolute}.art-video-player .art-contextmenus .art-contextmenu{cursor:pointer;border-bottom:1px solid #ffffff1a;padding:10px 15px;display:flex}.art-video-player .art-contextmenus .art-contextmenu span{padding:0 8px}.art-video-player .art-contextmenus .art-contextmenu span:hover,.art-video-player .art-contextmenus .art-contextmenu span.art-current{color:var(--art-theme)}.art-video-player .art-contextmenus .art-contextmenu:hover{background-color:#ffffff1a}.art-video-player .art-contextmenus .art-contextmenu:last-child{border-bottom:none}.art-video-player.art-contextmenu-show .art-contextmenus{display:flex}.art-video-player .art-settings{z-index:90;border-radius:var(--art-border-radius);max-height:var(--art-settings-max-height);left:auto;right:var(--art-padding);bottom:var(--art-control-height);transition:all var(--art-transition-duration)ease;background-color:var(--art-widget-background);flex-direction:column;display:none;position:absolute;overflow:hidden auto}.art-video-player .art-settings .art-setting-panel{flex-direction:column;display:none}.art-video-player .art-settings .art-setting-panel.art-current{display:flex}.art-video-player .art-settings .art-setting-panel .art-setting-item{cursor:pointer;transition:background-color var(--art-transition-duration)ease;justify-content:space-between;align-items:center;padding:0 5px;display:flex;overflow:hidden}.art-video-player .art-settings .art-setting-panel .art-setting-item:hover{background-color:#ffffff1a}.art-video-player .art-settings .art-setting-panel .art-setting-item.art-current{color:var(--art-theme)}.art-video-player .art-settings .art-setting-panel .art-setting-item .art-icon-check{visibility:hidden;height:15px}.art-video-player .art-settings .art-setting-panel .art-setting-item.art-current .art-icon-check{visibility:visible}.art-video-player .art-settings .art-setting-panel .art-setting-item .art-setting-item-left{flex-shrink:0;justify-content:center;align-items:center;gap:5px;display:flex}.art-video-player .art-settings .art-setting-panel .art-setting-item .art-setting-item-left .art-setting-item-left-icon{height:var(--art-settings-icon-size);width:var(--art-settings-icon-size);justify-content:center;align-items:center;display:flex}.art-video-player .art-settings .art-setting-panel .art-setting-item .art-setting-item-right{justify-content:center;align-items:center;gap:5px;font-size:12px;display:flex}.art-video-player .art-settings .art-setting-panel .art-setting-item .art-setting-item-right .art-setting-item-right-tooltip{white-space:nowrap;color:#ffffff80}.art-video-player .art-settings .art-setting-panel .art-setting-item .art-setting-item-right .art-setting-item-right-icon{justify-content:center;align-items:center;min-width:32px;height:24px;display:flex}.art-video-player .art-settings .art-setting-panel .art-setting-item .art-setting-item-right .art-setting-range{appearance:none;background-color:#fff3;outline:none;width:80px;height:3px}.art-video-player .art-settings .art-setting-panel .art-setting-item-back{border-bottom:1px solid #ffffff1a}.art-video-player.art-setting-show .art-settings{display:flex}.art-video-player .art-info{left:var(--art-padding);top:var(--art-padding);z-index:100;border-radius:var(--art-border-radius);background-color:var(--art-widget-background);padding:10px;font-size:12px;display:none;position:absolute}.art-video-player .art-info .art-info-panel{flex-direction:column;gap:5px;display:flex}.art-video-player .art-info .art-info-panel .art-info-item{align-items:center;gap:5px;display:flex}.art-video-player .art-info .art-info-panel .art-info-item .art-info-title{text-align:right;width:100px}.art-video-player .art-info .art-info-panel .art-info-item .art-info-content{text-overflow:ellipsis;white-space:nowrap;user-select:all;width:250px;overflow:hidden}.art-video-player .art-info .art-info-close{cursor:pointer;position:absolute;top:5px;right:5px}.art-video-player.art-info-show .art-info{display:flex}.art-hide-cursor *{cursor:none!important}.art-video-player[data-aspect-ratio]{overflow:hidden}.art-video-player[data-aspect-ratio] .art-video{object-fit:fill;box-sizing:content-box}.art-fullscreen{--art-progress-height:8px;--art-indicator-size:20px;--art-control-height:60px;--art-control-icon-scale:1.3}.art-fullscreen-web{--art-progress-height:8px;--art-indicator-size:20px;--art-control-height:60px;--art-control-icon-scale:1.3;z-index:var(--art-fullscreen-web-index);width:100%;height:100%;position:fixed;inset:0}.art-mini-popup{z-index:9999;border-radius:var(--art-border-radius);cursor:move;user-select:none;background:#000;width:320px;height:180px;transition:opacity .2s;position:fixed;overflow:hidden;box-shadow:0 0 5px #00000080}.art-mini-popup svg{fill:#fff}.art-mini-popup .art-video{pointer-events:none}.art-mini-popup .art-mini-close{z-index:20;cursor:pointer;opacity:0;transition:opacity .2s;position:absolute;top:10px;right:10px}.art-mini-popup .art-mini-state{z-index:30;pointer-events:none;opacity:0;background-color:#00000040;justify-content:center;align-items:center;width:100%;height:100%;transition:opacity .2s;display:flex;position:absolute;inset:0}.art-mini-popup .art-mini-state .art-icon{opacity:.75;cursor:pointer;pointer-events:auto;transition:transform .2s;transform:scale(3)}.art-mini-popup .art-mini-state .art-icon:active{transform:scale(2.5)}.art-mini-popup.art-mini-dragging{opacity:.9}.art-mini-popup:hover .art-mini-close,.art-mini-popup:hover .art-mini-state{opacity:1}.art-video-player[data-flip=horizontal] .art-video{transform:scaleX(-1)}.art-video-player[data-flip=vertical] .art-video{transform:scaleY(-1)}.art-video-player .art-layer-lock{height:var(--art-lock-size);width:var(--art-lock-size);top:50%;left:var(--art-padding);background-color:var(--art-tip-background);border-radius:50%;justify-content:center;align-items:center;display:none;position:absolute;transform:translateY(-50%)}.art-video-player .art-layer-auto-playback{border-radius:var(--art-border-radius);left:var(--art-padding);bottom:calc(var(--art-control-height) + var(--art-bottom-gap) + 10px);background-color:var(--art-widget-background);align-items:center;gap:10px;padding:10px;line-height:1;display:none;position:absolute}.art-video-player .art-layer-auto-playback .art-auto-playback-close{cursor:pointer;justify-content:center;align-items:center;display:flex}.art-video-player .art-layer-auto-playback .art-auto-playback-close svg{width:15px;height:15px;fill:var(--art-theme)}.art-video-player .art-layer-auto-playback .art-auto-playback-jump{color:var(--art-theme);cursor:pointer}.art-video-player.art-lock .art-subtitle{bottom:var(--art-subtitle-bottom)!important}.art-video-player.art-mini-progress-bar .art-bottom,.art-video-player.art-lock .art-bottom{opacity:1;background-image:none;padding:0}.art-video-player.art-mini-progress-bar .art-bottom .art-controls,.art-video-player.art-lock .art-bottom .art-controls,.art-video-player.art-mini-progress-bar .art-bottom .art-progress,.art-video-player.art-lock .art-bottom .art-progress{transform:translateY(calc(var(--art-control-height) + var(--art-bottom-gap) + var(--art-progress-height)/4))}.art-video-player.art-mini-progress-bar .art-bottom .art-progress-indicator,.art-video-player.art-lock .art-bottom .art-progress-indicator{display:none!important}.art-video-player.art-control-show .art-layer-lock{display:flex}.art-control-selector{justify-content:center;display:flex;position:relative}.art-control-selector .art-selector-list{text-align:center;border-radius:var(--art-border-radius);opacity:0;pointer-events:none;bottom:var(--art-control-height);max-height:var(--art-selector-max-height);background-color:var(--art-widget-background);transition:all var(--art-transition-duration)ease;flex-direction:column;align-items:center;display:flex;position:absolute;overflow:hidden auto;transform:translateY(10px)}.art-control-selector .art-selector-list .art-selector-item{flex-shrink:0;justify-content:center;align-items:center;width:100%;padding:10px 15px;line-height:1;display:flex}.art-control-selector .art-selector-list .art-selector-item:hover{background-color:#ffffff1a}.art-control-selector .art-selector-list .art-selector-item:hover,.art-control-selector .art-selector-list .art-selector-item.art-current{color:var(--art-theme)}.art-control-selector:hover .art-selector-list{opacity:1;pointer-events:auto;transform:translateY(0)}[class*=hint--]{font-style:normal;display:inline-block;position:relative}[class*=hint--]:before,[class*=hint--]:after{visibility:hidden;opacity:0;z-index:1000000;pointer-events:none;transition:all .3s;position:absolute;transform:translate(0,0)}[class*=hint--]:hover:before,[class*=hint--]:hover:after{visibility:visible;opacity:1;transition-delay:.1s}[class*=hint--]:before{content:"";z-index:1000001;background:0 0;border:6px solid #0000;position:absolute}[class*=hint--]:after{color:#fff;white-space:nowrap;background:#000;padding:8px 10px;font-family:Helvetica Neue,Helvetica,Arial,sans-serif;font-size:12px;line-height:12px}[class*=hint--][aria-label]:after{content:attr(aria-label)}[class*=hint--][data-hint]:after{content:attr(data-hint)}[aria-label=""]:before,[aria-label=""]:after,[data-hint=""]:before,[data-hint=""]:after{display:none!important}.hint--top-left:before,.hint--top-right:before,.hint--top:before{border-top-color:#000}.hint--bottom-left:before,.hint--bottom-right:before,.hint--bottom:before{border-bottom-color:#000}.hint--left:before{border-left-color:#000}.hint--right:before{border-right-color:#000}.hint--top:before{margin-bottom:-11px}.hint--top:before,.hint--top:after{bottom:100%;left:50%}.hint--top:before{left:calc(50% - 6px)}.hint--top:after{transform:translate(-50%)}.hint--top:hover:before{transform:translateY(-8px)}.hint--top:hover:after{transform:translate(-50%)translateY(-8px)}.hint--bottom:before{margin-top:-11px}.hint--bottom:before,.hint--bottom:after{top:100%;left:50%}.hint--bottom:before{left:calc(50% - 6px)}.hint--bottom:after{transform:translate(-50%)}.hint--bottom:hover:before{transform:translateY(8px)}.hint--bottom:hover:after{transform:translate(-50%)translateY(8px)}.hint--right:before{margin-bottom:-6px;margin-left:-11px}.hint--right:after{margin-bottom:-14px}.hint--right:before,.hint--right:after{bottom:50%;left:100%}.hint--right:hover:before,.hint--right:hover:after{transform:translate(8px)}.hint--left:before{margin-bottom:-6px;margin-right:-11px}.hint--left:after{margin-bottom:-14px}.hint--left:before,.hint--left:after{bottom:50%;right:100%}.hint--left:hover:before,.hint--left:hover:after{transform:translate(-8px)}.hint--top-left:before{margin-bottom:-11px}.hint--top-left:before,.hint--top-left:after{bottom:100%;left:50%}.hint--top-left:before{left:calc(50% - 6px)}.hint--top-left:after{margin-left:12px;transform:translate(-100%)}.hint--top-left:hover:before{transform:translateY(-8px)}.hint--top-left:hover:after{transform:translate(-100%)translateY(-8px)}.hint--top-right:before{margin-bottom:-11px}.hint--top-right:before,.hint--top-right:after{bottom:100%;left:50%}.hint--top-right:before{left:calc(50% - 6px)}.hint--top-right:after{margin-left:-12px;transform:translate(0)}.hint--top-right:hover:before,.hint--top-right:hover:after{transform:translateY(-8px)}.hint--bottom-left:before{margin-top:-11px}.hint--bottom-left:before,.hint--bottom-left:after{top:100%;left:50%}.hint--bottom-left:before{left:calc(50% - 6px)}.hint--bottom-left:after{margin-left:12px;transform:translate(-100%)}.hint--bottom-left:hover:before{transform:translateY(8px)}.hint--bottom-left:hover:after{transform:translate(-100%)translateY(8px)}.hint--bottom-right:before{margin-top:-11px}.hint--bottom-right:before,.hint--bottom-right:after{top:100%;left:50%}.hint--bottom-right:before{left:calc(50% - 6px)}.hint--bottom-right:after{margin-left:-12px;transform:translate(0)}.hint--bottom-right:hover:before,.hint--bottom-right:hover:after{transform:translateY(8px)}.hint--small:after,.hint--medium:after,.hint--large:after{white-space:normal;word-wrap:break-word;line-height:1.4em}.hint--small:after{width:80px}.hint--medium:after{width:150px}.hint--large:after{width:300px}[class*=hint--]:after{text-shadow:0 -1px #000;box-shadow:4px 4px 8px #0000004d}.hint--error:after{text-shadow:0 -1px #592726;background-color:#b34e4d}.hint--error.hint--top-left:before,.hint--error.hint--top-right:before,.hint--error.hint--top:before{border-top-color:#b34e4d}.hint--error.hint--bottom-left:before,.hint--error.hint--bottom-right:before,.hint--error.hint--bottom:before{border-bottom-color:#b34e4d}.hint--error.hint--left:before{border-left-color:#b34e4d}.hint--error.hint--right:before{border-right-color:#b34e4d}.hint--warning:after{text-shadow:0 -1px #6c5328;background-color:#c09854}.hint--warning.hint--top-left:before,.hint--warning.hint--top-right:before,.hint--warning.hint--top:before{border-top-color:#c09854}.hint--warning.hint--bottom-left:before,.hint--warning.hint--bottom-right:before,.hint--warning.hint--bottom:before{border-bottom-color:#c09854}.hint--warning.hint--left:before{border-left-color:#c09854}.hint--warning.hint--right:before{border-right-color:#c09854}.hint--info:after{text-shadow:0 -1px #1a3c4d;background-color:#3986ac}.hint--info.hint--top-left:before,.hint--info.hint--top-right:before,.hint--info.hint--top:before{border-top-color:#3986ac}.hint--info.hint--bottom-left:before,.hint--info.hint--bottom-right:before,.hint--info.hint--bottom:before{border-bottom-color:#3986ac}.hint--info.hint--left:before{border-left-color:#3986ac}.hint--info.hint--right:before{border-right-color:#3986ac}.hint--success:after{text-shadow:0 -1px #1a321a;background-color:#458746}.hint--success.hint--top-left:before,.hint--success.hint--top-right:before,.hint--success.hint--top:before{border-top-color:#458746}.hint--success.hint--bottom-left:before,.hint--success.hint--bottom-right:before,.hint--success.hint--bottom:before{border-bottom-color:#458746}.hint--success.hint--left:before{border-left-color:#458746}.hint--success.hint--right:before{border-right-color:#458746}.hint--always:after,.hint--always:before{opacity:1;visibility:visible}.hint--always.hint--top:before{transform:translateY(-8px)}.hint--always.hint--top:after{transform:translate(-50%)translateY(-8px)}.hint--always.hint--top-left:before{transform:translateY(-8px)}.hint--always.hint--top-left:after{transform:translate(-100%)translateY(-8px)}.hint--always.hint--top-right:before,.hint--always.hint--top-right:after{transform:translateY(-8px)}.hint--always.hint--bottom:before{transform:translateY(8px)}.hint--always.hint--bottom:after{transform:translate(-50%)translateY(8px)}.hint--always.hint--bottom-left:before{transform:translateY(8px)}.hint--always.hint--bottom-left:after{transform:translate(-100%)translateY(8px)}.hint--always.hint--bottom-right:before,.hint--always.hint--bottom-right:after{transform:translateY(8px)}.hint--always.hint--left:before,.hint--always.hint--left:after{transform:translate(-8px)}.hint--always.hint--right:before,.hint--always.hint--right:after{transform:translate(8px)}.hint--rounded:after{border-radius:4px}.hint--no-animate:before,.hint--no-animate:after{transition-duration:0s}.hint--bounce:before,.hint--bounce:after{-webkit-transition:opacity .3s,visibility .3s,-webkit-transform .3s cubic-bezier(.71,1.7,.77,1.24);-moz-transition:opacity .3s,visibility .3s,-moz-transform .3s cubic-bezier(.71,1.7,.77,1.24);transition:opacity .3s,visibility .3s,transform .3s cubic-bezier(.71,1.7,.77,1.24)}.hint--no-shadow:before,.hint--no-shadow:after{text-shadow:initial;box-shadow:initial}.hint--no-arrow:before{display:none}.art-video-player.art-mobile{--art-bottom-gap:10px;--art-control-height:38px;--art-control-icon-scale:1;--art-state-size:60px;--art-settings-max-height:180px;--art-selector-max-height:180px;--art-indicator-scale:1;--art-control-opacity:1}.art-video-player.art-mobile .art-controls-left{margin-left:calc(var(--art-padding)/-1)}.art-video-player.art-mobile .art-controls-right{margin-right:calc(var(--art-padding)/-1)}'},{}],g7VGh:[function(a,h,o,m){h.exports=function(){function e(l){return(e=typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?function(c){return typeof c}:function(c){return c&&typeof Symbol=="function"&&c.constructor===Symbol&&c!==Symbol.prototype?"symbol":typeof c})(l)}var t=Object.prototype.toString,u=function(l){if(l===void 0)return"undefined";if(l===null)return"null";var c=e(l);if(c==="boolean")return"boolean";if(c==="string")return"string";if(c==="number")return"number";if(c==="symbol")return"symbol";if(c==="function")return r(l)==="GeneratorFunction"?"generatorfunction":"function";if(Array.isArray?Array.isArray(l):l instanceof Array)return"array";if(l.constructor&&typeof l.constructor.isBuffer=="function"&&l.constructor.isBuffer(l))return"buffer";if(function(p){try{if(typeof p.length=="number"&&typeof p.callee=="function")return!0}catch(d){if(d.message.indexOf("callee")!==-1)return!0}return!1}(l))return"arguments";if(l instanceof Date||typeof l.toDateString=="function"&&typeof l.getDate=="function"&&typeof l.setDate=="function")return"date";if(l instanceof Error||typeof l.message=="string"&&l.constructor&&typeof l.constructor.stackTraceLimit=="number")return"error";if(l instanceof RegExp||typeof l.flags=="string"&&typeof l.ignoreCase=="boolean"&&typeof l.multiline=="boolean"&&typeof l.global=="boolean")return"regexp";switch(r(l)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(typeof l.throw=="function"&&typeof l.return=="function"&&typeof l.next=="function")return"generator";switch(c=t.call(l)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return c.slice(8,-1).toLowerCase().replace(/\s/g,"")};function r(l){return l.constructor?l.constructor.name:null}function i(l,c){var p=2","license":"MIT","homepage":"https://artplayer.org","repository":{"type":"git","url":"git+https://github.com/zhw2590582/ArtPlayer.git"},"bugs":{"url":"https://github.com/zhw2590582/ArtPlayer/issues"},"keywords":["html5","video","player"],"exports":{".":{"types":"./types/artplayer.d.ts","import":"./dist/artplayer.mjs","require":"./dist/artplayer.js"},"./legacy":{"types":"./types/artplayer.d.ts","import":"./dist/artplayer.legacy.js","require":"./dist/artplayer.legacy.js"},"./i18n/*":{"types":"./types/i18n.d.ts","import":"./dist/i18n/*.mjs","require":"./dist/i18n/*.js"}},"main":"./dist/artplayer.js","module":"./dist/artplayer.mjs","types":"./types/artplayer.d.ts","typesVersions":{"*":{"i18n/*":["types/i18n.d.ts"],"legacy":["types/artplayer.d.ts"]}},"legacy":"./dist/artplayer.legacy.js","browserslist":"last 1 Chrome version","dependencies":{"option-validator":"^2.0.6"}}')},{}],eJfh8:[function(a,h,o,m){a("@parcel/transformer-js/src/esmodule-helpers.js").defineInteropFlag(o),o.default={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webkitSupportsFullscreen","webkitDisplayingFullscreen","onenterpictureinpicture","onleavepictureinpicture","disablePictureInPicture","cancelVideoFrameCallback","requestVideoFrameCallback","getVideoPlaybackQuality","requestPictureInPicture","webkitEnterFullScreen","webkitEnterFullscreen","webkitExitFullScreen","webkitExitFullscreen"]}},{"@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],loqXi:[function(a,h,o,m){o.interopDefault=function(e){return e&&e.__esModule?e:{default:e}},o.defineInteropFlag=function(e){Object.defineProperty(e,"__esModule",{value:!0})},o.exportAll=function(e,t){return Object.keys(e).forEach(function(u){u==="default"||u==="__esModule"||Object.prototype.hasOwnProperty.call(t,u)||Object.defineProperty(t,u,{enumerable:!0,get:function(){return e[u]}})}),t},o.export=function(e,t,u){Object.defineProperty(e,t,{enumerable:!0,get:u})}},{}],"9zso8":[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o);var t=a("../utils"),u=a("../utils/component"),r=e.interopDefault(u),i=a("./aspectRatio"),n=e.interopDefault(i),s=a("./close"),l=e.interopDefault(s),c=a("./flip"),p=e.interopDefault(c),d=a("./info"),g=e.interopDefault(d),y=a("./playbackRate"),x=e.interopDefault(y),f=a("./version"),v=e.interopDefault(f);class w extends r.default{constructor(E){super(E),this.name="contextmenu",this.$parent=E.template.$contextmenu,t.isMobile||this.init()}init(){let{option:E,proxy:S,template:{$player:$,$contextmenu:C}}=this.art;E.playbackRate&&this.add((0,x.default)({name:"playbackRate",index:10})),E.aspectRatio&&this.add((0,n.default)({name:"aspectRatio",index:20})),E.flip&&this.add((0,p.default)({name:"flip",index:30})),this.add((0,g.default)({name:"info",index:40})),this.add((0,v.default)({name:"version",index:50})),this.add((0,l.default)({name:"close",index:60}));for(let T=0;T{if(!this.art.constructor.CONTEXTMENU)return;T.preventDefault(),this.show=!0;let q=T.clientX,B=T.clientY,{height:k,width:b,left:I,top:F}=(0,t.getRect)($),{height:D,width:M}=(0,t.getRect)(C),L=q-I,O=B-F;q+M>I+b&&(L=b-M),B+D>F+k&&(O=k-D),(0,t.setStyles)(C,{top:`${O}px`,left:`${L}px`})}),S($,"click",T=>{(0,t.includeFromEvent)(T,C)||(this.show=!1)}),this.art.on("blur",()=>{this.show=!1})}}o.default=w},{"../utils":"aBlEo","../utils/component":"idCEj","./aspectRatio":"6XHP2","./close":"eF6AX","./flip":"7Wg1P","./info":"fjRnU","./playbackRate":"hm1DY","./version":"aJBeL","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],aBlEo:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o);var t=a("./compatibility");e.exportAll(t,o);var u=a("./dom");e.exportAll(u,o);var r=a("./error");e.exportAll(r,o);var i=a("./file");e.exportAll(i,o);var n=a("./format");e.exportAll(n,o);var s=a("./property");e.exportAll(s,o);var l=a("./subtitle");e.exportAll(l,o);var c=a("./time");e.exportAll(c,o)},{"./compatibility":"jg0yq","./dom":"eANXw","./error":"4FwTI","./file":"i2JbS","./format":"dy9GH","./property":"jY49c","./subtitle":"ke7ox","./time":"f7gsx","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],jg0yq:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"userAgent",()=>t),e.export(o,"isSafari",()=>u),e.export(o,"isIOS",()=>r),e.export(o,"isIOS13",()=>i),e.export(o,"isMobile",()=>n),e.export(o,"isBrowser",()=>s);let t=(globalThis==null?void 0:globalThis.CUSTOM_USER_AGENT)??(typeof navigator<"u"?navigator.userAgent:""),u=/^(?:(?!chrome|android).)*safari/i.test(t),r=/iPad|iPhone|iPod/i.test(t)&&!window.MSStream,i=r||t.includes("Macintosh")&&navigator.maxTouchPoints>=1,n=/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(t)||i,s=typeof window<"u"&&typeof document<"u"},{"@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],eANXw:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"query",()=>u),e.export(o,"queryAll",()=>r),e.export(o,"addClass",()=>i),e.export(o,"removeClass",()=>n),e.export(o,"hasClass",()=>s),e.export(o,"append",()=>l),e.export(o,"remove",()=>c),e.export(o,"setStyle",()=>p),e.export(o,"setStyles",()=>d),e.export(o,"getStyle",()=>g),e.export(o,"siblings",()=>y),e.export(o,"inverseClass",()=>x),e.export(o,"tooltip",()=>f),e.export(o,"isInViewport",()=>v),e.export(o,"includeFromEvent",()=>w),e.export(o,"replaceElement",()=>j),e.export(o,"createElement",()=>E),e.export(o,"getIcon",()=>S),e.export(o,"setStyleText",()=>$),e.export(o,"supportsFlex",()=>C),e.export(o,"getRect",()=>T),e.export(o,"loadImg",()=>q),e.export(o,"getComposedPath",()=>B);var t=a("./compatibility");function u(k,b=document){return b.querySelector(k)}function r(k,b=document){return Array.from(b.querySelectorAll(k))}function i(k,b){return k.classList.add(b)}function n(k,b){return k.classList.remove(b)}function s(k,b){return k.classList.contains(b)}function l(k,b){return b instanceof Element?k.appendChild(b):k.insertAdjacentHTML("beforeend",String(b)),k.lastElementChild||k.lastChild}function c(k){return k.parentNode.removeChild(k)}function p(k,b,I){return k.style[b]=I,k}function d(k,b){for(let I in b)p(k,I,b[I]);return k}function g(k,b,I=!0){let F=window.getComputedStyle(k,null).getPropertyValue(b);return I?Number.parseFloat(F):F}function y(k){return Array.from(k.parentElement.children).filter(b=>b!==k)}function x(k,b){y(k).forEach(I=>n(I,b)),i(k,b)}function f(k,b,I="top"){t.isMobile||(k.setAttribute("aria-label",b),i(k,"hint--rounded"),i(k,`hint--${I}`))}function v(k,b=0){let I=k.getBoundingClientRect(),F=window.innerHeight||document.documentElement.clientHeight,D=window.innerWidth||document.documentElement.clientWidth,M=I.top-b<=F&&I.top+I.height+b>=0,L=I.left-b<=D+b&&I.left+I.width+b>=0;return M&&L}function w(k,b){return B(k).includes(b)}function j(k,b){return b.parentNode.replaceChild(k,b),k}function E(k){return document.createElement(k)}function S(k="",b=""){let I=E("i");return i(I,"art-icon"),i(I,`art-icon-${k}`),l(I,b),I}function $(k,b){let I=document.getElementById(k);I||((I=document.createElement("style")).id=k,document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>{document.head.appendChild(I)}):(document.head||document.documentElement).appendChild(I)),I.textContent=b}function C(){let k=document.createElement("div");return k.style.display="flex",k.style.display==="flex"}function T(k){return k.getBoundingClientRect()}function q(k,b){return new Promise((I,F)=>{let D=new Image;D.onload=function(){if(b&&b!==1){let M=document.createElement("canvas"),L=M.getContext("2d");M.width=D.width*b,M.height=D.height*b,L.drawImage(D,0,0,M.width,M.height),M.toBlob(O=>{let P=URL.createObjectURL(O),_=new Image;_.onload=function(){I(_)},_.onerror=function(){URL.revokeObjectURL(P),F(Error(`Image load failed: ${k}`))},_.src=P})}else I(D)},D.onerror=function(){F(Error(`Image load failed: ${k}`))},D.src=k})}function B(k){if(k.composedPath)return k.composedPath();let b=[],I=k.target;for(;I;)b.push(I),I=I.parentNode;return b.includes(window)||window===void 0||b.push(window),b}},{"./compatibility":"jg0yq","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],"4FwTI":[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"ArtPlayerError",()=>t),e.export(o,"errorHandle",()=>u);class t extends Error{constructor(i,n){super(i),typeof Error.captureStackTrace=="function"&&Error.captureStackTrace(this,n||this.constructor),this.name="ArtPlayerError"}}function u(r,i){if(!r)throw new t(i);return r}},{"@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],i2JbS:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");function t(u,r){let i=document.createElement("a");i.style.display="none",i.href=u,i.download=r,document.body.appendChild(i),i.click(),document.body.removeChild(i)}e.defineInteropFlag(o),e.export(o,"getExt",()=>function u(r){return r.includes("?")?u(r.split("?")[0]):r.includes("#")?u(r.split("#")[0]):r.trim().toLowerCase().split(".").pop()}),e.export(o,"download",()=>t)},{"@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],dy9GH:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");function t(s,l,c){return Math.max(Math.min(s,Math.max(l,c)),Math.min(l,c))}function u(s){return s.charAt(0).toUpperCase()+s.slice(1)}function r(s){if(!s)return"00:00";let l=Math.floor(s/3600),c=Math.floor((s-3600*l)/60),p=Math.floor(s-3600*l-60*c);return(l>0?[l,c,p]:[c,p]).map(d=>d<10?`0${d}`:String(d)).join(":")}function i(s){return s.replace(/[&<>'"]/g,l=>({"&":"&","<":"<",">":">","'":"'",'"':"""})[l]||l)}function n(s){let l={"&":"&","<":"<",">":">","'":"'",""":'"'},c=RegExp(`(${Object.keys(l).join("|")})`,"g");return s.replace(c,p=>l[p]||p)}e.defineInteropFlag(o),e.export(o,"clamp",()=>t),e.export(o,"capitalize",()=>u),e.export(o,"secondToTime",()=>r),e.export(o,"escape",()=>i),e.export(o,"unescape",()=>n)},{"@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],jY49c:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"def",()=>t),e.export(o,"has",()=>r),e.export(o,"get",()=>i),e.export(o,"mergeDeep",()=>function n(...s){let l=c=>c&&typeof c=="object"&&!Array.isArray(c);return s.reduce((c,p)=>(Object.keys(p).forEach(d=>{let g=c[d],y=p[d];Array.isArray(g)&&Array.isArray(y)?c[d]=g.concat(...y):l(g)&&l(y)?c[d]=n(g,y):c[d]=y}),c),{})});let t=Object.defineProperty,{hasOwnProperty:u}=Object.prototype;function r(n,s){return u.call(n,s)}function i(n,s){return Object.getOwnPropertyDescriptor(n,s)}},{"@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],ke7ox:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");function t(i){return`WEBVTT \r +\r +`.concat(i.replace(/(\d\d:\d\d:\d\d)[,.](\d+)/g,(n,s,l)=>{let c=l.slice(0,3);return l.length===1&&(c=`${l}00`),l.length===2&&(c=`${l}0`),`${s},${c}`}).replace(/\{\\([ibu])\}/g,"").replace(/\{\\([ibu])1\}/g,"<$1>").replace(/\{([ibu])\}/g,"<$1>").replace(/\{\/([ibu])\}/g,"").replace(/(\d\d:\d\d:\d\d),(\d\d\d)/g,"$1.$2").replace(/\{[\s\S]*?\}/g,"").concat(`\r +\r +`))}function u(i){return URL.createObjectURL(new Blob([i],{type:"text/vtt"}))}function r(i){let n=RegExp("Dialogue:\\s\\d,(\\d+:\\d\\d:\\d\\d.\\d\\d),(\\d+:\\d\\d:\\d\\d.\\d\\d),([^,]*),([^,]*),(?:[^,]*,){4}([\\s\\S]*)$","i");function s(l=""){return l.split(/[:.]/).map((c,p,d)=>{if(p===d.length-1){if(c.length===1)return`.${c}00`;if(c.length===2)return`.${c}0`}else if(c.length===1)return(p===0?"0":":0")+c;return p===0?c:p===d.length-1?`.${c}`:`:${c}`}).join("")}return`WEBVTT ${i.split(/\r?\n/).map(l=>{let c=l.match(n);return c?{start:s(c[1].trim()),end:s(c[2].trim()),text:c[5].replace(/\{[\s\S]*?\}/g,"").replace(/(\\N)/g,` +`).trim().split(/\r?\n/).map(p=>p.trim()).join(` +`)}:null}).filter(l=>l).map((l,c)=>l?`${c+1} ${l.start} --> ${l.end} ${l.text}`:"").filter(l=>l.trim()).join(` + +`)}`}e.defineInteropFlag(o),e.export(o,"srtToVtt",()=>t),e.export(o,"vttToBlob",()=>u),e.export(o,"assToVtt",()=>r)},{"@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],f7gsx:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");function t(i=0){return new Promise(n=>setTimeout(n,i))}function u(i,n){let s;return function(...l){let c=()=>(s=null,i.apply(this,l));clearTimeout(s),s=setTimeout(c,n)}}function r(i,n){let s=!1;return function(...l){s||(i.apply(this,l),s=!0,setTimeout(()=>{s=!1},n))}}e.defineInteropFlag(o),e.export(o,"sleep",()=>t),e.export(o,"debounce",()=>u),e.export(o,"throttle",()=>r)},{"@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],idCEj:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o);var t=a("option-validator"),u=e.interopDefault(t),r=a("../scheme"),i=a("./dom"),n=a("./error");o.default=class{constructor(s){this.id=0,this.art=s,this.cache=new Map,this.add=this.add.bind(this),this.remove=this.remove.bind(this),this.update=this.update.bind(this)}get show(){return(0,i.hasClass)(this.art.template.$player,`art-${this.name}-show`)}set show(s){let{$player:l}=this.art.template,c=`art-${this.name}-show`;s?(0,i.addClass)(l,c):(0,i.removeClass)(l,c),this.art.emit(this.name,s)}toggle(){this.show=!this.show}add(s){let l=typeof s=="function"?s(this.art):s;if(l.html=l.html||"",(0,u.default)(l,r.ComponentOption),!this.$parent||!this.name||l.disable)return;let c=l.name||`${this.name}${this.id}`,p=this.cache.get(c);(0,n.errorHandle)(!p,`Can't add an existing [${c}] to the [${this.name}]`),this.id+=1;let d=(0,i.createElement)("div");(0,i.addClass)(d,`art-${this.name}`),(0,i.addClass)(d,`art-${this.name}-${c}`);let g=Array.from(this.$parent.children);d.dataset.index=l.index||this.id;let y=g.find(f=>Number(f.dataset.index)>=Number(d.dataset.index));y?y.insertAdjacentElement("beforebegin",d):(0,i.append)(this.$parent,d),l.html&&(0,i.append)(d,l.html),l.style&&(0,i.setStyles)(d,l.style),l.tooltip&&(0,i.tooltip)(d,l.tooltip);let x=[];if(l.click){let f=this.art.events.proxy(d,"click",v=>{v.preventDefault(),l.click.call(this.art,this,v)});x.push(f)}return l.selector&&["left","right"].includes(l.position)&&this.selector(l,d,x),this[c]=d,this.cache.set(c,{$ref:d,events:x,option:l}),l.mounted&&l.mounted.call(this.art,d),d}remove(s){let l=this.cache.get(s);(0,n.errorHandle)(l,`Can't find [${s}] from the [${this.name}]`),l.option.beforeUnmount&&l.option.beforeUnmount.call(this.art,l.$ref);for(let c=0;cp);var t=a("../utils");let u="array",r="boolean",i="string",n="number",s="object",l="function";function c(d,g,y){return(0,t.errorHandle)(g===i||g===n||d instanceof Element,`${y.join(".")} require '${i}' or 'Element' type`)}let p={html:c,disable:`?${r}`,name:`?${i}`,index:`?${n}`,style:`?${s}`,click:`?${l}`,mounted:`?${l}`,tooltip:`?${i}|${n}`,width:`?${n}`,selector:`?${u}`,onSelect:`?${l}`,switch:`?${r}`,onSwitch:`?${l}`,range:`?${u}`,onRange:`?${l}`,onChange:`?${l}`};o.default={id:i,container:c,url:i,poster:i,type:i,theme:i,lang:i,volume:n,isLive:r,muted:r,autoplay:r,autoSize:r,autoMini:r,loop:r,flip:r,playbackRate:r,aspectRatio:r,screenshot:r,setting:r,hotkey:r,pip:r,mutex:r,backdrop:r,fullscreen:r,fullscreenWeb:r,subtitleOffset:r,miniProgressBar:r,useSSR:r,playsInline:r,lock:r,gesture:r,fastForward:r,autoPlayback:r,autoOrientation:r,airplay:r,proxy:`?${l}`,plugins:[l],layers:[p],contextmenu:[p],settings:[p],controls:[{...p,position:(d,g,y)=>{let x=["top","left","right"];return(0,t.errorHandle)(x.includes(d),`${y.join(".")} only accept ${x.toString()} as parameters`)}}],quality:[{default:`?${r}`,html:i,url:i}],highlight:[{time:n,text:i}],thumbnails:{url:i,number:n,column:n,width:n,height:n,scale:n},subtitle:{url:i,name:i,type:i,style:s,escape:r,encoding:i,onVttLoad:l},moreVideoAttr:s,i18n:s,icons:s,cssVar:s,customType:s}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],"6XHP2":[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){return i=>{let{i18n:n,constructor:{ASPECT_RATIO:s}}=i,l=s.map(c=>`${c==="default"?n.get("Default"):c}`).join("");return{...r,html:`${n.get("Aspect Ratio")}: ${l}`,click:(c,p)=>{let{value:d}=p.target.dataset;d&&(i.aspectRatio=d,c.show=!1)},mounted:c=>{let p=(0,t.query)('[data-value="default"]',c);p&&(0,t.inverseClass)(p,"art-current"),i.on("aspectRatio",d=>{let g=(0,t.queryAll)("span",c).find(y=>y.dataset.value===d);g&&(0,t.inverseClass)(g,"art-current")})}}}}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],eF6AX:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");function t(u){return r=>({...u,html:r.i18n.get("Close"),click:i=>{i.show=!1}})}e.defineInteropFlag(o),e.export(o,"default",()=>t)},{"@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],"7Wg1P":[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){return i=>{let{i18n:n,constructor:{FLIP:s}}=i,l=s.map(c=>`${n.get((0,t.capitalize)(c))}`).join("");return{...r,html:`${n.get("Video Flip")}: ${l}`,click:(c,p)=>{let{value:d}=p.target.dataset;d&&(i.flip=d.toLowerCase(),c.show=!1)},mounted:c=>{let p=(0,t.query)('[data-value="normal"]',c);p&&(0,t.inverseClass)(p,"art-current"),i.on("flip",d=>{let g=(0,t.queryAll)("span",c).find(y=>y.dataset.value===d);g&&(0,t.inverseClass)(g,"art-current")})}}}}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],fjRnU:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");function t(u){return r=>({...u,html:r.i18n.get("Video Info"),click:i=>{r.info.show=!0,i.show=!1}})}e.defineInteropFlag(o),e.export(o,"default",()=>t)},{"@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],hm1DY:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){return i=>{let{i18n:n,constructor:{PLAYBACK_RATE:s}}=i,l=s.map(c=>`${c===1?n.get("Normal"):c.toFixed(1)}`).join("");return{...r,html:`${n.get("Play Speed")}: ${l}`,click:(c,p)=>{let{value:d}=p.target.dataset;d&&(i.playbackRate=Number(d),c.show=!1)},mounted:c=>{let p=(0,t.query)('[data-value="1"]',c);p&&(0,t.inverseClass)(p,"art-current"),i.on("video:ratechange",()=>{let d=(0,t.queryAll)("span",c).find(g=>Number(g.dataset.value)===i.playbackRate);d&&(0,t.inverseClass)(d,"art-current")})}}}}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],aJBeL:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>function(u){return{...u,html:`ArtPlayer ${t.version}`}});var t=a("../../package.json")},{"../../package.json":"lh3R5","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],dp1yk:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o);var t=a("../utils"),u=a("../utils/component"),r=e.interopDefault(u),i=a("./airplay"),n=e.interopDefault(i),s=a("./fullscreen"),l=e.interopDefault(s),c=a("./fullscreenWeb"),p=e.interopDefault(c),d=a("./pip"),g=e.interopDefault(d),y=a("./playAndPause"),x=e.interopDefault(y),f=a("./progress"),v=e.interopDefault(f),w=a("./screenshot"),j=e.interopDefault(w),E=a("./setting"),S=e.interopDefault(E),$=a("./time"),C=e.interopDefault($),T=a("./volume"),q=e.interopDefault(T);class B extends r.default{constructor(b){super(b),this.isHover=!1,this.name="control",this.timer=Date.now();let{constructor:I}=b,{$player:F,$bottom:D}=this.art.template;b.on("mousemove",()=>{t.isMobile||(this.show=!0)}),b.on("click",()=>{t.isMobile?this.toggle():this.show=!0}),b.on("document:mousemove",M=>{this.isHover=(0,t.includeFromEvent)(M,D)}),b.on("video:timeupdate",()=>{!b.setting.show&&!this.isHover&&!b.isInput&&b.playing&&this.show&&Date.now()-this.timer>=I.CONTROL_HIDE_TIME&&(this.show=!1)}),b.on("control",M=>{M?((0,t.removeClass)(F,"art-hide-cursor"),(0,t.addClass)(F,"art-hover"),this.timer=Date.now()):((0,t.addClass)(F,"art-hide-cursor"),(0,t.removeClass)(F,"art-hover"))}),this.init()}init(){let{option:b}=this.art;b.isLive||this.add((0,v.default)({name:"progress",position:"top",index:10})),this.add({name:"thumbnails",position:"top",index:20}),this.add((0,x.default)({name:"playAndPause",position:"left",index:10})),this.add((0,q.default)({name:"volume",position:"left",index:20})),b.isLive||this.add((0,C.default)({name:"time",position:"left",index:30})),b.quality.length&&(0,t.sleep)().then(()=>{this.art.quality=b.quality}),b.screenshot&&!t.isMobile&&this.add((0,j.default)({name:"screenshot",position:"right",index:20})),b.setting&&this.add((0,S.default)({name:"setting",position:"right",index:30})),b.pip&&this.add((0,g.default)({name:"pip",position:"right",index:40})),b.airplay&&window.WebKitPlaybackTargetAvailabilityEvent&&this.add((0,n.default)({name:"airplay",position:"right",index:50})),b.fullscreenWeb&&this.add((0,p.default)({name:"fullscreenWeb",position:"right",index:60})),b.fullscreen&&this.add((0,l.default)({name:"fullscreen",position:"right",index:70}));for(let I=0;Ib.selector}),(0,t.def)(_,"$control_item",{get:()=>H}),(0,t.def)(_,"$control_value",{get:()=>M})}let O=D(L,"click",async P=>{let _=(0,t.getComposedPath)(P),H=b.selector.find(U=>U.$control_item===_.find(Z=>U.$control_item===Z));this.check(H),b.onSelect&&(M.innerHTML=await b.onSelect.call(this.art,H,H.$control_item,P))});F.push(O)}}o.default=B},{"../utils":"aBlEo","../utils/component":"idCEj","./airplay":"amOzz","./fullscreen":"3GuBU","./fullscreenWeb":"jj1KV","./pip":"jMeHN","./playAndPause":"u3h8M","./progress":"1XZSS","./screenshot":"dIscA","./setting":"aqA0g","./time":"ihweO","./volume":"fJVWn","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],amOzz:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){return i=>({...r,tooltip:i.i18n.get("AirPlay"),mounted:n=>{let{proxy:s,icons:l}=i;(0,t.append)(n,l.airplay),s(n,"click",()=>i.airplay())}})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],"3GuBU":[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){return i=>({...r,tooltip:i.i18n.get("Fullscreen"),mounted:n=>{let{proxy:s,icons:l,i18n:c}=i,p=(0,t.append)(n,l.fullscreenOn),d=(0,t.append)(n,l.fullscreenOff);(0,t.setStyle)(d,"display","none"),s(n,"click",()=>{i.fullscreen=!i.fullscreen}),i.on("fullscreen",g=>{g?((0,t.tooltip)(n,c.get("Exit Fullscreen")),(0,t.setStyle)(p,"display","none"),(0,t.setStyle)(d,"display","inline-flex")):((0,t.tooltip)(n,c.get("Fullscreen")),(0,t.setStyle)(p,"display","inline-flex"),(0,t.setStyle)(d,"display","none"))})}})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],jj1KV:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){return i=>({...r,tooltip:i.i18n.get("Web Fullscreen"),mounted:n=>{let{proxy:s,icons:l,i18n:c}=i,p=(0,t.append)(n,l.fullscreenWebOn),d=(0,t.append)(n,l.fullscreenWebOff);(0,t.setStyle)(d,"display","none"),s(n,"click",()=>{i.fullscreenWeb=!i.fullscreenWeb}),i.on("fullscreenWeb",g=>{g?((0,t.tooltip)(n,c.get("Exit Web Fullscreen")),(0,t.setStyle)(p,"display","none"),(0,t.setStyle)(d,"display","inline-flex")):((0,t.tooltip)(n,c.get("Web Fullscreen")),(0,t.setStyle)(p,"display","inline-flex"),(0,t.setStyle)(d,"display","none"))})}})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],jMeHN:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){return i=>({...r,tooltip:i.i18n.get("PIP Mode"),mounted:n=>{let{proxy:s,icons:l,i18n:c}=i;(0,t.append)(n,l.pip),s(n,"click",()=>{i.pip=!i.pip}),i.on("pip",p=>{(0,t.tooltip)(n,c.get(p?"Exit PIP Mode":"PIP Mode"))})}})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],u3h8M:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){return i=>({...r,mounted:n=>{let{proxy:s,icons:l,i18n:c}=i,p=(0,t.append)(n,l.play),d=(0,t.append)(n,l.pause);function g(){(0,t.setStyle)(p,"display","flex"),(0,t.setStyle)(d,"display","none")}function y(){(0,t.setStyle)(p,"display","none"),(0,t.setStyle)(d,"display","flex")}(0,t.tooltip)(p,c.get("Play")),(0,t.tooltip)(d,c.get("Pause")),s(p,"click",()=>{i.play()}),s(d,"click",()=>{i.pause()}),i.playing?y():g(),i.on("video:playing",()=>{y()}),i.on("video:pause",()=>{g()})}})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],"1XZSS":[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"getPosFromEvent",()=>u),e.export(o,"setCurrentTime",()=>r),e.export(o,"default",()=>i);var t=a("../utils");function u(n,s){let{$progress:l}=n.template,{left:c}=(0,t.getRect)(l),p=t.isMobile?s.touches[0].clientX:s.clientX,d=(0,t.clamp)(p-c,0,l.clientWidth),g=d/l.clientWidth*n.duration,y=(0,t.secondToTime)(g),x=(0,t.clamp)(d/l.clientWidth,0,1);return{second:g,time:y,width:d,percentage:x}}function r(n,s){if(n.isRotate){let l=s.touches[0].clientY/n.height,c=l*n.duration;n.emit("setBar","played",l,s),n.seek=c}else{let{second:l,percentage:c}=u(n,s);n.emit("setBar","played",c,s),n.seek=l}}function i(n){return s=>{let{icons:l,option:c,proxy:p}=s;return{...n,html:'
    ',mounted:d=>{let g=null,y=!1,x=(0,t.query)(".art-progress-hover",d),f=(0,t.query)(".art-progress-loaded",d),v=(0,t.query)(".art-progress-played",d),w=(0,t.query)(".art-progress-highlight",d),j=(0,t.query)(".art-progress-indicator",d),E=(0,t.query)(".art-progress-tip",d);function S($,C){let{width:T,time:q}=C||u(s,$);E.textContent=q;let B=E.clientWidth;T<=B/2?(0,t.setStyle)(E,"left",0):T>d.clientWidth-B/2?(0,t.setStyle)(E,"left",`${d.clientWidth-B}px`):(0,t.setStyle)(E,"left",`${T-B/2}px`)}l.indicator?(0,t.append)(j,l.indicator):(0,t.setStyle)(j,"backgroundColor","var(--art-theme)"),s.on("setBar",function($,C,T){let q=$==="played"&&T&&t.isMobile;$==="loaded"&&(0,t.setStyle)(f,"width",`${100*C}%`),$==="hover"&&(0,t.setStyle)(x,"width",`${100*C}%`),$==="played"&&((0,t.setStyle)(v,"width",`${100*C}%`),(0,t.setStyle)(j,"left",`${100*C}%`)),q&&((0,t.setStyle)(E,"display","flex"),S(T,{width:d.clientWidth*C,time:(0,t.secondToTime)(C*s.duration)}),clearTimeout(g),g=setTimeout(()=>{(0,t.setStyle)(E,"display","none")},500))}),s.on("video:loadedmetadata",function(){w.textContent="";for(let $=0;$`;(0,t.append)(w,q)}}),s.constructor.USE_RAF?s.on("raf",()=>{s.emit("setBar","played",s.played),s.emit("setBar","loaded",s.loaded)}):(s.on("video:timeupdate",()=>{s.emit("setBar","played",s.played)}),s.on("video:progress",()=>{s.emit("setBar","loaded",s.loaded)}),s.on("video:ended",()=>{s.emit("setBar","played",1)})),s.emit("setBar","loaded",s.loaded||0),t.isMobile||(p(d,"click",$=>{$.target!==j&&r(s,$)}),p(d,"mousemove",$=>{let{percentage:C}=u(s,$);if(s.emit("setBar","hover",C,$),(0,t.setStyle)(E,"display","flex"),(0,t.includeFromEvent)($,w)){let{width:T}=u(s,$),{text:q}=$.target.dataset;E.textContent=q;let B=E.clientWidth;T<=B/2?(0,t.setStyle)(E,"left",0):T>d.clientWidth-B/2?(0,t.setStyle)(E,"left",`${d.clientWidth-B}px`):(0,t.setStyle)(E,"left",`${T-B/2}px`)}else S($)}),p(d,"mouseleave",$=>{(0,t.setStyle)(E,"display","none"),s.emit("setBar","hover",0,$)}),p(d,"mousedown",$=>{y=$.button===0}),s.on("document:mousemove",$=>{if(y){let{second:C,percentage:T}=u(s,$);s.emit("setBar","played",T,$),s.seek=C}}),s.on("document:mouseup",()=>{y&&(y=!1)}))}}}}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],dIscA:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){return i=>({...r,tooltip:i.i18n.get("Screenshot"),mounted:n=>{let{proxy:s,icons:l}=i;(0,t.append)(n,l.screenshot),s(n,"click",()=>{i.screenshot()})}})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],aqA0g:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){return i=>({...r,tooltip:i.i18n.get("Show Setting"),mounted:n=>{let{proxy:s,icons:l,i18n:c}=i;(0,t.append)(n,l.setting),s(n,"click",()=>{i.setting.toggle(),i.setting.resize()}),i.on("setting",p=>{(0,t.tooltip)(n,c.get(p?"Hide Setting":"Show Setting"))})}})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],ihweO:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){return i=>({...r,style:t.isMobile?{fontSize:"12px",padding:"0 5px"}:{cursor:"auto",padding:"0 10px"},mounted:n=>{function s(){let c=`${(0,t.secondToTime)(i.currentTime)} / ${(0,t.secondToTime)(i.duration)}`;c!==n.textContent&&(n.textContent=c)}s();let l=["video:loadedmetadata","video:timeupdate","video:progress"];for(let c=0;cu);var t=a("../utils");function u(r){return i=>({...r,mounted:n=>{let{proxy:s,icons:l}=i,c=(0,t.append)(n,l.volume),p=(0,t.append)(n,l.volumeClose),d=(0,t.append)(n,'
    '),g=(0,t.append)(d,'
    '),y=(0,t.append)(g,'
    '),x=(0,t.append)(g,'
    '),f=(0,t.append)(x,'
    '),v=(0,t.append)(f,'
    '),w=(0,t.append)(x,'
    ');function j(S){let{top:$,height:C}=(0,t.getRect)(x);return 1-(S.clientY-$)/C}function E(){if(i.muted||i.volume===0)(0,t.setStyle)(c,"display","none"),(0,t.setStyle)(p,"display","flex"),(0,t.setStyle)(w,"top","100%"),(0,t.setStyle)(v,"top","100%"),y.textContent=0;else{let S=100*i.volume;(0,t.setStyle)(c,"display","flex"),(0,t.setStyle)(p,"display","none"),(0,t.setStyle)(w,"top",`${100-S}%`),(0,t.setStyle)(v,"top",`${100-S}%`),y.textContent=Math.floor(S)}}if(E(),i.on("video:volumechange",E),s(c,"click",()=>{i.muted=!0}),s(p,"click",()=>{i.muted=!1}),t.isMobile)(0,t.setStyle)(d,"display","none");else{let S=!1;s(x,"mousedown",$=>{S=$.button===0,i.volume=j($)}),i.on("document:mousemove",$=>{S&&(i.muted=!1,i.volume=j($))}),i.on("document:mouseup",()=>{S&&(S=!1)})}}})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],jmVSD:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o);var t=a("./clickInit"),u=e.interopDefault(t),r=a("./gestureInit"),i=e.interopDefault(r),n=a("./globalInit"),s=e.interopDefault(n),l=a("./hoverInit"),c=e.interopDefault(l),p=a("./moveInit"),d=e.interopDefault(p),g=a("./resizeInit"),y=e.interopDefault(g),x=a("./updateInit"),f=e.interopDefault(x),v=a("./viewInit"),w=e.interopDefault(v);o.default=class{constructor(j){this.destroyEvents=[],this.proxy=this.proxy.bind(this),this.hover=this.hover.bind(this),(0,u.default)(j,this),(0,c.default)(j,this),(0,d.default)(j,this),(0,y.default)(j,this),(0,i.default)(j,this),(0,w.default)(j,this),(0,s.default)(j,this),(0,f.default)(j,this)}proxy(j,E,S,$={}){if(Array.isArray(E))return E.map(T=>this.proxy(j,T,S,$));j.addEventListener(E,S,$);let C=()=>j.removeEventListener(E,S,$);return this.destroyEvents.push(C),C}hover(j,E,S){E&&this.proxy(j,"mouseenter",E),S&&this.proxy(j,"mouseleave",S)}remove(j){let E=this.destroyEvents.indexOf(j);E>-1&&(j(),this.destroyEvents.splice(E,1))}destroy(){for(let j=0;ju);var t=a("../utils");function u(r,i){let{constructor:n,template:{$player:s,$video:l}}=r;function c(d){(0,t.includeFromEvent)(d,s)?(r.isInput=d.target.tagName==="INPUT",r.isFocus=!0,r.emit("focus",d)):(r.isInput=!1,r.isFocus=!1,r.emit("blur",d))}r.on("document:click",c),r.on("document:contextmenu",c);let p=[];i.proxy(l,"click",d=>{let g=Date.now();p.push(g);let{MOBILE_CLICK_PLAY:y,DBCLICK_TIME:x,MOBILE_DBCLICK_PLAY:f,DBCLICK_FULLSCREEN:v}=n,w=p.filter(j=>g-j<=x);switch(w.length){case 1:r.emit("click",d),t.isMobile?!r.isLock&&y&&r.toggle():r.toggle(),p=w;break;case 2:r.emit("dblclick",d),t.isMobile?!r.isLock&&f&&r.toggle():v&&(r.fullscreen=!r.fullscreen),p=[];break;default:p=[]}})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],"9wEzB":[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>r);var t=a("../control/progress"),u=a("../utils");function r(i,n){if(u.isMobile&&!i.option.isLive){let{$video:s,$progress:l}=i.template,c=null,p=!1,d=0,g=0,y=0,x=v=>{if(v.touches.length===1&&!i.isLock){c===l&&(0,t.setCurrentTime)(i,v),p=!0;let{pageX:w,pageY:j}=v.touches[0];d=w,g=j,y=i.currentTime}},f=v=>{if(v.touches.length===1&&p&&i.duration){let{pageX:w,pageY:j}=v.touches[0],E=function(C,T,q,B){let k=T-B,b=q-C,I=0;if(2>Math.abs(b)&&2>Math.abs(k))return I;let F=180*Math.atan2(k,b)/Math.PI;return F>=-45&&F<45?I=4:F>=45&&F<135?I=1:F>=-135&&F<-45?I=2:(F>=135&&F<=180||F>=-180&&F<-135)&&(I=3),I}(d,g,w,j),S=[3,4].includes(E),$=[1,2].includes(E);if(S&&!i.isRotate||$&&i.isRotate){let C=(0,u.clamp)((w-d)/i.width,-1,1),T=(0,u.clamp)((j-g)/i.height,-1,1),q=i.isRotate?T:C,B=c===s?i.constructor.TOUCH_MOVE_RATIO:1,k=(0,u.clamp)(y+i.duration*q*B,0,i.duration);i.seek=k,i.emit("setBar","played",(0,u.clamp)(k/i.duration,0,1),v),i.notice.show=`${(0,u.secondToTime)(k)} / ${(0,u.secondToTime)(i.duration)}`}}};i.option.gesture&&(n.proxy(s,"touchstart",v=>{c=s,x(v)}),n.proxy(s,"touchmove",f)),n.proxy(l,"touchstart",v=>{c=l,x(v)}),n.proxy(l,"touchmove",f),i.on("document:touchend",()=>{p&&(d=0,g=0,y=0,p=!1,c=null)})}}},{"../control/progress":"1XZSS","../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],ikBrS:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");function t(u,r){let i=["click","mouseup","keydown","touchend","touchmove","mousemove","pointerup","contextmenu","pointermove","visibilitychange","webkitfullscreenchange"],n=["resize","scroll","orientationchange"],s=[];function l(c={}){for(let d=0;d{let g=c.document||p.ownerDocument||document,y=r.proxy(g,d,x=>{u.emit(`document:${d}`,x)});s.push(y)}),n.forEach(d=>{var x;let g=c.window||((x=p.ownerDocument)==null?void 0:x.defaultView)||window,y=r.proxy(g,d,f=>{u.emit(`window:${d}`,f)});s.push(y)})}l(),r.bindGlobalEvents=l}e.defineInteropFlag(o),e.export(o,"default",()=>t)},{"@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],jwNq0:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r,i){let{$player:n}=r.template;i.hover(n,s=>{(0,t.addClass)(n,"art-hover"),r.emit("hover",!0,s)},s=>{(0,t.removeClass)(n,"art-hover"),r.emit("hover",!1,s)})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],eqSsP:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");function t(u,r){let{$player:i}=u.template;r.proxy(i,"mousemove",n=>{u.emit("mousemove",n)})}e.defineInteropFlag(o),e.export(o,"default",()=>t)},{"@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],"42JNz":[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r,i){let{option:n,constructor:s}=r;r.on("resize",()=>{let{aspectRatio:c,notice:p}=r;r.state==="standard"&&n.autoSize&&r.autoSize(),r.aspectRatio=c,p.show=""});let l=(0,t.debounce)(()=>r.emit("resize"),s.RESIZE_TIME);r.on("window:orientationchange",()=>l()),r.on("window:resize",()=>l()),screen&&screen.orientation&&screen.orientation.onchange&&i.proxy(screen.orientation,"change",()=>l())}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],"7kM1M":[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");function t(u){if(u.constructor.USE_RAF){let r=null;(function i(){u.playing&&u.emit("raf"),u.isDestroy||(r=requestAnimationFrame(i))})(),u.on("destroy",()=>{cancelAnimationFrame(r)})}}e.defineInteropFlag(o),e.export(o,"default",()=>t)},{"@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],"2IW9m":[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){let{option:i,constructor:n,template:{$container:s}}=r,l=(0,t.throttle)(()=>{r.emit("view",(0,t.isInViewport)(s,n.SCROLL_GAP))},n.SCROLL_TIME);r.on("window:scroll",()=>l()),r.on("view",c=>{i.autoMini&&(r.mini=!c)})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],dswts:[function(a,h,o,m){a("@parcel/transformer-js/src/esmodule-helpers.js").defineInteropFlag(o);var e=a("./utils");o.default=class{constructor(t){this.art=t,this.keys={},t.option.hotkey&&!e.isMobile&&this.init()}init(){let{constructor:t}=this.art;this.add("Escape",()=>{this.art.fullscreenWeb&&(this.art.fullscreenWeb=!1)}),this.add("Space",()=>{this.art.toggle()}),this.add("ArrowLeft",()=>{this.art.backward=t.SEEK_STEP}),this.add("ArrowUp",()=>{this.art.volume+=t.VOLUME_STEP}),this.add("ArrowRight",()=>{this.art.forward=t.SEEK_STEP}),this.add("ArrowDown",()=>{this.art.volume-=t.VOLUME_STEP}),this.art.on("document:keydown",u=>{if(this.art.isFocus){let r=document.activeElement.tagName.toUpperCase(),i=document.activeElement.getAttribute("contenteditable");if(r!=="INPUT"&&r!=="TEXTAREA"&&i!==""&&i!=="true"&&!u.altKey&&!u.ctrlKey&&!u.metaKey&&!u.shiftKey){let n=this.keys[u.code];if(n){u.preventDefault();for(let s=0;s(0,W.getIcon)(ut,ft[ut])})}}},{"bundle-text:./airplay.svg":"gkZgZ","bundle-text:./arrow-left.svg":"kQyD4","bundle-text:./arrow-right.svg":"64ztm","bundle-text:./aspect-ratio.svg":"72LvA","bundle-text:./check.svg":"4QmBo","bundle-text:./close.svg":"j1hoe","bundle-text:./config.svg":"hNZaT","bundle-text:./error.svg":"dKh4l","bundle-text:./flip.svg":"lIEIE","bundle-text:./fullscreen-off.svg":"1533e","bundle-text:./fullscreen-on.svg":"76ut3","bundle-text:./fullscreen-web-off.svg":"3NzMk","bundle-text:./fullscreen-web-on.svg":"12xHc","bundle-text:./loading.svg":"iVcUF","bundle-text:./lock.svg":"1J4so","bundle-text:./pause.svg":"1KgkK","bundle-text:./pip.svg":"4h4tM","bundle-text:./play.svg":"jecAY","bundle-text:./playback-rate.svg":"anPe9","bundle-text:./screenshot.svg":"9BPYQ","bundle-text:./setting.svg":"hsI9k","bundle-text:./state.svg":"gr1ZU","bundle-text:./switch-off.svg":"6kdAr","bundle-text:./switch-on.svg":"ksdMo","bundle-text:./unlock.svg":"iz5Qc","bundle-text:./volume-close.svg":"3OZoa","bundle-text:./volume.svg":"hRYA4","../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],gkZgZ:[function(a,h,o,m){h.exports=''},{}],kQyD4:[function(a,h,o,m){h.exports=''},{}],"64ztm":[function(a,h,o,m){h.exports=''},{}],"72LvA":[function(a,h,o,m){h.exports=''},{}],"4QmBo":[function(a,h,o,m){h.exports=''},{}],j1hoe:[function(a,h,o,m){h.exports=''},{}],hNZaT:[function(a,h,o,m){h.exports=''},{}],dKh4l:[function(a,h,o,m){h.exports=''},{}],lIEIE:[function(a,h,o,m){h.exports=''},{}],"1533e":[function(a,h,o,m){h.exports=''},{}],"76ut3":[function(a,h,o,m){h.exports=''},{}],"3NzMk":[function(a,h,o,m){h.exports=''},{}],"12xHc":[function(a,h,o,m){h.exports=''},{}],iVcUF:[function(a,h,o,m){h.exports=''},{}],"1J4so":[function(a,h,o,m){h.exports=''},{}],"1KgkK":[function(a,h,o,m){h.exports=''},{}],"4h4tM":[function(a,h,o,m){h.exports=''},{}],jecAY:[function(a,h,o,m){h.exports=''},{}],anPe9:[function(a,h,o,m){h.exports=''},{}],"9BPYQ":[function(a,h,o,m){h.exports=''},{}],hsI9k:[function(a,h,o,m){h.exports=''},{}],gr1ZU:[function(a,h,o,m){h.exports=''},{}],"6kdAr":[function(a,h,o,m){h.exports=''},{}],ksdMo:[function(a,h,o,m){h.exports=''},{}],iz5Qc:[function(a,h,o,m){h.exports=''},{}],"3OZoa":[function(a,h,o,m){h.exports=''},{}],hRYA4:[function(a,h,o,m){h.exports=''},{}],kZ0F8:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o);var t=a("./utils"),u=a("./utils/component"),r=e.interopDefault(u);class i extends r.default{constructor(s){super(s),this.name="info",t.isMobile||this.init()}init(){let{proxy:s,constructor:l,template:{$infoPanel:c,$infoClose:p,$video:d}}=this.art;s(p,"click",()=>{this.show=!1});let g=null,y=(0,t.queryAll)("[data-video]",c)||[];this.art.on("destroy",()=>clearTimeout(g)),function x(){for(let f=0;f{(0,t.setStyle)(d,"display","none"),(0,t.setStyle)(g,"display",null)}),p.proxy(l.$state,"click",()=>s.play())}}o.default=i},{"./utils":"aBlEo","./utils/component":"idCEj","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],fPVaU:[function(a,h,o,m){a("@parcel/transformer-js/src/esmodule-helpers.js").defineInteropFlag(o);var e=a("./utils");o.default=class{constructor(t){this.art=t,this.timer=null}set show(t){let{constructor:u,template:{$player:r,$noticeInner:i}}=this.art;t?(i.textContent=t instanceof Error?t.message.trim():t,(0,e.addClass)(r,"art-notice-show"),clearTimeout(this.timer),this.timer=setTimeout(()=>{i.textContent="",(0,e.removeClass)(r,"art-notice-show")},u.NOTICE_TIME)):(0,e.removeClass)(r,"art-notice-show")}get show(){let{template:{$player:t}}=this.art;return t.classList.contains("art-notice-show")}}},{"./utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],uR0Sw:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o);var t=a("./airplayMix"),u=e.interopDefault(t),r=a("./aspectRatioMix"),i=e.interopDefault(r),n=a("./attrMix"),s=e.interopDefault(n),l=a("./autoHeightMix"),c=e.interopDefault(l),p=a("./autoSizeMix"),d=e.interopDefault(p),g=a("./cssVarMix"),y=e.interopDefault(g),x=a("./currentTimeMix"),f=e.interopDefault(x),v=a("./durationMix"),w=e.interopDefault(v),j=a("./eventInit"),E=e.interopDefault(j),S=a("./flipMix"),$=e.interopDefault(S),C=a("./fullscreenMix"),T=e.interopDefault(C),q=a("./fullscreenWebMix"),B=e.interopDefault(q),k=a("./loadedMix"),b=e.interopDefault(k),I=a("./miniMix"),F=e.interopDefault(I),D=a("./optionInit"),M=e.interopDefault(D),L=a("./pauseMix"),O=e.interopDefault(L),P=a("./pipMix"),_=e.interopDefault(P),H=a("./playbackRateMix"),U=e.interopDefault(H),Z=a("./playedMix"),rt=e.interopDefault(Z),at=a("./playingMix"),ot=e.interopDefault(at),it=a("./playMix"),nt=e.interopDefault(it),G=a("./posterMix"),V=e.interopDefault(G),st=a("./qualityMix"),J=e.interopDefault(st),lt=a("./rectMix"),Y=e.interopDefault(lt),R=a("./screenshotMix"),ht=e.interopDefault(R),X=a("./seekMix"),Q=e.interopDefault(X),tt=a("./stateMix"),et=e.interopDefault(tt),W=a("./subtitleOffsetMix"),ct=e.interopDefault(W),ft=a("./switchMix"),ut=e.interopDefault(ft),kt=a("./themeMix"),Et=e.interopDefault(kt),$t=a("./thumbnailsMix"),St=e.interopDefault($t),It=a("./toggleMix"),Tt=e.interopDefault(It),Mt=a("./typeMix"),Ct=e.interopDefault(Mt),Ft=a("./urlMix"),Rt=e.interopDefault(Ft),Lt=a("./volumeMix"),qt=e.interopDefault(Lt);o.default=class{constructor(A){(0,Rt.default)(A),(0,s.default)(A),(0,nt.default)(A),(0,O.default)(A),(0,Tt.default)(A),(0,Q.default)(A),(0,qt.default)(A),(0,f.default)(A),(0,w.default)(A),(0,ut.default)(A),(0,U.default)(A),(0,i.default)(A),(0,ht.default)(A),(0,T.default)(A),(0,B.default)(A),(0,_.default)(A),(0,b.default)(A),(0,rt.default)(A),(0,ot.default)(A),(0,d.default)(A),(0,Y.default)(A),(0,$.default)(A),(0,F.default)(A),(0,V.default)(A),(0,c.default)(A),(0,y.default)(A),(0,Et.default)(A),(0,Ct.default)(A),(0,et.default)(A),(0,ct.default)(A),(0,u.default)(A),(0,J.default)(A),(0,St.default)(A),(0,E.default)(A),(0,M.default)(A)}}},{"./airplayMix":"d8BTB","./aspectRatioMix":"aQNJl","./attrMix":"5DA9e","./autoHeightMix":"1swKn","./autoSizeMix":"lSbiD","./cssVarMix":"32Hp1","./currentTimeMix":"kfZbu","./durationMix":"eV1ag","./eventInit":"f8NQq","./flipMix":"ea3Qm","./fullscreenMix":"ffXE3","./fullscreenWebMix":"8tarF","./loadedMix":"f9syH","./miniMix":"dLuS7","./optionInit":"d1F69","./pauseMix":"kewk9","./pipMix":"4XzDs","./playbackRateMix":"jphfi","./playedMix":"iNpeS","./playingMix":"aBIWL","./playMix":"hRBri","./posterMix":"fgfXC","./qualityMix":"17rUP","./rectMix":"55qzI","./screenshotMix":"bC6TG","./seekMix":"j8GRO","./stateMix":"cn7iR","./subtitleOffsetMix":"2k4nP","./switchMix":"6SU6j","./themeMix":"7iMuh","./thumbnailsMix":"6P0RS","./toggleMix":"eNi78","./typeMix":"7AUBD","./urlMix":"cnlLL","./volumeMix":"iX66j","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],d8BTB:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){let{i18n:i,notice:n,proxy:s,template:{$video:l}}=r,c=!0;window.WebKitPlaybackTargetAvailabilityEvent&&l.webkitShowPlaybackTargetPicker?s(l,"webkitplaybacktargetavailabilitychanged",p=>{switch(p.availability){case"available":c=!0;break;case"not-available":c=!1}}):c=!1,(0,t.def)(r,"airplay",{value(){c?(l.webkitShowPlaybackTargetPicker(),r.emit("airplay")):n.show=i.get("AirPlay Not Available")}})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],aQNJl:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){let{i18n:i,notice:n,template:{$video:s,$player:l}}=r;(0,t.def)(r,"aspectRatio",{get:()=>l.dataset.aspectRatio||"default",set(c){if(c||(c="default"),c==="default")(0,t.setStyle)(s,"width",null),(0,t.setStyle)(s,"height",null),(0,t.setStyle)(s,"margin",null),delete l.dataset.aspectRatio;else{let p=c.split(":").map(Number),{clientWidth:d,clientHeight:g}=l,y=p[0]/p[1];d/g>y?((0,t.setStyle)(s,"width",`${y*g}px`),(0,t.setStyle)(s,"height","100%"),(0,t.setStyle)(s,"margin","0 auto")):((0,t.setStyle)(s,"width","100%"),(0,t.setStyle)(s,"height",`${d/y}px`),(0,t.setStyle)(s,"margin","auto 0")),l.dataset.aspectRatio=c}n.show=`${i.get("Aspect Ratio")}: ${c==="default"?i.get("Default"):c}`,r.emit("aspectRatio",c)}})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],"5DA9e":[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){let{template:{$video:i}}=r;(0,t.def)(r,"attr",{value(n,s){if(s===void 0)return i[n];i[n]=s}})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],"1swKn":[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){let{template:{$container:i,$video:n}}=r;(0,t.def)(r,"autoHeight",{value(){let{clientWidth:s}=i,{videoHeight:l,videoWidth:c}=n,p=s/c*l;(0,t.setStyle)(i,"height",`${p}px`),r.emit("autoHeight",p)}})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],lSbiD:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){let{$container:i,$player:n,$video:s}=r.template;(0,t.def)(r,"autoSize",{value(){let{videoWidth:l,videoHeight:c}=s,{width:p,height:d}=(0,t.getRect)(i),g=l/c;p/d>g?((0,t.setStyle)(n,"width",`${d*g/p*100}%`),(0,t.setStyle)(n,"height","100%")):((0,t.setStyle)(n,"width","100%"),(0,t.setStyle)(n,"height",`${p/g/d*100}%`)),r.emit("autoSize",{width:r.width,height:r.height})}})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],"32Hp1":[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){let{$player:i}=r.template;(0,t.def)(r,"cssVar",{value:(n,s)=>s?i.style.setProperty(n,s):getComputedStyle(i).getPropertyValue(n)})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],kfZbu:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){let{$video:i}=r.template;(0,t.def)(r,"currentTime",{get:()=>i.currentTime||0,set:n=>{Number.isNaN(n=Number.parseFloat(n))||(i.currentTime=(0,t.clamp)(n,0,r.duration))}})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],eV1ag:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){(0,t.def)(r,"duration",{get:()=>{let{duration:i}=r.template.$video;return i===1/0?0:i||0}})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],f8NQq:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>i);var t=a("../config"),u=e.interopDefault(t),r=a("../utils");function i(n){let{i18n:s,notice:l,option:c,constructor:p,proxy:d,template:{$player:g,$video:y,$poster:x}}=n,f=0;for(let v=0;v{n.emit(`video:${w.type}`,w)});n.on("video:canplay",()=>{f=0,n.loading.show=!1}),n.once("video:canplay",()=>{n.loading.show=!1,n.controls.show=!0,n.mask.show=!0,n.isReady=!0,n.emit("ready")}),n.on("video:ended",()=>{c.loop?(n.seek=0,n.play(),n.controls.show=!1,n.mask.show=!1):(n.controls.show=!0,n.mask.show=!0)}),n.on("video:error",async v=>{f{n.emit("resize"),r.isMobile&&(n.loading.show=!1,n.controls.show=!0,n.mask.show=!0)}),n.on("video:loadstart",()=>{n.loading.show=!0,n.mask.show=!1,n.controls.show=!0}),n.on("video:pause",()=>{n.controls.show=!0,n.mask.show=!0}),n.on("video:play",()=>{n.mask.show=!1,(0,r.setStyle)(x,"display","none")}),n.on("video:playing",()=>{n.mask.show=!1}),n.on("video:progress",()=>{n.playing&&(n.loading.show=!1)}),n.on("video:seeked",()=>{n.loading.show=!1,n.mask.show=!0}),n.on("video:seeking",()=>{n.loading.show=!0,n.mask.show=!1}),n.on("video:timeupdate",()=>{n.mask.show=!1}),n.on("video:waiting",()=>{n.loading.show=!0,n.mask.show=!1})}},{"../config":"eJfh8","../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],ea3Qm:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){let{template:{$player:i},i18n:n,notice:s}=r;(0,t.def)(r,"flip",{get:()=>i.dataset.flip||"normal",set(l){l||(l="normal"),l==="normal"?delete i.dataset.flip:i.dataset.flip=l,s.show=`${n.get("Video Flip")}: ${n.get((0,t.capitalize)(l))}`,r.emit("flip",l)}})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],ffXE3:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>i);var t=a("../libs/screenfull"),u=e.interopDefault(t),r=a("../utils");function i(n){let{i18n:s,notice:l,template:{$video:c,$player:p}}=n;n.once("video:loadedmetadata",()=>{u.default.isEnabled?(u.default.on("change",()=>{n.emit("fullscreen",u.default.isFullscreen),u.default.isFullscreen?(n.state="fullscreen",(0,r.addClass)(p,"art-fullscreen")):(0,r.removeClass)(p,"art-fullscreen"),n.emit("resize")}),u.default.on("error",d=>{n.emit("fullscreenError",d)}),(0,r.def)(n,"fullscreen",{get:()=>u.default.isFullscreen,async set(d){d?await u.default.request(p):await u.default.exit()}})):c.webkitSupportsFullscreen?(n.on("document:webkitfullscreenchange",()=>{n.emit("fullscreen",n.fullscreen),n.emit("resize")}),(0,r.def)(n,"fullscreen",{get:()=>document.fullscreenElement===c,set(d){d?(n.state="fullscreen",c.webkitEnterFullscreen()):c.webkitExitFullscreen()}})):(0,r.def)(n,"fullscreen",{get:()=>!1,set(){l.show=s.get("Fullscreen Not Supported")}}),(0,r.def)(n,"fullscreen",(0,r.get)(n,"fullscreen"))})}},{"../libs/screenfull":"iSPAQ","../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],iSPAQ:[function(a,h,o,m){a("@parcel/transformer-js/src/esmodule-helpers.js").defineInteropFlag(o);let e=[["requestFullscreen","exitFullscreen","fullscreenElement","fullscreenEnabled","fullscreenchange","fullscreenerror"],["webkitRequestFullscreen","webkitExitFullscreen","webkitFullscreenElement","webkitFullscreenEnabled","webkitfullscreenchange","webkitfullscreenerror"],["webkitRequestFullScreen","webkitCancelFullScreen","webkitCurrentFullScreenElement","webkitCancelFullScreen","webkitfullscreenchange","webkitfullscreenerror"],["mozRequestFullScreen","mozCancelFullScreen","mozFullScreenElement","mozFullScreenEnabled","mozfullscreenchange","mozfullscreenerror"],["msRequestFullscreen","msExitFullscreen","msFullscreenElement","msFullscreenEnabled","MSFullscreenChange","MSFullscreenError"]],t=(()=>{if(typeof document>"u")return!1;let i=e[0],n={};for(let s of e)if(s[1]in document){for(let[l,c]of s.entries())n[i[l]]=c;return n}return!1})(),u={change:t.fullscreenchange,error:t.fullscreenerror},r={request:(i=document.documentElement,n)=>new Promise((s,l)=>{let c=()=>{r.off("change",c),s()};r.on("change",c);let p=i[t.requestFullscreen](n);p instanceof Promise&&p.then(c).catch(l)}),exit:()=>new Promise((i,n)=>{if(!r.isFullscreen)return void i();let s=()=>{r.off("change",s),i()};r.on("change",s);let l=document[t.exitFullscreen]();l instanceof Promise&&l.then(s).catch(n)}),toggle:(i,n)=>r.isFullscreen?r.exit():r.request(i,n),onchange(i){r.on("change",i)},onerror(i){r.on("error",i)},on(i,n){let s=u[i];s&&document.addEventListener(s,n,!1)},off(i,n){let s=u[i];s&&document.removeEventListener(s,n,!1)},raw:t};Object.defineProperties(r,{isFullscreen:{get:()=>!!document[t.fullscreenElement]},element:{enumerable:!0,get:()=>document[t.fullscreenElement]},isEnabled:{enumerable:!0,get:()=>!!document[t.fullscreenEnabled]}}),o.default=r},{"@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],"8tarF":[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){let{constructor:i,template:{$container:n,$player:s}}=r,l="";(0,t.def)(r,"fullscreenWeb",{get:()=>(0,t.hasClass)(s,"art-fullscreen-web"),set(c){c?(l=s.style.cssText,i.FULLSCREEN_WEB_IN_BODY&&(0,t.append)(document.body,s),r.state="fullscreenWeb",(0,t.setStyle)(s,"width","100%"),(0,t.setStyle)(s,"height","100%"),(0,t.addClass)(s,"art-fullscreen-web"),r.emit("fullscreenWeb",!0)):(i.FULLSCREEN_WEB_IN_BODY&&(0,t.append)(n,s),l&&(s.style.cssText=l,l=""),(0,t.removeClass)(s,"art-fullscreen-web"),r.emit("fullscreenWeb",!1)),r.emit("resize")}})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],f9syH:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){let{$video:i}=r.template;(0,t.def)(r,"loaded",{get:()=>r.loadedTime/i.duration}),(0,t.def)(r,"loadedTime",{get:()=>i.buffered.length?i.buffered.end(i.buffered.length-1):0})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],dLuS7:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){let{icons:i,proxy:n,storage:s,template:{$player:l,$video:c}}=r,p=!1,d=0,g=0;function y(){let{$mini:v}=r.template;v&&((0,t.removeClass)(l,"art-mini"),(0,t.setStyle)(v,"display","none"),l.prepend(c),r.emit("mini",!1))}function x(v,w){r.playing?((0,t.setStyle)(v,"display","none"),(0,t.setStyle)(w,"display","flex")):((0,t.setStyle)(v,"display","flex"),(0,t.setStyle)(w,"display","none"))}function f(){let{$mini:v}=r.template,w=(0,t.getRect)(v),j=window.innerHeight-w.height-50,E=window.innerWidth-w.width-50;s.set("top",j),s.set("left",E),(0,t.setStyle)(v,"top",`${j}px`),(0,t.setStyle)(v,"left",`${E}px`)}(0,t.def)(r,"mini",{get:()=>(0,t.hasClass)(l,"art-mini"),set(v){if(v){r.state="mini",(0,t.addClass)(l,"art-mini");let w=function(){let{$mini:S}=r.template;if(S)return(0,t.append)(S,c),(0,t.setStyle)(S,"display","flex");{let $=(0,t.createElement)("div");(0,t.addClass)($,"art-mini-popup"),(0,t.append)(document.body,$),r.template.$mini=$,(0,t.append)($,c);let C=(0,t.append)($,'
    ');(0,t.append)(C,i.close),n(C,"click",y);let T=(0,t.append)($,'
    '),q=(0,t.append)(T,i.play),B=(0,t.append)(T,i.pause);return n(q,"click",()=>r.play()),n(B,"click",()=>r.pause()),x(q,B),r.on("video:playing",()=>x(q,B)),r.on("video:pause",()=>x(q,B)),r.on("video:timeupdate",()=>x(q,B)),n($,"mousedown",k=>{p=k.button===0,d=k.pageX,g=k.pageY}),r.on("document:mousemove",k=>{if(p){(0,t.addClass)($,"art-mini-dragging");let b=k.pageX-d,I=k.pageY-g;(0,t.setStyle)($,"transform",`translate(${b}px, ${I}px)`)}}),r.on("document:mouseup",()=>{if(p){p=!1,(0,t.removeClass)($,"art-mini-dragging");let k=(0,t.getRect)($);s.set("left",k.left),s.set("top",k.top),(0,t.setStyle)($,"left",`${k.left}px`),(0,t.setStyle)($,"top",`${k.top}px`),(0,t.setStyle)($,"transform",null)}}),$}}(),j=s.get("top"),E=s.get("left");typeof j=="number"&&typeof E=="number"?((0,t.setStyle)(w,"top",`${j}px`),(0,t.setStyle)(w,"left",`${E}px`),(0,t.isInViewport)(w)||f()):f(),r.emit("mini",!0)}else y()}})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],d1F69:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){let{option:i,storage:n,template:{$video:s,$poster:l}}=r;for(let p in i.moreVideoAttr)r.attr(p,i.moreVideoAttr[p]);i.muted&&(r.muted=i.muted),i.volume&&(s.volume=(0,t.clamp)(i.volume,0,1));let c=n.get("volume");for(let p in typeof c=="number"&&(s.volume=(0,t.clamp)(c,0,1)),i.poster&&(0,t.setStyle)(l,"backgroundImage",`url(${i.poster})`),i.autoplay&&(s.autoplay=i.autoplay),i.playsInline&&(s.playsInline=!0,s["webkit-playsinline"]=!0),i.theme&&(i.cssVar["--art-theme"]=i.theme),i.cssVar)r.cssVar(p,i.cssVar[p]);r.url=i.url}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],kewk9:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){let{template:{$video:i},i18n:n,notice:s}=r;(0,t.def)(r,"pause",{value(){let l=i.pause();return s.show=n.get("Pause"),r.emit("pause"),l}})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],"4XzDs":[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){let{i18n:i,notice:n,template:{$video:s}}=r;if(document.pictureInPictureEnabled){let{template:{$video:l},proxy:c,notice:p}=r;l.disablePictureInPicture=!1,(0,t.def)(r,"pip",{get:()=>document.pictureInPictureElement,set(d){d?(r.state="pip",l.requestPictureInPicture().catch(g=>{throw p.show=g,g})):document.exitPictureInPicture().catch(g=>{throw p.show=g,g})}}),c(l,"enterpictureinpicture",()=>{r.emit("pip",!0)}),c(l,"leavepictureinpicture",()=>{r.emit("pip",!1)})}else if(s.webkitSupportsPresentationMode){let{$video:l}=r.template;l.webkitSetPresentationMode("inline"),(0,t.def)(r,"pip",{get:()=>l.webkitPresentationMode==="picture-in-picture",set(c){c?(r.state="pip",l.webkitSetPresentationMode("picture-in-picture"),r.emit("pip",!0)):(l.webkitSetPresentationMode("inline"),r.emit("pip",!1))}})}else(0,t.def)(r,"pip",{get:()=>!1,set(){n.show=i.get("PIP Not Supported")}})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],jphfi:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){let{template:{$video:i},i18n:n,notice:s}=r;(0,t.def)(r,"playbackRate",{get:()=>i.playbackRate,set(l){l?l!==i.playbackRate&&(i.playbackRate=l,s.show=`${n.get("Rate")}: ${l===1?n.get("Normal"):`${l}x`}`):r.playbackRate=1}})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],iNpeS:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){(0,t.def)(r,"played",{get:()=>r.currentTime/r.duration})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],aBIWL:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){let{$video:i}=r.template;(0,t.def)(r,"playing",{get:()=>typeof i.playing=="boolean"?i.playing:i.currentTime>0&&!i.paused&&!i.ended&&i.readyState>2})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],hRBri:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){let{i18n:i,notice:n,option:s,constructor:{instances:l},template:{$video:c}}=r;(0,t.def)(r,"play",{async value(){let p=await c.play();if(n.show=i.get("Play"),r.emit("play"),s.mutex)for(let d=0;du);var t=a("../utils");function u(r){let{template:{$poster:i}}=r;(0,t.def)(r,"poster",{get:()=>{try{return i.style.backgroundImage.match(/"(.*)"/)[1]}catch{return""}},set(n){(0,t.setStyle)(i,"backgroundImage",`url(${n})`)}})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],"17rUP":[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){(0,t.def)(r,"quality",{set(i){let{controls:n,notice:s,i18n:l}=r,c=i.find(p=>p.default)||i[0];n.update({name:"quality",position:"right",index:10,style:{marginRight:"10px"},html:(c==null?void 0:c.html)||"",selector:i,onSelect:async p=>(await r.switchQuality(p.url),s.show=`${l.get("Switch Video")}: ${p.html}`,p.html)})}})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],"55qzI":[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){(0,t.def)(r,"rect",{get:()=>(0,t.getRect)(r.template.$player)});let i=["bottom","height","left","right","top","width"];for(let n=0;nr.rect[s]})}(0,t.def)(r,"x",{get:()=>r.left+window.pageXOffset}),(0,t.def)(r,"y",{get:()=>r.top+window.pageYOffset})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],bC6TG:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){let{notice:i,template:{$video:n}}=r,s=(0,t.createElement)("canvas");(0,t.def)(r,"getDataURL",{value:()=>new Promise((l,c)=>{try{s.width=n.videoWidth,s.height=n.videoHeight,s.getContext("2d").drawImage(n,0,0),l(s.toDataURL("image/png"))}catch(p){i.show=p,c(p)}})}),(0,t.def)(r,"getBlobUrl",{value:()=>new Promise((l,c)=>{try{s.width=n.videoWidth,s.height=n.videoHeight,s.getContext("2d").drawImage(n,0,0),s.toBlob(p=>{l(URL.createObjectURL(p))})}catch(p){i.show=p,c(p)}})}),(0,t.def)(r,"screenshot",{value:async l=>{let c=await r.getDataURL(),p=l||`artplayer_${(0,t.secondToTime)(n.currentTime)}`;return(0,t.download)(c,`${p}.png`),r.emit("screenshot",c),c}})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],j8GRO:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){let{notice:i}=r;(0,t.def)(r,"seek",{set(n){r.currentTime=n,r.duration&&(i.show=`${(0,t.secondToTime)(r.currentTime)} / ${(0,t.secondToTime)(r.duration)}`),r.emit("seek",r.currentTime)}}),(0,t.def)(r,"forward",{set(n){r.seek=r.currentTime+n}}),(0,t.def)(r,"backward",{set(n){r.seek=r.currentTime-n}})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],cn7iR:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){let i=["mini","pip","fullscreen","fullscreenWeb"];(0,t.def)(r,"state",{get:()=>i.find(n=>r[n])||"standard",set(n){for(let s=0;su);var t=a("../utils");function u(r){let{notice:i,i18n:n,template:s}=r;(0,t.def)(r,"subtitleOffset",{get:()=>{var l;return((l=s.$track)==null?void 0:l.offset)||0},set(l){let{cues:c}=r.subtitle;if(!s.$track||c.length===0)return;let p=(0,t.clamp)(l,-10,10);s.$track.offset=p;for(let d=0;du);var t=a("../utils");function u(r){function i(n,s){return new Promise((l,c)=>{if(n===r.url)return;let{playing:p,aspectRatio:d,playbackRate:g}=r;r.pause(),r.url=n,r.notice.show="",r.once("video:error",c),r.once("video:loadedmetadata",()=>{r.currentTime=s}),r.once("video:canplay",async()=>{r.playbackRate=g,r.aspectRatio=d,p&&await r.play(),r.notice.show="",l()})})}(0,t.def)(r,"switchQuality",{value:n=>i(n,r.currentTime)}),(0,t.def)(r,"switchUrl",{value:n=>i(n,0)}),(0,t.def)(r,"switch",{set:r.switchUrl})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],"7iMuh":[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){(0,t.def)(r,"theme",{get:()=>r.cssVar("--art-theme"),set(i){r.cssVar("--art-theme",i)}})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],"6P0RS":[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){let{events:i,option:n,template:{$progress:s,$video:l}}=r,c=null,p=null,d=!1,g=!1,y=!1;i.hover(s,()=>{y=!0},()=>{y=!1}),r.on("setBar",async(x,f,v)=>{var $;let w=($=r.controls)==null?void 0:$.thumbnails,{url:j,scale:E}=n.thumbnails;if(!w||!j)return;let S=x==="played"&&v&&t.isMobile;if(x==="hover"||S){if(d||(d=!0,p=await(0,t.loadImg)(j,E),g=!0),!g||!y)return;let C=s.clientWidth*f;(0,t.setStyle)(w,"display","flex"),C>0&&Cs.clientWidth-D/2?(0,t.setStyle)(q,"left",`${s.clientWidth-D}px`):(0,t.setStyle)(q,"left",`${T-D/2}px`)}(C):t.isMobile||(0,t.setStyle)(w,"display","none"),S&&(clearTimeout(c),c=setTimeout(()=>{(0,t.setStyle)(w,"display","none")},500))}}),(0,t.def)(r,"thumbnails",{get:()=>r.option.thumbnails,set(x){x.url&&!r.option.isLive&&(r.option.thumbnails=x,clearTimeout(c),c=null,p=null,d=!1,g=!1)}})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],eNi78:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){(0,t.def)(r,"toggle",{value:()=>r.playing?r.pause():r.play()})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],"7AUBD":[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){(0,t.def)(r,"type",{get:()=>r.option.type,set(i){r.option.type=i}})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],cnlLL:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){let{option:i,template:{$video:n}}=r;(0,t.def)(r,"url",{get:()=>n.src,async set(s){if(s){let l=r.url,c=i.type||(0,t.getExt)(s),p=i.customType[c];c&&p?(await(0,t.sleep)(),r.loading.show=!0,p.call(r,n,s,r)):(URL.revokeObjectURL(l),n.src=s),l!==r.url&&(r.option.url=s,r.isReady&&l&&r.once("video:canplay",()=>{r.emit("restart",s)}))}else await(0,t.sleep)(),r.loading.show=!0}})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],iX66j:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){let{template:{$video:i},i18n:n,notice:s,storage:l}=r;(0,t.def)(r,"volume",{get:()=>i.volume||0,set:c=>{i.volume=(0,t.clamp)(c,0,1),s.show=`${n.get("Volume")}: ${Number.parseInt(100*i.volume,10)}`,i.volume!==0&&l.set("volume",i.volume)}}),(0,t.def)(r,"muted",{get:()=>i.muted,set:c=>{i.muted=c,r.emit("muted",c)}})}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],cjxJL:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o);var t=a("../utils"),u=a("./autoOrientation"),r=e.interopDefault(u),i=a("./autoPlayback"),n=e.interopDefault(i),s=a("./fastForward"),l=e.interopDefault(s),c=a("./lock"),p=e.interopDefault(c),d=a("./miniProgressBar"),g=e.interopDefault(d);o.default=class{constructor(y){this.art=y,this.id=0;let{option:x}=y;x.miniProgressBar&&!x.isLive&&this.add(g.default),x.lock&&t.isMobile&&this.add(p.default),x.autoPlayback&&!x.isLive&&this.add(n.default),x.autoOrientation&&t.isMobile&&this.add(r.default),x.fastForward&&t.isMobile&&!x.isLive&&this.add(l.default);for(let f=0;fthis.next(y,f)):this.next(y,x)}next(y,x){let f=x&&x.name||y.name||`plugin${this.id}`;return(0,t.errorHandle)(!(0,t.has)(this,f),`Cannot add a plugin that already has the same name: ${f}`),(0,t.def)(this,f,{value:x}),this}}},{"../utils":"aBlEo","./autoOrientation":"jb9jb","./autoPlayback":"21HWM","./fastForward":"4sxBO","./lock":"fjy9V","./miniProgressBar":"d0xRp","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],jb9jb:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){let{notice:i,constructor:n,template:{$player:s,$video:l}}=r,c="art-auto-orientation",p="art-auto-orientation-fullscreen",d=!1;function g(){let{videoWidth:y,videoHeight:x}=l,f=document.documentElement.clientWidth,v=document.documentElement.clientHeight;return y>x&&fv}return r.on("fullscreenWeb",y=>{y?g()&&setTimeout(()=>{r.fullscreenWeb&&!(0,t.hasClass)(s,c)&&function(){let x=document.documentElement.clientWidth,f=document.documentElement.clientHeight;(0,t.setStyle)(s,"width",`${f}px`),(0,t.setStyle)(s,"height",`${x}px`),(0,t.setStyle)(s,"transform-origin","0 0"),(0,t.setStyle)(s,"transform",`rotate(90deg) translate(0, -${x}px)`),(0,t.addClass)(s,c),r.isRotate=!0,r.emit("resize")}()},Number(n.AUTO_ORIENTATION_TIME??0)):(0,t.hasClass)(s,c)&&((0,t.setStyle)(s,"width",""),(0,t.setStyle)(s,"height",""),(0,t.setStyle)(s,"transform-origin",""),(0,t.setStyle)(s,"transform",""),(0,t.removeClass)(s,c),r.isRotate=!1,r.emit("resize"))}),r.on("fullscreen",async y=>{var f;let x=!!((f=screen==null?void 0:screen.orientation)!=null&&f.lock);if(y){if(x&&g())try{let v=screen.orientation.type.startsWith("portrait")?"landscape":"portrait";await screen.orientation.lock(v),d=!0,(0,t.addClass)(s,p)}catch(v){d=!1,i.show=v}}else if((0,t.hasClass)(s,p)&&(0,t.removeClass)(s,p),x&&d){try{screen.orientation.unlock()}catch{}d=!1}}),{name:"autoOrientation",get state(){return(0,t.hasClass)(s,c)}}}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],"21HWM":[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){let{i18n:i,icons:n,storage:s,constructor:l,proxy:c,template:{$poster:p}}=r,d=r.layers.add({name:"auto-playback",html:'
    '}),g=(0,t.query)(".art-auto-playback-last",d),y=(0,t.query)(".art-auto-playback-jump",d),x=(0,t.query)(".art-auto-playback-close",d);(0,t.append)(x,n.close);let f=null;function v(){let w=(s.get("times")||{})[r.option.id||r.option.url];clearTimeout(f),(0,t.setStyle)(d,"display","none"),w&&w>=l.AUTO_PLAYBACK_MIN&&((0,t.setStyle)(d,"display","flex"),g.textContent=`${i.get("Last Seen")} ${(0,t.secondToTime)(w)}`,y.textContent=i.get("Jump Play"),c(x,"click",()=>{(0,t.setStyle)(d,"display","none")}),c(y,"click",()=>{r.seek=w,r.play(),(0,t.setStyle)(p,"display","none"),(0,t.setStyle)(d,"display","none")}),r.once("video:timeupdate",()=>{f=setTimeout(()=>{(0,t.setStyle)(d,"display","none")},l.AUTO_PLAYBACK_TIMEOUT)}))}return r.on("video:timeupdate",()=>{if(r.playing){let w=s.get("times")||{},j=Object.keys(w);j.length>l.AUTO_PLAYBACK_MAX&&delete w[j[0]],w[r.option.id||r.option.url]=r.currentTime,s.set("times",w)}}),r.on("ready",v),r.on("restart",v),{name:"auto-playback",get times(){return s.get("times")||{}},clear:()=>s.del("times"),delete(w){let j=s.get("times")||{};return delete j[w],s.set("times",j),j}}}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],"4sxBO":[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){let{constructor:i,proxy:n,template:{$player:s,$video:l}}=r,c=null,p=!1,d=1,g=()=>{clearTimeout(c),p&&(p=!1,r.playbackRate=d,(0,t.removeClass)(s,"art-fast-forward"))};return n(l,"touchstart",y=>{y.touches.length===1&&r.playing&&!r.isLock&&(c=setTimeout(()=>{p=!0,d=r.playbackRate,r.playbackRate=i.FAST_FORWARD_VALUE,(0,t.addClass)(s,"art-fast-forward")},i.FAST_FORWARD_TIME))}),r.on("document:touchmove",g),r.on("document:touchend",g),{name:"fastForward",get state(){return(0,t.hasClass)(s,"art-fast-forward")}}}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],fjy9V:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){let{layers:i,icons:n,template:{$player:s}}=r;function l(){return(0,t.hasClass)(s,"art-lock")}function c(){(0,t.addClass)(s,"art-lock"),r.isLock=!0,r.emit("lock",!0)}function p(){(0,t.removeClass)(s,"art-lock"),r.isLock=!1,r.emit("lock",!1)}return i.add({name:"lock",mounted(d){let g=(0,t.append)(d,n.lock),y=(0,t.append)(d,n.unlock);(0,t.setStyle)(g,"display","none"),r.on("lock",x=>{x?((0,t.setStyle)(g,"display","inline-flex"),(0,t.setStyle)(y,"display","none")):((0,t.setStyle)(g,"display","none"),(0,t.setStyle)(y,"display","inline-flex"))})},click(){l()?p():c()}}),{name:"lock",get state(){return l()},set state(d){d?c():p()}}}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],d0xRp:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){return r.on("control",i=>{i?(0,t.removeClass)(r.template.$player,"art-mini-progress-bar"):(0,t.addClass)(r.template.$player,"art-mini-progress-bar")}),{name:"mini-progress-bar"}}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],bwLGT:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o);var t=a("../utils"),u=a("../utils/component"),r=e.interopDefault(u),i=a("./aspectRatio"),n=e.interopDefault(i),s=a("./flip"),l=e.interopDefault(s),c=a("./playbackRate"),p=e.interopDefault(c),d=a("./subtitleOffset"),g=e.interopDefault(d);class y extends r.default{constructor(f){super(f);let{option:v,controls:w,template:{$setting:j}}=f;this.name="setting",this.$parent=j,this.id=0,this.active=null,this.cache=new Map,this.option=[...this.builtin,...v.settings],v.setting&&(this.format(),this.render(),f.on("blur",()=>{this.show&&(this.show=!1,this.render())}),f.on("focus",E=>{let S=(0,t.includeFromEvent)(E,w.setting),$=(0,t.includeFromEvent)(E,this.$parent);!this.show||S||$||(this.show=!1,this.render())}),f.on("resize",()=>this.resize()))}get builtin(){let f=[],{option:v}=this.art;return v.playbackRate&&f.push((0,p.default)(this.art)),v.aspectRatio&&f.push((0,n.default)(this.art)),v.flip&&f.push((0,l.default)(this.art)),v.subtitleOffset&&f.push((0,g.default)(this.art)),f}traverse(f,v=this.option){var w;for(let j=0;j{v.default=v===f,v.default&&v.$item&&(0,t.inverseClass)(v.$item,"art-current")},f.$option),this.render(f.$parents)}format(f=this.option,v,w,j=[]){for(let E=0;Ev}),(0,t.def)(S,"$parents",{get:()=>w}),(0,t.def)(S,"$option",{get:()=>f});let $=[];(0,t.def)(S,"$events",{get:()=>$}),(0,t.def)(S,"$formatted",{get:()=>!0})}this.format(S.selector||[],S,f,j)}this.option=f}find(f=""){let v=null;return this.traverse(w=>{w.name===f&&(v=w)}),v}resize(){var S,$;let{controls:f,constructor:{SETTING_WIDTH:v,SETTING_ITEM_HEIGHT:w},template:{$player:j,$setting:E}}=this.art;if(f.setting&&this.show){let C=(($=(S=this.active[0])==null?void 0:S.$parent)==null?void 0:$.width)||v,{left:T,width:q}=(0,t.getRect)(f.setting),{left:B,width:k}=(0,t.getRect)(j),b=T-B+q/2-C/2,I=this.active===this.option?this.active.length*w:(this.active.length+1)*w;if((0,t.setStyle)(E,"height",`${I}px`),(0,t.setStyle)(E,"width",`${C}px`),this.art.isRotate||t.isMobile)return;b+C>k?((0,t.setStyle)(E,"left",null),(0,t.setStyle)(E,"right",null)):((0,t.setStyle)(E,"left",`${b}px`),(0,t.setStyle)(E,"right","auto"))}}inactivate(f){for(let v=0;v'),C=(0,t.createElement)("div");(0,t.addClass)(C,"art-setting-item-left-icon"),(0,t.append)(C,j),(0,t.append)($,C),(0,t.append)($,f.$parent.html);let T=w(S,"click",()=>this.render(f.$parents));f.$parent.$events.push(T),(0,t.append)(v,S)}createItem(f,v=!1){var F,D;if(!this.cache.has(f.$option))return;let w=this.cache.get(f.$option),j=f.$item,E="selector";(0,t.has)(f,"switch")&&(E="switch"),(0,t.has)(f,"range")&&(E="range"),(0,t.has)(f,"onClick")&&(E="button");let{icons:S,proxy:$,constructor:C}=this.art,T=(0,t.createElement)("div");(0,t.addClass)(T,"art-setting-item"),(0,t.setStyle)(T,"height",`${C.SETTING_ITEM_HEIGHT}px`),T.dataset.name=f.name||"",T.dataset.value=f.value||"";let q=(0,t.append)(T,'
    '),B=(0,t.append)(T,'
    '),k=(0,t.createElement)("div");switch((0,t.addClass)(k,"art-setting-item-left-icon"),E){case"button":case"switch":case"range":(0,t.append)(k,f.icon||S.config);break;case"selector":(F=f.selector)!=null&&F.length?(0,t.append)(k,f.icon||S.config):(0,t.append)(k,S.check)}(0,t.append)(q,k),(0,t.def)(f,"$icon",{configurable:!0,get:()=>k}),(0,t.def)(f,"icon",{configurable:!0,get:()=>k.innerHTML,set(M){k.innerHTML="",(0,t.append)(k,M)}});let b=(0,t.createElement)("div");(0,t.addClass)(b,"art-setting-item-left-text"),(0,t.append)(b,f.html||""),(0,t.append)(q,b),(0,t.def)(f,"$html",{configurable:!0,get:()=>b}),(0,t.def)(f,"html",{configurable:!0,get:()=>b.innerHTML,set(M){b.innerHTML="",(0,t.append)(b,M)}});let I=(0,t.createElement)("div");switch((0,t.addClass)(I,"art-setting-item-right-tooltip"),(0,t.append)(I,f.tooltip||""),(0,t.append)(B,I),(0,t.def)(f,"$tooltip",{configurable:!0,get:()=>I}),(0,t.def)(f,"tooltip",{configurable:!0,get:()=>I.innerHTML,set(M){I.innerHTML="",(0,t.append)(I,M)}}),E){case"switch":{let M=(0,t.createElement)("div");(0,t.addClass)(M,"art-setting-item-right-icon");let L=(0,t.append)(M,S.switchOn),O=(0,t.append)(M,S.switchOff);(0,t.setStyle)(f.switch?O:L,"display","none"),(0,t.append)(B,M),(0,t.def)(f,"$switch",{configurable:!0,get:()=>M});let P=f.switch;(0,t.def)(f,"switch",{configurable:!0,get:()=>P,set(_){P=_,_?((0,t.setStyle)(O,"display","none"),(0,t.setStyle)(L,"display",null)):((0,t.setStyle)(O,"display",null),(0,t.setStyle)(L,"display","none"))}});break}case"range":{let M=(0,t.createElement)("div");(0,t.addClass)(M,"art-setting-item-right-icon");let L=(0,t.append)(M,'');L.value=f.range[0],L.min=f.range[1],L.max=f.range[2],L.step=f.range[3],(0,t.addClass)(L,"art-setting-range"),(0,t.append)(B,M),(0,t.def)(f,"$range",{configurable:!0,get:()=>L});let O=[...f.range];(0,t.def)(f,"range",{configurable:!0,get:()=>O,set(P){O=[...P],L.value=P[0],L.min=P[1],L.max=P[2],L.step=P[3]}})}break;case"selector":if((D=f.selector)!=null&&D.length){let M=(0,t.createElement)("div");(0,t.addClass)(M,"art-setting-item-right-icon"),(0,t.append)(M,S.arrowRight),(0,t.append)(B,M)}}switch(E){case"switch":if(f.onSwitch){let M=$(T,"click",async L=>{f.switch=await f.onSwitch.call(this.art,f,T,L)});f.$events.push(M)}break;case"range":if(f.$range){if(f.onRange){let M=$(f.$range,"change",async L=>{f.range[0]=f.$range.valueAsNumber,f.tooltip=await f.onRange.call(this.art,f,T,L)});f.$events.push(M)}if(f.onChange){let M=$(f.$range,"input",async L=>{f.range[0]=f.$range.valueAsNumber,f.tooltip=await f.onChange.call(this.art,f,T,L)});f.$events.push(M)}}break;case"selector":{let M=$(T,"click",async L=>{var O;(O=f.selector)!=null&&O.length?this.render(f.selector):(this.check(f),f.$parent.onSelect&&(f.$parent.tooltip=await f.$parent.onSelect.call(this.art,f,T,L)))});f.$events.push(M),f.default&&(0,t.addClass)(T,"art-current")}break;case"button":if(f.onClick){let M=$(T,"click",async L=>{f.tooltip=await f.onClick.call(this.art,f,T,L)});f.$events.push(M)}}(0,t.def)(f,"$item",{configurable:!0,get:()=>T}),v?(0,t.replaceElement)(T,j):(0,t.append)(w,T),f.mounted&&setTimeout(()=>f.mounted.call(this.art,f.$item,f),0)}render(f=this.option){var v;if(this.active=f,this.cache.has(f)){let w=this.cache.get(f);(0,t.inverseClass)(w,"art-current")}else{let w=(0,t.createElement)("div");this.cache.set(f,w),(0,t.addClass)(w,"art-setting-panel"),(0,t.append)(this.$parent,w),(0,t.inverseClass)(w,"art-current"),(v=f[0])!=null&&v.$parent&&this.createHeader(f[0]);for(let j=0;j({value:p,name:`aspect-ratio-${p}`,default:p===u.aspectRatio,html:l(p)})),onSelect:p=>(u.aspectRatio=p.value,p.html),mounted:()=>{c(),u.on("aspectRatio",()=>c())}}}e.defineInteropFlag(o),e.export(o,"default",()=>t)},{"@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],ljJTO:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o),e.export(o,"default",()=>u);var t=a("../utils");function u(r){let{i18n:i,icons:n,constructor:{SETTING_ITEM_WIDTH:s,FLIP:l}}=r;function c(d){return i.get((0,t.capitalize)(d))}function p(){let d=r.setting.find(`flip-${r.flip}`);r.setting.check(d)}return{width:s,name:"flip",html:i.get("Video Flip"),tooltip:c(r.flip),icon:n.flip,selector:l.map(d=>({value:d,name:`flip-${d}`,default:d===r.flip,html:c(d)})),onSelect:d=>(r.flip=d.value,d.html),mounted:()=>{p(),r.on("flip",()=>p())}}}},{"../utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],"3QcSQ":[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");function t(u){let{i18n:r,icons:i,constructor:{SETTING_ITEM_WIDTH:n,PLAYBACK_RATE:s}}=u;function l(p){return p===1?r.get("Normal"):p.toFixed(1)}function c(){let p=u.setting.find(`playback-rate-${u.playbackRate}`);u.setting.check(p)}return{width:n,name:"playback-rate",html:r.get("Play Speed"),tooltip:l(u.playbackRate),icon:i.playbackRate,selector:s.map(p=>({value:p,name:`playback-rate-${p}`,default:p===u.playbackRate,html:l(p)})),onSelect:p=>(u.playbackRate=p.value,p.html),mounted:()=>{c(),u.on("video:ratechange",()=>c())}}}e.defineInteropFlag(o),e.export(o,"default",()=>t)},{"@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],eB5hg:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");function t(u){let{i18n:r,icons:i,constructor:n}=u;return{width:n.SETTING_ITEM_WIDTH,name:"subtitle-offset",html:r.get("Subtitle Offset"),icon:i.subtitle,tooltip:"0s",range:[0,-10,10,.1],onChange:s=>(u.subtitleOffset=s.range[0],`${s.range[0]}s`),mounted:(s,l)=>{u.on("subtitleOffset",c=>{l.$range.value=c,l.tooltip=`${c}s`})}}}e.defineInteropFlag(o),e.export(o,"default",()=>t)},{"@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],kwqbK:[function(a,h,o,m){a("@parcel/transformer-js/src/esmodule-helpers.js").defineInteropFlag(o),o.default=class{constructor(){this.name="artplayer_settings",this.settings={}}get(e){try{let t=JSON.parse(window.localStorage.getItem(this.name))||{};return e?t[e]:t}catch{return e?this.settings[e]:this.settings}}set(e,t){try{let u=Object.assign({},this.get(),{[e]:t});window.localStorage.setItem(this.name,JSON.stringify(u))}catch{this.settings[e]=t}}del(e){try{let t=this.get();delete t[e],window.localStorage.setItem(this.name,JSON.stringify(t))}catch{delete this.settings[e]}}clear(){try{window.localStorage.removeItem(this.name)}catch{this.settings={}}}}},{"@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],k5613:[function(a,h,o,m){var e=a("@parcel/transformer-js/src/esmodule-helpers.js");e.defineInteropFlag(o);var t=a("option-validator"),u=e.interopDefault(t),r=a("./scheme"),i=e.interopDefault(r),n=a("./utils"),s=a("./utils/component"),l=e.interopDefault(s);class c extends l.default{constructor(d){super(d),this.name="subtitle",this.option=null,this.destroyEvent=()=>null,this.init(d.option.subtitle);let g=!1;d.on("video:timeupdate",()=>{if(!this.url)return;let y=this.art.template.$video.webkitDisplayingFullscreen;typeof y=="boolean"&&y!==g&&(g=y,this.createTrack(y?"subtitles":"metadata",this.url))})}get url(){return this.art.template.$track.src}set url(d){this.switch(d)}get textTrack(){var d,g;return(g=(d=this.art.template.$video)==null?void 0:d.textTracks)==null?void 0:g[0]}get activeCues(){return this.textTrack?Array.from(this.textTrack.activeCues):[]}get cues(){return this.textTrack?Array.from(this.textTrack.cues):[]}style(d,g){let{$subtitle:y}=this.art.template;return typeof d=="object"?(0,n.setStyles)(y,d):(0,n.setStyle)(y,d,g)}update(){let{option:{subtitle:d},template:{$subtitle:g}}=this.art;g.innerHTML="",this.activeCues.length&&(this.art.emit("subtitleBeforeUpdate",this.activeCues),g.innerHTML=this.activeCues.map((y,x)=>y.text.split(/\r?\n/).filter(f=>f.trim()).map(f=>`
    ${d.escape?(0,n.escape)(f):f}
    `).join("")).join(""),this.art.emit("subtitleAfterUpdate",this.activeCues))}async switch(d,g={}){let{i18n:y,notice:x,option:f}=this.art,v={...f.subtitle,...g,url:d},w=await this.init(v);return g.name&&(x.show=`${y.get("Switch Subtitle")}: ${g.name}`),w}createTrack(d,g){let{template:y,proxy:x,option:f}=this.art,{$video:v,$track:w}=y,j=(0,n.createElement)("track");j.default=!0,j.kind=d,j.src=g,j.label=f.subtitle.name||"Artplayer",j.track.mode="hidden",j.onload=()=>{this.art.emit("subtitleLoad",this.cues,this.option)},this.art.events.remove(this.destroyEvent),w.onload=null,(0,n.remove)(w),(0,n.append)(v,j),y.$track=j,this.destroyEvent=x(this.textTrack,"cuechange",()=>this.update())}async init(d){let{notice:g,template:{$subtitle:y}}=this.art;return this.textTrack?((0,u.default)(d,i.default.subtitle),d.url?(this.option=d,this.style(d.style),fetch(d.url).then(x=>x.arrayBuffer()).then(x=>{let f=new TextDecoder(d.encoding).decode(x);switch(d.type||(0,n.getExt)(d.url)){case"srt":{let v=(0,n.srtToVtt)(f),w=d.onVttLoad(v);return(0,n.vttToBlob)(w)}case"ass":{let v=(0,n.assToVtt)(f),w=d.onVttLoad(v);return(0,n.vttToBlob)(w)}case"vtt":{let v=d.onVttLoad(f);return(0,n.vttToBlob)(v)}default:return d.url}}).then(x=>(y.innerHTML="",this.url===x||(URL.revokeObjectURL(this.url),this.createTrack("metadata",x)),x)).catch(x=>{throw y.innerHTML="",g.show=x,x})):void 0):null}}o.default=c},{"option-validator":"g7VGh","./scheme":"biLjm","./utils":"aBlEo","./utils/component":"idCEj","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],fwOA1:[function(a,h,o,m){a("@parcel/transformer-js/src/esmodule-helpers.js").defineInteropFlag(o);var e=a("../package.json"),t=a("./utils");class u{constructor(i){this.art=i;let{option:n,constructor:s}=i;n.container instanceof Element?this.$container=n.container:(this.$container=(0,t.query)(n.container),(0,t.errorHandle)(this.$container,`No container element found by ${n.container}`)),(0,t.errorHandle)((0,t.supportsFlex)(),"The current browser does not support flex layout");let l=this.$container.tagName.toLowerCase();(0,t.errorHandle)(l==="div",`Unsupported container element type, only support 'div' but got '${l}'`),(0,t.errorHandle)(s.instances.every(c=>c.template.$container!==this.$container),"Cannot mount multiple instances on the same dom element"),this.query=this.query.bind(this),this.$container.dataset.artId=i.id,this.init()}static get html(){return`
    Player version:
    ${e.version}
    Video url:
    Video volume:
    Video time:
    Video duration:
    Video resolution:
    x
    [x]
    `}query(i){return(0,t.query)(i,this.$container)}init(){let{option:i}=this.art;if(i.useSSR||(this.$container.innerHTML=u.html),this.$player=this.query(".art-video-player"),this.$video=this.query(".art-video"),this.$track=this.query("track"),this.$poster=this.query(".art-poster"),this.$subtitle=this.query(".art-subtitle"),this.$danmuku=this.query(".art-danmuku"),this.$bottom=this.query(".art-bottom"),this.$progress=this.query(".art-progress"),this.$controls=this.query(".art-controls"),this.$controlsLeft=this.query(".art-controls-left"),this.$controlsCenter=this.query(".art-controls-center"),this.$controlsRight=this.query(".art-controls-right"),this.$layer=this.query(".art-layers"),this.$loading=this.query(".art-loading"),this.$notice=this.query(".art-notice"),this.$noticeInner=this.query(".art-notice-inner"),this.$mask=this.query(".art-mask"),this.$state=this.query(".art-state"),this.$setting=this.query(".art-settings"),this.$info=this.query(".art-info"),this.$infoPanel=this.query(".art-info-panel"),this.$infoClose=this.query(".art-info-close"),this.$contextmenu=this.query(".art-contextmenus"),i.proxy){let n=i.proxy.call(this.art,this.art);(0,t.errorHandle)(n instanceof HTMLVideoElement||n instanceof HTMLCanvasElement,"Function 'option.proxy' needs to return 'HTMLVideoElement' or 'HTMLCanvasElement'"),(0,t.replaceElement)(n,this.$video),n.className="art-video",this.$video=n}i.backdrop&&(0,t.addClass)(this.$player,"art-backdrop"),t.isMobile&&(0,t.addClass)(this.$player,"art-mobile")}destroy(i){i?this.$container.innerHTML="":(0,t.addClass)(this.$player,"art-destroy")}}o.default=u},{"../package.json":"lh3R5","./utils":"aBlEo","@parcel/transformer-js/src/esmodule-helpers.js":"loqXi"}],"4NM7P":[function(a,h,o,m){a("@parcel/transformer-js/src/esmodule-helpers.js").defineInteropFlag(o),o.default=class{on(e,t,u){let r=this.e||(this.e={});return(r[e]||(r[e]=[])).push({fn:t,ctx:u}),this}once(e,t,u){let r=this;function i(...n){r.off(e,i),t.apply(u,n)}return i._=t,this.on(e,i,u)}emit(e,...t){let u=((this.e||(this.e={}))[e]||[]).slice();for(let r=0;rc.destroy()),c.on(Hls.Events.ERROR,(p,d)=>{if(d.fatal)switch(d.type){case Hls.ErrorTypes.NETWORK_ERROR:console.warn("HLS network error, trying to recover..."),c.startLoad();break;case Hls.ErrorTypes.MEDIA_ERROR:console.warn("HLS media error, trying to recover..."),c.recoverMediaError();break;default:console.error("Fatal HLS error");break}})}else n.canPlayType("application/vnd.apple.mpegurl")&&(n.src=s)}},settings:[{html:"Speed",selector:[{html:"0.5x",value:.5},{html:"0.75x",value:.75},{html:"Normal",value:1,default:!0},{html:"1.25x",value:1.25},{html:"1.5x",value:1.5},{html:"2x",value:2}],onSelect(i){return N&&(N.playbackRate=i.value),i.html}}],icons:{loading:'
    ',state:''},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"}};return u.length>0&&(r.quality=u.map((i,n)=>({default:n===0,html:i,url:o}))),N=new Pt(r),N.on("ready",()=>{console.log("Player ready"),N.video&&(N.video.preload="auto")}),N.on("video:waiting",()=>{console.log("Buffering...")}),N.on("video:canplay",()=>{console.log("Can play")}),N.on("error",i=>{console.error("Player error:",i)}),N}function Ot(){N&&(N.destroy(),N=null)}const _t=4e3;function Vt(a,h="info"){const o=document.getElementById("toastContainer");if(!o)return;const m=document.createElement("div");m.className=`toast toast--${h}`,m.innerHTML=` + + ${Ht(h)} + + ${zt(a)} + `,o.appendChild(m),setTimeout(()=>{m.style.animation="slideIn 0.3s ease reverse",setTimeout(()=>m.remove(),300)},_t)}function Ht(a){switch(a){case"success":return'';case"error":return'';default:return''}}function zt(a){if(!a)return"";const h=document.createElement("div");return h.textContent=a,h.innerHTML}export{Dt as a,Ot as d,Nt as i,Vt as s}; diff --git a/backend/static/assets/main-B16Z87Li.js b/backend/static/assets/main-B16Z87Li.js new file mode 100644 index 0000000..f1680fb --- /dev/null +++ b/backend/static/assets/main-B16Z87Li.js @@ -0,0 +1,392 @@ +import{a as k,s as T,d as Z}from"./Toast-BwR22KmJ.js";async function X(){try{console.log("📂 Loading themed categories...");const e=await(await fetch("/api/rophim/categories/all")).json();return e&&e.categories?(console.log(`✓ Loaded ${Object.keys(e.categories).length} category sections`),e.categories):null}catch(t){return console.error("Error loading categories:",t),null}}function V(t){const e=document.createElement("div");return e.className="video-card__ranking",t<=3&&e.classList.add(`video-card__ranking--${t}`),e.textContent=`#${t}`,e}function W(t){if(!t)return null;const e=document.createElement("div");e.className="video-card__badge";const r=t.toUpperCase();return r.includes("HOT")?e.classList.add("video-card__badge--hot"):r.includes("NEW")?e.classList.add("video-card__badge--new"):r.includes("CINEMA")?e.classList.add("video-card__badge--cinema"):r.includes("FULL")&&e.classList.add("video-card__badge--full"),e.textContent=r,e}function ee(t,e){if(!t)return t;const r=t.querySelector(".video-card__container");if(!r)return t;if(e.badge){const i=W(e.badge);i&&r.appendChild(i)}if(e.ranking){const i=V(e.ranking);r.appendChild(i)}return t}typeof window<"u"&&(window.categorySystem={loadCategories:X,createRankingBadge:V,createQualityBadge:W,enhanceVideoCardWithBadges:ee});const N="kvstream-images-v1",te=500;class ae{constructor(){this.memoryCache=new Map,this.cacheEnabled="caches"in window,this.pendingRequests=new Map}async getCachedImage(e){if(!e||!this.cacheEnabled)return e;if(this.memoryCache.has(e))return this.memoryCache.get(e);if(this.pendingRequests.has(e))return this.pendingRequests.get(e);const r=this._fetchAndCache(e);this.pendingRequests.set(e,r);try{return await r}finally{this.pendingRequests.delete(e)}}async _fetchAndCache(e){try{const r=await caches.open(N),i=await r.match(e);if(i){const d=await i.blob(),m=URL.createObjectURL(d);return this.memoryCache.set(e,m),m}const s=await fetch(e,{mode:"cors",credentials:"omit"});if(s.ok){const d=s.clone();r.put(e,d);const m=await s.blob(),a=URL.createObjectURL(m);return this.memoryCache.set(e,a),this._cleanupCache(r),a}}catch{console.warn("Image cache failed:",e)}return e}async preloadImages(e){if(!e||e.length===0)return;const r=6;for(let i=0;ithis.getCachedImage(d)))}}createCachedImage(e,r="",i=""){const s=document.createElement("img");return s.alt=r,s.className=i,s.loading="lazy",s.decoding="async",s.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',e&&this.getCachedImage(e).then(d=>{s.src=d}),s}async _cleanupCache(e){try{const r=await e.keys();if(r.length>te){const i=Math.floor(r.length*.2);for(let s=0;sd)return!0}return!1}function se(t){var s;const e=(t.quality||"").toLowerCase(),r=((s=t.episodes)==null?void 0:s.length)||0,i=(t.category||t.type||"").toLowerCase();return e.includes("trailer")||i.includes("trailer")?"trailer":r>1||i.includes("series")||i.includes("phim-bo")||e.includes("tập")||e.includes("ep")?"series":i.includes("hoathinh")||i.includes("animation")||i.includes("anime")?"animation":"movie"}function oe(t){var s;const e=t.quality||"";if(e.match(/(?:tập\s*)?(\d+)(?:\s*\/\s*(\d+))?/i))return e;const i=((s=t.episodes)==null?void 0:s.length)||0;return i>1?`${i} Tập`:null}function ne(t,e,r){var I;const i=document.createElement("div");i.className="video-card",i.dataset.videoId=t.id;const s=t.thumbnail||"",d=t.year||new Date().getFullYear(),m=ie(t),a=se(t),o=oe(t);let u=t.quality||"HD";u=u.replace(/(?:tập\s*)?\d+(?:\s*\/\s*\d+)?/gi,"").trim()||"HD",u.length>6&&(u="HD");const n=parseFloat(t.rating||0),c=n>=7,g=Math.round(n*10);let h="";n>0&&(h=` +
    + ${n.toFixed(1)} +
    + `);let f="";n>0&&(f=` +
    + ${c?"🍅":"🥀"} + ${g}% +
    + `);const x='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';let b="";m&&(b+='MỚI'),a==="trailer"?b+='TRAILER':a==="series"?b+='PHIM BỘ':a==="animation"&&(b+='HOẠT HÌNH'),i.innerHTML=` +
    +
    + ${G(t.title)} + + +
    + ${b} +
    + + +
    + ${f} + ${h} + ${u} +
    + + +
    + ${d} + ${o?`${o}`:""} +
    + + + ${t.progress&&t.progress.percentage>0?` +
    +
    +
    + `:""} + + +
    + +
    +
    +
    + + +
    + ${G(t.title)} +
    + `;const y=i.querySelector(".video-card__img");if(y&&s){const B=new IntersectionObserver(Y=>{Y.forEach(J=>{J.isIntersecting&&(re.getCachedImage(s).then(Q=>{y.src=Q,y.classList.add("loaded")}).catch(()=>{y.src=s,y.onload=()=>y.classList.add("loaded"),y.onerror=()=>y.classList.add("loaded")}),B.unobserve(y))})},{rootMargin:"800px",threshold:0});B.observe(y)}return(I=i.querySelector('[data-action="play"]'))==null||I.addEventListener("click",B=>{B.stopPropagation(),e==null||e(t)}),i.addEventListener("click",()=>{e==null||e(t)}),i}function G(t){if(!t)return"";const e=document.createElement("div");return e.textContent=t,e.innerHTML}function le(t,e){let r;return function(...s){const d=()=>{clearTimeout(r),t(...s)};clearTimeout(r),r=setTimeout(d,e)}}function ce(t,e,r){if(!t||!e)return;const i=300;let s="";async function d(a){if(s=a,!a||a.length<2){e.classList.remove("active"),e.innerHTML="";return}try{const o=await k.searchRophim(a),u=(o==null?void 0:o.movies)||[];if(a!==s)return;u.length===0?e.innerHTML=` +
    + No results found for "${$(a)}" +
    + `:(e.innerHTML=u.map(n=>` +
    + ${$(n.name||n.title)} +
    +
    ${$(n.name||n.title)}
    +
    + ${n.quality?`${n.quality} • `:""} + ${n.year||""} +
    +
    +
    + `).join(""),e.querySelectorAll(".search__result[data-video-slug]").forEach(n=>{n.addEventListener("click",()=>{const c=n.dataset.videoSlug;window.location.href=`/watch.html?id=${c}&slug=${c}`})})),e.classList.add("active")}catch(o){console.error("Search error:",o),e.innerHTML=` +
    + Search failed. Please try again. +
    + `,e.classList.add("active")}}const m=le(d,i);t.addEventListener("input",a=>{m(a.target.value.trim())}),document.addEventListener("click",a=>{t&&e&&!t.contains(a.target)&&!e.contains(a.target)&&e.classList.remove("active")}),t.addEventListener("keydown",a=>{a.key==="Escape"&&(t.blur(),e.classList.remove("active"))}),t.addEventListener("focus",()=>{t.value.trim().length>=2&&e.classList.add("active")})}function $(t){if(!t)return"";const e=document.createElement("div");return e.textContent=t,e.innerHTML}class de{constructor(){this.currentFocus=null,this.isEnabled=!1,this.selectors=[".video-card",".hero__btn",".slider-btn","#topSearchBtn"]}init(){this.isEnabled=!0,document.addEventListener("keydown",this.handleKey.bind(this)),document.addEventListener("mousemove",this.handleMouseMove.bind(this))}handleMouseMove(){this.currentFocus&&(this.currentFocus.blur(),this.currentFocus.classList.remove("keyboard-focused"),this.currentFocus=null)}handleKey(e){if(["ArrowUp","ArrowDown","ArrowLeft","ArrowRight"].includes(e.key)){if(e.preventDefault(),!this.currentFocus){this.focusFirstVisible();return}let r=null;switch(e.key){case"ArrowRight":r=this.moveHorizontal(1);break;case"ArrowLeft":r=this.moveHorizontal(-1);break;case"ArrowUp":r=this.moveVertical(-1);break;case"ArrowDown":r=this.moveVertical(1);break}r&&this.setFocus(r)}else e.key==="Enter"&&this.currentFocus&&this.currentFocus.click()}focusFirstVisible(){const e=document.querySelectorAll(".video-card");e.length>0&&this.setFocus(e[0])}setFocus(e){this.currentFocus&&this.currentFocus.classList.remove("keyboard-focused"),this.currentFocus=e,e.classList.add("keyboard-focused"),e.focus({preventScroll:!0}),e.scrollIntoView({behavior:"smooth",block:"center",inline:"center"})}moveHorizontal(e){if(!this.currentFocus)return null;const r=Array.from(document.querySelectorAll(this.selectors.join(","))),i=r.indexOf(this.currentFocus);if(i===-1)return null;const s=i+e;if(s>=0&&sd.height*.5,m}return null}moveVertical(e){if(!this.currentFocus)return null;const r=this.currentFocus.getBoundingClientRect(),i=r.left+r.width/2,d=Array.from(document.querySelectorAll(this.selectors.join(","))).filter(o=>{if(o===this.currentFocus)return!1;const u=o.getBoundingClientRect();return e===1?u.top>=r.bottom-r.height*.2:u.bottom<=r.top+r.height*.2});if(d.length===0)return null;let m=null,a=1/0;return d.forEach(o=>{const u=o.getBoundingClientRect(),n=u.left+u.width/2;u.top+u.height/2;const c=Math.abs(u.top-r.top),g=Math.abs(n-i),h=Math.sqrt(Math.pow(c,2)+Math.pow(g,2));h{const i=r.dataset.view===t;r.classList.toggle("active",i),r.classList.toggle("text-white",i),r.classList.toggle("text-gray-400",!i);const s=r.querySelector(".material-symbols-outlined");s&&(s.style.fontVariationSettings=i?"'FILL' 1":"'FILL' 0")})}async function q(){ce(l.searchInput,l.searchResults),l.mobileBottomNavButtons&&l.mobileBottomNavButtons.forEach(i=>{i.addEventListener("click",s=>{s.preventDefault();const d=i.dataset.view;if(d){if(l.mobileBottomNavButtons.forEach(m=>m.classList.remove("active")),i.classList.add("active"),d==="home")ye();else if(d==="search")if(window.innerWidth<768)try{R()}catch(m){console.error("Search render failed",m)}else l.searchWrapper.classList.add("active"),l.searchInput.focus();else d==="mylist"?window.innerWidth<768?z():H("mylist"):d==="downloads"?T("Downloads feature coming soon!","info"):d==="profile"?fe():d==="cinema"?(_("cinema"),L("cinema")):L(d);window.scrollTo({top:0,behavior:"smooth"})}})}),me(),await L("home"),await E();const e=new URLSearchParams(window.location.search).get("view");e&&window.innerWidth<768&&(e==="search"?R():e==="mylist"?z():e==="cinema"&&L("cinema")),new de().init(),"serviceWorker"in navigator&&window.addEventListener("load",()=>{navigator.serviceWorker.register("/sw.js")})}function E(t=null){const e=document.getElementById("heroTitle"),r=document.getElementById("heroDescription"),i=document.getElementById("heroBg"),s=document.getElementById("heroTag"),d=document.getElementById("heroTagContainer"),m=document.getElementById("heroPlayBtn"),a=document.getElementById("heroInfoBtn"),o=document.getElementById("heroContent"),u=t||p.featuredVideo||p.videos[0];u&&(i&&(i.style.opacity="0.5"),o&&(o.style.opacity="0"),setTimeout(()=>{e&&(e.textContent=u.name||u.title||"Featured Movie"),r&&(r.textContent=u.description||u.content||"Watch now on StreamFlix");const n=u.backdrop||u.poster_url||u.thumb_url||u.thumbnail||"";if(i&&n&&(i.style.backgroundImage=`url('${n}')`),s&&d){const c=u.genres||u.category;d.classList.remove("hidden"),c&&Array.isArray(c)&&c.length>0?s.textContent=c[0]:typeof c=="string"?s.textContent=c:s.textContent="#1 in Movies Today"}if(m){const c=m.cloneNode(!0);m.parentNode.replaceChild(c,m),c.addEventListener("click",()=>w(u))}if(a){const c=a.cloneNode(!0);a.parentNode.replaceChild(c,a),c.addEventListener("click",()=>U(u))}i&&(i.style.opacity="1"),o&&(o.style.opacity="1")},300),p.featuredVideo=u)}function ue(){p.heroInterval&&clearInterval(p.heroInterval),!(!p.heroMovies||p.heroMovies.length<=1)&&(p.heroInterval=setInterval(()=>{p.currentHeroIndex++,p.currentHeroIndex>=p.heroMovies.length&&(p.currentHeroIndex=0),E(p.heroMovies[p.currentHeroIndex])},8e3))}function me(){var a,o,u,n;const t=document.getElementById("backToTop"),e=()=>{const c=window.scrollY;l.mainHeader&&(c>100?(l.mainHeader.classList.add("scrolled"),l.mainHeader.style.backgroundColor="#141414"):(l.mainHeader.classList.remove("scrolled"),l.mainHeader.style.backgroundColor="transparent")),t&&(c>500?t.classList.add("visible"):t.classList.remove("visible"))};window.addEventListener("scroll",e,{passive:!0}),e(),t&&t.addEventListener("click",()=>{window.scrollTo({top:0,behavior:"smooth"})}),(a=l.navLinks)==null||a.forEach(c=>{c.addEventListener("click",g=>{g.preventDefault();const h=c.dataset.category;l.navLinks.forEach(f=>f.classList.remove("active")),c.classList.add("active"),p.currentCategory=h,S(h,!0)})}),(o=l.mobileNavItems)==null||o.forEach(c=>{c.addEventListener("click",g=>{g.preventDefault();const h=c.dataset.view;if(l.mobileNavItems.forEach(f=>f.classList.remove("active")),c.classList.add("active"),l.mobileNavItems.forEach(f=>{f.dataset.view===h&&f.classList.add("active")}),h==="home"){l.videoGrid.style.display="block";const f=document.getElementById("newHotContainer");f&&(f.style.display="none"),p.currentCategory="all",S("all",!0)}else if(["movies","series","animation","cinema"].includes(h)){l.videoGrid.style.display="block";const f=document.getElementById("newHotContainer");f&&(f.style.display="none"),p.currentCategory=h,S(h,!0)}else if(h==="history"){l.videoGrid.style.display="block";const f=document.getElementById("newHotContainer");f&&(f.style.display="none"),H()}else if(h==="search"){const f=document.getElementById("headerSearchBtn");f&&f.click()}window.scrollTo({top:0,behavior:"smooth"})})});const r=document.querySelectorAll(".netflix-header__nav-link");r.forEach(c=>{c.addEventListener("click",g=>{g.preventDefault();const h=c.dataset.view;r.forEach(x=>x.classList.remove("active")),c.classList.add("active"),l.mobileNavItems.forEach(x=>{x.classList.remove("active"),x.dataset.view===h&&x.classList.add("active")}),l.videoGrid.style.display="block";const f=document.getElementById("newHotContainer");f&&(f.style.display="none"),h==="home"?(p.currentCategory="all",S("all",!0)):["movies","series","animation","cinema"].includes(h)?(p.currentCategory=h,S(h,!0)):h==="history"&&H(),window.scrollTo({top:0,behavior:"smooth"})})});const i=document.getElementById("headerSearchBtn");i&&i.addEventListener("click",c=>{c.preventDefault();const g=document.getElementById("searchModal"),h=document.getElementById("searchInput");g&&(g.classList.add("active"),h&&setTimeout(()=>h.focus(),100))});const s=document.getElementById("mobileSearchBtn");s&&s.addEventListener("click",c=>{c.preventDefault();const g=document.getElementById("searchModal"),h=document.getElementById("searchInput");g&&(g.classList.add("active"),h&&setTimeout(()=>h.focus(),100))});const d=document.getElementById("closeSearch");d&&d.addEventListener("click",()=>{const c=document.getElementById("searchModal");c&&c.classList.remove("active")});const m=document.querySelectorAll(".nav-link");m.forEach(c=>{c.addEventListener("click",g=>{g.preventDefault();const h=c.dataset.view;m.forEach(f=>{f.classList.remove("active","text-white"),f.classList.add("text-gray-300")}),c.classList.add("active","text-white"),c.classList.remove("text-gray-300"),h==="home"?(p.currentCategory="all",L("home")):h==="series"?(p.currentCategory="series",L("series")):h==="movies"?(p.currentCategory="movies",L("movies")):h==="cinema"?(p.currentCategory="cinema",L("cinema")):h==="history"&&H(),window.scrollTo({top:0,behavior:"smooth"})})}),(u=l.closePlayer)==null||u.addEventListener("click",F),(n=l.modalBackdrop)==null||n.addEventListener("click",F),document.addEventListener("keydown",c=>{var g,h;if(c.key==="Escape"){(g=l.playerModal)!=null&&g.classList.contains("active")&&F(),(h=l.searchWrapper)!=null&&h.classList.contains("active")&&l.searchWrapper.classList.remove("active");const f=document.getElementById("searchModal");f!=null&&f.classList.contains("active")&&f.classList.remove("active")}})}async function S(t="all",e=!1){if(p.isLoading||(e&&(p.page=1,p.hasMore=!0,p.videos=[],l.videoGrid.innerHTML=""),!p.hasMore))return;p.isLoading=!0,C(p.page===1);const r=(s,d=12e3)=>Promise.race([s,new Promise((m,a)=>setTimeout(()=>a(new Error("Timeout")),d))]),i=document.getElementById("topSearchBtn");i&&i.addEventListener("click",s=>{s.preventDefault();const d=document.getElementById("searchModal"),m=document.getElementById("searchInput");d&&(d.classList.add("active"),m&&setTimeout(()=>m.focus(),100))});try{let s=null,d=!1;if(s||(s=await r(k.getRophimCatalog({category:t!=="all"?t:null,page:p.page,limit:24}),12e3)),s&&s.movies&&s.movies.length>0){const m=s.movies.map(n=>({id:n.id||`api_${Date.now()}_${Math.random()}`,title:n.title||"Unknown Title",thumbnail:n.thumbnail||"https://via.placeholder.com/300x450?text=No+Image",backdrop:n.backdrop||n.thumbnail||"https://via.placeholder.com/1920x1080?text=No+Backdrop",preview_url:n.preview_url||"",duration:n.duration||0,resolution:n.quality||"HD",category:n.category||"movies",year:n.year||new Date().getFullYear(),description:n.description||"",matchScore:Math.floor(Math.random()*15)+85,source_url:n.source_url,slug:n.slug,cast:n.cast||[],director:n.director,country:n.country,episodes:n.episodes||[]})),a=new Set(p.videos.map(n=>n.id)),o=m.filter(n=>!a.has(n.id));p.videos=[...p.videos,...o],p.page+=1,m.length<24,p.page===2?D(p.videos,!1):D(o,!0),ge(),v&&v.classList.remove("loading"),p.isLoading=!1,C(!1);return}else p.hasMore=!1,v&&(v.classList.remove("loading"),v.style.display="none"),p.isLoading=!1,C(!1)}catch(s){if(console.warn("API load failed:",s),p.page===1){T("Using offline mode","info");const d=K();p.videos=d,p.featuredVideo=d[0],D(d)}p.isLoading=!1,C(!1)}}function A(t,e,r="poster"){const i=document.createElement("section");i.className="flex flex-col gap-4 mb-12 relative";const s=document.createElement("h2");s.className="text-xl md:text-2xl font-bold text-white hover:text-primary cursor-pointer transition-colors flex items-center gap-2 group px-4 md:px-12",s.innerHTML=` + ${t} + arrow_forward_ios + `,i.appendChild(s);const d=document.createElement("div");d.className="relative group/slider";const m=document.createElement("button");m.className="absolute left-0 top-1/2 -translate-y-1/2 z-20 w-12 h-full bg-gradient-to-r from-black/80 to-transparent opacity-0 group-hover/slider:opacity-100 transition-opacity flex items-center justify-start pl-2",m.innerHTML='chevron_left';const a=document.createElement("button");a.className="absolute right-0 top-1/2 -translate-y-1/2 z-20 w-12 h-full bg-gradient-to-l from-black/80 to-transparent opacity-0 group-hover/slider:opacity-100 transition-opacity flex items-center justify-end pr-2",a.innerHTML='chevron_right';const o=document.createElement("div");o.className="flex gap-3 overflow-x-auto scroll-smooth no-scrollbar px-4 md:px-12 pb-4",e.forEach((n,c)=>{let g;r==="landscape"?g=pe(n):g=he(n,!1,0,"horizontal"),g.className=g.className.replace("w-full",""),g.style.minWidth="280px",g.style.maxWidth="380px",g.style.flex="0 0 auto",o.appendChild(g)});const u=600;return m.addEventListener("click",()=>{o.scrollBy({left:-u,behavior:"smooth"})}),a.addEventListener("click",()=>{o.scrollBy({left:u,behavior:"smooth"})}),d.appendChild(m),d.appendChild(o),d.appendChild(a),i.appendChild(d),i}function he(t,e=!1,r=0,i="vertical"){const s=document.createElement("div"),d=i==="horizontal"?"aspect-video":"aspect-[2/3]";s.className="w-full cursor-pointer snap-start group relative transition-all duration-300 ease-in-out hover:z-30 hover:scale-105";let m=t.poster_url||t.thumb_url||t.thumbnail||"";i==="horizontal"&&t.backdrop&&(m=t.backdrop);const a=t.name||t.title||"Untitled",o=t.year||"",u=t.quality||"HD",n=t.slug||t.id||"",c=t.matchScore||Math.floor(Math.random()*10+90),g=Math.floor(Math.random()*19+80);s.innerHTML=` +
    +
    + + +
    + + +
    + ${!e&&o===new Date().getFullYear().toString()?'NEW':""} + ${t.quality?`${t.quality.replace("FHD","HD")}`:""} + ${t.current_episode?`EP ${t.current_episode}`:""} +
    + + + ${e?`${r}`:""} + + +
    + + +
    +
    + + +
    + +
    + + +
    +
    + ${c}% Match + ${u} + ${o} +
    + + +
    +
    + + local_pizza ${g}% + +
    + ${t.genres&&t.genres.length>0?`${t.genres[0]}`:""} +
    + +

    + ${a} +

    +
    +
    +
    + `,s.addEventListener("click",b=>{b.target.closest("button")||w(t)});const h=s.querySelector(".btn-play");h&&h.addEventListener("click",b=>{b.stopPropagation(),w(t)});const f=s.querySelector(".btn-add-list");f&&f.addEventListener("click",b=>{if(b.stopPropagation(),window.historyService){const y=window.historyService.toggleFavorite(t),I=f.querySelector("span");y?(I.textContent="check",T("Added to My List","success")):(I.textContent="add",T("Removed from My List","info"))}});const x=s.querySelector(".btn-info");return x&&x.addEventListener("click",b=>{b.stopPropagation(),U(t)}),s}function pe(t){var m,a;const e=document.createElement("div");e.className="flex-none w-[280px] group/card cursor-pointer snap-start";const r=t.backdrop||t.thumb_url||t.thumbnail||"",i=t.name||t.title||"Untitled",s=((m=t.progress)==null?void 0:m.percentage)||0,d=(a=t.progress)!=null&&a.episode?`S${t.season||1}:E${t.progress.episode}`:"";return e.innerHTML=` +
    +
    +
    + play_arrow +
    +
    +
    +
    +
    +
    + ${i} + ${d?`${d}`:""} +
    + `,e.addEventListener("click",()=>w(t)),e}function D(t,e=!1){if(e||(l.videoGrid.innerHTML="",l.videoGrid.innerHTML="",l.videoGrid.className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-x-4 gap-y-10"),t.length===0&&!e){l.emptyState&&(l.emptyState.style.display="flex");return}l.emptyState&&(l.emptyState.style.display="none"),t.forEach(r=>{const i=ne(r,w);l.videoGrid.appendChild(i)})}let M,v=null,j=0;function ge(){if(!p.hasMore){v&&(v.classList.remove("loading"),v.style.display="none"),M&&M.disconnect();return}M&&M.disconnect(),document.querySelectorAll(".scroll-sentinel").forEach(r=>r.remove()),v=null;const t={root:null,rootMargin:"50px",threshold:0};M=new IntersectionObserver(r=>{r.forEach(i=>{const s=Date.now();s-j<1500||i.isIntersecting&&!p.isLoading&&p.hasMore&&(j=s,v&&v.classList.add("loading"),S(p.currentCategory))})},t),v=document.createElement("div"),v.className="scroll-sentinel",v.id="scrollSentinel";const e=document.getElementById("infinite-scroll-container");e?e.parentNode.insertBefore(v,e.nextSibling):l.videoGrid.appendChild(v),M.observe(v)}function U(t){O(t)}function H(t="history"){if(l.mainHeader&&(l.mainHeader.style.display=""),!window.historyService){console.error("HistoryService not initialized");return}l.videoGrid.innerHTML="",l.emptyState&&(l.emptyState.style.display="none");const e=document.querySelector(".view-tabs");e&&e.remove();const r=document.createElement("div");r.className="view-tabs",r.innerHTML=` + + + `,l.videoGrid.before(r),r.querySelectorAll(".view-tab").forEach(a=>{a.addEventListener("click",()=>{r.remove(),H(a.dataset.tab)})});let i=[];if(t==="history"?i=window.historyService.getHistory():i=window.historyService.getFavorites(),i.length===0){if(l.emptyState){l.emptyState.style.display="flex";const a=l.emptyState.querySelector("h2"),o=l.emptyState.querySelector("p");t==="history"?(a&&(a.textContent="No history yet"),o&&(o.textContent="Movies you watch will appear here.")):(a&&(a.textContent="My List is empty"),o&&(o.textContent="Add movies to your list to watch later."))}return}i.sort((a,o)=>{const u=a.timestamp||a.year||0;return(o.timestamp||o.year||0)-u});const s=i.map((a,o)=>({...a,id:a.id||a.slug,orientation:"horizontal"}));l.mainHeader&&(l.mainHeader.style.display="block");const m=A(t==="history"?"Continue Watching":"My List",s,"poster");l.videoGrid.appendChild(m)}function w(t){sessionStorage.setItem("currentVideo",JSON.stringify(t)),sessionStorage.setItem("allVideos",JSON.stringify(p.videos)),O(t)}function O(t){window.location.href=`/watch.html?slug=${t.slug}`}function F(){l.playerModal.classList.remove("active"),Z(),l.playerContainer.innerHTML="",p.currentVideo=null}function C(t){l.loading&&(l.loading.style.display=t?"flex":"none"),l.videoGrid&&(l.videoGrid.style.display=t?"none":"block")}async function L(t){const e=document.querySelector(".view-tabs");e&&e.remove(),l.mainHeader&&(l.mainHeader.style.display=""),C(!0),l.videoGrid.innerHTML="",l.videoGrid.className="space-y-12";const r={home:[{title:"Continue Watching",type:"history",limit:12,cardType:"landscape"},{title:"Cinema Releases",category:"phim-chieu-rap",limit:12,isHeroSource:!0},{title:"Top Rated",category:"phim-le",sort:"rating",limit:12},{title:"Action & Adventure",category:"hanh-dong",limit:12},{title:"Animation",category:"hoat-hinh",limit:12},{title:"Korean Hits",category:"han-quoc",limit:12},{title:"Horror & Thriller",category:"kinh-di",limit:12},{title:"Romance",category:"tinh-cam",limit:12}],series:[{title:"Popular TV Shows",category:"phim-bo",limit:12,isHeroSource:!0},{title:"Korean Dramas",category:"korean",limit:12},{title:"Chinese Dramas",category:"china",limit:12},{title:"Anime Series",category:"hoat-hinh",limit:12},{title:"Documentaries",category:"tai-lieu",limit:12}],movies:[{title:"Blockbuster Movies",category:"phim-le",sort:"year",limit:12,isHeroSource:!0},{title:"Action & Adventure",category:"action",limit:12},{title:"Comedy Films",category:"comedy",limit:12},{title:"Cinema Releases",category:"phim-chieu-rap",limit:12},{title:"Horror Movies",category:"kinh-di",limit:12},{title:"Sci-Fi & Fantasy",category:"vien-tuong",limit:12}],cinema:[{title:"Now Showing",category:"phim-chieu-rap",limit:12,isHeroSource:!0},{title:"New Releases",category:"phim-le",sort:"year",limit:12},{title:"Top Rated",category:"phim-le",sort:"rating",limit:12},{title:"Action Blockbusters",category:"action",limit:12},{title:"Animated Features",category:"hoat-hinh",limit:12}]},i=r[t]||r.home;if(t==="home"||t==="cinema"){const d=sessionStorage.getItem(`view_cache_${t}`);if(d&&(l.videoGrid.innerHTML=d,C(!1),l.heroContainer&&(l.heroContainer.style.display=""),l.videoGrid.children.length>0))return}const s=3;try{let d=null;for(let a=0;a0){d||(d=u),o.isHeroSource&&(!p.heroMovies||p.heroMovies.length===0)&&u.length>0&&(p.heroMovies=u.slice(0,10),p.featuredVideo=u[0],p.videos=u,p.currentHeroIndex=0,E(p.heroMovies[0]),ue());const n=A(o.title,u,o.cardType||"poster");l.videoGrid.appendChild(n)}}(t==="home"||t==="cinema")&&sessionStorage.setItem(`view_cache_${t}`,l.videoGrid.innerHTML);const m=new IntersectionObserver(async(a,o)=>{for(const u of a)if(u.isIntersecting){const n=u.target,c=parseInt(n.dataset.configIndex),g=i[c];o.unobserve(n),n.innerHTML='
    ';const h=await P(g);if(h&&h.length>0){const f=A(g.title,h,g.cardType||"poster");n.replaceWith(f),(t==="home"||t==="cinema")&&sessionStorage.setItem(`view_cache_${t}`,l.videoGrid.innerHTML)}else n.remove()}},{rootMargin:"800px"});for(let a=s;a${i[a].title}`,l.videoGrid.appendChild(o),m.observe(o)}if(!p.featuredVideo)if(d&&d.length>0)p.featuredVideo=d[0],p.videos=d,E();else try{const a=K();a&&a.length>0&&(p.featuredVideo=a[0],p.videos=a,E())}catch(a){console.warn("Demo content fallback failed",a)}l.videoGrid.children.length===0&&(l.videoGrid.innerHTML=` +
    + movie +

    No content available for this category

    +
    + `)}catch(d){console.error("Error rendering category view:",d),l.videoGrid.innerHTML=` +
    + error +

    Failed to load content. Please try again.

    +
    + `}C(!1)}async function P(t){try{if(t.type==="history")return window.historyService?window.historyService.getHistory().slice(0,t.limit).map(o=>({id:o.slug||o.id,title:o.title,thumbnail:o.thumbnail||o.poster_url,slug:o.slug,year:o.year,quality:o.quality||"HD",view_progress:o.view_progress||0})):[];const e={category:t.category||null,limit:t.limit||40,sort:t.sort||"year"};t.country&&(e.country=t.country),t.genre&&(e.genre=t.genre);const r=async a=>{const o=[1,2,3,4,5,6,7,8].map(n=>k.getRophimCatalog({...a,page:n}).catch(c=>({movies:[]})));return(await Promise.all(o)).flatMap(n=>n.movies||[])};let i=await r(e);if(i.length<20&&t.sort&&t.sort!=="modified"){const a=await r({...e,sort:"modified"});i=[...i,...a]}const s=[],d=new Set;for(const a of i){if(!a)continue;const o=a.slug||a.id;d.has(o)||(d.add(o),s.push({id:a.id||a.slug,title:a.title,thumbnail:a.thumbnail,poster_url:a.poster_url||a.thumbnail,backdrop:a.backdrop||a.poster_url||a.thumbnail,slug:a.slug,year:a.year,quality:a.quality||"HD",rating:a.rating,category:a.category}))}const m=Math.max(t.limit||40,48);return s.slice(0,m)}catch(e){return console.error(`Error fetching section "${t.title}":`,e),[]}}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",q):q();function K(){const t="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",e={VENOM:"https://image.tmdb.org/t/p/w500/aosm8NMQ3UyoBVpSxyimorCQykC.jpg",SQUID:"https://images.unsplash.com/photo-1536440136628-849c177e76a1?w=800&auto=format&fit=crop",ARCANE:"https://images.unsplash.com/photo-1542751371-adc38448a05e?w=800&auto=format&fit=crop",PENGUIN:"https://images.unsplash.com/photo-1478720568477-152d9b164e63?w=800&auto=format&fit=crop",GLADIATOR:"https://images.unsplash.com/photo-1565060416-522204c35613?w=800&auto=format&fit=crop",MOANA:"https://images.unsplash.com/photo-1507525428034-b723cf961d3e?w=800&auto=format&fit=crop",WICKED:"https://images.unsplash.com/photo-1518709268805-4e9042af9f23?w=800&auto=format&fit=crop",DBZ:"https://images.unsplash.com/photo-1578632767115-351597cf2477?w=800&auto=format&fit=crop"};return[{id:"d1",title:"Venom: The Last Dance",thumbnail:e.VENOM,backdrop:"https://image.tmdb.org/t/p/original/3V4kLQg0kSqPLctI5ziYWabAZYF.jpg",preview_url:t,duration:7200,resolution:"4K",category:"action",year:2024,matchScore:98,director:"Kelly Marcel",country:"USA",cast:["Tom Hardy","Chiwetel Ejiofor","Juno Temple"],description:"Eddie and Venom are on the run. Hunted by both of their worlds and with the net closing in, the duo are forced into a devastating decision.",episodes:[]},{id:"d2",title:"Squid Game Season 2",thumbnail:e.SQUID,backdrop:e.SQUID,preview_url:"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4",duration:3600,resolution:"HD",category:"series",year:2024,matchScore:99,director:"Hwang Dong-hyuk",country:"Korea",cast:["Lee Jung-jae","Lee Byung-hun","Wi Ha-jun"],description:"Gi-hun returns to the death games after three years with a new resolution: to find the people behind and to put an end to the sport.",episodes:[{number:1,title:"Red Light, Green Light",url:t},{number:2,title:"The Man with the Umbrella",url:t},{number:3,title:"Stick to the Team",url:t}]},{id:"d3",title:"Arcane Season 2",thumbnail:e.ARCANE,backdrop:e.ARCANE,preview_url:"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4",duration:2400,resolution:"4K",category:"anime",year:2024,matchScore:97,director:"Christian Linke",country:"USA, France",cast:["Hailee Steinfeld","Ella Purnell","Katie Leung"],description:"As conflict between Piltover and Zaun reaches a boiling point, Jinx and Vi must decide what kind of future they are fighting for.",episodes:[{number:1,title:"Heavy Is the Crown",url:t},{number:2,title:"Watch It All Burn",url:t},{number:3,title:"Finally Got It Right",url:t}]},{id:"d4",title:"The Penguin",thumbnail:e.PENGUIN,backdrop:e.PENGUIN,preview_url:"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4",duration:3600,resolution:"HD",category:"series",year:2024,matchScore:95,director:"Craig Zobel",country:"USA",cast:["Colin Farrell","Cristin Milioti","Rhenzy Feliz"],description:"Following the events of The Batman, Oz Cobb makes a play for power in the underworld of Gotham City.",episodes:[]},{id:"d5",title:"Gladiator II",thumbnail:e.GLADIATOR,backdrop:e.GLADIATOR,preview_url:"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerFun.mp4",duration:8400,resolution:"4K",category:"action",year:2024,matchScore:96,director:"Ridley Scott",country:"USA, UK",cast:["Paul Mescal","Pedro Pascal","Denzel Washington"],description:"Years after witnessing the death of the revered hero Maximus at the hands of his uncle, Lucius is forced to enter the Colosseum.",episodes:[]},{id:"d6",title:"Moana 2",thumbnail:e.MOANA,backdrop:e.MOANA,preview_url:"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4",duration:6e3,resolution:"HD",category:"theater",year:2024,matchScore:94,director:"David G. Derrick Jr.",country:"USA",cast:["Auliʻi Cravalho","Dwayne Johnson","Alan Tudyk"],description:"After receiving an unexpected call from her wayfinding ancestors, Moana must journey to the far seas of Oceania.",episodes:[]},{id:"d7",title:"Wicked",thumbnail:e.WICKED,backdrop:e.WICKED,preview_url:"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerMeltdowns.mp4",duration:9e3,resolution:"4K",category:"theater",year:2024,matchScore:93,director:"Jon M. Chu",country:"USA",cast:["Cynthia Erivo","Ariana Grande","Jeff Goldblum"],description:"Elphaba, a misunderstood young woman with green skin, and Glinda, a popular blonde, forge an unlikely friendship.",episodes:[]},{id:"d8",title:"Dragon Ball Daima",thumbnail:e.DBZ,backdrop:e.DBZ,preview_url:"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4",duration:1440,resolution:"HD",category:"anime",year:2024,matchScore:98,director:"Yoshitaka Yashima",country:"Japan",cast:["Masako Nozawa","Ryō Horikawa"],description:"Goku and his friends are turned small due to a conspiracy. To fix things, they head off to a new world.",episodes:[{number:1,title:"Conspiracy",url:t}]}]}function fe(){l.mainHeader&&(l.mainHeader.style.display="");const t=document.getElementById("heroContainer");t&&(t.style.display="",E()),_("profile"),l.videoGrid.innerHTML="",l.videoGrid.className="profile-view pb-24 bg-background-light dark:bg-background-dark min-h-screen";const e=` + +
    + +

    Profile

    + +
    + +
    + +
    +
    +
    +
    +
    + edit +
    +
    +

    Isabella Hall

    + +
    + + +
    +
    +

    42

    +

    Movies

    +
    +
    +

    128h

    +

    Streamed

    +
    +
    +

    15

    +

    Reviews

    +
    +
    + + +
    + + + + + +
    + +

    Version 4.12.0

    +
    +
    + `;if(l.videoGrid.innerHTML=e,window.historyService){const r=window.historyService.getHistory().slice(0,10);if(r.length>0){const i=document.getElementById("profileHistoryContainer"),s=A("Continue Watching",r,"landscape");i.appendChild(s)}}}async function ye(){l.mainHeader&&(l.mainHeader.style.display="");const t=document.getElementById("heroContainer");if(t&&(t.style.display=""),_("home"),window.innerWidth<768){document.querySelectorAll("footer").forEach(r=>r.style.display="none");const e=document.getElementById("searchModal");e&&e.classList.remove("active")}else document.querySelectorAll("footer").forEach(e=>e.style.display="");await L("home")}async function R(){l.mainHeader&&(l.mainHeader.style.display="");const t=document.getElementById("heroContainer");t&&(t.style.display="",E()),document.querySelectorAll("footer").forEach(o=>o.style.display="none");const e=document.getElementById("searchModal");e&&e.classList.remove("active"),_("search"),l.videoGrid.innerHTML="",l.videoGrid.className="mobile-search-view bg-background-light dark:bg-background-dark";const r=` + +
    +
    +
    +
    + search +
    + +
    + mic +
    +
    + +
    + +
    + + + + + +
    +
    + + +
    +
    +

    Top Searches

    +
    +
    + +
    +

    Recommended for You

    +
    +
    +
    + `;l.videoGrid.innerHTML=r;const i=document.getElementById("mobileSearchInput"),s=document.getElementById("mobileSearchResults");let d=null;i&&s&&(i.addEventListener("input",o=>{clearTimeout(d);const u=o.target.value.trim();d=setTimeout(async()=>{if(!(u.length<2)){s.innerHTML='
    ';try{const n=await k.searchRophim(u);if(n&&n.movies&&n.movies.length>0){s.innerHTML=` +

    Results for "${u}"

    +
    + `;const c=s.querySelector(".grid");n.movies.forEach(g=>{const h=document.createElement("div");h.className="relative group aspect-[2/3] overflow-hidden rounded-lg cursor-pointer",h.innerHTML=` +
    +
    +
    +

    ${g.title}

    +
    +
    + `,h.addEventListener("click",()=>w(g)),c.appendChild(h)})}else s.innerHTML=` +
    + search_off +

    No results for "${u}"

    +
    + `}catch(n){console.error("Mobile search failed:",n),s.innerHTML='
    Search failed. Try again.
    '}}},300)}),i.focus());const m=document.getElementById("mobileSearchCancel");m&&m.addEventListener("click",()=>{const o=document.getElementById("mobileSearchInput");o&&(o.value="",o.focus()),R()});try{const o=await k.getRophimCatalog({category:"trending",limit:5});if(o&&o.movies){const n=document.getElementById("topSearchesList");o.movies.forEach(c=>{const g=document.createElement("div");g.className="group flex items-center gap-3 px-4 py-2 hover:bg-gray-100 dark:hover:bg-white/5 cursor-pointer transition-colors",g.innerHTML=` +
    +
    +
    +
    +

    ${c.title}

    +

    ${c.year||"2024"}

    +
    +
    + play_circle +
    + `,g.addEventListener("click",()=>w(c)),n.appendChild(g)})}const u=await k.getRophimCatalog({category:"phim-le",limit:9});if(u&&u.movies){const n=document.getElementById("recommendedGrid");u.movies.forEach(c=>{const g=document.createElement("div");g.className="relative group aspect-[2/3] overflow-hidden rounded-lg cursor-pointer",g.innerHTML=` +
    + `,g.addEventListener("click",()=>w(c)),n.appendChild(g)})}}catch(o){console.error("Failed to load mobile search content",o)}const a=document.querySelectorAll(".search-chip");a.forEach(o=>{o.addEventListener("click",async()=>{var g;const u=o.dataset.genre;if(!u)return;a.forEach(h=>{h.classList.remove("active","bg-white","text-black"),h.classList.add("bg-gray-200","dark:bg-surface-dark");const f=h.querySelector("p");f&&(f.classList.remove("font-bold"),f.classList.add("font-medium","text-slate-700","dark:text-gray-300"))}),o.classList.add("active","bg-white","text-black"),o.classList.remove("bg-gray-200","dark:bg-surface-dark");const n=o.querySelector("p");n&&(n.classList.add("font-bold"),n.classList.remove("font-medium","text-slate-700","dark:text-gray-300"));const c=document.getElementById("mobileSearchResults");if(c){c.innerHTML='
    ';try{const h=await k.getRophimCatalog({category:u,limit:12});if(h&&h.movies&&h.movies.length>0){const f=((g=o.querySelector("p"))==null?void 0:g.textContent)||u;c.innerHTML=` +

    ${f}

    +
    + `;const x=c.querySelector(".grid");h.movies.forEach(b=>{const y=document.createElement("div");y.className="relative group aspect-[2/3] overflow-hidden rounded-lg cursor-pointer",y.innerHTML=` +
    + `,y.addEventListener("click",()=>w(b)),x.appendChild(y)})}else c.innerHTML='

    No results found

    '}catch(h){console.error("Genre filter error:",h),c.innerHTML='

    Failed to load content

    '}}})})}async function z(){l.mainHeader&&(l.mainHeader.style.display="");const t=document.getElementById("heroContainer");t&&(t.style.display="",E()),document.querySelectorAll("footer").forEach(m=>m.style.display="none");const e=document.getElementById("searchModal");e&&e.classList.remove("active"),_("mylist");const r=window.historyService?window.historyService.getFavorites():[];l.videoGrid.innerHTML="",l.videoGrid.className="mobile-mylist-view min-h-screen bg-background-dark pb-24";const i=` + +
    +
    +

    My List

    + +
    + +
    + + + + +
    +
    + + +
    +
    +
    + `;l.videoGrid.innerHTML=i;const s=document.getElementById("mylistGrid");if(r.length>0)r.forEach(m=>{const a=document.createElement("div");a.className="group relative flex flex-col gap-2 cursor-pointer",a.innerHTML=` +
    +
    +
    +
    + `,a.addEventListener("click",()=>w(m)),s.appendChild(a)});else try{const m=await k.getRophimCatalog({category:"trending",limit:12});m&&m.movies&&m.movies.forEach((a,o)=>{const u=document.createElement("div");u.className="group relative flex flex-col gap-2 cursor-pointer",u.innerHTML=` +
    +
    + ${o===0?'
    New
    ':""} +
    +
    + `,u.addEventListener("click",()=>w(a)),s.appendChild(u)})}catch(m){console.error("Failed to load my list content",m)}const d=document.querySelectorAll(".mylist-chip");d.forEach(m=>{m.addEventListener("click",async()=>{const a=m.dataset.filter,o=m.dataset.category;if(!a||!o)return;d.forEach(c=>{c.classList.remove("active","bg-white"),c.classList.add("bg-surface-dark");const g=c.querySelector("p");g&&(g.classList.remove("font-bold","text-black"),g.classList.add("font-medium","text-gray-200"))}),m.classList.add("active","bg-white"),m.classList.remove("bg-surface-dark");const u=m.querySelector("p");u&&(u.classList.add("font-bold","text-black"),u.classList.remove("font-medium","text-gray-200"));const n=document.getElementById("mylistGrid");if(n){n.innerHTML='
    ';try{const c=await k.getRophimCatalog({category:o,limit:12});n.innerHTML="",c&&c.movies&&c.movies.length>0?c.movies.forEach((g,h)=>{const f=document.createElement("div");f.className="group relative flex flex-col gap-2 cursor-pointer",f.innerHTML=` +
    +
    + ${h===0?'
    New
    ':""} +
    +
    + `,f.addEventListener("click",()=>w(g)),n.appendChild(f)}):n.innerHTML='

    No content found

    '}catch(c){console.error("Filter error:",c),n.innerHTML='

    Failed to load content

    '}}})})} diff --git a/backend/static/assets/watch-Baf19X1S.js b/backend/static/assets/watch-Baf19X1S.js new file mode 100644 index 0000000..3c533cd --- /dev/null +++ b/backend/static/assets/watch-Baf19X1S.js @@ -0,0 +1,104 @@ +import{a as h,s as v,i as I,d as C}from"./Toast-BwR22KmJ.js";const s={video:null,currentEpisode:1,currentServer:0,recommendations:[],isLoading:!0};window.state=s;let t={};function k(){var e,i;t={videoPlayer:document.getElementById("videoPlayer"),videoPlayerContainer:document.getElementById("videoPlayerContainer"),playerLoading:document.getElementById("playerLoading"),closePlayer:document.getElementById("closePlayer"),heroBg:document.getElementById("heroBg"),movieTitle:document.getElementById("movieTitleDesktop"),movieMatch:document.getElementById("movieMatchDesktop"),movieYear:document.getElementById("movieYearDesktop"),movieRating:document.getElementById("movieRatingDesktop"),movieQuality:document.getElementById("movieQualityDesktop"),movieDescription:document.getElementById("movieDescriptionDesktop"),movieTags:document.getElementById("movieTags"),movieTitleMobile:document.getElementById("movieTitleMobile"),movieMatchMobile:document.getElementById("movieMatchMobile"),movieYearMobile:document.getElementById("movieYearMobile"),movieRatingMobile:document.getElementById("movieRatingMobile"),movieDuration:document.getElementById("movieDurationDesktop"),movieDurationMobile:document.getElementById("movieDurationMobile"),movieQualityMobile:document.getElementById("movieQualityMobile"),movieDescriptionMobile:document.getElementById("movieDescriptionMobile"),playBtn:document.getElementById("playBtnDesktop"),addListBtn:document.getElementById("addListBtnDesktop"),addListIcon:(e=document.getElementById("addListBtnDesktop"))==null?void 0:e.querySelector(".material-symbols-outlined"),addListText:(i=document.getElementById("addListBtnDesktop"))==null?void 0:i.querySelector("span:last-child"),playBtnMobile:document.getElementById("playBtnMobile"),addListBtnMobile:document.getElementById("addListBtnMobile"),shareBtnMobile:document.getElementById("shareBtnMobile"),mobilePlayBtn:document.getElementById("mobilePlayBtn"),watchHeader:document.getElementById("watchHeader"),tabNav:document.getElementById("tabNav"),watchBackBtn:document.getElementById("watchBackBtn"),episodesPanel:document.getElementById("episodesPanel"),trailersPanel:document.getElementById("trailersPanel"),detailsPanel:document.getElementById("detailsPanel"),seasonSelect:document.getElementById("seasonSelect"),seasonSelectContainer:document.getElementById("seasonSelectContainer"),episodeCount:document.getElementById("episodeCount"),episodesGrid:document.getElementById("episodesGrid"),episodesLoading:document.getElementById("episodesLoading"),castCarousel:document.getElementById("castCarousel"),recommendationsContainer:document.getElementById("recommendationsContainer"),detailsList:document.getElementById("detailsList"),searchModal:document.getElementById("searchModal"),searchBtn:document.getElementById("searchBtn"),searchInput:document.getElementById("searchInput"),closeSearch:document.getElementById("closeSearch")}}async function w(){const e=new URLSearchParams(window.location.search),i=e.get("id"),r=e.get("slug"),o=parseInt(e.get("ep"))||1;if(s.currentEpisode=o,!i&&!r){B("No video specified");return}k(),$(),await T(i,r),await j()}function $(){if(window.addEventListener("scroll",()=>{t.watchHeader&&(window.scrollY>50?t.watchHeader.style.backgroundColor="rgba(20,20,20,0.95)":t.watchHeader.style.backgroundColor="transparent")}),t.watchBackBtn&&t.watchBackBtn.addEventListener("click",i=>{i.preventDefault(),t.videoPlayerContainer&&(t.videoPlayerContainer.style.display!=="none"||!t.videoPlayerContainer.classList.contains("hidden"))?b():document.referrer&&document.referrer.includes(window.location.host)?window.history.back():window.location.href="/index.html"}),document.addEventListener("keydown",i=>{i.key==="Escape"&&(t.videoPlayerContainer&&!t.videoPlayerContainer.classList.contains("hidden")&&b(),t.searchModal&&!t.searchModal.classList.contains("hidden")&&t.searchModal.classList.add("hidden"))}),[t.playBtn,t.playBtnMobile,t.mobilePlayBtn].forEach(i=>{i&&i.addEventListener("click",()=>{t.videoPlayerContainer&&(t.videoPlayerContainer.classList.remove("hidden"),t.videoPlayerContainer.style.display="block"),t.videoPlayer&&(t.videoPlayer.style.display="block"),M()})}),t.closePlayer&&t.closePlayer.addEventListener("click",()=>{b()}),t.searchBtn&&t.searchBtn.addEventListener("click",()=>{t.searchModal&&(t.searchModal.classList.remove("hidden"),setTimeout(()=>{var i;return(i=t.searchInput)==null?void 0:i.focus()},100))}),t.closeSearch&&t.closeSearch.addEventListener("click",()=>{t.searchModal&&t.searchModal.classList.add("hidden")}),[t.addListBtn,t.addListBtnMobile].forEach(i=>{i&&i.addEventListener("click",()=>{var o;if(!s.video)return;const r=(o=window.historyService)==null?void 0:o.toggleFavorite(s.video);x(r),r?v("Added to My List","success"):v("Removed from My List","info")})}),t.shareBtnMobile&&t.shareBtnMobile.addEventListener("click",()=>{var i;navigator.share?navigator.share({title:((i=s.video)==null?void 0:i.title)||"StreamFlix",url:window.location.href}):(navigator.clipboard.writeText(window.location.href),v("Link copied to clipboard","success"))}),t.tabNav){const i=t.tabNav.querySelectorAll(".tab-btn"),r={episodes:t.episodesPanel,details:t.detailsPanel};i.forEach(o=>{o.addEventListener("click",()=>{const n=o.dataset.tab;i.forEach(l=>{l.classList.remove("text-white","font-bold","border-b-4","border-primary"),l.classList.add("text-gray-400","font-medium")}),o.classList.remove("text-gray-400","font-medium"),o.classList.add("text-white","font-bold","border-b-4","border-primary"),Object.entries(r).forEach(([l,d])=>{d&&(l===n?d.classList.remove("hidden"):d.classList.add("hidden"))})})})}document.querySelectorAll("#mobileBottomNav .nav-item").forEach(i=>{i.addEventListener("click",r=>{r.preventDefault();const o=i.dataset.view;o&&(window.location.href=`/index.html?view=${o}`)})})}function b(){const e=t.videoPlayerContainer||document.getElementById("videoPlayerContainer"),i=t.videoPlayer||document.getElementById("videoPlayer"),r=t.playerLoading||document.getElementById("playerLoading");e&&(e.classList.add("hidden"),e.style.display="none"),C(),i&&(i.innerHTML="",i.style.display="none"),r&&(r.style.display="none")}function x(e){const i=e?"check":"add",r=e?"In List":"My List";if(t.addListBtn){const o=t.addListBtn.querySelector(".material-symbols-outlined"),n=t.addListBtn.querySelector("span:last-child");o&&(o.textContent=i),n&&(n.textContent=r),e?t.addListBtn.classList.add("bg-white/20"):t.addListBtn.classList.remove("bg-white/20")}if(t.addListBtnMobile){const o=t.addListBtnMobile.querySelector(".material-symbols-outlined"),n=t.addListBtnMobile.querySelector("span:last-child");o&&(o.textContent=i),n&&(n.textContent=r),e?(t.addListBtnMobile.classList.add("bg-white/10"),t.addListBtnMobile.classList.remove("bg-[#2b2b2b]")):(t.addListBtnMobile.classList.remove("bg-white/10"),t.addListBtnMobile.classList.add("bg-[#2b2b2b]"))}}async function T(e,i){var r,o,n,l,d,c;try{s.isLoading=!0;let m=null;const u=i||e;if(u)try{const p=await h.getRophimMovie(u);if(p){const a=p.movie||p,y=p.episodes||[];m={id:a.slug||u,slug:a.slug||u,title:a.name||a.title||u,original_title:a.origin_name||a.original_title||"",description:a.content||a.description||"",thumbnail:a.poster_url||a.thumb_url||a.thumbnail||"",year:a.year,rating:((r=a.tmdb)==null?void 0:r.vote_average)||a.rating||"N/A",quality:a.quality||"HD",duration:a.time||a.duration||"",genres:Array.isArray(a.category)?a.category.map(g=>g.name||g):Array.isArray(a.genres)?a.genres:typeof a.genre=="string"?a.genre.split(",").map(g=>g.trim()):[],country:((n=(o=a.country)==null?void 0:o[0])==null?void 0:n.name)||a.country||"",country:((d=(l=a.country)==null?void 0:l[0])==null?void 0:d.name)||a.country||"",cast:a.actor||a.cast||[],director:((c=a.director)==null?void 0:c[0])||a.director||"",source_url:`https://phimmoichill.network/phim/${u}`,episodes:P(y)}}}catch(p){console.warn("API fetch failed:",p)}if(!m)throw new Error("Video data not found");s.video=m,window.historyService&&window.historyService.addToHistory(m,{episode:s.currentEpisode}),_(m),window.historyService&&x(window.historyService.isFavorite(m.slug))}catch(m){console.error("Failed to load video:",m),B("Failed to load video data")}finally{s.isLoading=!1}}function P(e){if(!e||!Array.isArray(e)||e.length===0)return[];const i=e[0];return((i==null?void 0:i.server_data)||[]).map((o,n)=>({number:n+1,name:o.name||`Episode ${n+1}`,title:o.filename||`Episode ${n+1}`,slug:o.slug||"",link_embed:o.link_embed||"",link_m3u8:o.link_m3u8||""}))}function _(e){if(t.heroBg){const i=e.backdrop||e.poster_url||e.thumb_url||e.thumbnail||"";i&&(t.heroBg.style.backgroundImage=`url('${i}')`)}if(t.movieTitle&&(t.movieTitle.textContent=e.title),t.movieYear&&(t.movieYear.textContent=e.year||""),t.movieDuration)if(e.runtime_minutes){const i=Math.floor(e.runtime_minutes/60),r=e.runtime_minutes%60;t.movieDuration.textContent=i>0?`${i}h ${r}m`:`${r}m`}else e.duration&&(t.movieDuration.textContent=e.duration);if(t.movieQuality&&(t.movieQuality.textContent=e.quality||"HD"),t.movieRating){const i=e.rating||e.tmdb_rating;i&&i!=="N/A"?t.movieRating.textContent=typeof i=="number"?`${i.toFixed(1)} ★`:i:t.movieRating.textContent="TV-MA"}if(t.movieMatch){const i=Math.floor(85+Math.random()*14);t.movieMatch.textContent=`${i}% Match`}if(t.movieDescription){const i=e.tmdb_description||e.description||"No description available.";t.movieDescription.innerHTML=i,t.movieDescriptionMobile&&(t.movieDescriptionMobile.innerHTML=i)}if(t.movieTitleMobile&&(t.movieTitleMobile.textContent=e.title),t.movieYearMobile&&(t.movieYearMobile.textContent=e.year||""),t.movieRatingMobile){const i=e.rating||e.tmdb_rating;t.movieRatingMobile.textContent=i&&i!=="N/A"?typeof i=="number"?i.toFixed(1):i:"TV-MA"}if(t.movieDurationMobile&&(t.movieDurationMobile.textContent=t.movieDuration?t.movieDuration.textContent:e.duration||""),t.movieQualityMobile&&(t.movieQualityMobile.textContent=e.quality||"HD"),t.movieMatchMobile&&t.movieMatch&&(t.movieMatchMobile.textContent=t.movieMatch.textContent),t.movieTags){const i=e.genres||[],r=e.director,o=e.country;let n="";i.length>0&&(n+=`
    Genres: ${i.join(", ")}
    `),r&&r!=="Unknown"&&(n+=`
    Director: ${r}
    `),o&&o!=="Unknown"&&(n+=`
    Country: ${o}
    `),t.movieTags.innerHTML=n}document.title=`${e.title} - StreamFlix`,window.historyService&&e.slug&&x(window.historyService.isFavorite(e.slug)),L(e),e.tmdb_cast&&e.tmdb_cast.length>0?E(e.tmdb_cast,!0):e.cast&&e.cast.length>0&&E(e.cast,!1),S(e)}function L(e){if(!t.episodesPanel)return;let i=[];if(Array.isArray(e.episodes)&&e.episodes.length>0&&(e.episodes[0].server_data?i=e.episodes[0].server_data:i=e.episodes),i.length<=1){t.seasonSelectContainer&&(t.seasonSelectContainer.style.display="none"),t.episodesLoading&&(t.episodesLoading.style.display="none"),t.episodesGrid&&(t.episodesGrid.innerHTML=` +
    + play_circle +
    +

    Full Movie

    +

    Click Play to watch

    +
    +
    + `);return}if(t.episodeCount&&(t.episodeCount.textContent=`${i.length} Episodes`),t.episodesLoading&&(t.episodesLoading.style.display="none"),t.episodesGrid){const o=i.length,n=o<=15,l=d=>{if(t.episodesGrid.innerHTML=i.slice(0,d).map((c,m)=>{const u=m+1,p=u===s.currentEpisode,a=c.name||`Episode ${u}`,y=c.title||c.filename||"";return` +
    +
    ${u}
    +
    +

    ${a}

    + ${y?`

    ${y}

    `:""} +
    + ${p?'play_circle':""} +
    + `}).join(""),dSee more episodes (${o-d} remaining) + expand_more + `,c.onclick=()=>l(o),t.episodesGrid.appendChild(c)}};l(n?o:10)}}function S(e){if(!t.detailsList)return;const i=[];e.original_title&&i.push({label:"Original Title",value:e.original_title}),e.director&&e.director!=="Unknown"&&i.push({label:"Director",value:e.director}),e.country&&e.country!=="Unknown"&&i.push({label:"Country",value:e.country}),e.year&&i.push({label:"Release Year",value:e.year}),e.quality&&i.push({label:"Quality",value:e.quality}),e.duration&&i.push({label:"Duration",value:e.duration}),e.genres&&e.genres.length>0&&i.push({label:"Genres",value:e.genres.join(", ")}),t.detailsList.innerHTML="",i.forEach(r=>{const o=document.createElement("div");o.className="flex gap-4";const n=document.createElement("span");n.className="text-white/50 min-w-[100px] font-medium",n.textContent=`${r.label}:`;const l=document.createElement("span");l.className="text-white font-medium",l.textContent=r.value,o.appendChild(n),o.appendChild(l),t.detailsList.appendChild(o)})}window.selectEpisode=e=>{s.currentEpisode=e;const i=new URL(window.location);i.searchParams.set("ep",e),window.history.replaceState({},"",i),L(s.video),M(),window.scrollTo({top:0,behavior:"smooth"})};async function M(){if(s.video){t.playerLoading&&(t.playerLoading.style.display="flex");try{let e=null,i=s.video.thumbnail,r=[];Array.isArray(s.video.episodes)&&s.video.episodes.length>0&&(s.video.episodes[0].server_data?r=s.video.episodes[0].server_data:r=s.video.episodes);const o=r[s.currentEpisode-1];if(window.historyService&&window.historyService.addToHistory(s.video,{episode:s.currentEpisode,timestamp:Date.now()}),o&&(o.link_m3u8?e=o.link_m3u8:o.link_embed&&(e=o.link_embed)),!e&&s.video.slug)try{const n=await h.getRophimStream(s.video.slug,s.currentEpisode);n!=null&&n.stream_url&&(e=n.stream_url)}catch(n){console.warn("Stream API fallback also failed",n)}if(t.playerLoading&&(t.playerLoading.style.display="none"),e){H(e,i,s.video.title);const n=r.length>1?`Episode ${s.currentEpisode} `:"Movie";v(`Playing ${n} `,"success")}else{const n=s.currentEpisode===1?"full":s.currentEpisode,l=`https://phimmoichill.network/xem-phim/${s.video.slug}/tap-${n}-sv-0`;D(l)}}catch(e){console.error(e),A(e.message)}}}function D(e){t.videoPlayer.innerHTML=` +
    + + + + + +

    It cannot load

    +

    This stream is currently unavailable. Please try again later or choose another source.

    +
    + `}function H(e,i,r){if(e.includes("embed")||!e.match(/\.(mp4|m3u8)$/i))t.videoPlayer.innerHTML=` + + `;else{const n=I(t.videoPlayer,{url:e,poster:i,title:r+` - Ep ${s.currentEpisode}`,autoplay:!0});if(n&&window.historyService){n.on("video:timeupdate",()=>{const c=n.currentTime,m=n.duration;c>0&&m>0&&Math.floor(c)%5===0&&window.historyService.addToHistory(s.video,{currentTime:c,duration:m,percentage:c/m*100,episode:s.currentEpisode})});const d=window.historyService.getHistory().find(c=>c.slug===s.video.slug);d&&d.progress&&d.progress.episode===s.currentEpisode&&d.progress.currentTime>0&&d.progress.percentage<95&&n.once("video:canplay",()=>{n.currentTime=d.progress.currentTime})}}}function A(e){t.videoPlayer.innerHTML=` +
    +

    Error loading video: ${e}

    + +
    + `}function E(e,i=!1){if(!t.castCarousel)return;const r=e.slice(0,10);i?t.castCarousel.innerHTML=r.map(o=>{const n=o.profile_photo&&!o.profile_photo.includes("ui-avatars.com"),l=o.profile_photo||"",d=`/?search=${encodeURIComponent(o.name)}`,c=o.name.split(" ").map(m=>m[0]).join("").toUpperCase().slice(0,2);return` + +
    + ${n?`${o.name}`:`
    ${c}
    `} +
    +

    ${o.name}

    +

    ${o.character||"Actor"}

    +
    + `}).join(""):t.castCarousel.innerHTML=r.map(o=>{const n=`/?search=${encodeURIComponent(o)}`,l=o.split(" ").map(d=>d[0]).join("").toUpperCase().slice(0,2);return` + +
    + ${l} +
    +

    ${o}

    +

    Actor

    +
    + `}).join("")}async function j(){const e=t.recommendationsContainer;if(e)try{e.innerHTML='
    ';const i=s.video;if(!i)return;const r=i.slug,o=new Set([r]),n=i.category?Object.values(i.category):i.genres||[],l=i.country?Object.values(i.country):i.countries||[],d=i.year,c=[];if(n.length>0){let a="";typeof n[0]=="object"&&n[0].slug?a=n[0].slug:typeof n[0]=="string"&&(a=n[0].toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g,"").replace(/đ/g,"d").replace(/\s+/g,"-")),a&&c.push(h.getRophimCatalog({page:1,limit:24,category:`the-loai/${a}`}).then(y=>({title:"More Like This",movies:y.movies||[]})).catch(()=>null))}if(l.length>0){let a="";typeof l[0]=="object"&&l[0].slug?a=l[0].slug:typeof l[0]=="string"&&(a=l[0].toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g,"").replace(/đ/g,"d").replace(/\s+/g,"-")),a&&c.push(h.getRophimCatalog({page:1,limit:24,category:`quoc-gia/${a}`}).then(y=>({title:`Movies from ${l[0].name||l[0]}`,movies:y.movies||[]})).catch(()=>null))}d&&c.push(h.getRophimCatalog({page:1,limit:24,category:`nam-phat-hanh/${d}`}).then(a=>({title:`Released in ${d}`,movies:a.movies||[]})).catch(()=>null));const m=await Promise.all(c);e.innerHTML="";const u=new Set;let p=!1;m.forEach(a=>{if(!a||!a.movies||a.movies.length===0||a.title&&u.has(a.title))return;a.title&&u.add(a.title);const y=a.movies.filter(f=>!o.has(f.slug));if(y.forEach(f=>o.add(f.slug)),y.length===0)return;p=!0;const g=` +
    + ${a.title?`

    ${a.title}

    `:""} +
    + ${y.map(f=>N(f)).join("")} +
    +
    + `;e.insertAdjacentHTML("beforeend",g)}),p||(e.innerHTML='

    No specific recommendations found.

    ')}catch(i){console.error("Failed to load recommendations:",i),e.innerHTML='

    Failed to load recommendations

    '}}function N(e){const i=e.poster_url||e.thumbnail||e.thumb_url||"",r=e.name||e.title||"Untitled",o=e.year||"",n=e.quality||"HD",l=e.matchScore||Math.floor(Math.random()*15+85),d=e.tmdb_rating||0,c=Math.round(d*10);return` +
    +
    +
    + + +
    + + +
    + ${o==new Date().getFullYear()?'NEW':""} + ${n.replace("FHD","HD")} +
    + + +
    +
    + + +
    + +

    ${r}

    +
    + ${l}% Match +
    + + local_pizza ${c}% + +
    +
    +
    +
    +
    + `}function B(e){document.body.innerHTML=` +
    + error +

    ${e}

    + Go Home +
    + `}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",w):w(); diff --git a/backend/static/history.html b/backend/static/history.html new file mode 100644 index 0000000..193ba23 --- /dev/null +++ b/backend/static/history.html @@ -0,0 +1,232 @@ + + + + + + + Watch History - KV-Stream + + + + + + + + + +
    + + + + +
    +
    +
    +

    Watch History

    +

    My List

    +
    + +
    + +
    + +
    + + +
    +
    + + + + + + \ No newline at end of file diff --git a/backend/static/index.html b/backend/static/index.html new file mode 100644 index 0000000..fb99218 --- /dev/null +++ b/backend/static/index.html @@ -0,0 +1,341 @@ + + + + + + + StreamFlix - Homepage + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + +
    +
    + +
    + + + + + +
    +
    +
    + arrow_drop_down +
    +
    +
    +
    + + +
    + +
    + +
    +
    +
    +
    +
    + +
    +
    +
    + +

    + +

    +

    + Loading... +

    +
    +
    + + +
    +
    +
    +
    + + +
    + +
    + +
    +
    +
    + Loading movies... +
    +
    +
    +
    + + + +
    + + + + + +
    +
    +
    + search + + +
    +
    + + +
    +
    + + + + + + \ No newline at end of file diff --git a/backend/static/info.html b/backend/static/info.html new file mode 100644 index 0000000..0162faf --- /dev/null +++ b/backend/static/info.html @@ -0,0 +1,305 @@ + + + + + + + Movie Details - KV-Stream + + + + + + + + +
    + + + +
    +
    + +
    + +
    + +
    + Poster +
    + + +
    +

    Loading...

    +
    + +
    + +
    + + +
    +
    + Trạng thái: + ... +
    +
    + Năm phát hành: + ... +
    +
    + Số tập: + ... +
    +
    + Quốc gia: + ... +
    +
    + Thể loại: + ... +
    +
    + Đạo diễn: + ... +
    +
    + Diễn viên: + ... +
    +
    + + + + + +
    Nội dung chi tiết
    +
    + + +
    + + +
    Có thể bạn sẽ thích (Top phim hay)
    +
    +
    +
    +
    +
    +
    + + + + + \ No newline at end of file diff --git a/backend/static/js/history-service.js b/backend/static/js/history-service.js new file mode 100644 index 0000000..a7e0113 --- /dev/null +++ b/backend/static/js/history-service.js @@ -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(); +} diff --git a/backend/static/manifest.json b/backend/static/manifest.json new file mode 100644 index 0000000..62464c7 --- /dev/null +++ b/backend/static/manifest.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/backend/static/sw.js b/backend/static/sw.js new file mode 100644 index 0000000..e3a8e52 --- /dev/null +++ b/backend/static/sw.js @@ -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)) + ); +}); diff --git a/backend/static/watch.html b/backend/static/watch.html new file mode 100644 index 0000000..27da594 --- /dev/null +++ b/backend/static/watch.html @@ -0,0 +1,401 @@ + + + + + + + StreamFlix - Movie Details + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    + + +
    + + + +
    +
    + play_arrow +
    +
    + + + +
    + + +
    + +
    +
    + movie + Series +
    +

    Loading...

    + +
    + 98% Match + 2024 + TV-MA + 2h 30m + HD +
    +
    + + +
    + +
    + + +
    +
    + + +
    +

    Loading...

    +
    + + +
    + + + + + +
    + +
    + +
    + +
    + +
    + + +
    + + +
    + + +
    + +
    + + Episodes +
    + +
    + +
    +
    + Loading episodes... +
    +
    +
    + + + + + + +
    + + +
    + +
    + +
    +
    +
    +
    + + + + + + + + +
    + + + + + + + + + + \ No newline at end of file diff --git a/backend/streamflow.db b/backend/streamflow.db new file mode 100644 index 0000000..2ba04d3 Binary files /dev/null and b/backend/streamflow.db differ diff --git a/backend/tmdb_service.py b/backend/tmdb_service.py new file mode 100644 index 0000000..094e41e --- /dev/null +++ b/backend/tmdb_service.py @@ -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()) diff --git a/backend/video_extractor.py b/backend/video_extractor.py new file mode 100644 index 0000000..f8a8ba3 --- /dev/null +++ b/backend/video_extractor.py @@ -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() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4523fe5 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..d091b30 --- /dev/null +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..2ce49a4 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,342 @@ + + + + + + + StreamFlix - Homepage + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + +
    +
    + +
    + + + + + +
    +
    +
    + arrow_drop_down +
    +
    +
    +
    + + +
    + +
    + +
    +
    +
    +
    +
    + +
    +
    +
    + +

    + +

    +

    + Loading... +

    +
    +
    + + +
    +
    +
    +
    + + +
    + +
    + +
    +
    +
    + Loading movies... +
    +
    +
    +
    + + + +
    + + + + + +
    +
    +
    + search + + +
    +
    + + +
    +
    + + + + + + + + + \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..78fc675 --- /dev/null +++ b/frontend/package-lock.json @@ -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 + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..2ded781 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} \ No newline at end of file diff --git a/frontend/public/history.html b/frontend/public/history.html new file mode 100644 index 0000000..193ba23 --- /dev/null +++ b/frontend/public/history.html @@ -0,0 +1,232 @@ + + + + + + + Watch History - KV-Stream + + + + + + + + + +
    + + + + +
    +
    +
    +

    Watch History

    +

    My List

    +
    + +
    + +
    + +
    + + +
    +
    + + + + + + \ No newline at end of file diff --git a/frontend/public/info.html b/frontend/public/info.html new file mode 100644 index 0000000..0162faf --- /dev/null +++ b/frontend/public/info.html @@ -0,0 +1,305 @@ + + + + + + + Movie Details - KV-Stream + + + + + + + + +
    + + + +
    +
    + +
    + +
    + +
    + Poster +
    + + +
    +

    Loading...

    +
    + +
    + +
    + + +
    +
    + Trạng thái: + ... +
    +
    + Năm phát hành: + ... +
    +
    + Số tập: + ... +
    +
    + Quốc gia: + ... +
    +
    + Thể loại: + ... +
    +
    + Đạo diễn: + ... +
    +
    + Diễn viên: + ... +
    +
    + + + + + +
    Nội dung chi tiết
    +
    + + +
    + + +
    Có thể bạn sẽ thích (Top phim hay)
    +
    +
    +
    +
    +
    +
    + + + + + \ No newline at end of file diff --git a/frontend/public/js/history-service.js b/frontend/public/js/history-service.js new file mode 100644 index 0000000..a7e0113 --- /dev/null +++ b/frontend/public/js/history-service.js @@ -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(); +} diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..62464c7 --- /dev/null +++ b/frontend/public/manifest.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..e3a8e52 --- /dev/null +++ b/frontend/public/sw.js @@ -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)) + ); +}); diff --git a/frontend/scripts/api.js b/frontend/scripts/api.js new file mode 100644 index 0000000..f86e51e --- /dev/null +++ b/frontend/scripts/api.js @@ -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} 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} 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} 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} 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} 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} 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} 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} 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} 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} 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} 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} 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} 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(); diff --git a/frontend/scripts/category-system.js b/frontend/scripts/category-system.js new file mode 100644 index 0000000..1802758 --- /dev/null +++ b/frontend/scripts/category-system.js @@ -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 + }; +} diff --git a/frontend/scripts/components/HeroSection.js b/frontend/scripts/components/HeroSection.js new file mode 100644 index 0000000..617597b --- /dev/null +++ b/frontend/scripts/components/HeroSection.js @@ -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} 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 = ` +
    + ${featured.title} +
    +
    + +
    +
    +

    ${featured.title}

    + +
    + ${metaItems.map((item, i) => ` + ${item} + ${i < metaItems.length - 1 ? '' : ''} + `).join('')} +
    + +

    ${featured.description || ''}

    + +
    + + +
    +
    +
    + + ${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 ` +
    + ${items.map((_, i) => ` + + `).join('')} +
    + `; +} + +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); +} + diff --git a/frontend/scripts/components/InfoModal.js b/frontend/scripts/components/InfoModal.js new file mode 100644 index 0000000..bc71344 --- /dev/null +++ b/frontend/scripts/components/InfoModal.js @@ -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 = ` + + + `; + + // 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; +} diff --git a/frontend/scripts/components/NewAndHot.js b/frontend/scripts/components/NewAndHot.js new file mode 100644 index 0000000..3cdeb9b --- /dev/null +++ b/frontend/scripts/components/NewAndHot.js @@ -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 = ` +
    + ${month} + ${day} +
    +
    +
    +
    + ${video.title} +
    + +
    +
    +
    +
    +

    ${video.title}

    +
    + + +
    +
    +

    ${video.description || 'Watch now on Netflix.'}

    +
    + ${(video.genres || ['Exciting', 'Action', 'Netflix Original']).map(t => `${t}`).join('')} +
    +
    +
    +
    + `; + + return item; +} + +export function renderNewAndHotView(container, videos) { + container.innerHTML = ` +
    +
    +
    + + +
    +
    +
    + +
    +
    + `; + + const feed = container.querySelector('.new-hot-feed'); + videos.forEach(video => { + feed.appendChild(createNewAndHotItem(video)); + }); +} diff --git a/frontend/scripts/components/SearchBar.js b/frontend/scripts/components/SearchBar.js new file mode 100644 index 0000000..a5a09d2 --- /dev/null +++ b/frontend/scripts/components/SearchBar.js @@ -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 = ` +
    + No results found for "${escapeHtml(query)}" +
    + `; + } else { + resultsEl.innerHTML = results.map(video => ` +
    + ${escapeHtml(video.name || video.title)} +
    +
    ${escapeHtml(video.name || video.title)}
    +
    + ${video.quality ? `${video.quality} • ` : ''} + ${video.year || ''} +
    +
    +
    + `).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 = ` +
    + Search failed. Please try again. +
    + `; + 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; +} diff --git a/frontend/scripts/components/Toast.js b/frontend/scripts/components/Toast.js new file mode 100644 index 0000000..c4cf31e --- /dev/null +++ b/frontend/scripts/components/Toast.js @@ -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 = ` + + ${getToastIcon(type)} + + ${escapeHtml(message)} + `; + + 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 ''; + case 'error': + return ''; + default: + return ''; + } +} + +/** + * 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; +} diff --git a/frontend/scripts/components/VideoCard.js b/frontend/scripts/components/VideoCard.js new file mode 100644 index 0000000..794ecd1 --- /dev/null +++ b/frontend/scripts/components/VideoCard.js @@ -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 = ` +
    + ${rating.toFixed(1)} +
    + `; + } + + // Build rating badge HTML (Rotten Tomatoes style) + let tomatoBadgeHTML = ''; + if (rating > 0) { + const tomatoIcon = isFresh ? '🍅' : '🥀'; + tomatoBadgeHTML = ` +
    + ${tomatoIcon} + ${ratingPercent}% +
    + `; + } + + // 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 += `MỚI`; + } + + // Type tag (SERIES / PHIM LẺ) + if (movieType === 'trailer') { + tagsHTML += `TRAILER`; + } else if (movieType === 'series') { + tagsHTML += `PHIM BỘ`; + } else if (movieType === 'animation') { + tagsHTML += `HOẠT HÌNH`; + } + + card.innerHTML = ` +
    +
    + ${escapeHtml(video.title)} + + +
    + ${tagsHTML} +
    + + +
    + ${tomatoBadgeHTML} + ${numericRatingHTML} + ${qualityBadge} +
    + + +
    + ${year} + ${episodeText ? `${episodeText}` : ''} +
    + + + ${video.progress && video.progress.percentage > 0 ? ` +
    +
    +
    + ` : ''} + + +
    + +
    +
    +
    + + +
    + ${escapeHtml(video.title)} +
    + `; + + // 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; +} diff --git a/frontend/scripts/components/VideoPlayer.js b/frontend/scripts/components/VideoPlayer.js new file mode 100644 index 0000000..2609ad3 --- /dev/null +++ b/frontend/scripts/components/VideoPlayer.js @@ -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: `
    `, + state: `` + }, + 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 = ` +
    + + + +
    + `; + + if (onClick) { + placeholder.addEventListener('click', onClick); + } + + return placeholder; +} diff --git a/frontend/scripts/info.js b/frontend/scripts/info.js new file mode 100644 index 0000000..a7bb94e --- /dev/null +++ b/frontend/scripts/info.js @@ -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 = `${status}`; + } + + 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 => + `${t}` + ).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 => ` + + +
    ${v.title}
    +
    ${v.year || ''}
    +
    + `).join(''); + } catch (e) { + console.warn('Failed to load recs', e); + } +} + +init(); diff --git a/frontend/scripts/keyboard-nav.js b/frontend/scripts/keyboard-nav.js new file mode 100644 index 0000000..6371397 --- /dev/null +++ b/frontend/scripts/keyboard-nav.js @@ -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; + } +} diff --git a/frontend/scripts/main.js b/frontend/scripts/main.js new file mode 100644 index 0000000..cfc2741 --- /dev/null +++ b/frontend/scripts/main.js @@ -0,0 +1,3087 @@ +/** + * KV-Netflix - Main Application Entry Point + * Initializes the video streaming application + */ + +import { api } from './api.js'; +import { createVideoCard } from './components/VideoCard.js'; +import { initPlayer, destroyPlayer } from './components/VideoPlayer.js'; +import { initSearch } from './components/SearchBar.js'; +import { showToast } from './components/Toast.js'; + +import { createInfoModal } from './components/InfoModal.js'; +import { renderNewAndHotView } from './components/NewAndHot.js'; +import { KeyboardNavigation } from './keyboard-nav.js'; +// Drag scroll removed per user request +// Application state +const state = { + videos: [], + currentCategory: 'all', + currentVideo: null, + isLoading: false, + featuredVideo: null, + heroMovies: [], + currentHeroIndex: 0, + heroInterval: null, + page: 1, + hasMore: true +}; + +// DOM elements +const elements = { + // Use videoGrid if exists, otherwise fall back to mainContent (Tailwind CSS design) + videoGrid: document.getElementById('videoGrid') || document.getElementById('mainContent'), + mainContent: document.getElementById('mainContent'), + loading: document.getElementById('loading'), + emptyState: document.getElementById('emptyState'), + categories: document.getElementById('categories'), + // Netflix-style navigation elements + mainHeader: document.getElementById('mainHeader'), + searchWrapper: document.getElementById('searchWrapper'), + searchToggle: document.getElementById('searchToggle'), + searchInput: document.getElementById('searchInput'), + searchResults: document.getElementById('searchResults'), + navLinks: document.querySelectorAll('.header__nav-link'), + + playerModal: document.getElementById('playerModal'), + playerContainer: document.getElementById('playerContainer'), + playerTitle: document.getElementById('playerTitle'), + playerMeta: document.getElementById('playerMeta'), + closePlayer: document.getElementById('closePlayer'), + modalBackdrop: document.getElementById('modalBackdrop'), + mobileNavItems: document.querySelectorAll('.mobile-nav__item, .sidebar__nav-item'), + mobileBottomNavButtons: document.querySelectorAll('#mobileBottomNav .nav-item') +}; + +/** + * Set the active state of mobile bottom navigation + * @param {string} viewName - 'home', 'cinema', 'mylist', or 'search' + */ +function setMobileNavActive(viewName) { + const navButtons = document.querySelectorAll('#mobileBottomNav .nav-item'); + navButtons.forEach(btn => { + const isActive = btn.dataset.view === viewName; + btn.classList.toggle('active', isActive); + btn.classList.toggle('text-white', isActive); + btn.classList.toggle('text-gray-400', !isActive); + + const icon = btn.querySelector('.material-symbols-outlined'); + if (icon) { + icon.style.fontVariationSettings = isActive ? "'FILL' 1" : "'FILL' 0"; + } + }); +} + +/** + * Initialize the application + */ +async function init() { + + // Initialize search + initSearch(elements.searchInput, elements.searchResults, handleVideoPlay); + + // Initialize Mobile Bottom Nav + if (elements.mobileBottomNavButtons) { + elements.mobileBottomNavButtons.forEach(btn => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + const view = btn.dataset.view; + if (!view) return; + + // Update active state + elements.mobileBottomNavButtons.forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + + // Handle routing + if (view === 'home') { + renderHome(); + } else if (view === 'search') { + // Mobile Search View + if (window.innerWidth < 768) { + try { + renderMobileSearch(); + } catch (e) { + console.error('Search render failed', e); + } + } else { + elements.searchWrapper.classList.add('active'); + elements.searchInput.focus(); + } + } else if (view === 'mylist') { + if (window.innerWidth < 768) { + renderMobileMyList(); + } else { + renderHistoryView('mylist'); + } + } else if (view === 'downloads') { + showToast('Downloads feature coming soon!', 'info'); + } else if (view === 'profile') { + renderProfileView(); + } else if (view === 'cinema') { + setMobileNavActive('cinema'); + renderCategoryView('cinema'); + } else { + renderCategoryView(view); + } + + // Roll back to hero banner (scroll to top) + window.scrollTo({ top: 0, behavior: 'smooth' }); + }); + }); + } + + // Set up event listeners + setupEventListeners(); + + // Load home view with organized sections + await renderCategoryView('home'); + + // Render hero with featured content + await renderHero(); + + // Handle view parameter from URL (e.g. for redirects from watch page) + const urlParams = new URLSearchParams(window.location.search); + const viewParam = urlParams.get('view'); + if (viewParam && window.innerWidth < 768) { + if (viewParam === 'search') renderMobileSearch(); + else if (viewParam === 'mylist') renderMobileMyList(); + else if (viewParam === 'cinema') renderCategoryView('cinema'); + } + + // Initialize TV-Style Keyboard Navigation + const nav = new KeyboardNavigation(); + nav.init(); + + // Register PWA Service Worker + if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/sw.js') + }); + } +} + +/** + * Render hero section with featured movie + * @param {Object} video - Optional video object to render (defaults to state.featuredVideo) + */ +function renderHero(video = null) { + const heroTitle = document.getElementById('heroTitle'); + const heroDescription = document.getElementById('heroDescription'); + const heroBg = document.getElementById('heroBg'); + const heroTag = document.getElementById('heroTag'); + const heroTagContainer = document.getElementById('heroTagContainer'); + const heroPlayBtn = document.getElementById('heroPlayBtn'); + const heroInfoBtn = document.getElementById('heroInfoBtn'); + const heroContent = document.getElementById('heroContent'); + + // Get featured video (param, or state.featuredVideo, or first video) + const featured = video || state.featuredVideo || state.videos[0]; + + if (!featured) { + return; + } + + // Add fade out effect + if (heroBg) heroBg.style.opacity = '0.5'; + if (heroContent) heroContent.style.opacity = '0'; + + setTimeout(() => { + // Update hero content + if (heroTitle) heroTitle.textContent = featured.name || featured.title || 'Featured Movie'; + if (heroDescription) heroDescription.textContent = featured.description || featured.content || 'Watch now on StreamFlix'; + + // Set background + const backdrop = featured.backdrop || featured.poster_url || featured.thumb_url || featured.thumbnail || ''; + if (heroBg && backdrop) { + heroBg.style.backgroundImage = `url('${backdrop}')`; + } + + // Set category tag + if (heroTag && heroTagContainer) { + const genres = featured.genres || featured.category; + + // Unhide container + heroTagContainer.classList.remove('hidden'); + + if (genres && Array.isArray(genres) && genres.length > 0) { + heroTag.textContent = genres[0]; + } else if (typeof genres === 'string') { + heroTag.textContent = genres; + } else { + heroTag.textContent = '#1 in Movies Today'; + } + } + + // Play button + // Remove old listeners to prevent stacking + if (heroPlayBtn) { + const newPlayBtn = heroPlayBtn.cloneNode(true); + heroPlayBtn.parentNode.replaceChild(newPlayBtn, heroPlayBtn); + newPlayBtn.addEventListener('click', () => handleVideoPlay(featured)); + } + + // Info button + if (heroInfoBtn) { + const newInfoBtn = heroInfoBtn.cloneNode(true); + heroInfoBtn.parentNode.replaceChild(newInfoBtn, heroInfoBtn); + newInfoBtn.addEventListener('click', () => handleShowInfo(featured)); + } + + // Fade in + if (heroBg) heroBg.style.opacity = '1'; + if (heroContent) heroContent.style.opacity = '1'; + }, 300); + + state.featuredVideo = featured; +} + +/** + * Start Hero Carousel + */ +function startHeroCarousel() { + if (state.heroInterval) clearInterval(state.heroInterval); + + // Only start if we have multiple movies + if (!state.heroMovies || state.heroMovies.length <= 1) return; + + state.heroInterval = setInterval(() => { + state.currentHeroIndex++; + if (state.currentHeroIndex >= state.heroMovies.length) { + state.currentHeroIndex = 0; + } + renderHero(state.heroMovies[state.currentHeroIndex]); + }, 8000); // 8 seconds +} + +/** + * Stop Hero Carousel + */ +function stopHeroCarousel() { + if (state.heroInterval) { + clearInterval(state.heroInterval); + state.heroInterval = null; + } +} + +/** + * Set up event listeners + */ +function setupEventListeners() { + // Header Scroll Effect - Master Instruction Logic + const backToTopBtn = document.getElementById('backToTop'); + const handleScroll = () => { + const scrollY = window.scrollY; + + // Header background change + if (elements.mainHeader) { + if (scrollY > 100) { + elements.mainHeader.classList.add('scrolled'); + elements.mainHeader.style.backgroundColor = '#141414'; // Strict Netflix Black + } else { + elements.mainHeader.classList.remove('scrolled'); + elements.mainHeader.style.backgroundColor = 'transparent'; // Gradient handled by CSS + } + } + + // Back to top button visibility + if (backToTopBtn) { + if (scrollY > 500) { + backToTopBtn.classList.add('visible'); + } else { + backToTopBtn.classList.remove('visible'); + } + } + }; + window.addEventListener('scroll', handleScroll, { passive: true }); + // Initial check + handleScroll(); + + // Back to Top Button Click Handler + if (backToTopBtn) { + backToTopBtn.addEventListener('click', () => { + window.scrollTo({ + top: 0, + behavior: 'smooth' + }); + }); + } + + // Expandable Search Logic - REMOVED (Unifying with Modal) + + // Category Navigation + elements.navLinks?.forEach(link => { + link.addEventListener('click', (e) => { + e.preventDefault(); + const category = link.dataset.category; + + // Update active state + elements.navLinks.forEach(l => l.classList.remove('active')); + link.classList.add('active'); + + // Load content for category + state.currentCategory = category; + loadVideos(category, true); // true = reset pagination + }); + }); + + // Mobile & Sidebar Navigation + elements.mobileNavItems?.forEach(item => { + item.addEventListener('click', (e) => { + e.preventDefault(); + const view = item.dataset.view; + + // Update active state + elements.mobileNavItems.forEach(i => i.classList.remove('active')); + // If it's a sidebar item, we might need to activate all matching items (desktop + mobile logic if split) + // For now just activate clicked + item.classList.add('active'); + + // Sync other items with same view (e.g. if both mobile nav and sidebar exist) + elements.mobileNavItems.forEach(i => { + if (i.dataset.view === view) i.classList.add('active'); + }); + + if (view === 'home') { + elements.videoGrid.style.display = 'block'; + const newHot = document.getElementById('newHotContainer'); + if (newHot) newHot.style.display = 'none'; + state.currentCategory = 'all'; + loadVideos('all', true); + } else if (['movies', 'series', 'animation', 'cinema'].includes(view)) { + // Category Views + elements.videoGrid.style.display = 'block'; + const newHot = document.getElementById('newHotContainer'); + if (newHot) newHot.style.display = 'none'; + + state.currentCategory = view; + loadVideos(view, true); // loadVideos handles the API call with category param + } else if (view === 'history') { + // History & My List View (SPA) + elements.videoGrid.style.display = 'block'; + const newHot = document.getElementById('newHotContainer'); + if (newHot) newHot.style.display = 'none'; + renderHistoryView(); + } else if (view === 'search') { + // Trigger search modal instead of legacy view + const searchBtn = document.getElementById('headerSearchBtn'); + if (searchBtn) searchBtn.click(); + } + + // Roll back to hero banner (scroll to top) + window.scrollTo({ top: 0, behavior: 'smooth' }); + }); + }); + + // Netflix Header Navigation (Desktop Top Nav) + const netflixNavLinks = document.querySelectorAll('.netflix-header__nav-link'); + netflixNavLinks.forEach(link => { + link.addEventListener('click', (e) => { + e.preventDefault(); + const view = link.dataset.view; + + // Update active state on Netflix header + netflixNavLinks.forEach(l => l.classList.remove('active')); + link.classList.add('active'); + + // Sync sidebar/mobile items + elements.mobileNavItems.forEach(i => { + i.classList.remove('active'); + if (i.dataset.view === view) i.classList.add('active'); + }); + + // Handle view switching + elements.videoGrid.style.display = 'block'; + const newHot = document.getElementById('newHotContainer'); + if (newHot) newHot.style.display = 'none'; + + if (view === 'home') { + state.currentCategory = 'all'; + loadVideos('all', true); + } else if (['movies', 'series', 'animation', 'cinema'].includes(view)) { + state.currentCategory = view; + loadVideos(view, true); + } else if (view === 'history') { + renderHistoryView(); + } + + // Roll back to hero banner (scroll to top) + window.scrollTo({ top: 0, behavior: 'smooth' }); + }); + }); + + // Netflix Header Search Button + const headerSearchBtn = document.getElementById('headerSearchBtn'); + if (headerSearchBtn) { + headerSearchBtn.addEventListener('click', (e) => { + e.preventDefault(); + const searchModal = document.getElementById('searchModal'); + const searchInput = document.getElementById('searchInput'); + if (searchModal) { + searchModal.classList.add('active'); + if (searchInput) setTimeout(() => searchInput.focus(), 100); + } + }); + } + + // Mobile Search Button + const mobileSearchBtn = document.getElementById('mobileSearchBtn'); + if (mobileSearchBtn) { + mobileSearchBtn.addEventListener('click', (e) => { + e.preventDefault(); + const searchModal = document.getElementById('searchModal'); + const searchInput = document.getElementById('searchInput'); + if (searchModal) { + searchModal.classList.add('active'); + if (searchInput) setTimeout(() => searchInput.focus(), 100); + } + }); + } + + // Close Search Modal + const closeSearch = document.getElementById('closeSearch'); + if (closeSearch) { + closeSearch.addEventListener('click', () => { + const searchModal = document.getElementById('searchModal'); + if (searchModal) searchModal.classList.remove('active'); + }); + } + + // StreamFlix Nav Links (Tailwind design) + const streamflixNavLinks = document.querySelectorAll('.nav-link'); + streamflixNavLinks.forEach(link => { + link.addEventListener('click', (e) => { + e.preventDefault(); + const view = link.dataset.view; + + // Update active state + streamflixNavLinks.forEach(l => { + l.classList.remove('active', 'text-white'); + l.classList.add('text-gray-300'); + }); + link.classList.add('active', 'text-white'); + link.classList.remove('text-gray-300'); + + // Handle view switching with organized category sections + if (view === 'home') { + state.currentCategory = 'all'; + renderCategoryView('home'); + } else if (view === 'series') { + state.currentCategory = 'series'; + renderCategoryView('series'); + } else if (view === 'movies') { + state.currentCategory = 'movies'; + renderCategoryView('movies'); + } else if (view === 'cinema') { + state.currentCategory = 'cinema'; + renderCategoryView('cinema'); + } else if (view === 'history') { + renderHistoryView(); + } + + // Roll back to hero banner (scroll to top) + window.scrollTo({ top: 0, behavior: 'smooth' }); + }); + }); + + // Modal close events + elements.closePlayer?.addEventListener('click', closePlayerModal); + elements.modalBackdrop?.addEventListener('click', closePlayerModal); + + + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + if (elements.playerModal?.classList.contains('active')) { + closePlayerModal(); + } + if (elements.searchWrapper?.classList.contains('active')) { + elements.searchWrapper.classList.remove('active'); + } + // Close search modal + const searchModal = document.getElementById('searchModal'); + if (searchModal?.classList.contains('active')) { + searchModal.classList.remove('active'); + } + } + }); +} + +/** + * Load videos from API - tries RoPhim first, then database, then demo + * @param {string} category - Optional category filter + * @param {boolean} reset - Whether to reset pagination (e.g. category change) + */ +async function loadVideos(category = 'all', reset = false) { + if (state.isLoading) return; + if (reset) { + state.page = 1; + state.hasMore = true; + state.videos = []; + elements.videoGrid.innerHTML = ''; + } + + if (!state.hasMore) return; + + state.isLoading = true; + showLoading(state.page === 1); // Only show full loader on first page + + // Helper function to add timeout to fetch + const fetchWithTimeout = (promise, timeout = 12000) => { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout')), timeout) + ) + ]); + }; + + // Top Search Button + const topSearchBtn = document.getElementById('topSearchBtn'); + if (topSearchBtn) { + topSearchBtn.addEventListener('click', (e) => { + e.preventDefault(); + const searchModal = document.getElementById('searchModal'); + const searchInput = document.getElementById('searchInput'); + if (searchModal) { + searchModal.classList.add('active'); + if (searchInput) setTimeout(() => searchInput.focus(), 100); + } + }); + } + + try { + + let apiResponse = null; + let isSectionMode = false; + + // Section Mode disabled to force Responsive Grid Layout per user request + + + // Fallback: Flat Catalog + if (!apiResponse) { + apiResponse = await fetchWithTimeout( + api.getRophimCatalog({ + category: category !== 'all' ? category : null, + page: state.page, + limit: 24 + }), 12000 + ); + } + + + + if (apiResponse && apiResponse.movies && apiResponse.movies.length > 0) { + + // Map API data to Video objects + const newVideos = apiResponse.movies.map(m => ({ + id: m.id || `api_${Date.now()}_${Math.random()}`, + title: m.title || 'Unknown Title', + thumbnail: m.thumbnail || 'https://via.placeholder.com/300x450?text=No+Image', + backdrop: m.backdrop || m.thumbnail || 'https://via.placeholder.com/1920x1080?text=No+Backdrop', + preview_url: m.preview_url || '', + duration: m.duration || 0, + resolution: m.quality || 'HD', + category: m.category || 'movies', + year: m.year || new Date().getFullYear(), + description: m.description || '', + matchScore: Math.floor(Math.random() * 15) + 85, // Random high match score + source_url: m.source_url, + slug: m.slug, + // Rich Metadata + cast: m.cast || [], + director: m.director, + country: m.country, + episodes: m.episodes || [] + })); + + // Append new videos + // Deduplicate based on ID or slug + const existingIds = new Set(state.videos.map(v => v.id)); + const uniqueNewVideos = newVideos.filter(v => !existingIds.has(v.id)); + + state.videos = [...state.videos, ...uniqueNewVideos]; + state.page += 1; // Increment page for next fetch + + // Detect if we reached end of content? (Usually API returns empty list, but here we got items) + if (newVideos.length < 24) { + // state.hasMore = false; // Optional optimization + } + + + // Force Responsive Grid Layout for ALL categories + const isFirstBatch = state.page === 2; // Page bumped after fetch + if (isFirstBatch) { + renderVideoGrid(state.videos, false); + } else { + renderVideoGrid(uniqueNewVideos, true); + } + + // Preload featured video for Hero only on first load + + + // Setup/Update Infinite Scroll trigger + setupInfiniteScrollTrigger(); + + // Hide loading state on sentinel + if (scrollSentinel) scrollSentinel.classList.remove('loading'); + + state.isLoading = false; + showLoading(false); + return; + } else { + state.hasMore = false; + // Hide sentinel when no more content + if (scrollSentinel) { + scrollSentinel.classList.remove('loading'); + scrollSentinel.style.display = 'none'; + } + state.isLoading = false; + showLoading(false); + } + + } catch (error) { + console.warn('API load failed:', error); + // Only fallback to demo on first page load + if (state.page === 1) { + showToast('Using offline mode', 'info'); + const demoVideos = getDemoContent(); + state.videos = demoVideos; + state.featuredVideo = demoVideos[0]; + renderVideoGrid(demoVideos); + + + } + state.isLoading = false; + showLoading(false); + } +} + +/** + * Render content as horizontal sliders - PhimMoi Style + * @param {Array} videos - Videos to group + */ +/** + * Render content as horizontal sliders - Apple TV+ Style + * Enhanced with smart categorization and genre-based sections + * @param {Array} videos - Videos to group + */ +/** + * Render content as horizontal sliders - Apple TV+ Style + * Enhanced with smart categorization and genre-based sections + * @param {Array} videos - Videos to group + */ +function renderBackendSection(title, movies, isTop10, container = elements.videoGrid, usedIds = null) { + if (!movies || movies.length === 0) return; + + // Deduplicate if set provided + let uniqueMovies = movies; + if (usedIds) { + uniqueMovies = movies.filter(m => !usedIds.has(m.id || m.slug)); + if (uniqueMovies.length === 0) return; + uniqueMovies.forEach(m => usedIds.add(m.id || m.slug)); + } + + // Normalize + const normalizedVideo = uniqueMovies.map(m => ({ + id: m.id || m.slug, + title: m.title, + thumbnail: m.thumbnail, + backdrop: m.backdrop || m.thumbnail, + slug: m.slug, + year: m.year, + badge: m.badge, + ranking: m.ranking + })); + + const section = isTop10 + ? createTop10Section(title, normalizedVideo) + : createSliderSection(title, normalizedVideo); + + container.appendChild(section); +} + +async function renderSliders(videos) { + elements.videoGrid.innerHTML = ''; + // Use Tailwind CSS layout classes + elements.videoGrid.className = 'space-y-12'; + + if (elements.emptyState) elements.emptyState.style.display = 'none'; + + // 1. DISABLED: Curated sections API was overriding sectionConfigs. + // Now using only sectionConfigs for full control over categories. + /* + try { + const curatedResponse = await api.getCuratedSections(); + + if (curatedResponse && curatedResponse.sections && curatedResponse.sections.length > 0) { + + curatedResponse.sections.forEach(section => { + if (section.movies && section.movies.length > 0) { + // Normalize movies + const normalizedMovies = section.movies.map(m => ({ + id: m.id || m.slug, + title: m.title, + thumbnail: m.thumbnail, + backdrop: m.poster_url || m.thumbnail, + slug: m.slug, + year: m.year, + quality: m.quality || 'HD', + resolution: m.quality || 'HD', + rating: m.rating, + tmdb_rating: m.tmdb_rating, + genres: m.genres, + category: m.category + })); + + // Determine if this is a "Top Rated" section + const isTopRated = section.title.includes('Top Rated') || section.title.includes('🏆'); + + const sectionEl = isTopRated + ? createTop10Section(section.title, normalizedMovies) + : createSliderSection(section.title, normalizedMovies); + elements.videoGrid.appendChild(sectionEl); + } + }); + + if (elements.videoGrid.children.length > 0) { + return; + } + } + } catch (e) { + console.warn('Curated sections failed, trying backend categories...', e); + } + */ + + // 2. DISABLED: Backend structured categories were also overriding sectionConfigs. + // All rendering now happens in renderCategoryView() using sectionConfigs. + /* + try { + let backendCategories = null; + + // Try categorySystem first, then direct API call + if (window.categorySystem) { + backendCategories = await window.categorySystem.loadCategories(); + } + + // Fallback: fetch directly from API + if (!backendCategories) { + const response = await fetch('/api/rophim/categories/all'); + const data = await response.json(); + backendCategories = data.categories; + } + + if (backendCategories) { + + // Defined section order and titles + const sectionConfig = [ + { key: 'hot', title: '🔥 Phim Hot (Movies)', isTop10: false }, + { key: 'top10', title: '🏆 Top 10 Phim Lẻ', isTop10: true }, + { key: 'series', title: '📺 Phim Bộ Mới (Series)', isTop10: false }, + { key: 'cinema', title: '🍿 Phim Chiếu Rạp', isTop10: false }, + { key: 'animated', title: '🎌 Hoạt Hình & Anime', isTop10: false }, + { key: 'vietnamese', title: '🇻🇳 Phim Việt Nam', isTop10: false }, + { key: 'tv_shows', title: '🎬 TV Shows', isTop10: false }, + { key: 'action', title: '💥 Action Movies', isTop10: false }, + { key: 'new_releases', title: '✨ Mới Cập Nhật', isTop10: false } + ]; + + // Track used videos to prevent duplicates across sections + const globalUsedIds = new Set(); + + // Render sections in order + sectionConfig.forEach(config => { + if (backendCategories[config.key] && backendCategories[config.key].length > 0) { + // Skip deduplication for Top 10 - it's a ranked section and should always show + const skipDedup = config.isTop10; + renderBackendSection( + config.title, + backendCategories[config.key], + config.isTop10, + elements.videoGrid, + skipDedup ? null : globalUsedIds + ); + } + }); + + // If we successfully rendered backend categories, return here + if (elements.videoGrid.children.length > 0) { + return; + } + } + } catch (e) { + console.warn('Failed to load backend categories, falling back to local logic', e); + } + */ + + // --- FALLBACK / ORIGINAL LOGIC --- + + // Sort videos by year descending (newest first) + videos.sort((a, b) => (b.year || 0) - (a.year || 0)); + + // Track videos already added to sections to prevent duplicates + const usedVideoIds = new Set(); + + /** + * Helper: Add videos to a section and track them + */ + function addSection(title, videos, isTop10 = false) { + if (!videos || videos.length === 0) return; + + // Filter out already-used videos + const availableVideos = videos.filter(v => !usedVideoIds.has(v.id)); + if (availableVideos.length === 0) return; + + // Take up to 12 videos (or 10 for Top10) + const limit = isTop10 ? 10 : 12; + const sectionVideos = availableVideos.slice(0, limit); + + // Mark videos as used + sectionVideos.forEach(v => usedVideoIds.add(v.id)); + + // Create and append section + const section = isTop10 + ? createTop10Section(title, sectionVideos) + : createSliderSection(title, sectionVideos); + elements.videoGrid.appendChild(section); + + } + + /** + * Helper: Extract unique genres from videos + */ + function extractGenres(videos) { + const genreCounts = {}; + videos.forEach(v => { + if (v.category && typeof v.category === 'string') { + // Normalize category names + const normalized = v.category.toLowerCase(); + const genreMap = { + 'phim-le': 'Movies', + 'phim-bo': 'Series', + 'hoat-hinh': 'Animation', + 'tv-shows': 'TV Shows' + }; + const genre = genreMap[normalized] || v.category; + genreCounts[genre] = (genreCounts[genre] || 0) + 1; + } + }); + return genreCounts; + } + + // ========================================== + // PRIORITY SECTION 1: Featured/Top Content + // ========================================== + addSection('Top Charts: Movies', videos, true); + + // ========================================== + // PRIORITY SECTION 2: Year-Based (Newest First) + // ========================================== + const currentYear = new Date().getFullYear(); + + addSection('2024 New Releases', + videos.filter(v => v.year === currentYear)); + + addSection('2023 Hits', + videos.filter(v => v.year === currentYear - 1)); + + // ========================================== + // PRIORITY SECTION 3: Quality-Based + // ========================================== + addSection('4K Ultra HD', + videos.filter(v => v.resolution === '4K' || v.quality === '4K')); + + // ========================================== + // PRIORITY SECTION 4: Category-Based + // ========================================== + addSection('Must-Watch Series', + videos.filter(v => v.category === 'series' || v.category === 'phim-bo' || v.category === 'tv-shows')); + + addSection('Anime & Animation', + videos.filter(v => v.category === 'anime' || v.category === 'hoat-hinh')); + + addSection('Action & Blockbusters', + videos.filter(v => v.category === 'movies' || v.category === 'theater' || v.category === 'phim-le')); + + // ========================================== + // PRIORITY SECTION 5: Country/Region-Based + // ========================================== + addSection('Korean Cinema', + videos.filter(v => v.country && (v.country.includes('Korea') || v.country.includes('Hàn Quốc')))); + + addSection('Japanese Films', + videos.filter(v => v.country && (v.country.includes('Japan') || v.country.includes('Nhật Bản')))); + + addSection('Hollywood Blockbusters', + videos.filter(v => v.country && (v.country.includes('US') || v.country.includes('USA') || v.country.includes('Mỹ')))); + + addSection('European Collection', + videos.filter(v => v.country && ( + v.country.includes('UK') || v.country.includes('France') || + v.country.includes('Germany') || v.country.includes('Spain') || + v.country.includes('Anh') || v.country.includes('Pháp') || v.country.includes('Đức') + ))); + + addSection('Asian Cinema', + videos.filter(v => v.country && ( + v.country.includes('China') || v.country.includes('Thailand') || + v.country.includes('Hong Kong') || v.country.includes('Taiwan') || + v.country.includes('Trung Quốc') || v.country.includes('Thái Lan') || v.country.includes('Hồng Kông') + ))); + + // ========================================== + // PRIORITY SECTION 6: Time-Period Based + // ========================================== + addSection('Recent Favorites (2020-2022)', + videos.filter(v => v.year && v.year >= 2020 && v.year <= 2022)); + + addSection('Modern Classics (2015-2019)', + videos.filter(v => v.year && v.year >= 2015 && v.year < 2020)); + + addSection('Timeless Classics', + videos.filter(v => v.year && v.year < 2015)); + + // ========================================== + // PRIORITY SECTION 7: Dynamic Genre Sections + // ========================================== + // Get remaining unused videos + const unusedVideos = videos.filter(v => !usedVideoIds.has(v.id)); + + if (unusedVideos.length > 0) { + const genreCounts = extractGenres(unusedVideos); + + // Sort genres by count and create sections for top genres + const sortedGenres = Object.entries(genreCounts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5); + + sortedGenres.forEach(([genre, count]) => { + if (count >= 6) { + addSection(`${genre} Collection`, + unusedVideos.filter(v => v.category === genre || v.category?.toLowerCase().includes(genre.toLowerCase()))); + } + }); + } + + // ========================================== + // FINAL FALLBACK: Hidden Gems (minimal) + // ========================================== + const stillUnused = videos.filter(v => !usedVideoIds.has(v.id)); + + if (stillUnused.length >= 6) { + addSection('Hidden Gems', stillUnused); + } + + // Log categorization summary +} + +/** + * Create a Top 10 Section with numbered rankings - PhimMoi Style + */ +function createTop10Section(title, videos) { + const section = document.createElement('section'); + section.className = 'slider-section top10-section'; + + section.innerHTML = ` +

    ${title}

    +
    + +
    + +
    + +
    + `; + + const track = section.querySelector('.slider-track'); + videos.slice(0, 10).forEach((video, index) => { + const card = createRankedCard(video, index + 1); + track.appendChild(card); + }); + + // Mouse drag scrolling removed per user request + + // Slider Logic + const btnLeft = section.querySelector('.slider-btn--left'); + const btnRight = section.querySelector('.slider-btn--right'); + + btnRight.addEventListener('click', () => { + track.scrollBy({ left: window.innerWidth * 0.6, behavior: 'smooth' }); + }); + + btnLeft.addEventListener('click', () => { + track.scrollBy({ left: -window.innerWidth * 0.6, behavior: 'smooth' }); + }); + + return section; +} + +/** + * Create a ranked card with number - PhimMoi Top 10 style + */ +function createRankedCard(video, rank) { + const card = document.createElement('div'); + card.className = 'ranked-card'; + card.innerHTML = ` + ${rank} +
    + ${video.title} + ${video.resolution || 'HD'} +
    +
    +
    ${video.title}
    +
    ${video.year || ''} • ${video.country || ''}
    +
    + `; + + return card; +} + +/** + * Create a Slider Section - Netflix-style Horizontal Scroll (Tailwind CSS) + */ +/** + * Create a Horizontal Slider Section with scroll arrows + */ +function createSliderSection(title, videos, cardType = 'poster') { + const section = document.createElement('section'); + section.className = 'flex flex-col gap-4 mb-12 relative'; + + // Section Header + const header = document.createElement('h2'); + header.className = 'text-xl md:text-2xl font-bold text-white hover:text-primary cursor-pointer transition-colors flex items-center gap-2 group px-4 md:px-12'; + header.innerHTML = ` + ${title} + arrow_forward_ios + `; + section.appendChild(header); + + // Slider wrapper (for positioning arrows) + const sliderWrapper = document.createElement('div'); + sliderWrapper.className = 'relative group/slider'; + + // Left Arrow Button + const leftBtn = document.createElement('button'); + leftBtn.className = 'absolute left-0 top-1/2 -translate-y-1/2 z-20 w-12 h-full bg-gradient-to-r from-black/80 to-transparent opacity-0 group-hover/slider:opacity-100 transition-opacity flex items-center justify-start pl-2'; + leftBtn.innerHTML = 'chevron_left'; + + // Right Arrow Button + const rightBtn = document.createElement('button'); + rightBtn.className = 'absolute right-0 top-1/2 -translate-y-1/2 z-20 w-12 h-full bg-gradient-to-l from-black/80 to-transparent opacity-0 group-hover/slider:opacity-100 transition-opacity flex items-center justify-end pr-2'; + rightBtn.innerHTML = 'chevron_right'; + + // Horizontal Scroll Container - bigger cards + const container = document.createElement('div'); + container.className = 'flex gap-3 overflow-x-auto scroll-smooth no-scrollbar px-4 md:px-12 pb-4'; + + videos.forEach((video, index) => { + let card; + if (cardType === 'landscape') { + card = createContinueWatchingCard(video); + } else { + // All cards use horizontal orientation with larger size + card = createTailwindCard(video, false, 0, 'horizontal'); + } + // Apply larger fixed width for cards in slider (bigger cards) + card.className = card.className.replace('w-full', ''); + card.style.minWidth = '280px'; + card.style.maxWidth = '380px'; + card.style.flex = '0 0 auto'; + container.appendChild(card); + }); + + // Scroll functionality + const scrollAmount = 600; + leftBtn.addEventListener('click', () => { + container.scrollBy({ left: -scrollAmount, behavior: 'smooth' }); + }); + rightBtn.addEventListener('click', () => { + container.scrollBy({ left: scrollAmount, behavior: 'smooth' }); + }); + + sliderWrapper.appendChild(leftBtn); + sliderWrapper.appendChild(container); + sliderWrapper.appendChild(rightBtn); + section.appendChild(sliderWrapper); + + return section; +} + +/** + * Create a movie/poster card with Tailwind CSS (Netflix style) + */ +/** + * Create a movie/poster card with Tailwind CSS (Netflix strict style) + */ +/** + * Create a movie/poster card with Tailwind CSS (Netflix strict style) + */ +/** + * Create a movie/poster card with Tailwind CSS (Netflix strict style) + * @param {Object} video - Video object + * @param {boolean} showRank - Show ranking number + * @param {number} rank - Rank number + * @param {string} orientation - 'vertical' or 'horizontal' + */ +function createTailwindCard(video, showRank = false, rank = 0, orientation = 'vertical') { + const card = document.createElement('div'); + + // Let grid control width; aspect ratio for sizing + const aspectClass = orientation === 'horizontal' ? 'aspect-video' : 'aspect-[2/3]'; + + // Use w-full to fill grid cell, no fixed width + card.className = `w-full cursor-pointer snap-start group relative transition-all duration-300 ease-in-out hover:z-30 hover:scale-105`; + + // Prioritize backdrop for horizontal cards + let image = video.poster_url || video.thumb_url || video.thumbnail || ''; + if (orientation === 'horizontal' && video.backdrop) { + image = video.backdrop; + } + + const title = video.name || video.title || 'Untitled'; + const year = video.year || ''; + const quality = video.quality || 'HD'; + const slug = video.slug || video.id || ''; + + // Random match score for visual fidelity (90-99%) + const matchScore = video.matchScore || Math.floor(Math.random() * (99 - 90 + 1) + 90); + + // Simulate Rotten Tomatoes (random 80-98%) + const rtScore = Math.floor(Math.random() * (98 - 80 + 1) + 80); + + card.innerHTML = ` +
    +
    + + +
    + + +
    + ${!showRank && year === new Date().getFullYear().toString() ? `NEW` : ''} + ${video.quality ? `${video.quality.replace('FHD', 'HD')}` : ''} + ${video.current_episode ? `EP ${video.current_episode}` : ''} +
    + + + ${showRank ? `${rank}` : ''} + + +
    + + +
    +
    + + +
    + +
    + + +
    +
    + ${matchScore}% Match + ${quality} + ${year} +
    + + +
    +
    + + local_pizza ${rtScore}% + +
    + ${video.genres && video.genres.length > 0 ? `${video.genres[0]}` : ''} +
    + +

    + ${title} +

    +
    +
    +
    + `; + + // Click handler for play (background click) + card.addEventListener('click', (e) => { + if (!e.target.closest('button')) { + handleVideoPlay(video); + } + }); + + // Button Handlers + const playBtn = card.querySelector('.btn-play'); + if (playBtn) playBtn.addEventListener('click', (e) => { + e.stopPropagation(); + handleVideoPlay(video); + }); + + const addBtn = card.querySelector('.btn-add-list'); + if (addBtn) addBtn.addEventListener('click', (e) => { + e.stopPropagation(); + if (window.historyService) { + const added = window.historyService.toggleFavorite(video); + // Visual toggle + const icon = addBtn.querySelector('span'); + if (added) { + icon.textContent = 'check'; + showToast('Added to My List', 'success'); + } else { + icon.textContent = 'add'; + showToast('Removed from My List', 'info'); + } + } + }); + + const infoBtn = card.querySelector('.btn-info'); + if (infoBtn) infoBtn.addEventListener('click', (e) => { + e.stopPropagation(); + // Updated to use the new navigation logical as per previous request + handleShowInfo(video); + }); + + return card; +} + +/** + * Create a Continue Watching card (landscape with progress bar) + */ +/** + * Create a Continue Watching card (strict fit per preset) + */ +function createContinueWatchingCard(video) { + const card = document.createElement('div'); + card.className = 'flex-none w-[280px] group/card cursor-pointer snap-start'; + + const poster = video.backdrop || video.thumb_url || video.thumbnail || ''; + const title = video.name || video.title || 'Untitled'; + const progress = video.progress?.percentage || 0; + const episode = video.progress?.episode ? `S${video.season || 1}:E${video.progress.episode}` : ''; + + card.innerHTML = ` +
    +
    +
    + play_arrow +
    +
    +
    +
    +
    +
    + ${title} + ${episode ? `${episode}` : ''} +
    + `; + + card.addEventListener('click', () => handleVideoPlay(video)); + return card; +} + + + + +/** + * Render video grid (standard grid for search/categories) + * @param {Array} videos - Array of video objects + * @param {boolean} append - Whether to append to existing grid + */ +function renderVideoGrid(videos, append = false) { + // If not appending, clear the grid + if (!append) { + elements.videoGrid.innerHTML = ''; + elements.videoGrid.innerHTML = ''; + elements.videoGrid.className = 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-x-4 gap-y-10'; + } + + if (videos.length === 0 && !append) { + if (elements.emptyState) elements.emptyState.style.display = 'flex'; + return; + } + + if (elements.emptyState) elements.emptyState.style.display = 'none'; + + videos.forEach(video => { + const card = createVideoCard(video, handleVideoPlay, handleShowInfo); + elements.videoGrid.appendChild(card); + }); +} + +function renderInfiniteGrid(videos) { + + if (!videos || videos.length === 0) { + console.warn('No videos to render in infinite grid'); + return; + } + + let infiniteContainer = document.getElementById('infinite-scroll-container'); + if (!infiniteContainer) { + infiniteContainer = document.createElement('div'); + infiniteContainer.id = 'infinite-scroll-container'; + // Reduce top margin as the "Khám Phá Thêm" header from renderSliders already provides spacing + infiniteContainer.style.marginTop = '1vw'; + elements.videoGrid.appendChild(infiniteContainer); + + // Removed redundant 'More to Explore' header here as it duplicates the one from renderSliders + } + + // Group videos by year + const currentYear = new Date().getFullYear(); + const moviesByYear = {}; + + videos.forEach(video => { + const year = video.year || currentYear; + if (!moviesByYear[year]) { + moviesByYear[year] = []; + } + moviesByYear[year].push(video); + }); + + // Sort years descending (newest first) + const years = Object.keys(moviesByYear).sort((a, b) => b - a); + + // Create slider sections for each year + let cardsAdded = 0; + years.forEach(year => { + const movies = moviesByYear[year]; + if (movies.length > 0) { + const yearLabel = year == currentYear ? `${year} New Releases` : + year == currentYear - 1 ? `${year} Hits` : + `${year} Movies`; + const section = createSliderSection(yearLabel, movies); + infiniteContainer.appendChild(section); + cardsAdded += movies.length; + } + }); + +} + +let scrollObserver; +let scrollSentinel = null; +let lastScrollTrigger = 0; // Debounce timer + +function setupInfiniteScrollTrigger() { + // If no more content, hide sentinel and don't set up observer + if (!state.hasMore) { + if (scrollSentinel) { + scrollSentinel.classList.remove('loading'); + scrollSentinel.style.display = 'none'; + } + if (scrollObserver) scrollObserver.disconnect(); + return; + } + + if (scrollObserver) scrollObserver.disconnect(); + + // Remove any existing sentinels first to prevent duplicates + document.querySelectorAll('.scroll-sentinel').forEach(el => el.remove()); + scrollSentinel = null; + + const options = { + root: null, + rootMargin: '50px', // Reduced from 200px to prevent early triggering + threshold: 0.0 // Trigger when any part is visible + }; + + scrollObserver = new IntersectionObserver((entries) => { + entries.forEach(entry => { + // Debounce: require at least 1.5 seconds between triggers + const now = Date.now(); + if (now - lastScrollTrigger < 1500) { + return; + } + if (entry.isIntersecting && !state.isLoading && state.hasMore) { + lastScrollTrigger = now; + // Show loading state on sentinel + if (scrollSentinel) scrollSentinel.classList.add('loading'); + loadVideos(state.currentCategory); + } + }); + }, options); + + // Create single sentinel element + scrollSentinel = document.createElement('div'); + scrollSentinel.className = 'scroll-sentinel'; + scrollSentinel.id = 'scrollSentinel'; + + // Place sentinel at the proper location - after infinite container or at end of videoGrid + const infiniteContainer = document.getElementById('infinite-scroll-container'); + if (infiniteContainer) { + // Insert after the infinite container for proper positioning + infiniteContainer.parentNode.insertBefore(scrollSentinel, infiniteContainer.nextSibling); + } else { + elements.videoGrid.appendChild(scrollSentinel); + } + + scrollObserver.observe(scrollSentinel); +} + +function handleShowInfo(video) { + navigateToWatch(video); +} + +/** + * Render History View - Shows user's watch history and saved content + * @param {string} tab - 'history' or 'mylist' + */ +function renderHistoryView(tab = 'history') { + if (elements.mainHeader) elements.mainHeader.style.display = ''; + if (!window.historyService) { + console.error('HistoryService not initialized'); + return; + } + + // Clear the grid + elements.videoGrid.innerHTML = ''; + if (elements.emptyState) elements.emptyState.style.display = 'none'; + + // Remove any existing history tabs + const existingTabs = document.querySelector('.view-tabs'); + if (existingTabs) existingTabs.remove(); + + // Create tabs for switching between History and My List + const tabsContainer = document.createElement('div'); + tabsContainer.className = 'view-tabs'; + tabsContainer.innerHTML = ` + + + `; + elements.videoGrid.before(tabsContainer); + + // Tab click listeners + tabsContainer.querySelectorAll('.view-tab').forEach(btn => { + btn.addEventListener('click', () => { + tabsContainer.remove(); + renderHistoryView(btn.dataset.tab); + }); + }); + + let items = []; + if (tab === 'history') { + items = window.historyService.getHistory(); + } else { + items = window.historyService.getFavorites(); + } + + if (items.length === 0) { + if (elements.emptyState) { + elements.emptyState.style.display = 'flex'; + const emptyTitle = elements.emptyState.querySelector('h2'); + const emptyDesc = elements.emptyState.querySelector('p'); + + if (tab === 'history') { + if (emptyTitle) emptyTitle.textContent = 'No history yet'; + if (emptyDesc) emptyDesc.textContent = 'Movies you watch will appear here.'; + } else { + if (emptyTitle) emptyTitle.textContent = 'My List is empty'; + if (emptyDesc) emptyDesc.textContent = 'Add movies to your list to watch later.'; + } + } + return; + } + + // 1. Sort by Latest (Year/Date) + items.sort((a, b) => { + const dateA = a.timestamp || a.year || 0; + const dateB = b.timestamp || b.year || 0; + return dateB - dateA; + }); + + // Normalize items with horizontal orientation for slider + const normalizedItems = items.map((item, index) => { + return { + ...item, + id: item.id || item.slug, + orientation: 'horizontal' + }; + }); + + // Ensure header is shown + if (elements.mainHeader) elements.mainHeader.style.display = 'block'; + + // Use horizontal slider layout (same as home page) + const title = tab === 'history' ? 'Continue Watching' : 'My List'; + const sliderSection = createSliderSection(title, normalizedItems, 'poster'); + elements.videoGrid.appendChild(sliderSection); +} + +/** + * Render Library View - Legacy fallback for history/favorites + */ +function renderLibraryView() { + renderHistoryView('mylist'); +} + +/** + * Render Movies View - Shows movies in horizontal sliders organized by year + */ +async function renderMoviesView() { + // Show loading state + showLoading(true); + + // Clear the grid + elements.videoGrid.innerHTML = ''; + if (elements.emptyState) elements.emptyState.style.display = 'none'; + + try { + // Load movies if not already loaded + if (state.videos.length === 0 || state.currentCategory !== 'movies') { + state.currentCategory = 'movies'; + state.page = 1; + state.hasMore = true; + + const apiResponse = await api.getRophimCatalog({ + category: 'phim-le', // movies category + page: 1, + limit: 48 // Load more movies for better categorization + }); + + if (apiResponse && apiResponse.movies && apiResponse.movies.length > 0) { + state.videos = apiResponse.movies.map(m => ({ + id: m.id || `api_${Date.now()}_${Math.random()}`, + title: m.title || 'Unknown Title', + thumbnail: m.thumbnail || 'https://via.placeholder.com/300x450?text=No+Image', + backdrop: m.backdrop || m.thumbnail || 'https://via.placeholder.com/1920x1080?text=No+Backdrop', + preview_url: m.preview_url || '', + duration: m.duration || 0, + resolution: m.quality || 'HD', + category: m.category || 'movies', + year: m.year || new Date().getFullYear(), + description: m.description || '', + matchScore: Math.floor(Math.random() * 15) + 85, + source_url: m.source_url, + slug: m.slug, + cast: m.cast || [], + director: m.director, + country: m.country, + episodes: m.episodes || [] + })); + } + } + + // Group movies by year + const moviesByYear = {}; + const currentYear = new Date().getFullYear(); + + state.videos.forEach(video => { + const year = video.year || currentYear; + if (!moviesByYear[year]) { + moviesByYear[year] = []; + } + moviesByYear[year].push(video); + }); + + // Sort years descending (newest first) + const years = Object.keys(moviesByYear).sort((a, b) => b - a); + + // Create slider sections for each year + years.forEach(year => { + const movies = moviesByYear[year]; + if (movies.length > 0) { + const yearLabel = year == currentYear ? `${year} New Releases` : + year == currentYear - 1 ? `${year} Hits` : + `${year} Movies`; + const section = createSliderSection(yearLabel, movies); + elements.videoGrid.appendChild(section); + } + }); + + showLoading(false); + + } catch (error) { + console.error('Error loading movies:', error); + showLoading(false); + if (elements.emptyState) elements.emptyState.style.display = 'flex'; + } +} + + +/** + * Render demo content when API is not available + */ +function renderDemoContent() { + // Using CORS-friendly sample videos that work in browsers + const SAMPLE_VIDEO = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8'; // Big Buck Bunny HLS + const SAMPLE_MP4 = 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4'; + + const demoVideos = [ + { + id: 1, + title: 'Venom: The Last Dance', + thumbnail: 'https://image.tmdb.org/t/p/w500/aosm8NMQ3UyoBVpSxyimorCQykC.jpg', + backdrop: 'https://image.tmdb.org/t/p/original/3V4kLQg0kSqPLctI5ziYWabAZYF.jpg', + duration: 7200, + resolution: '4K', + category: 'movies', + year: 2024, + description: 'Eddie và Venom đang chạy trốn. Bị cả hai thế giới truy đuổi, họ buộc phải đưa ra quyết định khốc liệt...', + } + ]; + + // Fuzzy match title + const isBanner = bannerCategories.some(cat => title.includes(cat)); + + // Attempt to find a video with a real backdrop first (landscape) + // to avoid stretching portrait thumbnails + const bannerVideo = videos.find(v => v.backdrop && v.backdrop !== v.thumbnail) || videos[0] || {}; + const backdrop = bannerVideo.backdrop || bannerVideo.thumbnail || ''; + + // If we are using a thumbnail (likely portrait), apply blur + const isPortrait = backdrop === bannerVideo.thumbnail; + const bgStyle = isPortrait ? `background-image: url('${backdrop}'); filter: blur(20px) brightness(0.7); transform: scale(1.2);` : `background-image: url('${backdrop}');`; + + if (isBanner && backdrop) { + // Create Banner Header with separate BG for zoom effects + const bannerHeader = document.createElement('div'); + bannerHeader.className = 'section-banner group'; + + bannerHeader.innerHTML = ` +
    +
    +
    +

    ${title}

    + Explore Collection +
    + `; + section.appendChild(bannerHeader); + } else { + // Standard Header + const header = document.createElement('h2'); + header.className = 'section-title-apple'; + header.textContent = title; + section.appendChild(header); + } + + // Split videos into chunks (Rows) + const rowSize = 21; + + const createRow = (rowVideos) => { + const container = document.createElement('div'); + container.className = 'slider-container'; + // Add vertical spacing between rows + container.style.marginBottom = '1.5rem'; + + container.innerHTML = ` + +
    + +
    + + `; + + const track = container.querySelector('.slider-track'); + rowVideos.forEach(video => { + const card = createVideoCard(video, handleVideoPlay, handleShowInfo); + track.appendChild(card); + }); + + // Slider Logic + const btnLeft = container.querySelector('.slider-btn--left'); + const btnRight = container.querySelector('.slider-btn--right'); + + btnRight.addEventListener('click', () => { + track.scrollBy({ left: window.innerWidth * 0.7, behavior: 'smooth' }); + }); + + btnLeft.addEventListener('click', () => { + track.scrollBy({ left: -window.innerWidth * 0.7, behavior: 'smooth' }); + }); + + return container; + }; + + // Create rows + for (let i = 0; i < videos.length; i += rowSize) { + const chunk = videos.slice(i, i + rowSize); + // Avoid creating a tiny final row if it has very few items compared to rowSize, + // unless it's the only row. + if (i > 0 && chunk.length < 5) break; + + section.appendChild(createRow(chunk)); + } + + return section; + function setupInfiniteScrollTrigger() { + // If no more content, hide sentinel and don't set up observer + if (!state.hasMore) { + if (scrollSentinel) { + scrollSentinel.classList.remove('loading'); + scrollSentinel.style.display = 'none'; + } + if (scrollObserver) scrollObserver.disconnect(); + return; + } + + if (scrollObserver) scrollObserver.disconnect(); + + // Remove any existing sentinels first to prevent duplicates + document.querySelectorAll('.scroll-sentinel').forEach(el => el.remove()); + scrollSentinel = null; + + const options = { + root: null, + rootMargin: '50px', // Reduced from 200px to prevent early triggering + threshold: 0.0 // Trigger when any part is visible + }; + + scrollObserver = new IntersectionObserver((entries) => { + entries.forEach(entry => { + // Debounce: require at least 1.5 seconds between triggers + const now = Date.now(); + if (now - lastScrollTrigger < 1500) { + return; + } + if (entry.isIntersecting && !state.isLoading && state.hasMore) { + lastScrollTrigger = now; + // Show loading state on sentinel + if (scrollSentinel) scrollSentinel.classList.add('loading'); + loadVideos(state.currentCategory); + } + }); + }, options); + + // Create single sentinel element + scrollSentinel = document.createElement('div'); + scrollSentinel.className = 'scroll-sentinel'; + scrollSentinel.id = 'scrollSentinel'; + + // Place sentinel at the proper location - after infinite container or at end of videoGrid + const infiniteContainer = document.getElementById('infinite-scroll-container'); + if (infiniteContainer) { + // Insert after the infinite container for proper positioning + infiniteContainer.parentNode.insertBefore(scrollSentinel, infiniteContainer.nextSibling); + } else { + elements.videoGrid.appendChild(scrollSentinel); + } + + scrollObserver.observe(scrollSentinel); + } + + function handleShowInfo(video) { + + // Smart Recommendations: Filter by category/genre + let recommendations = state.videos.filter(v => + v.id !== video.id && + (v.category === video.category || v.resolution === video.resolution) + ); + + // Fallback if not enough matches + if (recommendations.length < 6) { + const remaining = state.videos.filter(v => v.id !== video.id && !recommendations.includes(v)); + recommendations = [...recommendations, ...remaining]; + } + + // Shuffle and slice + recommendations = recommendations.sort(() => Math.random() - 0.5).slice(0, 6); + + const modal = createInfoModal(video, (modalEl) => { + modalEl.classList.remove('active'); + setTimeout(() => modalEl.remove(), 400); + }, handleVideoPlay, recommendations); + + } +} + +/** + * Render hero section with featured content + */ + + +/** + * Handle video play action - Navigate to dedicated watch page + * @param {Object} video - Video object + */ +function handleVideoPlay(video) { + // Store video data in sessionStorage for the watch page + sessionStorage.setItem('currentVideo', JSON.stringify(video)); + + // Store all videos for recommendations + sessionStorage.setItem('allVideos', JSON.stringify(state.videos)); + + // Navigation to Watch => NOW INFO PAGE + navigateToWatch(video); +} + +function navigateToWatch(video) { + window.location.href = `/watch.html?slug=${video.slug}`; +} + + +/** + * Load specific episode with server + */ +async function loadEpisode(video, episode, server) { + try { + let streamUrl = null; + let poster = video.thumbnail; + + // Check if this is a PhimMoiChill movie + const isPhimMoiChill = video.source_url && ( + video.source_url.includes('royalcanalbikehire') || + video.source_url.includes('phimmoichill') || + video.source_url.includes('/phim/') || + video.slug + ); + + if (isPhimMoiChill) { + showToast('Loading stream...', 'info'); + try { + const streamData = await api.getRophimStreamByUrl(video.source_url, video.slug, episode, server); + if (streamData && streamData.stream_url) { + streamUrl = streamData.stream_url; + } + } catch (phimmoiError) { + console.warn('PhimMoiChill stream extraction failed:', phimmoiError.message); + } + } + + // Fallback: try yt-dlp extraction + if (!streamUrl && video.source_url) { + try { + const extraction = await api.extractVideo(video.source_url); + if (extraction && extraction.stream_url) { + streamUrl = extraction.stream_url; + poster = extraction.thumbnail || poster; + } + } catch (extractError) { + console.warn('Extraction failed:', extractError.message); + } + } + + // Final fallback: use source_url directly + if (!streamUrl && video.source_url) { + if (video.source_url.match(/\.(mp4|m3u8|webm)(\?|$)/i)) { + streamUrl = video.source_url; + } + } + + if (streamUrl) { + const isEmbedUrl = streamUrl.includes('goatembed') || + streamUrl.includes('/embed/') || + streamUrl.includes('player.') || + (streamUrl.includes('embed') && !streamUrl.match(/\.(mp4|m3u8|webm)/i)); + + if (isEmbedUrl) { + elements.playerContainer.innerHTML = ` +
    + +
    + `; + } else { + const art = initPlayer(elements.playerContainer, { + url: streamUrl, + poster: poster, + title: video.title, + autoplay: true + }); + + if (art && window.historyService) { + art.on('video:timeupdate', () => { + const currentTime = art.currentTime; + const duration = art.duration; + if (currentTime > 0 && duration > 0 && Math.floor(currentTime) % 5 === 0) { + window.historyService.addToHistory(video, { + currentTime, + duration, + percentage: (currentTime / duration) * 100, + episode: 1 // Default to 1 for modal player + }); + } + }); + } + } + showToast('Playing...', 'success'); + } else { + throw new Error('Không tìm thấy nguồn phát phim'); + } + + } catch (error) { + console.error('Video playback failed:', error); + showToast(`Lỗi: ${error.message}`, 'error'); + elements.playerContainer.innerHTML = ` +
    + + + +

    Cannot load video

    +

    ${video.title}

    + +
    + `; + } +} + + +/** + * Close player modal + */ +function closePlayerModal() { + elements.playerModal.classList.remove('active'); + destroyPlayer(); + elements.playerContainer.innerHTML = ''; + state.currentVideo = null; +} + +/** + * Close add video modal + */ +function closeAddModal() { + elements.addVideoModal.classList.remove('active'); + elements.addVideoForm.reset(); +} + +/** + * Handle add video form submission + * @param {Event} e - Form submit event + */ +async function handleAddVideo(e) { + e.preventDefault(); + + const url = document.getElementById('videoUrl').value; + const title = document.getElementById('videoTitle').value; + const category = document.getElementById('videoCategory').value; + + try { + showToast('Extracting video info...', 'info'); + + // First extract to get metadata + const extraction = await api.extractVideo(url); + + // Add to library + await api.addVideo({ + title: title || extraction.title, + source_url: url, + thumbnail: extraction.thumbnail, + category: category || null + }); + + showToast('Video added successfully!', 'success'); + closeAddModal(); + + // Reload videos + await loadVideos(state.currentCategory); + + } catch (error) { + console.error('Failed to add video:', error); + showToast(`Failed to add video: ${error.message}`, 'error'); + } +} + +/** + * Set active category tab + * @param {string} category - Category to activate + */ +function setActiveCategory(category) { + state.currentCategory = category; + + elements.categories.querySelectorAll('.category').forEach(btn => { + btn.classList.toggle('category--active', btn.dataset.category === category); + }); +} + +/** + * Show/hide loading indicator + * @param {boolean} show - Whether to show loading + */ +function showLoading(show) { + if (elements.loading) { + elements.loading.style.display = show ? 'flex' : 'none'; + } + // Support both old videoGrid and new mainContent layouts + if (elements.videoGrid) { + elements.videoGrid.style.display = show ? 'none' : 'block'; + } +} + +/** + * Render organized category view based on view type + * Netflix-style: Multiple horizontal slider sections per view + * @param {string} viewType - 'home', 'series', 'movies', or 'cinema' + */ +async function renderCategoryView(viewType) { + // Cleanup History Tabs if they exist + const historyTabs = document.querySelector('.view-tabs'); + if (historyTabs) historyTabs.remove(); + + if (elements.mainHeader) elements.mainHeader.style.display = ''; + showLoading(true); + elements.videoGrid.innerHTML = ''; + elements.videoGrid.className = 'space-y-12'; + + // Section configurations per view type (2 rows per category) + const sectionConfigs = { + home: [ + { title: 'Continue Watching', type: 'history', limit: 12, cardType: 'landscape' }, + { title: 'Cinema Releases', category: 'phim-chieu-rap', limit: 12, isHeroSource: true }, + { title: 'Top Rated', category: 'phim-le', sort: 'rating', limit: 12 }, + { title: 'Action & Adventure', category: 'hanh-dong', limit: 12 }, + { title: 'Animation', category: 'hoat-hinh', limit: 12 }, + { title: 'Korean Hits', category: 'han-quoc', limit: 12 }, + { title: 'Horror & Thriller', category: 'kinh-di', limit: 12 }, + { title: 'Romance', category: 'tinh-cam', limit: 12 }, + ], + series: [ + { title: 'Popular TV Shows', category: 'phim-bo', limit: 12, isHeroSource: true }, + { title: 'Korean Dramas', category: 'korean', limit: 12 }, + { title: 'Chinese Dramas', category: 'china', limit: 12 }, + { title: 'Anime Series', category: 'hoat-hinh', limit: 12 }, + { title: 'Documentaries', category: 'tai-lieu', limit: 12 }, + ], + movies: [ + { title: 'Blockbuster Movies', category: 'phim-le', sort: 'year', limit: 12, isHeroSource: true }, + { title: 'Action & Adventure', category: 'action', limit: 12 }, + { title: 'Comedy Films', category: 'comedy', limit: 12 }, + { title: 'Cinema Releases', category: 'phim-chieu-rap', limit: 12 }, + { title: 'Horror Movies', category: 'kinh-di', limit: 12 }, + { title: 'Sci-Fi & Fantasy', category: 'vien-tuong', limit: 12 }, + ], + cinema: [ + { title: 'Now Showing', category: 'phim-chieu-rap', limit: 12, isHeroSource: true }, + { title: 'New Releases', category: 'phim-le', sort: 'year', limit: 12 }, + { title: 'Top Rated', category: 'phim-le', sort: 'rating', limit: 12 }, + { title: 'Action Blockbusters', category: 'action', limit: 12 }, + { title: 'Animated Features', category: 'hoat-hinh', limit: 12 }, + ] + }; + + const sections = sectionConfigs[viewType] || sectionConfigs.home; + + // Check sessionStorage for cached view (Home/Cinema only to keep it fresh) + if (viewType === 'home' || viewType === 'cinema') { + const cachedHTML = sessionStorage.getItem(`view_cache_${viewType}`); + if (cachedHTML) { + elements.videoGrid.innerHTML = cachedHTML; + showLoading(false); + if (elements.heroContainer) elements.heroContainer.style.display = ''; + if (elements.videoGrid.children.length > 0) return; + } + } + + // Lazy loading configuration + const EAGER_LOAD_COUNT = 3; // Load first 3 sections immediately + + try { + let firstAvailableMovies = null; + + // Render eager sections immediately + for (let i = 0; i < Math.min(EAGER_LOAD_COUNT, sections.length); i++) { + const sectionConfig = sections[i]; + const movies = await fetchSectionMovies(sectionConfig); + if (movies && movies.length > 0) { + if (!firstAvailableMovies) { + firstAvailableMovies = movies; + } + + // Set featured video for hero banner from first valid section + if (sectionConfig.isHeroSource && (!state.heroMovies || state.heroMovies.length === 0) && movies.length > 0) { + state.heroMovies = movies.slice(0, 10); + state.featuredVideo = movies[0]; + state.videos = movies; + state.currentHeroIndex = 0; + renderHero(state.heroMovies[0]); + startHeroCarousel(); + } + + const sliderSection = createSliderSection(sectionConfig.title, movies, sectionConfig.cardType || 'poster'); + elements.videoGrid.appendChild(sliderSection); + } + } + + // Cache the eager sections + if (viewType === 'home' || viewType === 'cinema') { + sessionStorage.setItem(`view_cache_${viewType}`, elements.videoGrid.innerHTML); + } + + // Create lazy-load placeholders for remaining sections + const lazyObserver = new IntersectionObserver(async (entries, observer) => { + for (const entry of entries) { + if (entry.isIntersecting) { + const placeholder = entry.target; + const configIndex = parseInt(placeholder.dataset.configIndex); + const sectionConfig = sections[configIndex]; + + observer.unobserve(placeholder); + + // Show loading indicator + placeholder.innerHTML = '
    '; + + const movies = await fetchSectionMovies(sectionConfig); + if (movies && movies.length > 0) { + const sliderSection = createSliderSection(sectionConfig.title, movies, sectionConfig.cardType || 'poster'); + placeholder.replaceWith(sliderSection); + + // Update cache as we load more + if (viewType === 'home' || viewType === 'cinema') { + sessionStorage.setItem(`view_cache_${viewType}`, elements.videoGrid.innerHTML); + } + } else { + placeholder.remove(); + } + } + } + }, { rootMargin: '800px' }); + + // Add placeholders for lazy sections + for (let i = EAGER_LOAD_COUNT; i < sections.length; i++) { + const placeholder = document.createElement('div'); + placeholder.className = 'lazy-section-placeholder h-32 mb-12'; + placeholder.dataset.configIndex = i; + placeholder.innerHTML = `

    ${sections[i].title}

    `; + elements.videoGrid.appendChild(placeholder); + lazyObserver.observe(placeholder); + } + + // Fallback: If hero is still empty, use first available content + + if (!state.featuredVideo) { + if (firstAvailableMovies && firstAvailableMovies.length > 0) { + state.featuredVideo = firstAvailableMovies[0]; + state.videos = firstAvailableMovies; + renderHero(); + } else { + // Absolute final fallback: Demo content to prevent broken UI + try { + const demo = getDemoContent(); + if (demo && demo.length > 0) { + state.featuredVideo = demo[0]; + state.videos = demo; + renderHero(); + } + } catch (e) { console.warn('Demo content fallback failed', e); } + } + } + + // If no sections were rendered, show a message + if (elements.videoGrid.children.length === 0) { + elements.videoGrid.innerHTML = ` +
    + movie +

    No content available for this category

    +
    + `; + } + } catch (error) { + console.error('Error rendering category view:', error); + elements.videoGrid.innerHTML = ` +
    + error +

    Failed to load content. Please try again.

    +
    + `; + } + + showLoading(false); +} + +/** + * Fetch movies for a specific section configuration + * @param {Object} config - Section configuration + * @returns {Array} Array of movies + */ +async function fetchSectionMovies(config) { + try { + // Handle history section (Continue Watching) + if (config.type === 'history') { + if (window.historyService) { + const history = window.historyService.getHistory(); + return history.slice(0, config.limit).map(m => ({ + id: m.slug || m.id, + title: m.title, + thumbnail: m.thumbnail || m.poster_url, + slug: m.slug, + year: m.year, + quality: m.quality || 'HD', + view_progress: m.view_progress || 0 // Ensure progress + })); + } + return []; + } + + // Build Base API request parameters + const baseParams = { + category: config.category || null, + limit: config.limit || 40, + sort: config.sort || 'year' + }; + + if (config.country) baseParams.country = config.country; + if (config.genre) baseParams.genre = config.genre; + + // Strategy: Aggressive Fetching (Pages 1-8) + // Some categories with specific sorts (like year) might have broken pagination or limited data. + // We fetch many pages to maximize chance of filling the grid. + const fetchPages = async (params) => { + const promises = [1, 2, 3, 4, 5, 6, 7, 8].map(page => + api.getRophimCatalog({ ...params, page }) + .catch(e => ({ movies: [] })) + ); + const res = await Promise.all(promises); + return res.flatMap(r => r.movies || []); + }; + + let rawMovies = await fetchPages(baseParams); + + // Fallback Strategy: If specific sort yielded too few results (< 20), + // try fetching with default sort ('modified') to fill the grid. + if (rawMovies.length < 20 && config.sort && config.sort !== 'modified') { + const fallbackMovies = await fetchPages({ ...baseParams, sort: 'modified' }); + rawMovies = [...rawMovies, ...fallbackMovies]; + } + + // Deduplicate and Format + const allMovies = []; + const seenIds = new Set(); + + for (const m of rawMovies) { + if (!m) continue; + const id = m.slug || m.id; + if (!seenIds.has(id)) { + seenIds.add(id); + allMovies.push({ + id: m.id || m.slug, + title: m.title, + thumbnail: m.thumbnail, + poster_url: m.poster_url || m.thumbnail, + backdrop: m.backdrop || m.poster_url || m.thumbnail, + slug: m.slug, + year: m.year, + quality: m.quality || 'HD', + rating: m.rating, + category: m.category + }); + } + } + + // Return up to limit (ensure we don't return too many if we over-fetched) + // But also return enough to fill 6 rows if possible! + const limit = Math.max(config.limit || 40, 48); + return allMovies.slice(0, limit); + } catch (error) { + console.error(`Error fetching section "${config.title}":`, error); + return []; + } +} + +// Initialize app when DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + init(); +} +/** + * Get high-fidelity demo content for Netflix 2025 layout + */ +/** + * Get high-fidelity demo content for Netflix 2025 layout + */ +/** + * Get high-fidelity demo content for Netflix 2025 layout + */ +/** + * Get high-fidelity demo content for Netflix 2025 layout + */ +function getDemoContent() { + const SAMPLE_MP4 = 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4'; + + // Unsplash Thematic Placeholders for Offline Mode + const IMAGES = { + VENOM: 'https://image.tmdb.org/t/p/w500/aosm8NMQ3UyoBVpSxyimorCQykC.jpg', // TMDB Verified + SQUID: 'https://images.unsplash.com/photo-1536440136628-849c177e76a1?w=800&auto=format&fit=crop', // Red/Triangles + ARCANE: 'https://images.unsplash.com/photo-1542751371-adc38448a05e?w=800&auto=format&fit=crop', // Neon/Cyberpunk + PENGUIN: 'https://images.unsplash.com/photo-1478720568477-152d9b164e63?w=800&auto=format&fit=crop', // Rainy City + GLADIATOR: 'https://images.unsplash.com/photo-1565060416-522204c35613?w=800&auto=format&fit=crop', // Colosseum + MOANA: 'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?w=800&auto=format&fit=crop', // Ocean + WICKED: 'https://images.unsplash.com/photo-1518709268805-4e9042af9f23?w=800&auto=format&fit=crop', // Green/Magic + DBZ: 'https://images.unsplash.com/photo-1578632767115-351597cf2477?w=800&auto=format&fit=crop' // Anime/Fire + }; + + return [ + { + id: 'd1', + title: 'Venom: The Last Dance', + thumbnail: IMAGES.VENOM, + backdrop: 'https://image.tmdb.org/t/p/original/3V4kLQg0kSqPLctI5ziYWabAZYF.jpg', + preview_url: SAMPLE_MP4, + duration: 7200, + resolution: '4K', + category: 'action', + year: 2024, + matchScore: 98, + director: 'Kelly Marcel', + country: 'USA', + cast: ['Tom Hardy', 'Chiwetel Ejiofor', 'Juno Temple'], + description: 'Eddie and Venom are on the run. Hunted by both of their worlds and with the net closing in, the duo are forced into a devastating decision.', + episodes: [] + }, + { + id: 'd2', + title: 'Squid Game Season 2', + thumbnail: IMAGES.SQUID, + backdrop: IMAGES.SQUID, + preview_url: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4', + duration: 3600, + resolution: 'HD', + category: 'series', + year: 2024, + matchScore: 99, + director: 'Hwang Dong-hyuk', + country: 'Korea', + cast: ['Lee Jung-jae', 'Lee Byung-hun', 'Wi Ha-jun'], + description: 'Gi-hun returns to the death games after three years with a new resolution: to find the people behind and to put an end to the sport.', + episodes: [ + { number: 1, title: 'Red Light, Green Light', url: SAMPLE_MP4 }, + { number: 2, title: 'The Man with the Umbrella', url: SAMPLE_MP4 }, + { number: 3, title: 'Stick to the Team', url: SAMPLE_MP4 } + ] + }, + { + id: 'd3', + title: 'Arcane Season 2', + thumbnail: IMAGES.ARCANE, + backdrop: IMAGES.ARCANE, // Use high-res version normally + preview_url: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4', + duration: 2400, + resolution: '4K', + category: 'anime', + year: 2024, + matchScore: 97, + director: 'Christian Linke', + country: 'USA, France', + cast: ['Hailee Steinfeld', 'Ella Purnell', 'Katie Leung'], + description: 'As conflict between Piltover and Zaun reaches a boiling point, Jinx and Vi must decide what kind of future they are fighting for.', + episodes: [ + { number: 1, title: 'Heavy Is the Crown', url: SAMPLE_MP4 }, + { number: 2, title: 'Watch It All Burn', url: SAMPLE_MP4 }, + { number: 3, title: 'Finally Got It Right', url: SAMPLE_MP4 } + ] + }, + { + id: 'd4', + title: 'The Penguin', + thumbnail: IMAGES.PENGUIN, + backdrop: IMAGES.PENGUIN, + preview_url: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4', + duration: 3600, + resolution: 'HD', + category: 'series', + year: 2024, + matchScore: 95, + director: 'Craig Zobel', + country: 'USA', + cast: ['Colin Farrell', 'Cristin Milioti', 'Rhenzy Feliz'], + description: 'Following the events of The Batman, Oz Cobb makes a play for power in the underworld of Gotham City.', + episodes: [] + }, + { + id: 'd5', + title: 'Gladiator II', + thumbnail: IMAGES.GLADIATOR, + backdrop: IMAGES.GLADIATOR, + preview_url: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerFun.mp4', + duration: 8400, + resolution: '4K', + category: 'action', + year: 2024, + matchScore: 96, + director: 'Ridley Scott', + country: 'USA, UK', + cast: ['Paul Mescal', 'Pedro Pascal', 'Denzel Washington'], + description: 'Years after witnessing the death of the revered hero Maximus at the hands of his uncle, Lucius is forced to enter the Colosseum.', + episodes: [] + }, + { + id: 'd6', + title: 'Moana 2', + thumbnail: IMAGES.MOANA, + backdrop: IMAGES.MOANA, + preview_url: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4', + duration: 6000, + resolution: 'HD', + category: 'theater', + year: 2024, + matchScore: 94, + director: 'David G. Derrick Jr.', + country: 'USA', + cast: ['Auliʻi Cravalho', 'Dwayne Johnson', 'Alan Tudyk'], + description: 'After receiving an unexpected call from her wayfinding ancestors, Moana must journey to the far seas of Oceania.', + episodes: [] + }, + { + id: 'd7', + title: 'Wicked', + thumbnail: IMAGES.WICKED, + backdrop: IMAGES.WICKED, + preview_url: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerMeltdowns.mp4', + duration: 9000, + resolution: '4K', + category: 'theater', + year: 2024, + matchScore: 93, + director: 'Jon M. Chu', + country: 'USA', + cast: ['Cynthia Erivo', 'Ariana Grande', 'Jeff Goldblum'], + description: 'Elphaba, a misunderstood young woman with green skin, and Glinda, a popular blonde, forge an unlikely friendship.', + episodes: [] + }, + { + id: 'd8', + title: 'Dragon Ball Daima', + thumbnail: IMAGES.DBZ, + backdrop: IMAGES.DBZ, + preview_url: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4', + duration: 1440, + resolution: 'HD', + category: 'anime', + year: 2024, + matchScore: 98, + director: 'Yoshitaka Yashima', + country: 'Japan', + cast: ['Masako Nozawa', 'Ryō Horikawa'], + description: 'Goku and his friends are turned small due to a conspiracy. To fix things, they head off to a new world.', + episodes: [ + { number: 1, title: 'Conspiracy', url: SAMPLE_MP4 } + ] + } + ]; +} + +/** + * Render Category Shortcuts (Horizontal Slider of Cards) + */ +function renderCategoryShortcuts() { + const shortcuts = [ + { title: 'Phim Hot', sub: '(Movies)', tag: 'Phim Hot' }, + { title: 'Phim Bộ Mới', sub: '(Series)', tag: 'Phim Bộ Mới' }, + { title: 'Hoạt Hình & Anime', sub: '(Animation)', tag: 'Hoạt Hình' }, + { title: 'Phim Việt Nam', sub: '(Local)', tag: 'Phim Việt Nam' } + ]; + + const section = document.createElement('section'); + section.className = 'category-shortcuts-section scrollbar-hide'; + // Style handled in CSS + + const track = document.createElement('div'); + track.className = 'category-shortcuts-track'; + + shortcuts.forEach(item => { + const card = document.createElement('div'); + card.className = 'shortcut-card'; + card.innerHTML = ` +

    ${item.title}

    + ${item.sub} +
    + `; + + card.addEventListener('click', () => { + // Scroll to section logic + const titles = Array.from(document.querySelectorAll('.section-title-apple, .section-banner__title')); + const target = titles.find(t => t.textContent.includes(item.tag)); + if (target) { + target.closest('section')?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } else { + console.warn("Section not found:", item.tag); + } + }); + track.appendChild(card); + }); + + section.appendChild(track); + return section; +} +/** + * Render Profile View - Mobile first profile screen + */ +function renderProfileView() { + // Show standard header and hero section + if (elements.mainHeader) elements.mainHeader.style.display = ''; + const heroContainer = document.getElementById('heroContainer'); + if (heroContainer) { + heroContainer.style.display = ''; + renderHero(); + } + + // Update bottom nav active state (profile is not in nav, so none will be active) + setMobileNavActive('profile'); + + // Clear content + elements.videoGrid.innerHTML = ''; + elements.videoGrid.className = 'profile-view pb-24 bg-background-light dark:bg-background-dark min-h-screen'; + + // HTML Structure based on user example + const profileHTML = ` + +
    + +

    Profile

    + +
    + +
    + +
    +
    +
    +
    +
    + edit +
    +
    +

    Isabella Hall

    + +
    + + +
    +
    +

    42

    +

    Movies

    +
    +
    +

    128h

    +

    Streamed

    +
    +
    +

    15

    +

    Reviews

    +
    +
    + + +
    + + + + + +
    + +

    Version 4.12.0

    +
    +
    + `; + + elements.videoGrid.innerHTML = profileHTML; + + // Inject history + if (window.historyService) { + const historyItems = window.historyService.getHistory().slice(0, 10); + if (historyItems.length > 0) { + const historyContainer = document.getElementById('profileHistoryContainer'); + // Re-use createSliderSection. Note: it has padding baked in from previous task. + // We might want to ensure it looks good here. + const slider = createSliderSection('Continue Watching', historyItems, 'landscape'); + historyContainer.appendChild(slider); + } + } +} + +/** + * Render Home View Wrapper + */ +async function renderHome() { + if (elements.mainHeader) elements.mainHeader.style.display = ''; + + // Show hero section + const heroContainer = document.getElementById('heroContainer'); + if (heroContainer) heroContainer.style.display = ''; + + // Update bottom nav + setMobileNavActive('home'); + + // Hide footer on mobile + if (window.innerWidth < 768) { + document.querySelectorAll('footer').forEach(f => f.style.display = 'none'); + const searchModal = document.getElementById('searchModal'); + if (searchModal) searchModal.classList.remove('active'); + } else { + document.querySelectorAll('footer').forEach(f => f.style.display = ''); + } + + await renderCategoryView('home'); +} + +/** + * Render Mobile Search View + */ +async function renderMobileSearch() { + // Show standard header and hero section + if (elements.mainHeader) elements.mainHeader.style.display = ''; + const heroContainer = document.getElementById('heroContainer'); + if (heroContainer) { + heroContainer.style.display = ''; + renderHero(); // Ensure hero is populated + } + + // Hide all footers on mobile + document.querySelectorAll('footer').forEach(f => f.style.display = 'none'); + + // Explicitly hide search modal/popup if it somehow triggered + const searchModal = document.getElementById('searchModal'); + if (searchModal) searchModal.classList.remove('active'); + + // Update bottom nav + setMobileNavActive('search'); + + // Clear content - leave room for fixed bottom nav (80px = nav height + safe area) + elements.videoGrid.innerHTML = ''; + elements.videoGrid.className = 'mobile-search-view bg-background-light dark:bg-background-dark'; + + // HTML Structure based on user example + const searchHTML = ` + +
    +
    +
    +
    + search +
    + +
    + mic +
    +
    + +
    + +
    + + + + + +
    +
    + + +
    +
    +

    Top Searches

    +
    +
    + +
    +

    Recommended for You

    +
    +
    +
    + `; + + elements.videoGrid.innerHTML = searchHTML; + + // Wire up mobile search input with proper API search + const mobileInput = document.getElementById('mobileSearchInput'); + const resultsContainer = document.getElementById('mobileSearchResults'); + let searchTimeout = null; + + if (mobileInput && resultsContainer) { + mobileInput.addEventListener('input', (e) => { + clearTimeout(searchTimeout); + const query = e.target.value.trim(); + + searchTimeout = setTimeout(async () => { + if (query.length < 2) { + // Show default content (top searches, recommended) + return; + } + + // Show loading + resultsContainer.innerHTML = '
    '; + + try { + const response = await api.searchRophim(query); + + if (response && response.movies && response.movies.length > 0) { + resultsContainer.innerHTML = ` +

    Results for "${query}"

    +
    + `; + const grid = resultsContainer.querySelector('.grid'); + response.movies.forEach(movie => { + const card = document.createElement('div'); + card.className = 'relative group aspect-[2/3] overflow-hidden rounded-lg cursor-pointer'; + card.innerHTML = ` +
    +
    +
    +

    ${movie.title}

    +
    +
    + `; + card.addEventListener('click', () => handleVideoPlay(movie)); + grid.appendChild(card); + }); + } else { + resultsContainer.innerHTML = ` +
    + search_off +

    No results for "${query}"

    +
    + `; + } + } catch (error) { + console.error('Mobile search failed:', error); + resultsContainer.innerHTML = '
    Search failed. Try again.
    '; + } + }, 300); + }); + + // Focus input automatically + mobileInput.focus(); + } + + // Cancel button clears search and restores default content + const cancelBtn = document.getElementById('mobileSearchCancel'); + if (cancelBtn) { + cancelBtn.addEventListener('click', () => { + const input = document.getElementById('mobileSearchInput'); + if (input) { + input.value = ''; + input.focus(); + } + // Re-render the mobile search view to restore default content + renderMobileSearch(); + }); + } + // Populate Top Searches (Trending) + try { + const trending = await api.getRophimCatalog({ category: 'trending', limit: 5 }); + if (trending && trending.movies) { + const container = document.getElementById('topSearchesList'); + trending.movies.forEach(movie => { + const el = document.createElement('div'); + el.className = 'group flex items-center gap-3 px-4 py-2 hover:bg-gray-100 dark:hover:bg-white/5 cursor-pointer transition-colors'; + el.innerHTML = ` +
    +
    +
    +
    +

    ${movie.title}

    +

    ${movie.year || '2024'}

    +
    +
    + play_circle +
    + `; + el.addEventListener('click', () => handleVideoPlay(movie)); + container.appendChild(el); + }); + } + + // Populate Recommended + const recommended = await api.getRophimCatalog({ category: 'phim-le', limit: 9 }); + if (recommended && recommended.movies) { + const grid = document.getElementById('recommendedGrid'); + recommended.movies.forEach(movie => { + const card = document.createElement('div'); + card.className = 'relative group aspect-[2/3] overflow-hidden rounded-lg cursor-pointer'; + card.innerHTML = ` +
    + `; + card.addEventListener('click', () => handleVideoPlay(movie)); + grid.appendChild(card); + }); + } + + } catch (e) { + console.error('Failed to load mobile search content', e); + } + + // Set up genre filter chip click handlers + const filterChips = document.querySelectorAll('.search-chip'); + filterChips.forEach(chip => { + chip.addEventListener('click', async () => { + const genre = chip.dataset.genre; + if (!genre) return; + + // Update active chip styling + filterChips.forEach(c => { + c.classList.remove('active', 'bg-white', 'text-black'); + c.classList.add('bg-gray-200', 'dark:bg-surface-dark'); + const p = c.querySelector('p'); + if (p) { + p.classList.remove('font-bold'); + p.classList.add('font-medium', 'text-slate-700', 'dark:text-gray-300'); + } + }); + chip.classList.add('active', 'bg-white', 'text-black'); + chip.classList.remove('bg-gray-200', 'dark:bg-surface-dark'); + const chipP = chip.querySelector('p'); + if (chipP) { + chipP.classList.add('font-bold'); + chipP.classList.remove('font-medium', 'text-slate-700', 'dark:text-gray-300'); + } + + // Fetch and display genre content + const resultsContainer = document.getElementById('mobileSearchResults'); + if (resultsContainer) { + resultsContainer.innerHTML = '
    '; + + try { + const response = await api.getRophimCatalog({ category: genre, limit: 12 }); + if (response && response.movies && response.movies.length > 0) { + const chipName = chip.querySelector('p')?.textContent || genre; + resultsContainer.innerHTML = ` +

    ${chipName}

    +
    + `; + const grid = resultsContainer.querySelector('.grid'); + response.movies.forEach(movie => { + const card = document.createElement('div'); + card.className = 'relative group aspect-[2/3] overflow-hidden rounded-lg cursor-pointer'; + card.innerHTML = ` +
    + `; + card.addEventListener('click', () => handleVideoPlay(movie)); + grid.appendChild(card); + }); + } else { + resultsContainer.innerHTML = '

    No results found

    '; + } + } catch (e) { + console.error('Genre filter error:', e); + resultsContainer.innerHTML = '

    Failed to load content

    '; + } + } + }); + }); +} + +/** + * Render Mobile My List View - Netflix-style grid layout + */ +async function renderMobileMyList() { + // Show standard header and hero + if (elements.mainHeader) elements.mainHeader.style.display = ''; + const heroContainer = document.getElementById('heroContainer'); + if (heroContainer) { + heroContainer.style.display = ''; + renderHero(); + } + + // Hide all footers on mobile + document.querySelectorAll('footer').forEach(f => f.style.display = 'none'); + + // Explicitly hide search modal/popup + const searchModal = document.getElementById('searchModal'); + if (searchModal) searchModal.classList.remove('active'); + + // Update nav active state + setMobileNavActive('mylist'); + + // Get saved items + const items = window.historyService ? window.historyService.getFavorites() : []; + + elements.videoGrid.innerHTML = ''; + elements.videoGrid.className = 'mobile-mylist-view min-h-screen bg-background-dark pb-24'; + + const mylistHTML = ` + +
    +
    +

    My List

    + +
    + +
    + + + + +
    +
    + + +
    +
    +
    + `; + + elements.videoGrid.innerHTML = mylistHTML; + + // Populate grid with saved items or fallback content + const grid = document.getElementById('mylistGrid'); + + if (items.length > 0) { + items.forEach(movie => { + const card = document.createElement('div'); + card.className = 'group relative flex flex-col gap-2 cursor-pointer'; + card.innerHTML = ` +
    +
    +
    +
    + `; + card.addEventListener('click', () => handleVideoPlay(movie)); + grid.appendChild(card); + }); + } else { + // Load trending as placeholder + try { + const trending = await api.getRophimCatalog({ category: 'trending', limit: 12 }); + if (trending && trending.movies) { + trending.movies.forEach((movie, index) => { + const card = document.createElement('div'); + card.className = 'group relative flex flex-col gap-2 cursor-pointer'; + card.innerHTML = ` +
    +
    + ${index === 0 ? '
    New
    ' : ''} +
    +
    + `; + card.addEventListener('click', () => handleVideoPlay(movie)); + grid.appendChild(card); + }); + } + } catch (e) { + console.error('Failed to load my list content', e); + } + } + + // Set up My List filter chip click handlers + const mylistChips = document.querySelectorAll('.mylist-chip'); + mylistChips.forEach(chip => { + chip.addEventListener('click', async () => { + const filter = chip.dataset.filter; + const category = chip.dataset.category; + if (!filter || !category) return; + + // Update active chip styling + mylistChips.forEach(c => { + c.classList.remove('active', 'bg-white'); + c.classList.add('bg-surface-dark'); + const p = c.querySelector('p'); + if (p) { + p.classList.remove('font-bold', 'text-black'); + p.classList.add('font-medium', 'text-gray-200'); + } + }); + chip.classList.add('active', 'bg-white'); + chip.classList.remove('bg-surface-dark'); + const chipP = chip.querySelector('p'); + if (chipP) { + chipP.classList.add('font-bold', 'text-black'); + chipP.classList.remove('font-medium', 'text-gray-200'); + } + + // Fetch and display filtered content + const grid = document.getElementById('mylistGrid'); + if (grid) { + grid.innerHTML = '
    '; + + try { + const response = await api.getRophimCatalog({ category: category, limit: 12 }); + grid.innerHTML = ''; + if (response && response.movies && response.movies.length > 0) { + response.movies.forEach((movie, index) => { + const card = document.createElement('div'); + card.className = 'group relative flex flex-col gap-2 cursor-pointer'; + card.innerHTML = ` +
    +
    + ${index === 0 ? '
    New
    ' : ''} +
    +
    + `; + card.addEventListener('click', () => handleVideoPlay(movie)); + grid.appendChild(card); + }); + } else { + grid.innerHTML = '

    No content found

    '; + } + } catch (e) { + console.error('Filter error:', e); + grid.innerHTML = '

    Failed to load content

    '; + } + } + }); + }); +} diff --git a/frontend/scripts/search.js b/frontend/scripts/search.js new file mode 100644 index 0000000..1f1b632 --- /dev/null +++ b/frontend/scripts/search.js @@ -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 ` +
    +
    +
    + ${movie.title} +
    +
    +
    +

    ${movie.title}

    +
    + ${movie.year || ''} + ${movie.quality ? `${movie.quality}` : ''} +
    +
    +
    +
    +
    + `; + }).join(''); + } else { + // No results + searchGrid.innerHTML = ` +
    + + + +

    No results found for "${query}"

    +
    + `; + } + } catch (error) { + console.error('Search failed:', error); + searchLoading.style.display = 'none'; + searchGrid.innerHTML = ` +
    +

    Search failed. Please try again.

    +
    + `; + } +} + +/** + * 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(); +} diff --git a/frontend/scripts/services/imageCache.js b/frontend/scripts/services/imageCache.js new file mode 100644 index 0000000..f22c045 --- /dev/null +++ b/frontend/scripts/services/imageCache.js @@ -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} - 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; diff --git a/frontend/scripts/watch.js b/frontend/scripts/watch.js new file mode 100644 index 0000000..03b3f73 --- /dev/null +++ b/frontend/scripts/watch.js @@ -0,0 +1,1076 @@ +/** + * KV-Stream Watch Page + * Handles video playback, episode navigation, and recommendations + */ + +import { api } from './api.js'; +import { showToast } from './components/Toast.js'; +import { initPlayer, destroyPlayer } from './components/VideoPlayer.js'; + +// Page State +const state = { + video: null, + currentEpisode: 1, + currentServer: 0, + recommendations: [], + isLoading: true +}; + +// Expose state for debugging +window.state = state; + +// DOM Elements - Resolved at runtime for robustness +let elements = {}; + +function initElements() { + elements = { + // Video player + videoPlayer: document.getElementById('videoPlayer'), + videoPlayerContainer: document.getElementById('videoPlayerContainer'), + playerLoading: document.getElementById('playerLoading'), + closePlayer: document.getElementById('closePlayer'), + + // Hero section (Desktop) + heroBg: document.getElementById('heroBg'), + movieTitle: document.getElementById('movieTitleDesktop'), + movieMatch: document.getElementById('movieMatchDesktop'), + movieYear: document.getElementById('movieYearDesktop'), + movieRating: document.getElementById('movieRatingDesktop'), + movieQuality: document.getElementById('movieQualityDesktop'), + movieDescription: document.getElementById('movieDescriptionDesktop'), + movieTags: document.getElementById('movieTags'), + + // Mobile Elements + movieTitleMobile: document.getElementById('movieTitleMobile'), + movieMatchMobile: document.getElementById('movieMatchMobile'), + movieYearMobile: document.getElementById('movieYearMobile'), + movieRatingMobile: document.getElementById('movieRatingMobile'), + movieDuration: document.getElementById('movieDurationDesktop'), // Added this + movieDurationMobile: document.getElementById('movieDurationMobile'), + movieQualityMobile: document.getElementById('movieQualityMobile'), + movieDescriptionMobile: document.getElementById('movieDescriptionMobile'), + + // Action buttons + playBtn: document.getElementById('playBtnDesktop'), + addListBtn: document.getElementById('addListBtnDesktop'), + addListIcon: document.getElementById('addListBtnDesktop')?.querySelector('.material-symbols-outlined'), + addListText: document.getElementById('addListBtnDesktop')?.querySelector('span:last-child'), + playBtnMobile: document.getElementById('playBtnMobile'), + addListBtnMobile: document.getElementById('addListBtnMobile'), + shareBtnMobile: document.getElementById('shareBtnMobile'), + mobilePlayBtn: document.getElementById('mobilePlayBtn'), + + // Navigation + watchHeader: document.getElementById('watchHeader'), + tabNav: document.getElementById('tabNav'), + watchBackBtn: document.getElementById('watchBackBtn'), + + // Panels + episodesPanel: document.getElementById('episodesPanel'), + trailersPanel: document.getElementById('trailersPanel'), + detailsPanel: document.getElementById('detailsPanel'), + + // Content + seasonSelect: document.getElementById('seasonSelect'), + seasonSelectContainer: document.getElementById('seasonSelectContainer'), + episodeCount: document.getElementById('episodeCount'), + episodesGrid: document.getElementById('episodesGrid'), + episodesLoading: document.getElementById('episodesLoading'), + castCarousel: document.getElementById('castCarousel'), + recommendationsContainer: document.getElementById('recommendationsContainer'), + detailsList: document.getElementById('detailsList'), + + // Search + searchModal: document.getElementById('searchModal'), + searchBtn: document.getElementById('searchBtn'), + searchInput: document.getElementById('searchInput'), + closeSearch: document.getElementById('closeSearch') + }; +} + + +/** + * Initialize watch page + */ +async function init() { + // Parse URL parameters + const params = new URLSearchParams(window.location.search); + const videoId = params.get('id'); + const videoSlug = params.get('slug'); + const episode = parseInt(params.get('ep')) || 1; + + state.currentEpisode = episode; + + if (!videoId && !videoSlug) { + showError('No video specified'); + return; + } + + // Resolve elements once DOM is ready + initElements(); + + // Setup event listeners + setupEventListeners(); + + // Load video data + await loadVideoData(videoId, videoSlug); + + // Load recommendations + await loadRecommendations(); +} + +/** + * Setup event listeners (StreamFlix Tailwind Design) + */ +function setupEventListeners() { + // Scroll listener for header background + window.addEventListener('scroll', () => { + if (elements.watchHeader) { + if (window.scrollY > 50) { + elements.watchHeader.style.backgroundColor = 'rgba(20,20,20,0.95)'; + } else { + elements.watchHeader.style.backgroundColor = 'transparent'; + } + } + }); + + // Back Button Logic (Robust Close) + if (elements.watchBackBtn) { + elements.watchBackBtn.addEventListener('click', (e) => { + e.preventDefault(); + if (elements.videoPlayerContainer && (elements.videoPlayerContainer.style.display !== 'none' || !elements.videoPlayerContainer.classList.contains('hidden'))) { + closeVideoPlayer(); + } else if (document.referrer && document.referrer.includes(window.location.host)) { + window.history.back(); + } else { + window.location.href = '/index.html'; + } + }); + } + + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + // Close video player + if (elements.videoPlayerContainer && !elements.videoPlayerContainer.classList.contains('hidden')) { + closeVideoPlayer(); + } + // Close search modal + if (elements.searchModal && !elements.searchModal.classList.contains('hidden')) { + elements.searchModal.classList.add('hidden'); + } + } + }); + + [elements.playBtn, elements.playBtnMobile, elements.mobilePlayBtn].forEach(btn => { + if (btn) { + btn.addEventListener('click', () => { + if (elements.videoPlayerContainer) { + elements.videoPlayerContainer.classList.remove('hidden'); + elements.videoPlayerContainer.style.display = 'block'; // Ensure visible + } + if (elements.videoPlayer) { + elements.videoPlayer.style.display = 'block'; + } + playCurrentEpisode(); + }); + } + }); + + // Close player button + if (elements.closePlayer) { + elements.closePlayer.addEventListener('click', () => { + closeVideoPlayer(); + }); + } + + // Search button - open search modal + if (elements.searchBtn) { + elements.searchBtn.addEventListener('click', () => { + if (elements.searchModal) { + elements.searchModal.classList.remove('hidden'); + setTimeout(() => elements.searchInput?.focus(), 100); + } + }); + } + + // Close search button + if (elements.closeSearch) { + elements.closeSearch.addEventListener('click', () => { + if (elements.searchModal) { + elements.searchModal.classList.add('hidden'); + } + }); + } + + // Add to List button + [elements.addListBtn, elements.addListBtnMobile].forEach(btn => { + if (btn) { + btn.addEventListener('click', () => { + if (!state.video) return; + + const added = window.historyService?.toggleFavorite(state.video); + updateAddListUI(added); + + if (added) { + showToast('Added to My List', 'success'); + } else { + showToast('Removed from My List', 'info'); + } + }); + } + }); + + // Share button + if (elements.shareBtnMobile) { + elements.shareBtnMobile.addEventListener('click', () => { + if (navigator.share) { + navigator.share({ + title: state.video?.title || 'StreamFlix', + url: window.location.href + }); + } else { + // Fallback: Copy to clipboard + navigator.clipboard.writeText(window.location.href); + showToast('Link copied to clipboard', 'success'); + } + }); + } + + // Tab Navigation (Tailwind design) + if (elements.tabNav) { + const tabs = elements.tabNav.querySelectorAll('.tab-btn'); + const panels = { + episodes: elements.episodesPanel, + details: elements.detailsPanel + }; + + tabs.forEach(tab => { + tab.addEventListener('click', () => { + const targetPanel = tab.dataset.tab; + + // Update active tab styling + tabs.forEach(t => { + t.classList.remove('text-white', 'font-bold', 'border-b-4', 'border-primary'); + t.classList.add('text-gray-400', 'font-medium'); + }); + tab.classList.remove('text-gray-400', 'font-medium'); + tab.classList.add('text-white', 'font-bold', 'border-b-4', 'border-primary'); + + // Show/hide panels + Object.entries(panels).forEach(([key, panel]) => { + if (panel) { + if (key === targetPanel) { + panel.classList.remove('hidden'); + } else { + panel.classList.add('hidden'); + } + } + }); + }); + }); + } + // Mobile Bottom Navigation Handlers + const mobileNavButtons = document.querySelectorAll('#mobileBottomNav .nav-item'); + mobileNavButtons.forEach(btn => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + const view = btn.dataset.view; + if (view) { + // Redirect to home with view parameter + window.location.href = `/index.html?view=${view}`; + } + }); + }); +} + + +/** + * Close Video Player (Robust Cleanup) + */ +function closeVideoPlayer() { + // Re-resolve just in case + const container = elements.videoPlayerContainer || document.getElementById('videoPlayerContainer'); + const player = elements.videoPlayer || document.getElementById('videoPlayer'); + const loader = elements.playerLoading || document.getElementById('playerLoading'); + + if (container) { + container.classList.add('hidden'); + container.style.display = 'none'; // Forced hide + } + + // Destroy ArtPlayer instance + destroyPlayer(); + + if (player) { + player.innerHTML = ''; + player.style.display = 'none'; + } + + if (loader) { + loader.style.display = 'none'; + } +} + +/** + * Update Add to List UI buttons + */ +function updateAddListUI(isAdded) { + const icon = isAdded ? 'check' : 'add'; + const text = isAdded ? 'In List' : 'My List'; + + // Update Desktop + if (elements.addListBtn) { + const iconEl = elements.addListBtn.querySelector('.material-symbols-outlined'); + const textEl = elements.addListBtn.querySelector('span:last-child'); + if (iconEl) iconEl.textContent = icon; + if (textEl) textEl.textContent = text; + if (isAdded) elements.addListBtn.classList.add('bg-white/20'); + else elements.addListBtn.classList.remove('bg-white/20'); + } + + // Update Mobile + if (elements.addListBtnMobile) { + const iconEl = elements.addListBtnMobile.querySelector('.material-symbols-outlined'); + const textEl = elements.addListBtnMobile.querySelector('span:last-child'); + if (iconEl) iconEl.textContent = icon; + if (textEl) textEl.textContent = text; + if (isAdded) { + elements.addListBtnMobile.classList.add('bg-white/10'); + elements.addListBtnMobile.classList.remove('bg-[#2b2b2b]'); + } else { + elements.addListBtnMobile.classList.remove('bg-white/10'); + elements.addListBtnMobile.classList.add('bg-[#2b2b2b]'); + } + } +} + +/** + * Load video data from API or stored state + */ +async function loadVideoData(videoId, videoSlug) { + try { + state.isLoading = true; + + let video = null; + const slug = videoSlug || videoId; + + // Fetch fresh movie details from API + if (slug) { + try { + const movieDetails = await api.getRophimMovie(slug); + + // API returns flat object, not nested under 'movie' + if (movieDetails) { + const movie = movieDetails.movie || movieDetails; // Support both structures + const episodes = movieDetails.episodes || []; + + video = { + id: movie.slug || slug, + slug: movie.slug || slug, + title: movie.name || movie.title || slug, + original_title: movie.origin_name || movie.original_title || '', + description: movie.content || movie.description || '', + thumbnail: movie.poster_url || movie.thumb_url || movie.thumbnail || '', + year: movie.year, + rating: movie.tmdb?.vote_average || movie.rating || 'N/A', + quality: movie.quality || 'HD', + duration: movie.time || movie.duration || '', + + genres: (() => { + if (Array.isArray(movie.category)) return movie.category.map(c => c.name || c); + if (Array.isArray(movie.genres)) return movie.genres; + if (typeof movie.genre === 'string') return movie.genre.split(',').map(g => g.trim()); + return []; + })(), + country: movie.country?.[0]?.name || movie.country || '', + country: movie.country?.[0]?.name || movie.country || '', + cast: movie.actor || movie.cast || [], + director: movie.director?.[0] || movie.director || '', + source_url: `https://phimmoichill.network/phim/${slug}`, + episodes: parseEpisodes(episodes) + }; + } + } catch (apiError) { + console.warn('API fetch failed:', apiError); + } + } + + if (!video) { + throw new Error('Video data not found'); + } + + state.video = video; + + // Save to watch history + if (window.historyService) { + window.historyService.addToHistory(video, { + episode: state.currentEpisode + }); + } + + // Render video info + renderVideoInfo(video); + + // Update Favorite Status + if (window.historyService) { + updateAddListUI(window.historyService.isFavorite(video.slug)); + } + + // Video is ready, but wait for user interaction to play + // await playCurrentEpisode(); // Disabled auto-play per user request + + } catch (error) { + console.error('Failed to load video:', error); + showError('Failed to load video data'); + } finally { + state.isLoading = false; + } +} + +/** + * Parse episodes + */ +function parseEpisodes(episodesData) { + if (!episodesData || !Array.isArray(episodesData) || episodesData.length === 0) { + return []; + } + const server = episodesData[0]; + const serverData = server?.server_data || []; + + return serverData.map((ep, index) => ({ + number: index + 1, + name: ep.name || `Episode ${index + 1}`, + title: ep.filename || `Episode ${index + 1}`, + slug: ep.slug || '', + link_embed: ep.link_embed || '', + link_m3u8: ep.link_m3u8 || '' + })); +} + +/** + * Render video information (StreamFlix Tailwind Design) + */ +function renderVideoInfo(video) { + // Hero Background Image + if (elements.heroBg) { + const backdrop = video.backdrop || video.poster_url || video.thumb_url || video.thumbnail || ''; + if (backdrop) { + elements.heroBg.style.backgroundImage = `url('${backdrop}')`; + } + } + + // Title + if (elements.movieTitle) elements.movieTitle.textContent = video.title; + + // Meta Data + if (elements.movieYear) elements.movieYear.textContent = video.year || ''; + if (elements.movieDuration) { + if (video.runtime_minutes) { + const hours = Math.floor(video.runtime_minutes / 60); + const mins = video.runtime_minutes % 60; + elements.movieDuration.textContent = hours > 0 ? `${hours}h ${mins}m` : `${mins}m`; + } else if (video.duration) { + elements.movieDuration.textContent = video.duration; + } + } + if (elements.movieQuality) elements.movieQuality.textContent = video.quality || 'HD'; + + // Rating (show as PG-13 style or numeric) + if (elements.movieRating) { + const rating = video.rating || video.tmdb_rating; + if (rating && rating !== 'N/A') { + elements.movieRating.textContent = typeof rating === 'number' ? `${rating.toFixed(1)} ★` : rating; + } else { + elements.movieRating.textContent = 'TV-MA'; + } + } + + // Match percentage (fake Netflix-style) + if (elements.movieMatch) { + const matchPercent = Math.floor(85 + Math.random() * 14); // 85-98% + elements.movieMatch.textContent = `${matchPercent}% Match`; + } + + // Description + if (elements.movieDescription) { + const description = video.tmdb_description || video.description || 'No description available.'; + // Use innerHTML to render any HTML tags provided by the API (e.g.

    ,
    ) + elements.movieDescription.innerHTML = description; + if (elements.movieDescriptionMobile) elements.movieDescriptionMobile.innerHTML = description; + } + + // Mobile Data Population + if (elements.movieTitleMobile) elements.movieTitleMobile.textContent = video.title; + if (elements.movieYearMobile) elements.movieYearMobile.textContent = video.year || ''; + if (elements.movieRatingMobile) { + const rating = video.rating || video.tmdb_rating; + elements.movieRatingMobile.textContent = (rating && rating !== 'N/A') ? (typeof rating === 'number' ? rating.toFixed(1) : rating) : 'TV-MA'; + } + if (elements.movieDurationMobile) elements.movieDurationMobile.textContent = elements.movieDuration ? elements.movieDuration.textContent : (video.duration || ''); + if (elements.movieQualityMobile) elements.movieQualityMobile.textContent = video.quality || 'HD'; + if (elements.movieMatchMobile && elements.movieMatch) elements.movieMatchMobile.textContent = elements.movieMatch.textContent; + + // Genre Tags + if (elements.movieTags) { + const genres = video.genres || []; + const director = video.director; + const country = video.country; + + let tagsHTML = ''; + if (genres.length > 0) { + tagsHTML += `

    Genres: ${genres.join(', ')}
    `; + } + if (director && director !== 'Unknown') { + tagsHTML += `
    Director: ${director}
    `; + } + if (country && country !== 'Unknown') { + tagsHTML += `
    Country: ${country}
    `; + } + + elements.movieTags.innerHTML = tagsHTML; + } + + // Update page title + document.title = `${video.title} - StreamFlix`; + + // Update Add to List button state + if (window.historyService && video.slug) { + updateAddListUI(window.historyService.isFavorite(video.slug)); + } + + // Render episodes + renderEpisodes(video); + + // Render cast + if (video.tmdb_cast && video.tmdb_cast.length > 0) { + renderCast(video.tmdb_cast, true); + } else if (video.cast && video.cast.length > 0) { + renderCast(video.cast, false); + } + + // Render additional details + renderDetails(video); +} + +/** + * Render episodes grid (StreamFlix Tailwind Design) + */ +function renderEpisodes(video) { + if (!elements.episodesPanel) return; + + // Get episodes from the API response format + let episodes = []; + if (Array.isArray(video.episodes) && video.episodes.length > 0) { + if (video.episodes[0].server_data) { + episodes = video.episodes[0].server_data; + } else { + episodes = video.episodes; + } + } + + // Hide episodes section for single-episode movies + if (episodes.length <= 1) { + if (elements.seasonSelectContainer) elements.seasonSelectContainer.style.display = 'none'; + if (elements.episodesLoading) elements.episodesLoading.style.display = 'none'; + + // Show "Play Movie" message instead + if (elements.episodesGrid) { + elements.episodesGrid.innerHTML = ` +
    + play_circle +
    +

    Full Movie

    +

    Click Play to watch

    +
    +
    + `; + } + return; + } + + // Update episode count + if (elements.episodeCount) elements.episodeCount.textContent = `${episodes.length} Episodes`; + if (elements.episodesLoading) elements.episodesLoading.style.display = 'none'; + + // Render episode cards + if (elements.episodesGrid) { + const INITIAL_LIMIT = 10; + const totalEp = episodes.length; + const showAll = totalEp <= (INITIAL_LIMIT + 5); // If only a few more, just show all + + const renderBatch = (limit) => { + elements.episodesGrid.innerHTML = episodes.slice(0, limit).map((ep, index) => { + const epNumber = index + 1; + const isActive = epNumber === state.currentEpisode; + const epName = ep.name || `Episode ${epNumber}`; + const epTitle = ep.title || ep.filename || ''; + + return ` +
    +
    ${epNumber}
    +
    +

    ${epName}

    + ${epTitle ? `

    ${epTitle}

    ` : ''} +
    + ${isActive ? 'play_circle' : ''} +
    + `; + }).join(''); + + if (limit < totalEp) { + const seeMoreBtn = document.createElement('button'); + seeMoreBtn.className = 'w-full py-4 text-gray-400 hover:text-white font-medium flex items-center justify-center gap-2 border-t border-white/5 mt-2 transition-colors'; + seeMoreBtn.innerHTML = ` + See more episodes (${totalEp - limit} remaining) + expand_more + `; + seeMoreBtn.onclick = () => renderBatch(totalEp); + elements.episodesGrid.appendChild(seeMoreBtn); + } + }; + + renderBatch(showAll ? totalEp : INITIAL_LIMIT); + } +} + +/** + * Render additional details (About section) + */ +function renderDetails(video) { + if (!elements.detailsList) return; + + const details = []; + + if (video.original_title) details.push({ label: 'Original Title', value: video.original_title }); + if (video.director && video.director !== 'Unknown') details.push({ label: 'Director', value: video.director }); + if (video.country && video.country !== 'Unknown') details.push({ label: 'Country', value: video.country }); + if (video.year) details.push({ label: 'Release Year', value: video.year }); + if (video.quality) details.push({ label: 'Quality', value: video.quality }); + if (video.duration) details.push({ label: 'Duration', value: video.duration }); + if (video.genres && video.genres.length > 0) details.push({ label: 'Genres', value: video.genres.join(', ') }); + + // Clear existing + elements.detailsList.innerHTML = ''; + + details.forEach(d => { + const row = document.createElement('div'); + row.className = 'flex gap-4'; + + const label = document.createElement('span'); + label.className = 'text-white/50 min-w-[100px] font-medium'; + label.textContent = `${d.label}:`; + + const value = document.createElement('span'); + value.className = 'text-white font-medium'; + value.textContent = d.value; + + row.appendChild(label); + row.appendChild(value); + elements.detailsList.appendChild(row); + }); +} + +// Global scope for onclick +window.selectEpisode = (episodeNumber) => { + state.currentEpisode = episodeNumber; + + // Update URL + const url = new URL(window.location); + url.searchParams.set('ep', episodeNumber); + window.history.replaceState({}, '', url); + + // Re-render to update active state + renderEpisodes(state.video); + + // Play + playCurrentEpisode(); + + // Scroll to top + window.scrollTo({ top: 0, behavior: 'smooth' }); +}; + +/** + * Play current episode + */ +async function playCurrentEpisode() { + if (!state.video) return; + + if (elements.playerLoading) elements.playerLoading.style.display = 'flex'; + + try { + let streamUrl = null; + let poster = state.video.thumbnail; + + // Get episodes from the API response format (ophim format has server_data) + let episodes = []; + if (Array.isArray(state.video.episodes) && state.video.episodes.length > 0) { + if (state.video.episodes[0].server_data) { + episodes = state.video.episodes[0].server_data; + } else { + episodes = state.video.episodes; + } + } + + const currentEp = episodes[state.currentEpisode - 1]; + + // Save to history + if (window.historyService) { + window.historyService.addToHistory(state.video, { + episode: state.currentEpisode, + timestamp: Date.now() + }); + } + + // Try to get stream URL from episode data (ophim provides direct links) + if (currentEp) { + // Prefer m3u8 for native playback, fallback to embed + if (currentEp.link_m3u8) { + streamUrl = currentEp.link_m3u8; + } else if (currentEp.link_embed) { + streamUrl = currentEp.link_embed; + } + } + + // If still no stream, try getting it via the getRophimStream method + if (!streamUrl && state.video.slug) { + try { + const streamData = await api.getRophimStream(state.video.slug, state.currentEpisode); + if (streamData?.stream_url) streamUrl = streamData.stream_url; + } catch (e) { + console.warn('Stream API fallback also failed', e); + } + } + + if (elements.playerLoading) elements.playerLoading.style.display = 'none'; + + if (streamUrl) { + renderPlayer(streamUrl, poster, state.video.title); + const epLabel = episodes.length > 1 ? `Episode ${state.currentEpisode} ` : 'Movie'; + showToast(`Playing ${epLabel} `, 'success'); + } else { + // Show watch externally option + const episodeStr = state.currentEpisode === 1 ? 'full' : state.currentEpisode; + const externalUrl = `https://phimmoichill.network/xem-phim/${state.video.slug}/tap-${episodeStr}-sv-0`; + showExternalPlayerOption(externalUrl); + } + } catch (error) { + console.error(error); + showPlaybackError(error.message); + } +} + +function showExternalPlayerOption(externalUrl) { + elements.videoPlayer.innerHTML = ` +
    + + + + + +

    It cannot load

    +

    This stream is currently unavailable. Please try again later or choose another source.

    +
    + `; +} + +/** + * Render player + */ +function renderPlayer(streamUrl, poster, title) { + // Check if embed (add simple check for common embed domains) + const isEmbed = streamUrl.includes('embed') || !streamUrl.match(/\.(mp4|m3u8)$/i); + + if (isEmbed) { + elements.videoPlayer.innerHTML = ` + + `; + } else { + const art = initPlayer(elements.videoPlayer, { + url: streamUrl, + poster: poster, + title: title + ` - Ep ${state.currentEpisode}`, + autoplay: true + }); + + // Track progress + if (art && window.historyService) { + art.on('video:timeupdate', () => { + const currentTime = art.currentTime; + const duration = art.duration; + if (currentTime > 0 && duration > 0) { + // Save every 5 seconds to avoid excessive writes + if (Math.floor(currentTime) % 5 === 0) { + window.historyService.addToHistory(state.video, { + currentTime, + duration, + percentage: (currentTime / duration) * 100, + episode: state.currentEpisode + }); + } + } + }); + + // Resume from last position if available + const history = window.historyService.getHistory(); + const entry = history.find(item => item.slug === state.video.slug); + if (entry && entry.progress && entry.progress.episode === state.currentEpisode) { + if (entry.progress.currentTime > 0 && entry.progress.percentage < 95) { + art.once('video:canplay', () => { + art.currentTime = entry.progress.currentTime; + }); + } + } + } + } +} + +function showPlaybackError(msg) { + elements.videoPlayer.innerHTML = ` +
    +

    Error loading video: ${msg}

    + +
    + `; +} + +/** + * Render Cast (StreamFlix Tailwind Design - circular avatars) + */ +function renderCast(cast, isTMDB = false) { + if (!elements.castCarousel) return; + + const displayCast = cast.slice(0, 10); + + if (isTMDB) { + elements.castCarousel.innerHTML = displayCast.map(person => { + const hasPhoto = person.profile_photo && !person.profile_photo.includes('ui-avatars.com'); + const photoUrl = person.profile_photo || ''; + const searchUrl = `/?search=${encodeURIComponent(person.name)}`; + const initials = person.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2); + + return ` + +
    + ${hasPhoto + ? `${person.name}` + : `
    ${initials}
    ` + } +
    +

    ${person.name}

    +

    ${person.character || 'Actor'}

    +
    + `; + }).join(''); + } else { + elements.castCarousel.innerHTML = displayCast.map(actor => { + const searchUrl = `/?search=${encodeURIComponent(actor)}`; + const initials = actor.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2); + + return ` + +
    + ${initials} +
    +

    ${actor}

    +

    Actor

    +
    + `; + }).join(''); + } +} + +/** + * Load Recommendations (StreamFlix Tailwind Design) + */ +/** + * Load Recommendations (Expanded: Genre, Country, Year) + */ +async function loadRecommendations() { + const container = elements.recommendationsContainer; + if (!container) return; + + try { + container.innerHTML = '
    '; + + const video = state.video; + if (!video) return; + + const currentSlug = video.slug; + const usedSlugs = new Set([currentSlug]); + + // 1. Prepare Categories + const genres = video.category ? Object.values(video.category) : (video.genres || []); + const countries = video.country ? Object.values(video.country) : (video.countries || []); + const year = video.year; + + const requests = []; + + // Category 1: Similar (Genre) + if (genres.length > 0) { + let genreSlug = ''; + // Handle both object {id: name} and string array + if (typeof genres[0] === 'object' && genres[0].slug) { + genreSlug = genres[0].slug; + } else if (typeof genres[0] === 'string') { + genreSlug = genres[0].toLowerCase() + .normalize('NFD').replace(/[\u0300-\u036f]/g, '') + .replace(/đ/g, 'd').replace(/\s+/g, '-'); + } + + // Adjust slug logic if needed based on API + // For RoPhim it's often 'the-loai/' + if (genreSlug) { + requests.push( + api.getRophimCatalog({ page: 1, limit: 24, category: `the-loai/${genreSlug}` }) + .then(res => ({ title: "More Like This", movies: res.movies || [] })) + .catch(() => null) + ); + } + } + + // Category 2: Same Country + if (countries.length > 0) { + let countrySlug = ''; + if (typeof countries[0] === 'object' && countries[0].slug) { + countrySlug = countries[0].slug; + } else if (typeof countries[0] === 'string') { + countrySlug = countries[0].toLowerCase() + .normalize('NFD').replace(/[\u0300-\u036f]/g, '') + .replace(/đ/g, 'd').replace(/\s+/g, '-'); + } + + if (countrySlug) { + requests.push( + api.getRophimCatalog({ page: 1, limit: 24, category: `quoc-gia/${countrySlug}` }) + .then(res => ({ title: `Movies from ${countries[0].name || countries[0]}`, movies: res.movies || [] })) + .catch(() => null) + ); + } + } + + // Category 3: Same Year + if (year) { + requests.push( + api.getRophimCatalog({ page: 1, limit: 24, category: `nam-phat-hanh/${year}` }) + .then(res => ({ title: `Released in ${year}`, movies: res.movies || [] })) + .catch(() => null) + ); + } + + // Execute all requests + const results = await Promise.all(requests); + + container.innerHTML = ''; // Clear loading + + const renderedTitles = new Set(); + let hasContent = false; + + results.forEach(section => { + if (!section || !section.movies || section.movies.length === 0) return; + + // Deduplicate Titles (Prevent multiple 'More Like This') + if (section.title && renderedTitles.has(section.title)) return; + if (section.title) renderedTitles.add(section.title); + + // Filter duplicates + const uniqueMovies = section.movies.filter(m => !usedSlugs.has(m.slug)); + uniqueMovies.forEach(m => usedSlugs.add(m.slug)); + + if (uniqueMovies.length === 0) return; + + hasContent = true; + + const sectionHtml = ` +
    + ${section.title ? `

    ${section.title}

    ` : ''} +
    + ${uniqueMovies.map(v => createCardHtml(v)).join('')} +
    +
    + `; + container.insertAdjacentHTML('beforeend', sectionHtml); + }); + + if (!hasContent) { + container.innerHTML = '

    No specific recommendations found.

    '; + } + + } catch (error) { + console.error('Failed to load recommendations:', error); + container.innerHTML = '

    Failed to load recommendations

    '; + } +} + +/** + * Helper to create card HTML (Smaller for Recommendations) + */ +function createCardHtml(v) { + const poster = v.poster_url || v.thumbnail || v.thumb_url || ''; + const title = v.name || v.title || 'Untitled'; + const year = v.year || ''; + const quality = v.quality || 'HD'; + const match = v.matchScore || Math.floor(Math.random() * (99 - 85 + 1) + 85); + const tmdb = v.tmdb_rating || 0; + const rtScore = Math.round(tmdb * 10); + const slug = v.slug || v.id || ''; + + // Smaller card dimensions for "More Like This" + return ` +
    +
    +
    + + +
    + + +
    + ${year == new Date().getFullYear() ? `NEW` : ''} + ${quality.replace('FHD', 'HD')} +
    + + +
    +
    + + +
    + +

    ${title}

    +
    + ${match}% Match +
    + + local_pizza ${rtScore}% + +
    +
    +
    +
    +
    + `; +} + + + +function showError(msg) { + document.body.innerHTML = ` +
    + error +

    ${msg}

    + Go Home +
    + `; +} + +// Init +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + init(); +} diff --git a/frontend/styles/base.css b/frontend/styles/base.css new file mode 100644 index 0000000..8941c69 --- /dev/null +++ b/frontend/styles/base.css @@ -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; +} \ No newline at end of file diff --git a/frontend/styles/components/buttons.css b/frontend/styles/components/buttons.css new file mode 100644 index 0000000..b2347a2 --- /dev/null +++ b/frontend/styles/components/buttons.css @@ -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; +} \ No newline at end of file diff --git a/frontend/styles/components/cards.css b/frontend/styles/components/cards.css new file mode 100644 index 0000000..918ebc3 --- /dev/null +++ b/frontend/styles/components/cards.css @@ -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); +} \ No newline at end of file diff --git a/frontend/styles/components/forms.css b/frontend/styles/components/forms.css new file mode 100644 index 0000000..1c224e9 --- /dev/null +++ b/frontend/styles/components/forms.css @@ -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); +} \ No newline at end of file diff --git a/frontend/styles/components/loading.css b/frontend/styles/components/loading.css new file mode 100644 index 0000000..57ccc3c --- /dev/null +++ b/frontend/styles/components/loading.css @@ -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); +} \ No newline at end of file diff --git a/frontend/styles/components/modals.css b/frontend/styles/components/modals.css new file mode 100644 index 0000000..700204f --- /dev/null +++ b/frontend/styles/components/modals.css @@ -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; +} \ No newline at end of file diff --git a/frontend/styles/grid-patch.css b/frontend/styles/grid-patch.css new file mode 100644 index 0000000..5f72e98 --- /dev/null +++ b/frontend/styles/grid-patch.css @@ -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; + } +} \ No newline at end of file diff --git a/frontend/styles/index.css b/frontend/styles/index.css new file mode 100644 index 0000000..91c32d7 --- /dev/null +++ b/frontend/styles/index.css @@ -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'; \ No newline at end of file diff --git a/frontend/styles/layout.css b/frontend/styles/layout.css new file mode 100644 index 0000000..e374516 --- /dev/null +++ b/frontend/styles/layout.css @@ -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; +} \ No newline at end of file diff --git a/frontend/styles/responsive-patch.css b/frontend/styles/responsive-patch.css new file mode 100644 index 0000000..37dd52c --- /dev/null +++ b/frontend/styles/responsive-patch.css @@ -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; + } +} \ No newline at end of file diff --git a/frontend/styles/responsive.css b/frontend/styles/responsive.css new file mode 100644 index 0000000..e487ce8 --- /dev/null +++ b/frontend/styles/responsive.css @@ -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; + } +} \ No newline at end of file diff --git a/frontend/styles/search-modal.css b/frontend/styles/search-modal.css new file mode 100644 index 0000000..e83571d --- /dev/null +++ b/frontend/styles/search-modal.css @@ -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; + } +} \ No newline at end of file diff --git a/frontend/styles/sections/feed.css b/frontend/styles/sections/feed.css new file mode 100644 index 0000000..43728d7 --- /dev/null +++ b/frontend/styles/sections/feed.css @@ -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); +} \ No newline at end of file diff --git a/frontend/styles/sections/hero.css b/frontend/styles/sections/hero.css new file mode 100644 index 0000000..9949387 --- /dev/null +++ b/frontend/styles/sections/hero.css @@ -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%; +} \ No newline at end of file diff --git a/frontend/styles/sections/sliders.css b/frontend/styles/sections/sliders.css new file mode 100644 index 0000000..bbc9ab8 --- /dev/null +++ b/frontend/styles/sections/sliders.css @@ -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); +} \ No newline at end of file diff --git a/frontend/styles/variables.css b/frontend/styles/variables.css new file mode 100644 index 0000000..e75a6b4 --- /dev/null +++ b/frontend/styles/variables.css @@ -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; +} \ No newline at end of file diff --git a/frontend/styles/watch.css b/frontend/styles/watch.css new file mode 100644 index 0000000..fe968f0 --- /dev/null +++ b/frontend/styles/watch.css @@ -0,0 +1,1060 @@ +/* ============================================ + KV-Netflix Watch Page Styles + Apple TV+ Inspired Design + ============================================ */ + +/* ============================================ + Watch Page Variables + ============================================ */ +:root { + --watch-bg: #000000; + --watch-text-primary: #ffffff; + --watch-text-secondary: rgba(255, 255, 255, 0.55); + --watch-accent: #ffffff; + /* Apple uses white for selection/focus */ + --watch-button-bg: #ffffff; + --watch-button-text: #000000; + --watch-glass-bg: rgba(255, 255, 255, 0.1); + --watch-glass-border: rgba(255, 255, 255, 0.15); + + --font-primary: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, sans-serif; + --font-display: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, sans-serif; +} + +/* ============================================ + Page Layout + ============================================ */ +.watch-page { + background: var(--watch-bg); + color: var(--watch-text-primary); + min-height: 100vh; + font-family: var(--font-primary); + overflow-x: hidden; +} + +/* ============================================ + Watch Header - Minimal Design + ============================================ */ +.watch-header { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 70px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 40px; + z-index: 1000; + background: linear-gradient(180deg, rgba(0, 0, 0, 0.8) 0%, transparent 100%); + transition: background 0.4s ease, backdrop-filter 0.4s ease; +} + +.watch-header.scrolled { + background: rgba(0, 0, 0, 0.85); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); +} + +.watch-header__left, +.watch-header__right { + display: flex; + align-items: center; + gap: 20px; + flex: 1; +} + +.watch-header__center { + flex: 0; +} + +.watch-header__right { + justify-content: flex-end; +} + +.watch-header__back { + display: flex; + align-items: center; + gap: 10px; + color: rgba(255, 255, 255, 0.8); + text-decoration: none; + font-size: 15px; + font-weight: 500; + transition: all 0.2s ease; + padding: 8px 12px; + border-radius: 50px; +} + +.watch-header__back:hover { + color: #fff; + background: rgba(255, 255, 255, 0.1); +} + +.watch-header__logo { + font-family: var(--font-display); + font-size: 22px; + font-weight: 600; + color: #fff; + text-decoration: none; + letter-spacing: -0.5px; + opacity: 0.9; +} + +.watch-header__btn { + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + border-radius: 50%; + color: rgba(255, 255, 255, 0.8); + cursor: pointer; + transition: all 0.2s ease; +} + +.watch-header__btn:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; + transform: scale(1.05); +} + +/* ============================================ + Watch Main Content + ============================================ */ +.watch-main { + padding-bottom: 80px; +} + +/* ============================================ + Video Theater Section + ============================================ */ +.video-theater { + position: relative; + width: 100%; + background: #000; + height: 70vh; + /* Reduced for better spacing */ + max-height: 900px; + min-height: 500px; +} + +.video-theater__container { + position: relative; + width: 100%; + height: 100%; +} + +.video-theater__player { + position: relative; + width: 100%; + height: 100%; + background: #000; +} + +/* Ensure iframe fills container */ +.video-theater__player iframe { + width: 100%; + height: 100%; + border: none; +} + +/* Loading State */ +.video-theater__loading { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + color: rgba(255, 255, 255, 0.6); +} + +.loading-spinner { + width: 50px; + height: 50px; + border: 3px solid rgba(255, 255, 255, 0.1); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Gradient Overlay - Crucial for the "Immersive" feel */ +.video-theater__gradient { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 400px; + background: linear-gradient(to bottom, transparent 0%, #000000 90%); + pointer-events: none; + z-index: 2; +} + +/* ============================================ + Movie Details Section - Immersive Overlay + ============================================ */ +.movie-details { + position: relative; + margin-top: 0; + /* Clean separation - no overlap */ + padding-top: 40px; + z-index: 10; + padding-bottom: 40px; +} + +.movie-details__container { + max-width: 1400px; + /* Wide container */ + margin: 0 auto; + padding: 0 60px; +} + +/* Info Elements */ +.info-content { + max-width: 800px; +} + +/* Dynamic Badge Container */ +.info-badges { + display: flex; + gap: 12px; + margin-bottom: 16px; + align-items: center; +} + +.badge-logo { + height: 20px; + width: auto; + opacity: 0.8; +} + +/* Title */ +.info-title { + font-family: var(--font-display); + font-size: 56px; + font-weight: 700; + color: #fff; + margin: 0 0 8px 0; + letter-spacing: -1px; + line-height: 1.1; + text-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); +} + +.info-original-title { + font-size: 18px; + color: rgba(255, 255, 255, 0.6); + margin: 0 0 20px 0; + font-weight: 400; +} + +/* Metadata Row */ +.info-meta { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 24px; + font-size: 15px; + font-weight: 500; + color: rgba(255, 255, 255, 0.7); +} + +.meta-divider { + width: 3px; + height: 3px; + background-color: rgba(255, 255, 255, 0.4); + border-radius: 50%; +} + +.meta-quality { + border: 1px solid rgba(255, 255, 255, 0.4); + padding: 1px 4px; + border-radius: 4px; + font-size: 11px; + line-height: 1.2; +} + +/* Action Buttons */ +.info-actions { + display: flex; + gap: 16px; + margin-bottom: 32px; + margin-top: 32px; +} + +.action-btn { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 32px; + border-radius: 40px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94); + border: none; + letter-spacing: -0.01em; +} + +/* Primary Play Button - White Pill */ +.action-btn--primary { + background: #ffffff; + color: #000000; +} + +.action-btn--primary:hover { + transform: scale(1.04); + box-shadow: 0 0 20px rgba(255, 255, 255, 0.3); +} + +/* Secondary Buttons - Glass/Circle */ +.action-btn--glass { + background: var(--glass-bg); + color: #ffffff; + backdrop-filter: var(--glass-blur) var(--glass-saturate); + -webkit-backdrop-filter: var(--glass-blur) var(--glass-saturate); + border: var(--apple-border); + padding: 12px 24px; + border-radius: 40px; +} + +.action-btn--glass.icon-only { + padding: 0; + border-radius: 50%; + width: 48px; + height: 48px; + min-width: 48px; + justify-content: center; + flex: 0 0 48px; + /* Force circle */ +} + +.action-btn--glass:hover { + background: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.3); + transform: scale(1.06); +} + +.action-btn--glass.active { + background: #ffffff; + color: #000000; + box-shadow: 0 0 15px rgba(255, 255, 255, 0.4); +} + +/* Description */ +.info-description { + font-size: 17px; + line-height: 1.6; + color: rgba(255, 255, 255, 0.7); + margin-bottom: 50px; + max-width: 700px; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); +} + +/* Genres */ +.info-genres { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.genre-tag { + font-size: 14px; + color: rgba(255, 255, 255, 0.6); + cursor: pointer; + transition: color 0.2s; +} + +.genre-tag:hover { + color: #fff; + text-decoration: underline; +} + +/* ============================================ + Section Styling (Shared) + ============================================ */ +.content-section { + padding: 40px 0; + opacity: 0; + animation: fadeIn 0.8s ease forwards; + animation-delay: 0.3s; +} + +.content-container { + max-width: 1400px; + margin: 0 auto; + padding: 0 60px; +} + +.section-title { + font-family: var(--font-display); + font-size: 24px; + font-weight: 700; + color: #fff; + margin: 0 0 24px 0; + letter-spacing: -0.5px; +} + +/* ============================================ + Episodes Section + ============================================ */ +.episodes-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 20px; +} + +/* Episode Card */ +.episode-card { + background: transparent; + border-radius: 12px; + overflow: hidden; + cursor: pointer; + transition: transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94); +} + +.episode-card:hover { + transform: scale(1.02); +} + +.episode-card.active .episode-thumb { + box-shadow: 0 0 0 3px #fff; +} + +.episode-thumb { + position: relative; + aspect-ratio: 16 / 9; + border-radius: 12px; + overflow: hidden; + margin-bottom: 12px; + background: #1a1a1a; +} + +.episode-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + transition: opacity 0.3s; +} + +.episode-card:hover .episode-thumb img { + opacity: 0.8; +} + +/* Modal Content */ +.modal__content { + position: relative; + width: 100%; + max-width: 480px; + margin: var(--spacing-xl); + padding: var(--spacing-xl); + background: var(--glass-bg); + backdrop-filter: var(--glass-blur-deep) var(--glass-saturate); + -webkit-backdrop-filter: var(--glass-blur-deep) var(--glass-saturate); + border: var(--apple-border); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-xl); + animation: slideUp 0.3s ease; +} + +/* Play Icon Overlay */ +.episode-play { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 44px; + height: 44px; + background: rgba(0, 0, 0, 0.6); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: all 0.3s; + backdrop-filter: blur(4px); +} + +.episode-card:hover .episode-play { + opacity: 1; +} + +.episode-info h3 { + font-size: 15px; + font-weight: 600; + color: #fff; + margin: 0 0 4px 0; +} + +.episode-info p { + font-size: 13px; + color: rgba(255, 255, 255, 0.5); + margin: 0; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* ============================================ + Carousel (Recommendations & Cast) + ============================================ */ +.carousel-wrapper { + position: relative; + margin: 0 -60px; + /* Bleed out */ + padding: 0 60px; + /* Padding in */ + overflow-x: auto; + display: flex; + gap: 20px; + padding-bottom: 30px; + /* Space for scrollbar/shadow */ + scrollbar-width: none; + -ms-overflow-style: none; +} + +.carousel-wrapper::-webkit-scrollbar { + display: none; +} + +/* Recommended Video Card */ +.rec-card { + flex: 0 0 180px; + /* Smaller cards for more movies */ + cursor: pointer; + transition: transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94); +} + +.rec-card:hover { + transform: scale(1.03); +} + +.rec-img-container { + aspect-ratio: 2 / 3; + /* Poster style */ + border-radius: 8px; + overflow: hidden; + margin-bottom: 10px; + background: #1a1a1a; +} + +.rec-img-container img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.rec-title { + font-size: 13px; + font-weight: 600; + color: #fff; + margin-bottom: 2px; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.rec-meta { + font-size: 11px; + color: rgba(255, 255, 255, 0.5); +} + +/* Cast Specifics */ +.cast-member { + flex: 0 0 100px; + /* Reduced from 140px */ + text-align: center; +} + +.cast-avatar { + width: 100px; + /* Reduced from 140px */ + height: 100px; + border-radius: 50%; + overflow: hidden; + margin-bottom: 8px; + /* Reduced from 12px */ + background: #1a1a1a; + transition: transform 0.3s; +} + +.cast-member:hover .cast-avatar { + transform: scale(1.05); +} + +.cast-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* Hide avatar if it's a placeholder */ +.cast-member.no-photo .cast-avatar { + display: none; +} + +.cast-name { + font-size: 13px; + /* Reduced from 14px */ + font-weight: 600; + color: #fff; + margin-bottom: 2px; +} + +.cast-role { + font-size: 11px; + /* Reduced from 12px */ + color: rgba(255, 255, 255, 0.5); +} + +/* Clickable cast hover effect */ +a.cast-member:hover .cast-name { + text-decoration: underline; + color: var(--color-accent, #e5c07b); +} + +/* ============================================ + Animations + ============================================ */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(20px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ============================================ + Responsive Design + ============================================ */ +@media (max-width: 1024px) { + .video-theater { + height: 60vh; + min-height: auto; + } + + .movie-details { + margin-top: -100px; + } + + .info-title { + font-size: 40px; + } + + .movie-details__container, + .content-container { + padding: 0 30px; + } + + .carousel-wrapper { + margin: 0 -30px; + padding: 0 30px; + } +} + +@media (max-width: 768px) { + .watch-header { + padding: 0 20px; + height: 60px; + } + + .video-theater { + height: 50vh; + } + + .info-title { + font-size: 32px; + } + + .info-actions { + flex-wrap: wrap; + } + + .action-btn { + flex: 0 0 auto; + justify-content: center; + } + + .movie-details__container, + .content-container { + padding: 0 20px; + } + + .carousel-wrapper { + margin: 0 -20px; + padding: 0 20px; + } + + .rec-card { + flex: 0 0 140px; + } +} + +/* Toast Override */ +.toast-container { + z-index: 2000; +} + +/* ============================================ + Netflix Title Page Styles + ============================================ */ + +/* Player Section */ +.title-player-section { + padding-top: 70px; + background: #000; +} + +.title-player-section .video-theater__player { + max-width: 1600px; + margin: 0 auto; + aspect-ratio: 16 / 9; + background: #000; + border-radius: 0; +} + +/* Title Details Container */ +.title-details { + background: #141414; + padding: 40px 0 60px; +} + +.title-details__container { + max-width: 1400px; + margin: 0 auto; + padding: 0 60px; +} + +/* Title Info */ +.title-info { + margin-bottom: 32px; +} + +.title-info__name { + font-family: var(--font-display); + font-size: 2.5rem; + font-weight: 700; + color: #fff; + margin: 0 0 12px; + letter-spacing: -0.5px; +} + +.title-info__meta { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; + font-size: 15px; + color: rgba(255, 255, 255, 0.7); +} + +.title-info__meta .meta-quality { + border: 1px solid rgba(255, 255, 255, 0.4); + padding: 2px 6px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; +} + +.title-info__meta .meta-divider { + width: 4px; + height: 4px; + background: rgba(255, 255, 255, 0.4); + border-radius: 50%; +} + +.title-info__description { + font-size: 1rem; + line-height: 1.6; + color: rgba(255, 255, 255, 0.75); + margin: 0 0 24px; + max-width: 800px; +} + +/* Title Action Buttons */ +.title-actions { + display: flex; + gap: 12px; + align-items: center; +} + +.title-action-btn { + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 50%; + color: #fff; + cursor: pointer; + transition: all 0.2s ease; +} + +.title-action-btn--icon { + width: 44px; + height: 44px; +} + +.title-action-btn:hover { + background: rgba(255, 255, 255, 0.2); + transform: scale(1.05); +} + +.title-action-btn.active { + background: #fff; + color: #000; + border-color: #fff; +} + +/* ============================================ + Netflix Floating Pill Tabs + ============================================ */ +.title-tabs { + display: flex; + gap: 8px; + margin: 40px 0 24px; + padding: 0; + border-bottom: none; +} + +.title-tab { + padding: 10px 24px; + background: rgba(255, 255, 255, 0.08); + border: none; + border-radius: 9999px; + color: rgba(255, 255, 255, 0.8); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.title-tab:hover { + background: rgba(255, 255, 255, 0.15); + color: #fff; +} + +.title-tab.active { + background: rgba(255, 255, 255, 0.2); + color: #fff; +} + +/* Title Panels */ +.title-panel { + animation: fadeIn 0.3s ease; +} + +/* ============================================ + Episode Buttons Grid (Netflix Style) + ============================================ */ +.episodes-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.episodes-title { + font-size: 1.25rem; + font-weight: 600; + color: #fff; + margin: 0; +} + +.episodes-count { + font-size: 0.9rem; + color: rgba(255, 255, 255, 0.5); +} + +.episodes-grid { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.episode-btn { + min-width: 54px; + height: 44px; + padding: 10px 18px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + color: #fff; + font-size: 0.95rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.episode-btn:hover { + background: rgba(255, 255, 255, 0.2); + transform: scale(1.03); +} + +.episode-btn.active { + background: #e50914; + border-color: #e50914; + color: #fff; +} + +/* ============================================ + More Like This Section + ============================================ */ +.rec-section { + margin-top: 32px; + padding-top: 24px; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +.rec-section:first-child { + margin-top: 0; + padding-top: 0; + border-top: none; +} + +.rec-section-title { + font-size: 1.2rem; + font-weight: 600; + color: #fff; + margin: 0 0 16px; +} + +.rec-carousel { + display: flex; + gap: 12px; + overflow-x: auto; + padding-bottom: 16px; + scroll-behavior: smooth; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.rec-carousel::-webkit-scrollbar { + display: none; +} + +/* Netflix Poster Cards - 150x210px */ +.rec-card { + flex: 0 0 150px; + text-decoration: none; + color: #fff; + transition: transform 0.2s ease; +} + +.rec-card:hover { + transform: scale(1.05); +} + +.rec-card .rec-img-container { + width: 150px; + height: 210px; + border-radius: 4px; + overflow: hidden; + background: #2a2a2a; + margin-bottom: 8px; +} + +.rec-card .rec-img-container img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.rec-card-title { + font-size: 0.85rem; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 2px; +} + +.rec-card-meta { + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.5); +} + +/* ============================================ + Details Panel - Cast & Crew + ============================================ */ +.details-section { + margin-bottom: 32px; +} + +.details-section__title { + font-size: 1.2rem; + font-weight: 600; + color: #fff; + margin: 0 0 16px; +} + +.cast-carousel { + display: flex; + gap: 16px; + overflow-x: auto; + padding-bottom: 16px; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.cast-carousel::-webkit-scrollbar { + display: none; +} + +/* ============================================ + Responsive - Title Page + ============================================ */ +@media (max-width: 1024px) { + .title-details__container { + padding: 0 40px; + } + + .title-info__name { + font-size: 2rem; + } +} + +@media (max-width: 768px) { + .title-details__container { + padding: 0 20px; + } + + .title-info__name { + font-size: 1.5rem; + } + + .title-tabs { + flex-wrap: wrap; + } + + .title-tab { + padding: 8px 16px; + font-size: 13px; + } + + .rec-card { + flex: 0 0 120px; + } + + .rec-card .rec-img-container { + width: 120px; + height: 168px; + } +} \ No newline at end of file diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..879a0ef --- /dev/null +++ b/frontend/vite.config.js @@ -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') + } + } + } +}) diff --git a/frontend/watch.html b/frontend/watch.html new file mode 100644 index 0000000..9413550 --- /dev/null +++ b/frontend/watch.html @@ -0,0 +1,401 @@ + + + + + + + StreamFlix - Movie Details + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    + + +
    + + + +
    +
    + play_arrow +
    +
    + + + +
    + + +
    + +
    +
    + movie + Series +
    +

    Loading...

    + +
    + 98% Match + 2024 + TV-MA + 2h 30m + HD +
    +
    + + +
    + +
    + + +
    +
    + + +
    +

    Loading...

    +
    + + +
    + + + + + +
    + +
    + +
    + +
    + +
    + + +
    + + +
    + + +
    + +
    + + Episodes +
    + +
    + +
    +
    + Loading episodes... +
    +
    +
    + + + + + + +
    + + +
    + +
    + +
    +
    +
    +
    + + + + + + + + +
    + + + + + + + + + + + + \ No newline at end of file