v1.0.5 Gold Master - Final Release
This commit is contained in:
parent
b2160a68bb
commit
a9da3c360e
57 changed files with 3837 additions and 698 deletions
61
backend/image_service.py
Normal file
61
backend/image_service.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import os
|
||||||
|
import httpx
|
||||||
|
import hashlib
|
||||||
|
from PIL import Image
|
||||||
|
from io import BytesIO
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
CACHE_DIR = "cache/images"
|
||||||
|
os.makedirs(CACHE_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
async def get_proxied_image(url: str, width: Optional[int] = None):
|
||||||
|
"""
|
||||||
|
Fetch an image, resize it, convert to WebP, and cache it.
|
||||||
|
"""
|
||||||
|
# Create a unique cache key based on URL and width
|
||||||
|
cache_key = hashlib.md5(f"{url}_{width}".encode()).hexdigest()
|
||||||
|
cache_path = os.path.join(CACHE_DIR, f"{cache_key}.webp")
|
||||||
|
|
||||||
|
# 1. Check if cached version exists
|
||||||
|
if os.path.exists(cache_path):
|
||||||
|
with open(cache_path, "rb") as f:
|
||||||
|
return Response(content=f.read(), media_type="image/webp")
|
||||||
|
|
||||||
|
# 2. Fetch original image
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||||
|
try:
|
||||||
|
response = await client.get(url, timeout=10.0)
|
||||||
|
response.raise_for_status()
|
||||||
|
except Exception as e:
|
||||||
|
# Fallback or error
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 3. Process image with Pillow
|
||||||
|
try:
|
||||||
|
img = Image.open(BytesIO(response.content))
|
||||||
|
|
||||||
|
# Convert to RGB if necessary (e.g., from RGBA or CMYK)
|
||||||
|
if img.mode in ("RGBA", "P"):
|
||||||
|
img = img.convert("RGB")
|
||||||
|
|
||||||
|
# Resize if width specified
|
||||||
|
if width and img.width > width:
|
||||||
|
ratio = width / float(img.width)
|
||||||
|
height = int(float(img.height) * float(ratio))
|
||||||
|
img = img.resize((width, height), Image.LANCZOS)
|
||||||
|
|
||||||
|
# 4. Save to buffer as WebP
|
||||||
|
output = BytesIO()
|
||||||
|
img.save(output, format="WEBP", quality=80)
|
||||||
|
webp_data = output.getvalue()
|
||||||
|
|
||||||
|
# 5. Save to cache
|
||||||
|
with open(cache_path, "wb") as f:
|
||||||
|
f.write(webp_data)
|
||||||
|
|
||||||
|
return Response(content=webp_data, media_type="image/webp")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error processing image: {e}")
|
||||||
|
return None
|
||||||
127
backend/main.py
127
backend/main.py
|
|
@ -14,6 +14,8 @@ from fastapi.responses import FileResponse, JSONResponse
|
||||||
from cache import cache
|
from cache import cache
|
||||||
from video_extractor import extractor, VideoInfo
|
from video_extractor import extractor, VideoInfo
|
||||||
from database import init_db, get_db, VideoRepository, Video
|
from database import init_db, get_db, VideoRepository, Video
|
||||||
|
from security import verify_hmac
|
||||||
|
from image_service import get_proxied_image
|
||||||
|
|
||||||
# Initialize FastAPI app
|
# Initialize FastAPI app
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
|
|
@ -25,7 +27,13 @@ app = FastAPI(
|
||||||
# CORS middleware
|
# CORS middleware
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=[
|
||||||
|
"https://nf.khoavo.myds.me",
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://localhost:3000",
|
||||||
|
"capacitor://localhost",
|
||||||
|
"http://localhost"
|
||||||
|
],
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
|
|
@ -80,6 +88,17 @@ async def startup():
|
||||||
print("ℹ Use /api/admin/update to update dependencies")
|
print("ℹ Use /api/admin/update to update dependencies")
|
||||||
|
|
||||||
|
|
||||||
|
# Get images via proxy
|
||||||
|
@app.get("/api/images/proxy")
|
||||||
|
async def proxy_image(url: str, width: Optional[int] = None):
|
||||||
|
"""
|
||||||
|
Proxy and optimize images (WebP + Resizing)
|
||||||
|
"""
|
||||||
|
response = await get_proxied_image(url, width)
|
||||||
|
if not response:
|
||||||
|
raise HTTPException(status_code=404, detail="Image not found or could not be processed")
|
||||||
|
return response
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
|
|
@ -95,7 +114,7 @@ async def health_check():
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
@app.get("/api/admin/version")
|
@app.get("/api/admin/version")
|
||||||
async def get_versions():
|
async def get_versions(authorized: bool = Depends(verify_hmac)):
|
||||||
"""Get versions of all managed dependencies"""
|
"""Get versions of all managed dependencies"""
|
||||||
from auto_updater import get_all_versions
|
from auto_updater import get_all_versions
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
@ -110,7 +129,7 @@ async def get_versions():
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/update")
|
@app.post("/api/admin/update")
|
||||||
async def trigger_update(package: str = None):
|
async def trigger_update(package: str = None, authorized: bool = Depends(verify_hmac)):
|
||||||
"""Trigger manual update of dependencies
|
"""Trigger manual update of dependencies
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -141,7 +160,7 @@ async def trigger_update(package: str = None):
|
||||||
|
|
||||||
# Video extraction endpoint
|
# Video extraction endpoint
|
||||||
@app.post("/api/extract", response_model=ExtractResponse)
|
@app.post("/api/extract", response_model=ExtractResponse)
|
||||||
async def extract_video(request: ExtractRequest):
|
async def extract_video(request: ExtractRequest, authorized: bool = Depends(verify_hmac)):
|
||||||
"""
|
"""
|
||||||
Extract video stream URL from source.
|
Extract video stream URL from source.
|
||||||
Uses cache-aside pattern with 3-hour TTL.
|
Uses cache-aside pattern with 3-hour TTL.
|
||||||
|
|
@ -193,7 +212,7 @@ async def extract_video(request: ExtractRequest):
|
||||||
|
|
||||||
# Get available qualities
|
# Get available qualities
|
||||||
@app.get("/api/qualities")
|
@app.get("/api/qualities")
|
||||||
async def get_qualities(url: str):
|
async def get_qualities(url: str, authorized: bool = Depends(verify_hmac)):
|
||||||
"""Get available quality options for a video"""
|
"""Get available quality options for a video"""
|
||||||
try:
|
try:
|
||||||
qualities = await extractor.get_available_qualities(url)
|
qualities = await extractor.get_available_qualities(url)
|
||||||
|
|
@ -204,7 +223,7 @@ async def get_qualities(url: str):
|
||||||
|
|
||||||
# Video CRUD endpoints
|
# Video CRUD endpoints
|
||||||
@app.post("/api/videos", response_model=VideoResponse)
|
@app.post("/api/videos", response_model=VideoResponse)
|
||||||
async def create_video(video: VideoCreate, db=Depends(get_db)):
|
async def create_video(video: VideoCreate, db=Depends(get_db), authorized: bool = Depends(verify_hmac)):
|
||||||
"""Add a video to the library"""
|
"""Add a video to the library"""
|
||||||
repo = VideoRepository(db)
|
repo = VideoRepository(db)
|
||||||
|
|
||||||
|
|
@ -222,7 +241,8 @@ async def list_videos(
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(50, ge=1, le=100),
|
limit: int = Query(50, ge=1, le=100),
|
||||||
category: Optional[str] = None,
|
category: Optional[str] = None,
|
||||||
db=Depends(get_db)
|
db=Depends(get_db),
|
||||||
|
authorized: bool = Depends(verify_hmac)
|
||||||
):
|
):
|
||||||
"""List all videos with pagination"""
|
"""List all videos with pagination"""
|
||||||
repo = VideoRepository(db)
|
repo = VideoRepository(db)
|
||||||
|
|
@ -232,7 +252,7 @@ async def list_videos(
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/videos/{video_id}", response_model=VideoResponse)
|
@app.get("/api/videos/{video_id}", response_model=VideoResponse)
|
||||||
async def get_video(video_id: int, db=Depends(get_db)):
|
async def get_video(video_id: int, db=Depends(get_db), authorized: bool = Depends(verify_hmac)):
|
||||||
"""Get video by ID"""
|
"""Get video by ID"""
|
||||||
repo = VideoRepository(db)
|
repo = VideoRepository(db)
|
||||||
video = repo.get_by_id(video_id)
|
video = repo.get_by_id(video_id)
|
||||||
|
|
@ -242,7 +262,7 @@ async def get_video(video_id: int, db=Depends(get_db)):
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/videos/{video_id}")
|
@app.delete("/api/videos/{video_id}")
|
||||||
async def delete_video(video_id: int, db=Depends(get_db)):
|
async def delete_video(video_id: int, db=Depends(get_db), authorized: bool = Depends(verify_hmac)):
|
||||||
"""Delete video from library"""
|
"""Delete video from library"""
|
||||||
repo = VideoRepository(db)
|
repo = VideoRepository(db)
|
||||||
if repo.delete(video_id):
|
if repo.delete(video_id):
|
||||||
|
|
@ -255,7 +275,8 @@ async def delete_video(video_id: int, db=Depends(get_db)):
|
||||||
async def search_videos(
|
async def search_videos(
|
||||||
q: str = Query(..., min_length=1),
|
q: str = Query(..., min_length=1),
|
||||||
limit: int = Query(20, ge=1, le=50),
|
limit: int = Query(20, ge=1, le=50),
|
||||||
db=Depends(get_db)
|
db=Depends(get_db),
|
||||||
|
authorized: bool = Depends(verify_hmac)
|
||||||
):
|
):
|
||||||
"""Search videos by title"""
|
"""Search videos by title"""
|
||||||
repo = VideoRepository(db)
|
repo = VideoRepository(db)
|
||||||
|
|
@ -271,7 +292,8 @@ async def get_phimmoichill_catalog(
|
||||||
category: Optional[str] = None,
|
category: Optional[str] = None,
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
limit: int = Query(24, ge=1, le=50),
|
limit: int = Query(24, ge=1, le=50),
|
||||||
sort: str = Query("modified", description="Sort by: modified, year, rating")
|
sort: str = Query("modified", description="Sort by: modified, year, rating"),
|
||||||
|
authorized: bool = Depends(verify_hmac)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get movie catalog from ophim API with sorting support.
|
Get movie catalog from ophim API with sorting support.
|
||||||
|
|
@ -440,7 +462,8 @@ async def get_phimmoichill_catalog(
|
||||||
@app.get("/api/rophim/search")
|
@app.get("/api/rophim/search")
|
||||||
async def search_phimmoichill(
|
async def search_phimmoichill(
|
||||||
q: str = Query(..., min_length=1),
|
q: str = Query(..., min_length=1),
|
||||||
limit: int = Query(20, ge=1, le=50)
|
limit: int = Query(20, ge=1, le=50),
|
||||||
|
authorized: bool = Depends(verify_hmac)
|
||||||
):
|
):
|
||||||
"""Search movies by title AND actors using ophim API"""
|
"""Search movies by title AND actors using ophim API"""
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
@ -527,7 +550,7 @@ async def search_phimmoichill(
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/rophim/categories/discover")
|
@app.get("/api/rophim/categories/discover")
|
||||||
async def discover_categories():
|
async def discover_categories(authorized: bool = Depends(verify_hmac)):
|
||||||
"""
|
"""
|
||||||
Discover all available categories from PhimMoiChill
|
Discover all available categories from PhimMoiChill
|
||||||
Returns types, genres, countries, and years
|
Returns types, genres, countries, and years
|
||||||
|
|
@ -556,7 +579,8 @@ async def discover_categories():
|
||||||
async def get_movies_by_category(
|
async def get_movies_by_category(
|
||||||
slug: str = Query(..., description="Category slug (e.g., 'the-loai/hanh-dong', 'danh-sach/phim-le')"),
|
slug: str = Query(..., description="Category slug (e.g., 'the-loai/hanh-dong', 'danh-sach/phim-le')"),
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
limit: int = Query(24, ge=1, le=50)
|
limit: int = Query(24, ge=1, le=50),
|
||||||
|
authorized: bool = Depends(verify_hmac)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get movies for a specific category
|
Get movies for a specific category
|
||||||
|
|
@ -587,7 +611,7 @@ async def get_movies_by_category(
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/rophim/home/curated")
|
@app.get("/api/rophim/home/curated")
|
||||||
async def get_curated_homepage_sections():
|
async def get_curated_homepage_sections(authorized: bool = Depends(verify_hmac)):
|
||||||
"""
|
"""
|
||||||
Get curated homepage sections with TOP RATED, NEW RELEASES, and popular genres.
|
Get curated homepage sections with TOP RATED, NEW RELEASES, and popular genres.
|
||||||
This provides a Rotten Tomatoes / Moviewiser style layout.
|
This provides a Rotten Tomatoes / Moviewiser style layout.
|
||||||
|
|
@ -693,7 +717,7 @@ async def get_curated_homepage_sections():
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/rophim/stream/{slug}")
|
@app.get("/api/rophim/stream/{slug}")
|
||||||
async def get_rophim_stream(slug: str, episode: int = 1):
|
async def get_rophim_stream(slug: str, episode: int = 1, authorized: bool = Depends(verify_hmac)):
|
||||||
"""
|
"""
|
||||||
Get video stream URL from ophim API for a specific slug and episode.
|
Get video stream URL from ophim API for a specific slug and episode.
|
||||||
"""
|
"""
|
||||||
|
|
@ -715,7 +739,7 @@ async def get_rophim_stream(slug: str, episode: int = 1):
|
||||||
return JSONResponse(status_code=500, content={"detail": str(e)})
|
return JSONResponse(status_code=500, content={"detail": str(e)})
|
||||||
|
|
||||||
@app.post("/api/rophim/stream")
|
@app.post("/api/rophim/stream")
|
||||||
async def get_rophim_stream_post(data: dict):
|
async def get_rophim_stream_post(data: dict, authorized: bool = Depends(verify_hmac)):
|
||||||
"""
|
"""
|
||||||
Get video stream URL (POST) - supports source_url if needed
|
Get video stream URL (POST) - supports source_url if needed
|
||||||
"""
|
"""
|
||||||
|
|
@ -748,7 +772,7 @@ async def get_rophim_stream_post(data: dict):
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/rophim/home/sections")
|
@app.get("/api/rophim/home/sections")
|
||||||
async def get_home_more_sections(page: int = Query(1, ge=1), view: str = Query('home')):
|
async def get_home_more_sections(page: int = Query(1, ge=1), view: str = Query('home'), authorized: bool = Depends(verify_hmac)):
|
||||||
"""
|
"""
|
||||||
Get paginated sections for homepage OR specific views (infinite scroll).
|
Get paginated sections for homepage OR specific views (infinite scroll).
|
||||||
Returns dynamic sections (Genres, Countries, etc.) or View specific sections.
|
Returns dynamic sections (Genres, Countries, etc.) or View specific sections.
|
||||||
|
|
@ -788,7 +812,7 @@ def clean_movie_description(movie: Dict) -> Dict:
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/rophim/movie/{slug}")
|
@app.get("/api/rophim/movie/{slug}")
|
||||||
async def get_phimmoichill_movie(slug: str):
|
async def get_phimmoichill_movie(slug: str, authorized: bool = Depends(verify_hmac)):
|
||||||
"""Get detailed movie info from PhimMoiChill with optional TMDB enrichment"""
|
"""Get detailed movie info from PhimMoiChill with optional TMDB enrichment"""
|
||||||
import asyncio
|
import asyncio
|
||||||
from rophim_scraper import get_movie_details
|
from rophim_scraper import get_movie_details
|
||||||
|
|
@ -825,7 +849,8 @@ async def get_phimmoichill_movie(slug: str):
|
||||||
async def get_phimmoichill_stream(
|
async def get_phimmoichill_stream(
|
||||||
slug: str,
|
slug: str,
|
||||||
episode: int = Query(1, ge=1),
|
episode: int = Query(1, ge=1),
|
||||||
server: int = Query(0, ge=0, le=2)
|
server: int = Query(0, ge=0, le=2),
|
||||||
|
authorized: bool = Depends(verify_hmac)
|
||||||
):
|
):
|
||||||
"""Get video stream URL for a movie/episode using ophim API"""
|
"""Get video stream URL for a movie/episode using ophim API"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
@ -861,7 +886,7 @@ class PhimMoiChillStreamRequest(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/rophim/stream")
|
@app.post("/api/rophim/stream")
|
||||||
async def get_phimmoichill_stream_by_url(request: PhimMoiChillStreamRequest):
|
async def get_phimmoichill_stream_by_url(request: PhimMoiChillStreamRequest, authorized: bool = Depends(verify_hmac)):
|
||||||
"""Get video stream URL using slug from source_url - uses ophim API"""
|
"""Get video stream URL using slug from source_url - uses ophim API"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import re
|
import re
|
||||||
|
|
@ -906,7 +931,8 @@ async def get_phimmoichill_stream_by_url(request: PhimMoiChillStreamRequest):
|
||||||
@app.post("/api/crawl/trigger")
|
@app.post("/api/crawl/trigger")
|
||||||
async def trigger_crawl(
|
async def trigger_crawl(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
limit: int = Query(50, ge=1, le=100)
|
limit: int = Query(50, ge=1, le=100),
|
||||||
|
authorized: bool = Depends(verify_hmac)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Trigger a movie catalog crawl.
|
Trigger a movie catalog crawl.
|
||||||
|
|
@ -949,7 +975,7 @@ async def crawl_status():
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
@app.get("/api/rophim/categories/all")
|
@app.get("/api/rophim/categories/all")
|
||||||
async def get_all_categories():
|
async def get_all_categories(authorized: bool = Depends(verify_hmac)):
|
||||||
"""Get all themed category sections in one call"""
|
"""Get all themed category sections in one call"""
|
||||||
import asyncio
|
import asyncio
|
||||||
from category_scraper import get_categories_sync
|
from category_scraper import get_categories_sync
|
||||||
|
|
@ -967,7 +993,7 @@ async def get_all_categories():
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/rophim/categories/hot")
|
@app.get("/api/rophim/categories/hot")
|
||||||
async def get_hot_category(limit: int = Query(24, ge=1, le=50)):
|
async def get_hot_category(limit: int = Query(24, ge=1, le=50), authorized: bool = Depends(verify_hmac)):
|
||||||
"""Get Hot Movies category"""
|
"""Get Hot Movies category"""
|
||||||
import asyncio
|
import asyncio
|
||||||
from category_scraper import PhimMoiChillCategoryScraper
|
from category_scraper import PhimMoiChillCategoryScraper
|
||||||
|
|
@ -992,7 +1018,7 @@ async def get_hot_category(limit: int = Query(24, ge=1, le=50)):
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/rophim/categories/new-releases")
|
@app.get("/api/rophim/categories/new-releases")
|
||||||
async def get_new_releases_category(limit: int = Query(24, ge=1, le=50)):
|
async def get_new_releases_category(limit: int = Query(24, ge=1, le=50), authorized: bool = Depends(verify_hmac)):
|
||||||
"""Get New Releases category"""
|
"""Get New Releases category"""
|
||||||
import asyncio
|
import asyncio
|
||||||
from category_scraper import PhimMoiChillCategoryScraper
|
from category_scraper import PhimMoiChillCategoryScraper
|
||||||
|
|
@ -1017,7 +1043,7 @@ async def get_new_releases_category(limit: int = Query(24, ge=1, le=50)):
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/rophim/categories/top10")
|
@app.get("/api/rophim/categories/top10")
|
||||||
async def get_top10_category():
|
async def get_top10_category(authorized: bool = Depends(verify_hmac)):
|
||||||
"""Get Top 10 Most Watched"""
|
"""Get Top 10 Most Watched"""
|
||||||
import asyncio
|
import asyncio
|
||||||
from category_scraper import PhimMoiChillCategoryScraper
|
from category_scraper import PhimMoiChillCategoryScraper
|
||||||
|
|
@ -1042,7 +1068,7 @@ async def get_top10_category():
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/rophim/categories/cinema")
|
@app.get("/api/rophim/categories/cinema")
|
||||||
async def get_cinema_category(limit: int = Query(24, ge=1, le=50)):
|
async def get_cinema_category(limit: int = Query(24, ge=1, le=50), authorized: bool = Depends(verify_hmac)):
|
||||||
"""Get Cinema Releases category"""
|
"""Get Cinema Releases category"""
|
||||||
import asyncio
|
import asyncio
|
||||||
from category_scraper import PhimMoiChillCategoryScraper
|
from category_scraper import PhimMoiChillCategoryScraper
|
||||||
|
|
@ -1079,22 +1105,14 @@ print(f"🔍 DEBUG: Path exists: {os.path.exists(frontend_path)}")
|
||||||
if os.path.exists(frontend_path):
|
if os.path.exists(frontend_path):
|
||||||
print(f"✓ Serving frontend from {frontend_path}")
|
print(f"✓ Serving frontend from {frontend_path}")
|
||||||
|
|
||||||
# Mount directories only if they exist (Vite production builds often flatten these)
|
# Mount main directories
|
||||||
for folder in ["assets", "icons", "scripts", "styles", "js"]:
|
for folder in ["assets", "icons", "scripts", "styles", "js", "public"]:
|
||||||
folder_path = os.path.join(frontend_path, folder)
|
folder_path = os.path.join(frontend_path, folder)
|
||||||
if os.path.exists(folder_path):
|
if os.path.exists(folder_path):
|
||||||
app.mount(f"/{folder}", StaticFiles(directory=folder_path), name=folder)
|
app.mount(f"/{folder}", StaticFiles(directory=folder_path), name=folder)
|
||||||
print(f" - Mounted /{folder}")
|
print(f" - Mounted /{folder}")
|
||||||
|
|
||||||
@app.get("/")
|
# Direct file responses for root files
|
||||||
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")
|
@app.get("/manifest.json")
|
||||||
async def serve_manifest():
|
async def serve_manifest():
|
||||||
return FileResponse(os.path.join(frontend_path, "manifest.json"))
|
return FileResponse(os.path.join(frontend_path, "manifest.json"))
|
||||||
|
|
@ -1103,13 +1121,38 @@ if os.path.exists(frontend_path):
|
||||||
async def serve_sw():
|
async def serve_sw():
|
||||||
return FileResponse(os.path.join(frontend_path, "sw.js"))
|
return FileResponse(os.path.join(frontend_path, "sw.js"))
|
||||||
|
|
||||||
# Catch-all for any other routes (SPA support)
|
@app.get("/favicon.ico")
|
||||||
|
async def serve_favicon():
|
||||||
|
favicon = os.path.join(frontend_path, "favicon.ico")
|
||||||
|
if os.path.exists(favicon):
|
||||||
|
return FileResponse(favicon)
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
@app.get("/watch")
|
||||||
|
@app.get("/watch.html")
|
||||||
|
async def serve_watch():
|
||||||
|
return FileResponse(os.path.join(frontend_path, "watch.html"))
|
||||||
|
|
||||||
|
# Root index
|
||||||
|
@app.get("/")
|
||||||
|
async def serve_index():
|
||||||
|
return FileResponse(os.path.join(frontend_path, "index.html"))
|
||||||
|
|
||||||
|
# Catch-all for SPA navigation (only for GET requests and non-API, non-file paths)
|
||||||
@app.exception_handler(404)
|
@app.exception_handler(404)
|
||||||
async def custom_404_handler(request, exc):
|
async def custom_404_handler(request: Request, exc):
|
||||||
if not request.url.path.startswith("/api"):
|
path = request.url.path
|
||||||
|
# Don't intercept API or static file requests
|
||||||
|
if (not path.startswith("/api") and
|
||||||
|
not any(path.startswith(f"/{f}") for f in ["assets", "scripts", "styles", "js", "icons"]) and
|
||||||
|
"." not in path.split("/")[-1]):
|
||||||
if os.path.exists(os.path.join(frontend_path, "index.html")):
|
if os.path.exists(os.path.join(frontend_path, "index.html")):
|
||||||
return FileResponse(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"})
|
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=404,
|
||||||
|
content={"detail": "Not found", "path": path}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -28,3 +28,6 @@ httpx>=0.25.0
|
||||||
|
|
||||||
# Multipart uploads
|
# Multipart uploads
|
||||||
python-multipart>=0.0.6
|
python-multipart>=0.0.6
|
||||||
|
|
||||||
|
# Image Processing
|
||||||
|
Pillow>=10.0.0
|
||||||
|
|
|
||||||
59
backend/security.py
Normal file
59
backend/security.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
from fastapi import Request, HTTPException, Security
|
||||||
|
from fastapi.security import APIKeyHeader
|
||||||
|
|
||||||
|
# In production, this should be an environment variable
|
||||||
|
# For now, we'll use a placeholder that the user can set
|
||||||
|
SECRET_KEY = os.getenv("STREAMFLIX_SECRET_KEY", "your-super-secret-key-change-this")
|
||||||
|
|
||||||
|
signature_header = APIKeyHeader(name="X-Signature", auto_error=False)
|
||||||
|
timestamp_header = APIKeyHeader(name="X-Timestamp", auto_error=False)
|
||||||
|
|
||||||
|
def verify_hmac(
|
||||||
|
request: Request,
|
||||||
|
signature: str = Security(signature_header),
|
||||||
|
timestamp: str = Security(timestamp_header)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Verify HMAC signature of the request.
|
||||||
|
Signature = HMAC_SHA256(secret, timestamp + path + method + body)
|
||||||
|
"""
|
||||||
|
if not signature or not timestamp:
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication headers missing")
|
||||||
|
|
||||||
|
# 1. Check timestamp (prevents replay attacks, 5 minute window)
|
||||||
|
try:
|
||||||
|
request_time = int(timestamp)
|
||||||
|
current_time = int(time.time())
|
||||||
|
if abs(current_time - request_time) > 300: # 5 minutes
|
||||||
|
raise HTTPException(status_code=401, detail="Request expired")
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid timestamp")
|
||||||
|
|
||||||
|
# 2. Reconstruct payload
|
||||||
|
# Note: For GET requests, body is empty
|
||||||
|
body = b""
|
||||||
|
if request.method in ["POST", "PUT", "PATCH"]:
|
||||||
|
# This is tricky in FastAPI as reading body consumes it
|
||||||
|
# We'll need to handle this carefully if we want to sign the body
|
||||||
|
pass
|
||||||
|
|
||||||
|
path = request.url.path
|
||||||
|
method = request.method
|
||||||
|
|
||||||
|
payload = f"{timestamp}{path}{method}".encode()
|
||||||
|
|
||||||
|
# 3. Calculate signature
|
||||||
|
expected_signature = hmac.new(
|
||||||
|
SECRET_KEY.encode(),
|
||||||
|
payload,
|
||||||
|
hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
if not hmac.compare_digest(signature, expected_signature):
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid signature")
|
||||||
|
|
||||||
|
return True
|
||||||
File diff suppressed because one or more lines are too long
47
backend/static/assets/keyboard-nav-CZ5sEhKF.js
Normal file
47
backend/static/assets/keyboard-nav-CZ5sEhKF.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
392
backend/static/assets/main-xeQDVOBN.js
Normal file
392
backend/static/assets/main-xeQDVOBN.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
104
backend/static/assets/watch-CsORLc4P.js
Normal file
104
backend/static/assets/watch-CsORLc4P.js
Normal file
File diff suppressed because one or more lines are too long
1
backend/static/assets/web-Bp6c6Vk9.js
Normal file
1
backend/static/assets/web-Bp6c6Vk9.js
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
import{W as a,I as i,N as r}from"./keyboard-nav-CZ5sEhKF.js";class o extends a{constructor(){super(...arguments),this.selectionStarted=!1}async impact(t){const e=this.patternForImpact(t==null?void 0:t.style);this.vibrateWithPattern(e)}async notification(t){const e=this.patternForNotification(t==null?void 0:t.type);this.vibrateWithPattern(e)}async vibrate(t){const e=(t==null?void 0:t.duration)||300;this.vibrateWithPattern([e])}async selectionStart(){this.selectionStarted=!0}async selectionChanged(){this.selectionStarted&&this.vibrateWithPattern([70])}async selectionEnd(){this.selectionStarted=!1}patternForImpact(t=i.Heavy){return t===i.Medium?[43]:t===i.Light?[20]:[61]}patternForNotification(t=r.Success){return t===r.Warning?[30,40,30,50,60]:t===r.Error?[27,45,50]:[35,65,21]}vibrateWithPattern(t){if(navigator.vibrate)navigator.vibrate(t);else throw this.unavailable("Browser does not support the vibrate API")}}export{o as HapticsWeb};
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
|
<meta content="width=device-width, initial-scale=1.0, viewport-fit=cover" name="viewport" />
|
||||||
<title>StreamFlix - Homepage</title>
|
<title>StreamFlix - Homepage</title>
|
||||||
<meta name="description" content="StreamFlix - Premium Movie Streaming">
|
<meta name="description" content="StreamFlix - Premium Movie Streaming">
|
||||||
<meta name="theme-color" content="#141414">
|
<meta name="theme-color" content="#141414">
|
||||||
|
|
@ -42,6 +42,21 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
:root {
|
||||||
|
--safe-top: env(safe-area-inset-top, 0px);
|
||||||
|
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mainHeader {
|
||||||
|
padding-top: calc(0.5rem + var(--safe-top));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
#mobileBottomNav {
|
||||||
|
padding-bottom: calc(1.25rem + var(--safe-bottom)) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||||
.no-scrollbar::-webkit-scrollbar {
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
@ -97,6 +112,81 @@
|
||||||
.search-modal.active {
|
.search-modal.active {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* TV Focus Ring */
|
||||||
|
.keyboard-focused {
|
||||||
|
outline: 3px solid #ea2a33 !important;
|
||||||
|
outline-offset: 2px !important;
|
||||||
|
transform: scale(1.05) !important;
|
||||||
|
z-index: 50 !important;
|
||||||
|
transition: transform 0.2s ease, outline 0.2s ease !important;
|
||||||
|
box-shadow: 0 0 20px rgba(234, 42, 51, 0.4) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Splash Screen */
|
||||||
|
#splash-screen {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
background-color: #141414;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: opacity 0.8s cubic-bezier(0.4, 0, 0.2, 1), visibility 0.8s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#splash-screen.fade-out {
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splash-logo {
|
||||||
|
width: 300px;
|
||||||
|
max-width: 80%;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
animation: pulse-logo 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-logo {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: scale(1.02);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container {
|
||||||
|
width: 240px;
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loading-bar {
|
||||||
|
width: 0%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #FF0000, #B30000);
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: width 0.4s ease-out;
|
||||||
|
box-shadow: 0 0 10px rgba(234, 42, 51, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#loading-text {
|
||||||
|
margin-top: 15px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<!-- PWA Meta Tags -->
|
<!-- PWA Meta Tags -->
|
||||||
|
|
@ -107,11 +197,21 @@
|
||||||
<meta name="apple-mobile-web-app-title" content="StreamFlix">
|
<meta name="apple-mobile-web-app-title" content="StreamFlix">
|
||||||
<link rel="icon" type="image/png" href="/icons/icon-512.png">
|
<link rel="icon" type="image/png" href="/icons/icon-512.png">
|
||||||
<link rel="apple-touch-icon" href="/icons/icon-512.png">
|
<link rel="apple-touch-icon" href="/icons/icon-512.png">
|
||||||
<script type="module" crossorigin src="/assets/main-B16Z87Li.js"></script>
|
<script type="module" crossorigin src="/assets/main-xeQDVOBN.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/Toast-BwR22KmJ.js">
|
<link rel="modulepreload" crossorigin href="/assets/keyboard-nav-CZ5sEhKF.js">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="bg-background-light dark:bg-background-dark text-white font-display overflow-x-hidden antialiased">
|
<body class="bg-background-light dark:bg-background-dark text-white font-display overflow-x-hidden antialiased">
|
||||||
|
|
||||||
|
<!-- Splash Screen -->
|
||||||
|
<div id="splash-screen">
|
||||||
|
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0MDAgMTAwIj4KICAgIDxkZWZzPgogICAgICAgIDxsaW5lYXJHcmFkaWVudCBpZD0ibG9nb0dyYWRpZW50IiB4MT0iMCUiIHkxPSIwJSIgeDI9IjEwMCUiIHkyPSIwJSI+CiAgICAgICAgICAgIDxzdG9wIG9mZnNldD0iMCUiIHN0eWxlPSJzdG9wLWNvbG9yOiNGRjAwMDA7c3RvcC1vcGFjaXR5OjEiIC8+CiAgICAgICAgICAgIDxzdG9wIG9mZnNldD0iMTAwJSIgc3R5bGU9InN0b3AtY29sb3I6I0IzMDAwMDtzdG9wLW9wYWNpdHk6MSIgLz4KICAgICAgICA8L2xpbmVhckdyYWRpZW50PgogICAgICAgIDxmaWx0ZXIgaWQ9InNoYWRvdyIgeD0iLTIwJSIgeT0iLTIwJSIgd2lkdGg9IjE0MCUiIGhlaWdodD0iMTQwJSI+CiAgICAgICAgICAgIDxmZUdhdXNzaWFuQmx1ciBpbj0iU291cmNlQWxwaGEiIHN0ZERldmlhdGlvbj0iMiIgLz4KICAgICAgICAgICAgPGZlT2Zmc2V0IGR4PSIwIiBkeT0iMiIgcmVzdWx0PSJvZmZzZXRibHVyIiAvPgogICAgICAgICAgICA8ZmVDb21wb25lbnRUcmFuc2Zlcj4KICAgICAgICAgICAgICAgIDxmZUZ1bmNBIHR5cGU9ImxpbmVhciIgc2xvcGU9IjAuNSIgLz4KICAgICAgICAgICAgPC9mZUNvbXBvbmVudFRyYW5zZmVyPgogICAgICAgICAgICA8ZmVNZXJnZT4KICAgICAgICAgICAgICAgIDxmZU1lcmdlTm9kZSAvPgogICAgICAgICAgICAgICAgPGZlTWVyZ2VOb2RlIGluPSJTb3VyY2VHcmFwaGljIiAvPgogICAgICAgICAgICA8L2ZlTWVyZ2U+CiAgICAgICAgPC9maWx0ZXI+CiAgICA8L2RlZnM+CiAgICAKICAgIDwhLS0gUyBJY29uL01hcmsgLS0+CiAgICA8cGF0aCBkPSJNNDAgMjAgTDYwIDIwIEw2MCA0MCBMNDAgNDAgTDQwIDYwIEw2MCA2MCBMNjAgODAgTDQwIDgwIiBmaWxsPSJub25lIiBzdHJva2U9InVybCgjbG9nb0dyYWRpZW50KSIgc3Ryb2tlLXdpZHRoPSIxMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBmaWx0ZXI9InVybCgjc2hhZG93KSIgLz4KICAgIAogICAgPCEtLSBTdHJlYW1GbGl4IFRleHQgLS0+CiAgICA8dGV4dCB4PSI4MCIgeT0iNzAiIGZvbnQtZmFtaWx5PSJBcmlhbCwgc2Fucy1zZXJpZiIgZm9udC13ZWlnaHQ9IjkwMCIgZm9udC1zaXplPSI1MiIgZmlsbD0id2hpdGUiIHN0eWxlPSJsZXR0ZXItc3BhY2luZzogLTJweDsiPgogICAgICAgIFNUUkVBTTx0c3BhbiBmaWxsPSJ1cmwoI2xvZ29HcmFkaWVudCkiPkZMSVg8L3RzcGFuPgogICAgPC90ZXh0PgogICAgCiAgICA8IS0tIFN1YnRpdGxlIC0tPgogICAgPHRleHQgeD0iODIiIHk9IjkwIiBmb250LWZhbWlseT0iQXJpYWwsIHNhbnMtc2VyaWYiIGZvbnQtd2VpZ2h0PSI0MDAiIGZvbnQtc2l6ZT0iMTIiIGZpbGw9IiM4ODgiIHN0eWxlPSJsZXR0ZXItc3BhY2luZzogNHB4OyB0ZXh0LXRyYW5zZm9ybTogdXBwZXJjYXNlOyI+CiAgICAgICAgUHJlbWl1bSBDaW5lbWEgRXhwZXJpZW5jZQogICAgPC90ZXh0Pgo8L3N2Zz4K" alt="StreamFlix" class="splash-logo">
|
||||||
|
<div class="loading-container">
|
||||||
|
<div id="loading-bar"></div>
|
||||||
|
</div>
|
||||||
|
<div id="loading-text">Initializing StreamFlix...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="relative flex min-h-screen flex-col">
|
<div class="relative flex min-h-screen flex-col">
|
||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
|
|
@ -121,10 +221,8 @@
|
||||||
<div class="px-4 md:px-12 py-2 md:py-4 flex items-center justify-between">
|
<div class="px-4 md:px-12 py-2 md:py-4 flex items-center justify-between">
|
||||||
<div class="flex items-center gap-8">
|
<div class="flex items-center gap-8">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<a class="flex items-center gap-2 text-primary hover:opacity-90 transition-opacity" href="/">
|
<a class="flex items-center gap-2 hover:opacity-90 transition-opacity" href="/">
|
||||||
<span class="material-symbols-outlined text-4xl text-primary"
|
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0MDAgMTAwIj4KICAgIDxkZWZzPgogICAgICAgIDxsaW5lYXJHcmFkaWVudCBpZD0ibG9nb0dyYWRpZW50IiB4MT0iMCUiIHkxPSIwJSIgeDI9IjEwMCUiIHkyPSIwJSI+CiAgICAgICAgICAgIDxzdG9wIG9mZnNldD0iMCUiIHN0eWxlPSJzdG9wLWNvbG9yOiNGRjAwMDA7c3RvcC1vcGFjaXR5OjEiIC8+CiAgICAgICAgICAgIDxzdG9wIG9mZnNldD0iMTAwJSIgc3R5bGU9InN0b3AtY29sb3I6I0IzMDAwMDtzdG9wLW9wYWNpdHk6MSIgLz4KICAgICAgICA8L2xpbmVhckdyYWRpZW50PgogICAgICAgIDxmaWx0ZXIgaWQ9InNoYWRvdyIgeD0iLTIwJSIgeT0iLTIwJSIgd2lkdGg9IjE0MCUiIGhlaWdodD0iMTQwJSI+CiAgICAgICAgICAgIDxmZUdhdXNzaWFuQmx1ciBpbj0iU291cmNlQWxwaGEiIHN0ZERldmlhdGlvbj0iMiIgLz4KICAgICAgICAgICAgPGZlT2Zmc2V0IGR4PSIwIiBkeT0iMiIgcmVzdWx0PSJvZmZzZXRibHVyIiAvPgogICAgICAgICAgICA8ZmVDb21wb25lbnRUcmFuc2Zlcj4KICAgICAgICAgICAgICAgIDxmZUZ1bmNBIHR5cGU9ImxpbmVhciIgc2xvcGU9IjAuNSIgLz4KICAgICAgICAgICAgPC9mZUNvbXBvbmVudFRyYW5zZmVyPgogICAgICAgICAgICA8ZmVNZXJnZT4KICAgICAgICAgICAgICAgIDxmZU1lcmdlTm9kZSAvPgogICAgICAgICAgICAgICAgPGZlTWVyZ2VOb2RlIGluPSJTb3VyY2VHcmFwaGljIiAvPgogICAgICAgICAgICA8L2ZlTWVyZ2U+CiAgICAgICAgPC9maWx0ZXI+CiAgICA8L2RlZnM+CiAgICAKICAgIDwhLS0gUyBJY29uL01hcmsgLS0+CiAgICA8cGF0aCBkPSJNNDAgMjAgTDYwIDIwIEw2MCA0MCBMNDAgNDAgTDQwIDYwIEw2MCA2MCBMNjAgODAgTDQwIDgwIiBmaWxsPSJub25lIiBzdHJva2U9InVybCgjbG9nb0dyYWRpZW50KSIgc3Ryb2tlLXdpZHRoPSIxMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBmaWx0ZXI9InVybCgjc2hhZG93KSIgLz4KICAgIAogICAgPCEtLSBTdHJlYW1GbGl4IFRleHQgLS0+CiAgICA8dGV4dCB4PSI4MCIgeT0iNzAiIGZvbnQtZmFtaWx5PSJBcmlhbCwgc2Fucy1zZXJpZiIgZm9udC13ZWlnaHQ9IjkwMCIgZm9udC1zaXplPSI1MiIgZmlsbD0id2hpdGUiIHN0eWxlPSJsZXR0ZXItc3BhY2luZzogLTJweDsiPgogICAgICAgIFNUUkVBTTx0c3BhbiBmaWxsPSJ1cmwoI2xvZ29HcmFkaWVudCkiPkZMSVg8L3RzcGFuPgogICAgPC90ZXh0PgogICAgCiAgICA8IS0tIFN1YnRpdGxlIC0tPgogICAgPHRleHQgeD0iODIiIHk9IjkwIiBmb250LWZhbWlseT0iQXJpYWwsIHNhbnMtc2VyaWYiIGZvbnQtd2VpZ2h0PSI0MDAiIGZvbnQtc2l6ZT0iMTIiIGZpbGw9IiM4ODgiIHN0eWxlPSJsZXR0ZXItc3BhY2luZzogNHB4OyB0ZXh0LXRyYW5zZm9ybTogdXBwZXJjYXNlOyI+CiAgICAgICAgUHJlbWl1bSBDaW5lbWEgRXhwZXJpZW5jZQogICAgPC90ZXh0Pgo8L3N2Zz4K" alt="StreamFlix" class="h-8 md:h-10">
|
||||||
style="font-variation-settings: 'FILL' 1;">movie</span>
|
|
||||||
<span class="text-2xl font-bold tracking-tighter text-white hidden sm:block">StreamFlix</span>
|
|
||||||
</a>
|
</a>
|
||||||
<!-- Desktop Nav Links -->
|
<!-- Desktop Nav Links -->
|
||||||
<nav class="hidden md:flex items-center gap-6" id="mainNav">
|
<nav class="hidden md:flex items-center gap-6" id="mainNav">
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
|
<meta content="width=device-width, initial-scale=1.0, viewport-fit=cover" name="viewport" />
|
||||||
<title>StreamFlix - Movie Details</title>
|
<title>StreamFlix - Movie Details</title>
|
||||||
<meta name="description" content="StreamFlix - Watch Movies Online">
|
<meta name="description" content="StreamFlix - Watch Movies Online">
|
||||||
<meta name="theme-color" content="#141414">
|
<meta name="theme-color" content="#141414">
|
||||||
|
|
@ -42,6 +42,21 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
:root {
|
||||||
|
--safe-top: env(safe-area-inset-top, 0px);
|
||||||
|
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
#watchHeader>div {
|
||||||
|
padding-top: calc(1.5rem + var(--safe-top)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mobileBottomNav {
|
||||||
|
padding-bottom: calc(1.25rem + var(--safe-bottom)) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Custom scrollbar */
|
/* Custom scrollbar */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
|
|
@ -85,6 +100,16 @@
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* TV Focus Ring */
|
||||||
|
.keyboard-focused {
|
||||||
|
outline: 3px solid #ea2a33 !important;
|
||||||
|
outline-offset: 2px !important;
|
||||||
|
transform: scale(1.05) !important;
|
||||||
|
z-index: 50 !important;
|
||||||
|
transition: transform 0.2s ease, outline 0.2s ease !important;
|
||||||
|
box-shadow: 0 0 20px rgba(234, 42, 51, 0.4) !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<!-- PWA Meta Tags -->
|
<!-- PWA Meta Tags -->
|
||||||
|
|
@ -95,8 +120,8 @@
|
||||||
<meta name="apple-mobile-web-app-title" content="StreamFlix">
|
<meta name="apple-mobile-web-app-title" content="StreamFlix">
|
||||||
<link rel="icon" type="image/png" href="/icons/icon-512.png">
|
<link rel="icon" type="image/png" href="/icons/icon-512.png">
|
||||||
<link rel="apple-touch-icon" href="/icons/icon-512.png">
|
<link rel="apple-touch-icon" href="/icons/icon-512.png">
|
||||||
<script type="module" crossorigin src="/assets/watch-Baf19X1S.js"></script>
|
<script type="module" crossorigin src="/assets/watch-CsORLc4P.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/Toast-BwR22KmJ.js">
|
<link rel="modulepreload" crossorigin href="/assets/keyboard-nav-CZ5sEhKF.js">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="bg-background-light dark:bg-background-dark text-slate-900 dark:text-white font-display overflow-x-hidden">
|
<body class="bg-background-light dark:bg-background-dark text-slate-900 dark:text-white font-display overflow-x-hidden">
|
||||||
|
|
@ -249,8 +274,18 @@
|
||||||
|
|
||||||
<!-- Video Player (Hidden by default, shown when playing) -->
|
<!-- Video Player (Hidden by default, shown when playing) -->
|
||||||
<div class="fixed inset-0 z-[100] bg-black hidden" id="videoPlayerContainer">
|
<div class="fixed inset-0 z-[100] bg-black hidden" id="videoPlayerContainer">
|
||||||
<button class="absolute top-4 right-4 z-10 text-white hover:text-gray-300" id="closePlayer">
|
<!-- Prominent Back Button -->
|
||||||
<span class="material-symbols-outlined text-3xl">close</span>
|
<button
|
||||||
|
class="absolute top-6 left-6 z-[110] flex items-center gap-2 text-white bg-black/40 hover:bg-black/60 backdrop-blur-md px-4 py-2 rounded-full transition-all active:scale-95 group"
|
||||||
|
id="playerBackButton">
|
||||||
|
<span
|
||||||
|
class="material-symbols-outlined text-3xl group-hover:-translate-x-1 transition-transform">arrow_back</span>
|
||||||
|
<span class="font-bold text-lg hidden sm:block">Go Back</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="absolute top-6 right-6 z-[110] text-white/70 hover:text-white transition-colors"
|
||||||
|
id="closePlayer">
|
||||||
|
<span class="material-symbols-outlined text-4xl">close</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="w-full h-full" id="videoPlayer">
|
<div class="w-full h-full" id="videoPlayer">
|
||||||
<div class="w-full h-full flex items-center justify-center" id="playerLoading">
|
<div class="w-full h-full flex items-center justify-center" id="playerLoading">
|
||||||
|
|
|
||||||
BIN
frontend/StreamFlix-Universal-v1.0.2.apk
Normal file
BIN
frontend/StreamFlix-Universal-v1.0.2.apk
Normal file
Binary file not shown.
BIN
frontend/StreamFlix-Universal-v1.0.3.apk
Normal file
BIN
frontend/StreamFlix-Universal-v1.0.3.apk
Normal file
Binary file not shown.
101
frontend/android/.gitignore
vendored
Normal file
101
frontend/android/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||||
|
|
||||||
|
# Built application files
|
||||||
|
*.apk
|
||||||
|
*.aar
|
||||||
|
*.ap_
|
||||||
|
*.aab
|
||||||
|
|
||||||
|
# Files for the ART/Dalvik VM
|
||||||
|
*.dex
|
||||||
|
|
||||||
|
# Java class files
|
||||||
|
*.class
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
bin/
|
||||||
|
gen/
|
||||||
|
out/
|
||||||
|
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||||
|
# release/
|
||||||
|
|
||||||
|
# Gradle files
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Local configuration file (sdk path, etc)
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# Proguard folder generated by Eclipse
|
||||||
|
proguard/
|
||||||
|
|
||||||
|
# Log Files
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Android Studio Navigation editor temp files
|
||||||
|
.navigation/
|
||||||
|
|
||||||
|
# Android Studio captures folder
|
||||||
|
captures/
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
*.iml
|
||||||
|
.idea/workspace.xml
|
||||||
|
.idea/tasks.xml
|
||||||
|
.idea/gradle.xml
|
||||||
|
.idea/assetWizardSettings.xml
|
||||||
|
.idea/dictionaries
|
||||||
|
.idea/libraries
|
||||||
|
# Android Studio 3 in .gitignore file.
|
||||||
|
.idea/caches
|
||||||
|
.idea/modules.xml
|
||||||
|
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||||
|
.idea/navEditor.xml
|
||||||
|
|
||||||
|
# Keystore files
|
||||||
|
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||||
|
#*.jks
|
||||||
|
#*.keystore
|
||||||
|
|
||||||
|
# External native build folder generated in Android Studio 2.2 and later
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Google Services (e.g. APIs or Firebase)
|
||||||
|
# google-services.json
|
||||||
|
|
||||||
|
# Freeline
|
||||||
|
freeline.py
|
||||||
|
freeline/
|
||||||
|
freeline_project_description.json
|
||||||
|
|
||||||
|
# fastlane
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/Preview.html
|
||||||
|
fastlane/screenshots
|
||||||
|
fastlane/test_output
|
||||||
|
fastlane/readme.md
|
||||||
|
|
||||||
|
# Version control
|
||||||
|
vcs.xml
|
||||||
|
|
||||||
|
# lint
|
||||||
|
lint/intermediates/
|
||||||
|
lint/generated/
|
||||||
|
lint/outputs/
|
||||||
|
lint/tmp/
|
||||||
|
# lint/reports/
|
||||||
|
|
||||||
|
# Android Profiling
|
||||||
|
*.hprof
|
||||||
|
|
||||||
|
# Cordova plugins for Capacitor
|
||||||
|
capacitor-cordova-android-plugins
|
||||||
|
|
||||||
|
# Copied web assets
|
||||||
|
app/src/main/assets/public
|
||||||
|
|
||||||
|
# Generated Config files
|
||||||
|
app/src/main/assets/capacitor.config.json
|
||||||
|
app/src/main/assets/capacitor.plugins.json
|
||||||
|
app/src/main/res/xml/config.xml
|
||||||
2
frontend/android/app/.gitignore
vendored
Normal file
2
frontend/android/app/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/build/*
|
||||||
|
!/build/.npmkeep
|
||||||
54
frontend/android/app/build.gradle
Normal file
54
frontend/android/app/build.gradle
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
apply plugin: 'com.android.application'
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace "com.streamflix.app"
|
||||||
|
compileSdk rootProject.ext.compileSdkVersion
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "com.streamflix.app"
|
||||||
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0"
|
||||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
aaptOptions {
|
||||||
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||||
|
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
flatDir{
|
||||||
|
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||||
|
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||||
|
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||||
|
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||||
|
implementation project(':capacitor-android')
|
||||||
|
testImplementation "junit:junit:$junitVersion"
|
||||||
|
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||||
|
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||||
|
implementation project(':capacitor-cordova-android-plugins')
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: 'capacitor.build.gradle'
|
||||||
|
|
||||||
|
try {
|
||||||
|
def servicesJSON = file('google-services.json')
|
||||||
|
if (servicesJSON.text) {
|
||||||
|
apply plugin: 'com.google.gms.google-services'
|
||||||
|
}
|
||||||
|
} catch(Exception e) {
|
||||||
|
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||||
|
}
|
||||||
20
frontend/android/app/capacitor.build.gradle
Normal file
20
frontend/android/app/capacitor.build.gradle
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
|
dependencies {
|
||||||
|
implementation project(':capacitor-haptics')
|
||||||
|
implementation project(':capacitor-status-bar')
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (hasProperty('postBuildExtras')) {
|
||||||
|
postBuildExtras()
|
||||||
|
}
|
||||||
21
frontend/android/app/proguard-rules.pro
vendored
Normal file
21
frontend/android/app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package com.getcapacitor.myapp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumented test, which will execute on an Android device.
|
||||||
|
*
|
||||||
|
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4.class)
|
||||||
|
public class ExampleInstrumentedTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void useAppContext() throws Exception {
|
||||||
|
// Context of the app under test.
|
||||||
|
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||||
|
|
||||||
|
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||||
|
}
|
||||||
|
}
|
||||||
45
frontend/android/app/src/main/AndroidManifest.xml
Normal file
45
frontend/android/app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||||
|
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:banner="@mipmap/ic_launcher"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/AppTheme">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:label="@string/title_activity_main"
|
||||||
|
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:exported="true">
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths"></meta-data>
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
<!-- Permissions -->
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
</manifest>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
package com.streamflix.app;
|
||||||
|
|
||||||
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
|
public class MainActivity extends BridgeActivity {}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108">
|
||||||
|
<path
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:strokeWidth="1">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="78.5885"
|
||||||
|
android:endY="90.9159"
|
||||||
|
android:startX="48.7653"
|
||||||
|
android:startY="61.0927"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:strokeWidth="1" />
|
||||||
|
</vector>
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#26A69A"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M9,0L9,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,0L19,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,0L29,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,0L39,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,0L49,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,0L59,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,0L69,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,0L79,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M89,0L89,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M99,0L99,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,9L108,9"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,19L108,19"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,29L108,29"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,39L108,39"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,49L108,49"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,59L108,59"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,69L108,69"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,79L108,79"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,89L108,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,99L108,99"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,29L89,29"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,39L89,39"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,49L89,49"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,59L89,59"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,69L89,69"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,79L89,79"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,19L29,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,19L39,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,19L49,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,19L59,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,19L69,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,19L79,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
</vector>
|
||||||
12
frontend/android/app/src/main/res/layout/activity_main.xml
Normal file
12
frontend/android/app/src/main/res/layout/activity_main.xml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
tools:context=".MainActivity">
|
||||||
|
|
||||||
|
<WebView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#FFFFFF</color>
|
||||||
|
</resources>
|
||||||
7
frontend/android/app/src/main/res/values/strings.xml
Normal file
7
frontend/android/app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">StreamFlix</string>
|
||||||
|
<string name="title_activity_main">StreamFlix</string>
|
||||||
|
<string name="package_name">com.streamflix.app</string>
|
||||||
|
<string name="custom_url_scheme">com.streamflix.app</string>
|
||||||
|
</resources>
|
||||||
22
frontend/android/app/src/main/res/values/styles.xml
Normal file
22
frontend/android/app/src/main/res/values/styles.xml
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||||
|
<!-- Customize your theme here. -->
|
||||||
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
|
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||||
|
<item name="colorAccent">@color/colorAccent</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
|
<item name="windowActionBar">false</item>
|
||||||
|
<item name="windowNoTitle">true</item>
|
||||||
|
<item name="android:background">@null</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||||
|
<item name="android:background">@drawable/splash</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
5
frontend/android/app/src/main/res/xml/file_paths.xml
Normal file
5
frontend/android/app/src/main/res/xml/file_paths.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<external-path name="my_images" path="." />
|
||||||
|
<cache-path name="my_cache_images" path="." />
|
||||||
|
</paths>
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
package com.getcapacitor.myapp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example local unit test, which will execute on the development machine (host).
|
||||||
|
*
|
||||||
|
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||||
|
*/
|
||||||
|
public class ExampleUnitTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void addition_isCorrect() throws Exception {
|
||||||
|
assertEquals(4, 2 + 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
frontend/android/build.gradle
Normal file
41
frontend/android/build.gradle
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
|
||||||
|
buildscript {
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
classpath 'com.android.tools.build:gradle:8.7.3'
|
||||||
|
classpath 'com.google.gms:google-services:4.4.0'
|
||||||
|
|
||||||
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
|
// in the individual module build.gradle files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "variables.gradle"
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subprojects {
|
||||||
|
configurations.all {
|
||||||
|
resolutionStrategy {
|
||||||
|
eachDependency { details ->
|
||||||
|
if (details.requested.group == 'org.jetbrains.kotlin') {
|
||||||
|
details.useVersion '1.8.22'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
task clean(type: Delete) {
|
||||||
|
delete rootProject.buildDir
|
||||||
|
}
|
||||||
9
frontend/android/capacitor.settings.gradle
Normal file
9
frontend/android/capacitor.settings.gradle
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
|
include ':capacitor-android'
|
||||||
|
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||||
|
|
||||||
|
include ':capacitor-haptics'
|
||||||
|
project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android')
|
||||||
|
|
||||||
|
include ':capacitor-status-bar'
|
||||||
|
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
|
||||||
4
frontend/android/gradle.properties
Normal file
4
frontend/android/gradle.properties
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
org.gradle.jvmargs=-Xmx1536m -Dfile.encoding=UTF-8
|
||||||
|
# org.gradle.java.home=/Users/khoa.vo/Downloads/Streamflow-main/frontend/android/.jdk21/Contents/Home
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.enableJetifier=true
|
||||||
BIN
frontend/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
frontend/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
frontend/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
frontend/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
248
frontend/android/gradlew
vendored
Executable file
248
frontend/android/gradlew
vendored
Executable file
|
|
@ -0,0 +1,248 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command;
|
||||||
|
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||||
|
# shell script including quotes and variable substitutions, so put them in
|
||||||
|
# double quotes to make sure that they get re-expanded; and
|
||||||
|
# * put everything else in single quotes, so that it's not re-expanded.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
92
frontend/android/gradlew.bat
vendored
Normal file
92
frontend/android/gradlew.bat
vendored
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
5
frontend/android/settings.gradle
Normal file
5
frontend/android/settings.gradle
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
include ':app'
|
||||||
|
include ':capacitor-cordova-android-plugins'
|
||||||
|
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||||
|
|
||||||
|
apply from: 'capacitor.settings.gradle'
|
||||||
16
frontend/android/variables.gradle
Normal file
16
frontend/android/variables.gradle
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
ext {
|
||||||
|
minSdkVersion = 22
|
||||||
|
compileSdkVersion = 35
|
||||||
|
targetSdkVersion = 35
|
||||||
|
androidxActivityVersion = '1.8.0'
|
||||||
|
androidxAppCompatVersion = '1.6.1'
|
||||||
|
androidxCoordinatorLayoutVersion = '1.2.0'
|
||||||
|
androidxCoreVersion = '1.12.0'
|
||||||
|
androidxFragmentVersion = '1.6.2'
|
||||||
|
coreSplashScreenVersion = '1.0.1'
|
||||||
|
androidxWebkitVersion = '1.9.0'
|
||||||
|
junitVersion = '4.13.2'
|
||||||
|
androidxJunitVersion = '1.1.5'
|
||||||
|
androidxEspressoCoreVersion = '3.5.1'
|
||||||
|
cordovaAndroidVersion = '10.1.1'
|
||||||
|
}
|
||||||
32
frontend/assets/logo.svg
Normal file
32
frontend/assets/logo.svg
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 100">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="logoGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" style="stop-color:#FF0000;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#B30000;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feGaussianBlur in="SourceAlpha" stdDeviation="2" />
|
||||||
|
<feOffset dx="0" dy="2" result="offsetblur" />
|
||||||
|
<feComponentTransfer>
|
||||||
|
<feFuncA type="linear" slope="0.5" />
|
||||||
|
</feComponentTransfer>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- S Icon/Mark -->
|
||||||
|
<path d="M40 20 L60 20 L60 40 L40 40 L40 60 L60 60 L60 80 L40 80" fill="none" stroke="url(#logoGradient)" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" filter="url(#shadow)" />
|
||||||
|
|
||||||
|
<!-- StreamFlix Text -->
|
||||||
|
<text x="80" y="70" font-family="Arial, sans-serif" font-weight="900" font-size="52" fill="white" style="letter-spacing: -2px;">
|
||||||
|
STREAM<tspan fill="url(#logoGradient)">FLIX</tspan>
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<!-- Subtitle -->
|
||||||
|
<text x="82" y="90" font-family="Arial, sans-serif" font-weight="400" font-size="12" fill="#888" style="letter-spacing: 4px; text-transform: uppercase;">
|
||||||
|
Premium Cinema Experience
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
5
frontend/capacitor.config.json
Normal file
5
frontend/capacitor.config.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"appId": "com.streamflix.app",
|
||||||
|
"appName": "StreamFlix",
|
||||||
|
"webDir": "dist"
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
|
<meta content="width=device-width, initial-scale=1.0, viewport-fit=cover" name="viewport" />
|
||||||
<title>StreamFlix - Homepage</title>
|
<title>StreamFlix - Homepage</title>
|
||||||
<meta name="description" content="StreamFlix - Premium Movie Streaming">
|
<meta name="description" content="StreamFlix - Premium Movie Streaming">
|
||||||
<meta name="theme-color" content="#141414">
|
<meta name="theme-color" content="#141414">
|
||||||
|
|
@ -42,6 +42,21 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
:root {
|
||||||
|
--safe-top: env(safe-area-inset-top, 0px);
|
||||||
|
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mainHeader {
|
||||||
|
padding-top: calc(0.5rem + var(--safe-top));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
#mobileBottomNav {
|
||||||
|
padding-bottom: calc(1.25rem + var(--safe-bottom)) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||||
.no-scrollbar::-webkit-scrollbar {
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
@ -97,6 +112,81 @@
|
||||||
.search-modal.active {
|
.search-modal.active {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* TV Focus Ring */
|
||||||
|
.keyboard-focused {
|
||||||
|
outline: 3px solid #ea2a33 !important;
|
||||||
|
outline-offset: 2px !important;
|
||||||
|
transform: scale(1.05) !important;
|
||||||
|
z-index: 50 !important;
|
||||||
|
transition: transform 0.2s ease, outline 0.2s ease !important;
|
||||||
|
box-shadow: 0 0 20px rgba(234, 42, 51, 0.4) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Splash Screen */
|
||||||
|
#splash-screen {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
background-color: #141414;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: opacity 0.8s cubic-bezier(0.4, 0, 0.2, 1), visibility 0.8s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#splash-screen.fade-out {
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splash-logo {
|
||||||
|
width: 300px;
|
||||||
|
max-width: 80%;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
animation: pulse-logo 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-logo {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: scale(1.02);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container {
|
||||||
|
width: 240px;
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loading-bar {
|
||||||
|
width: 0%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #FF0000, #B30000);
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: width 0.4s ease-out;
|
||||||
|
box-shadow: 0 0 10px rgba(234, 42, 51, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#loading-text {
|
||||||
|
margin-top: 15px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<!-- PWA Meta Tags -->
|
<!-- PWA Meta Tags -->
|
||||||
|
|
@ -110,6 +200,16 @@
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="bg-background-light dark:bg-background-dark text-white font-display overflow-x-hidden antialiased">
|
<body class="bg-background-light dark:bg-background-dark text-white font-display overflow-x-hidden antialiased">
|
||||||
|
|
||||||
|
<!-- Splash Screen -->
|
||||||
|
<div id="splash-screen">
|
||||||
|
<img src="/assets/logo.svg" alt="StreamFlix" class="splash-logo">
|
||||||
|
<div class="loading-container">
|
||||||
|
<div id="loading-bar"></div>
|
||||||
|
</div>
|
||||||
|
<div id="loading-text">Initializing StreamFlix...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="relative flex min-h-screen flex-col">
|
<div class="relative flex min-h-screen flex-col">
|
||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
|
|
@ -119,10 +219,8 @@
|
||||||
<div class="px-4 md:px-12 py-2 md:py-4 flex items-center justify-between">
|
<div class="px-4 md:px-12 py-2 md:py-4 flex items-center justify-between">
|
||||||
<div class="flex items-center gap-8">
|
<div class="flex items-center gap-8">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<a class="flex items-center gap-2 text-primary hover:opacity-90 transition-opacity" href="/">
|
<a class="flex items-center gap-2 hover:opacity-90 transition-opacity" href="/">
|
||||||
<span class="material-symbols-outlined text-4xl text-primary"
|
<img src="/assets/logo.svg" alt="StreamFlix" class="h-8 md:h-10">
|
||||||
style="font-variation-settings: 'FILL' 1;">movie</span>
|
|
||||||
<span class="text-2xl font-bold tracking-tighter text-white hidden sm:block">StreamFlix</span>
|
|
||||||
</a>
|
</a>
|
||||||
<!-- Desktop Nav Links -->
|
<!-- Desktop Nav Links -->
|
||||||
<nav class="hidden md:flex items-center gap-6" id="mainNav">
|
<nav class="hidden md:flex items-center gap-6" id="mainNav">
|
||||||
|
|
|
||||||
1261
frontend/package-lock.json
generated
1261
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -9,9 +9,14 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@capacitor/android": "^8.0.0",
|
||||||
|
"@capacitor/haptics": "^8.0.0",
|
||||||
|
"@capacitor/status-bar": "^8.0.0",
|
||||||
"artplayer": "^5.1.1"
|
"artplayer": "^5.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@capacitor/cli": "^6.2.1",
|
||||||
|
"@capacitor/core": "^8.0.0",
|
||||||
"vite": "^5.0.8"
|
"vite": "^5.0.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,66 @@
|
||||||
* Handles all communication with the backend
|
* Handles all communication with the backend
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const API_BASE = '/api';
|
const API_BASE = window.location.origin.includes('localhost') || window.location.origin.includes('127.0.0.1')
|
||||||
|
? '/api'
|
||||||
|
: 'https://nf.khoavo.myds.me/api';
|
||||||
|
// In production, this should NOT be hardcoded if possible, or obfuscated.
|
||||||
|
// Simple obfuscation for the secret key (should be improved in production)
|
||||||
|
const _s = [121, 111, 117, 114, 45, 115, 117, 112, 101, 114, 45, 115, 101, 99, 114, 101, 116, 45, 107, 101, 121, 45, 99, 104, 97, 110, 103, 101, 45, 116, 104, 105, 115];
|
||||||
|
const SECRET_KEY = String.fromCharCode(..._s);
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
|
/**
|
||||||
|
* Generate HMAC signature for a request
|
||||||
|
* @param {string} path - API path (e.g., /api/extract)
|
||||||
|
* @param {string} method - HTTP method
|
||||||
|
* @returns {Object} Headers with Signature and Timestamp
|
||||||
|
*/
|
||||||
|
async signRequest(path, method = 'GET') {
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000).toString();
|
||||||
|
// Path needs to be strictly /api/... as per backend request.url.path
|
||||||
|
const fullPath = path.startsWith('/api') ? path : `/api${path}`;
|
||||||
|
|
||||||
|
const payload = `${timestamp}${fullPath}${method.toUpperCase()}`;
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const keyData = encoder.encode(SECRET_KEY);
|
||||||
|
const payloadData = encoder.encode(payload);
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
keyData,
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
|
||||||
|
const signatureBuffer = await crypto.subtle.sign(
|
||||||
|
'HMAC',
|
||||||
|
key,
|
||||||
|
payloadData
|
||||||
|
);
|
||||||
|
|
||||||
|
const signatureArray = Array.from(new Uint8Array(signatureBuffer));
|
||||||
|
const signatureHex = signatureArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
|
||||||
|
return {
|
||||||
|
'X-Signature': signatureHex,
|
||||||
|
'X-Timestamp': timestamp
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a proxied and optimized image URL
|
||||||
|
* @param {string} url - Original image URL
|
||||||
|
* @param {number} width - Desired width
|
||||||
|
* @returns {string} Proxied URL
|
||||||
|
*/
|
||||||
|
getProxyUrl(url, width = 200) {
|
||||||
|
if (!url) return '';
|
||||||
|
return `${API_BASE}/images/proxy?url=${encodeURIComponent(url)}&width=${width}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract video stream URL
|
* Extract video stream URL
|
||||||
* @param {string} url - Source video URL
|
* @param {string} url - Source video URL
|
||||||
|
|
@ -13,9 +70,15 @@ class ApiClient {
|
||||||
* @returns {Promise<Object>} Extraction result with stream URL
|
* @returns {Promise<Object>} Extraction result with stream URL
|
||||||
*/
|
*/
|
||||||
async extractVideo(url, quality = null) {
|
async extractVideo(url, quality = null) {
|
||||||
|
const path = '/api/extract';
|
||||||
|
const authHeaders = await this.signRequest(path, 'POST');
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}/extract`, {
|
const response = await fetch(`${API_BASE}/extract`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...authHeaders
|
||||||
|
},
|
||||||
body: JSON.stringify({ url, quality })
|
body: JSON.stringify({ url, quality })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -27,13 +90,28 @@ class ApiClient {
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateHeaders(options = {}, path, method = 'GET') {
|
||||||
|
const authHeaders = await this.signRequest(path, method);
|
||||||
|
return {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...options.headers,
|
||||||
|
...authHeaders
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get available quality options for a video
|
* Get available quality options for a video
|
||||||
* @param {string} url - Source video URL
|
* @param {string} url - Source video URL
|
||||||
* @returns {Promise<string[]>} List of available qualities
|
* @returns {Promise<string[]>} List of available qualities
|
||||||
*/
|
*/
|
||||||
async getQualities(url) {
|
async getQualities(url) {
|
||||||
const response = await fetch(`${API_BASE}/qualities?url=${encodeURIComponent(url)}`);
|
const path = `/api/qualities`;
|
||||||
|
const authHeaders = await this.signRequest(path, 'GET');
|
||||||
|
const response = await fetch(`${API_BASE}/qualities?url=${encodeURIComponent(url)}`, {
|
||||||
|
headers: authHeaders
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to get qualities');
|
throw new Error('Failed to get qualities');
|
||||||
|
|
@ -54,7 +132,9 @@ class ApiClient {
|
||||||
url += `&category=${encodeURIComponent(category)}`;
|
url += `&category=${encodeURIComponent(category)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(url);
|
const path = '/api/videos';
|
||||||
|
const authHeaders = await this.signRequest(path, 'GET');
|
||||||
|
const response = await fetch(url, { headers: authHeaders });
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch videos');
|
throw new Error('Failed to fetch videos');
|
||||||
|
|
@ -69,9 +149,15 @@ class ApiClient {
|
||||||
* @returns {Promise<Object>} Created video
|
* @returns {Promise<Object>} Created video
|
||||||
*/
|
*/
|
||||||
async addVideo(video) {
|
async addVideo(video) {
|
||||||
|
const path = '/api/videos';
|
||||||
|
const authHeaders = await this.signRequest(path, 'POST');
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}/videos`, {
|
const response = await fetch(`${API_BASE}/videos`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...authHeaders
|
||||||
|
},
|
||||||
body: JSON.stringify(video)
|
body: JSON.stringify(video)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -88,8 +174,12 @@ class ApiClient {
|
||||||
* @param {number} id - Video ID
|
* @param {number} id - Video ID
|
||||||
*/
|
*/
|
||||||
async deleteVideo(id) {
|
async deleteVideo(id) {
|
||||||
|
const path = `/api/videos/${id}`;
|
||||||
|
const authHeaders = await this.signRequest(path, 'DELETE');
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}/videos/${id}`, {
|
const response = await fetch(`${API_BASE}/videos/${id}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE',
|
||||||
|
headers: authHeaders
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -104,9 +194,10 @@ class ApiClient {
|
||||||
* @returns {Promise<Array>} Search results
|
* @returns {Promise<Array>} Search results
|
||||||
*/
|
*/
|
||||||
async searchVideos(query, limit = 20) {
|
async searchVideos(query, limit = 20) {
|
||||||
const response = await fetch(
|
const url = `${API_BASE}/search?q=${encodeURIComponent(query)}&limit=${limit}`;
|
||||||
`${API_BASE}/search?q=${encodeURIComponent(query)}&limit=${limit}`
|
const path = '/api/search';
|
||||||
);
|
const authHeaders = await this.signRequest(path, 'GET');
|
||||||
|
const response = await fetch(url, { headers: authHeaders });
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Search failed');
|
throw new Error('Search failed');
|
||||||
|
|
@ -139,7 +230,9 @@ class ApiClient {
|
||||||
if (country) url += `&country=${encodeURIComponent(country)}`;
|
if (country) url += `&country=${encodeURIComponent(country)}`;
|
||||||
if (genre) url += `&genre=${encodeURIComponent(genre)}`;
|
if (genre) url += `&genre=${encodeURIComponent(genre)}`;
|
||||||
|
|
||||||
const response = await fetch(url);
|
const path = '/api/rophim/catalog';
|
||||||
|
const authHeaders = await this.signRequest(path, 'GET');
|
||||||
|
const response = await fetch(url, { headers: authHeaders });
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch RoPhim catalog');
|
throw new Error('Failed to fetch RoPhim catalog');
|
||||||
|
|
@ -153,7 +246,11 @@ class ApiClient {
|
||||||
* @returns {Promise<Object>} Sections with movies sorted by rating
|
* @returns {Promise<Object>} Sections with movies sorted by rating
|
||||||
*/
|
*/
|
||||||
async getCuratedSections() {
|
async getCuratedSections() {
|
||||||
const response = await fetch(`${API_BASE}/rophim/home/curated`);
|
const path = '/api/rophim/home/curated';
|
||||||
|
const authHeaders = await this.signRequest(path, 'GET');
|
||||||
|
const response = await fetch(`${API_BASE}/rophim/home/curated`, {
|
||||||
|
headers: authHeaders
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch curated sections');
|
throw new Error('Failed to fetch curated sections');
|
||||||
|
|
@ -169,9 +266,10 @@ class ApiClient {
|
||||||
* @returns {Promise<Object>} Search results
|
* @returns {Promise<Object>} Search results
|
||||||
*/
|
*/
|
||||||
async searchRophim(query, limit = 20) {
|
async searchRophim(query, limit = 20) {
|
||||||
const response = await fetch(
|
const url = `${API_BASE}/rophim/search?q=${encodeURIComponent(query)}&limit=${limit}`;
|
||||||
`${API_BASE}/rophim/search?q=${encodeURIComponent(query)}&limit=${limit}`
|
const path = '/api/rophim/search';
|
||||||
);
|
const authHeaders = await this.signRequest(path, 'GET');
|
||||||
|
const response = await fetch(url, { headers: authHeaders });
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('RoPhim search failed');
|
throw new Error('RoPhim search failed');
|
||||||
|
|
@ -186,7 +284,11 @@ class ApiClient {
|
||||||
* @returns {Promise<Object>} Sections
|
* @returns {Promise<Object>} Sections
|
||||||
*/
|
*/
|
||||||
async getHomeSections(page = 2, view = 'home') {
|
async getHomeSections(page = 2, view = 'home') {
|
||||||
const response = await fetch(`${API_BASE}/rophim/home/sections?page=${page}&view=${view}`);
|
const path = '/api/rophim/home/sections';
|
||||||
|
const authHeaders = await this.signRequest(path, 'GET');
|
||||||
|
const response = await fetch(`${API_BASE}/rophim/home/sections?page=${page}&view=${view}`, {
|
||||||
|
headers: authHeaders
|
||||||
|
});
|
||||||
if (!response.ok) throw new Error('Failed to fetch home sections');
|
if (!response.ok) throw new Error('Failed to fetch home sections');
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
@ -197,7 +299,11 @@ class ApiClient {
|
||||||
* @returns {Promise<Object>} Movie details
|
* @returns {Promise<Object>} Movie details
|
||||||
*/
|
*/
|
||||||
async getRophimMovie(slug) {
|
async getRophimMovie(slug) {
|
||||||
const response = await fetch(`${API_BASE}/rophim/movie/${encodeURIComponent(slug)}`);
|
const path = `/api/rophim/movie/${encodeURIComponent(slug)}`;
|
||||||
|
const authHeaders = await this.signRequest(path, 'GET');
|
||||||
|
const response = await fetch(`${API_BASE}/rophim/movie/${encodeURIComponent(slug)}`, {
|
||||||
|
headers: authHeaders
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch movie details');
|
throw new Error('Failed to fetch movie details');
|
||||||
|
|
@ -213,8 +319,12 @@ class ApiClient {
|
||||||
* @returns {Promise<Object>} Stream URL
|
* @returns {Promise<Object>} Stream URL
|
||||||
*/
|
*/
|
||||||
async getRophimStream(slug, episode = 1) {
|
async getRophimStream(slug, episode = 1) {
|
||||||
|
const path = `/api/rophim/stream/${encodeURIComponent(slug)}`;
|
||||||
|
const authHeaders = await this.signRequest(path, 'GET');
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${API_BASE}/rophim/stream/${encodeURIComponent(slug)}?episode=${episode}`
|
`${API_BASE}/rophim/stream/${encodeURIComponent(slug)}?episode=${episode}`,
|
||||||
|
{ headers: authHeaders }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -234,9 +344,15 @@ class ApiClient {
|
||||||
* @returns {Promise<Object>} Stream URL
|
* @returns {Promise<Object>} Stream URL
|
||||||
*/
|
*/
|
||||||
async getRophimStreamByUrl(sourceUrl, slug = '', episode = 1, server = 0) {
|
async getRophimStreamByUrl(sourceUrl, slug = '', episode = 1, server = 0) {
|
||||||
|
const path = '/api/rophim/stream';
|
||||||
|
const authHeaders = await this.signRequest(path, 'POST');
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}/rophim/stream`, {
|
const response = await fetch(`${API_BASE}/rophim/stream`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...authHeaders
|
||||||
|
},
|
||||||
body: JSON.stringify({ source_url: sourceUrl, slug: slug || '', episode, server })
|
body: JSON.stringify({ source_url: sourceUrl, slug: slug || '', episode, server })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -247,6 +363,70 @@ class ApiClient {
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover all available categories
|
||||||
|
* @returns {Promise<Object>} Categories
|
||||||
|
*/
|
||||||
|
async discoverCategories() {
|
||||||
|
const path = '/api/rophim/categories/discover';
|
||||||
|
const authHeaders = await this.signRequest(path, 'GET');
|
||||||
|
const response = await fetch(`${API_BASE}/rophim/categories/discover`, {
|
||||||
|
headers: authHeaders
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to discover categories');
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get movies for a specific category
|
||||||
|
* @param {string} slug - Category slug
|
||||||
|
* @param {number} page - Page
|
||||||
|
* @returns {Promise<Object>} Movies
|
||||||
|
*/
|
||||||
|
async getMoviesByCategory(slug, page = 1, limit = 24) {
|
||||||
|
const path = '/api/rophim/category';
|
||||||
|
const authHeaders = await this.signRequest(path, 'GET');
|
||||||
|
const response = await fetch(`${API_BASE}/rophim/category?slug=${encodeURIComponent(slug)}&page=${page}&limit=${limit}`, {
|
||||||
|
headers: authHeaders
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch category');
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Get themed movie sections
|
||||||
|
*/
|
||||||
|
async getHotMovies(limit = 24) {
|
||||||
|
const path = '/api/rophim/categories/hot';
|
||||||
|
const authHeaders = await this.signRequest(path, 'GET');
|
||||||
|
const response = await fetch(`${API_BASE}/rophim/categories/hot?limit=${limit}`, { headers: authHeaders });
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch hot movies');
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNewReleases(limit = 24) {
|
||||||
|
const path = '/api/rophim/categories/new-releases';
|
||||||
|
const authHeaders = await this.signRequest(path, 'GET');
|
||||||
|
const response = await fetch(`${API_BASE}/rophim/categories/new-releases?limit=${limit}`, { headers: authHeaders });
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch new releases');
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTop10() {
|
||||||
|
const path = '/api/rophim/categories/top10';
|
||||||
|
const authHeaders = await this.signRequest(path, 'GET');
|
||||||
|
const response = await fetch(`${API_BASE}/rophim/categories/top10`, { headers: authHeaders });
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch top 10');
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCinemaReleases(limit = 24) {
|
||||||
|
const path = '/api/rophim/categories/cinema';
|
||||||
|
const authHeaders = await this.signRequest(path, 'GET');
|
||||||
|
const response = await fetch(`${API_BASE}/rophim/categories/cinema?limit=${limit}`, { headers: authHeaders });
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch cinema releases');
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = new ApiClient();
|
export const api = new ApiClient();
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
* Netflix 2025 Info Modal Component
|
* Netflix 2025 Info Modal Component
|
||||||
* Premium, cinematic modal with video preview and rich metadata
|
* Premium, cinematic modal with video preview and rich metadata
|
||||||
*/
|
*/
|
||||||
|
import { hapticLight, hapticMedium } from '../haptics.js';
|
||||||
|
|
||||||
export function createInfoModal(video, onClose, onPlay, recommendations = []) {
|
export function createInfoModal(video, onClose, onPlay, recommendations = []) {
|
||||||
const modal = document.createElement('div');
|
const modal = document.createElement('div');
|
||||||
modal.className = 'modal modal--info active';
|
modal.className = 'modal modal--info active';
|
||||||
|
|
@ -145,9 +147,17 @@ export function createInfoModal(video, onClose, onPlay, recommendations = []) {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Event Listeners
|
// Event Listeners
|
||||||
modal.querySelector('.modal__close').addEventListener('click', () => onClose(modal));
|
modal.querySelector('.modal__close').addEventListener('click', () => {
|
||||||
modal.querySelector('.modal__backdrop').addEventListener('click', () => onClose(modal));
|
hapticLight();
|
||||||
modal.querySelector('[data-action="play"]').addEventListener('click', () => onPlay(video));
|
onClose(modal);
|
||||||
|
});
|
||||||
|
modal.querySelector('.modal__backdrop').addEventListener('click', () => {
|
||||||
|
onClose(modal);
|
||||||
|
});
|
||||||
|
modal.querySelector('[data-action="play"]').addEventListener('click', () => {
|
||||||
|
hapticMedium();
|
||||||
|
onPlay(video);
|
||||||
|
});
|
||||||
|
|
||||||
// Autoplay header video
|
// Autoplay header video
|
||||||
const headerVideo = modal.querySelector('.modal__header-preview');
|
const headerVideo = modal.querySelector('.modal__header-preview');
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { api } from '../api.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Netflix 2025 "New & Hot" Feed Component
|
* Netflix 2025 "New & Hot" Feed Component
|
||||||
* Optimized for mobile vertical scrolling
|
* Optimized for mobile vertical scrolling
|
||||||
|
|
@ -11,6 +13,9 @@ export function createNewAndHotItem(video) {
|
||||||
const month = months[Math.floor(Math.random() * 12)];
|
const month = months[Math.floor(Math.random() * 12)];
|
||||||
const day = Math.floor(Math.random() * 28) + 1;
|
const day = Math.floor(Math.random() * 28) + 1;
|
||||||
|
|
||||||
|
// Use image proxy for performance (width 400 for better quality on larger cards)
|
||||||
|
const imgUrl = api.getProxyUrl(video.backdrop || video.thumbnail, 400);
|
||||||
|
|
||||||
item.innerHTML = `
|
item.innerHTML = `
|
||||||
<div class="new-hot-item__sidebar">
|
<div class="new-hot-item__sidebar">
|
||||||
<span class="new-hot-item__month">${month}</span>
|
<span class="new-hot-item__month">${month}</span>
|
||||||
|
|
@ -19,7 +24,7 @@ export function createNewAndHotItem(video) {
|
||||||
<div class="new-hot-item__content">
|
<div class="new-hot-item__content">
|
||||||
<div class="new-hot-item__card">
|
<div class="new-hot-item__card">
|
||||||
<div class="new-hot-item__img-wrapper">
|
<div class="new-hot-item__img-wrapper">
|
||||||
<img src="${video.backdrop || video.thumbnail}" alt="${video.title}">
|
<img src="${imgUrl}" alt="${video.title}">
|
||||||
<div class="new-hot-item__play">
|
<div class="new-hot-item__play">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" width="40" height="40"><path d="M8 5v14l11-7z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor" width="40" height="40"><path d="M8 5v14l11-7z"/></svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -63,23 +63,26 @@ export function initSearch(inputEl, resultsEl, onSelect) {
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
resultsEl.innerHTML = results.map(video => `
|
resultsEl.innerHTML = results.map(video => {
|
||||||
<div class="search__result" data-video-slug="${video.slug}">
|
const thumbUrl = api.getProxyUrl(video.poster_url || video.thumb_url || video.thumbnail, 80);
|
||||||
<img
|
return `
|
||||||
src="${video.poster_url || video.thumb_url || video.thumbnail || 'data:image/svg+xml,%3Csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 80 45\" fill=\"%231a1a1a\"%3E%3Crect width=\"80\" height=\"45\"/%3E%3C/svg%3E'}"
|
<div class="search__result" data-video-slug="${video.slug}">
|
||||||
alt="${escapeHtml(video.name || video.title)}"
|
<img
|
||||||
class="search__result-thumb"
|
src="${thumbUrl || 'data:image/svg+xml,%3Csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 80 45\" fill=\"%231a1a1a\"%3E%3Crect width=\"80\" height=\"45\"/%3E%3C/svg%3E'}"
|
||||||
loading="lazy"
|
alt="${escapeHtml(video.name || video.title)}"
|
||||||
>
|
class="search__result-thumb"
|
||||||
<div class="search__result-info">
|
loading="lazy"
|
||||||
<div class="search__result-title">${escapeHtml(video.name || video.title)}</div>
|
>
|
||||||
<div class="search__result-meta">
|
<div class="search__result-info">
|
||||||
${video.quality ? `${video.quality} • ` : ''}
|
<div class="search__result-title">${escapeHtml(video.name || video.title)}</div>
|
||||||
${video.year || ''}
|
<div class="search__result-meta">
|
||||||
|
${video.quality ? `${video.quality} • ` : ''}
|
||||||
|
${video.year || ''}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
`;
|
||||||
`).join('');
|
}).join('');
|
||||||
|
|
||||||
// Add click handlers - navigate to watch page
|
// Add click handlers - navigate to watch page
|
||||||
resultsEl.querySelectorAll('.search__result[data-video-slug]').forEach(el => {
|
resultsEl.querySelectorAll('.search__result[data-video-slug]').forEach(el => {
|
||||||
|
|
@ -94,10 +97,10 @@ export function initSearch(inputEl, resultsEl, onSelect) {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Search error:', error);
|
console.error('Search error:', error);
|
||||||
resultsEl.innerHTML = `
|
resultsEl.innerHTML = `
|
||||||
<div class="search__result" style="color: var(--color-error);">
|
< div class="search__result" style = "color: var(--color-error);" >
|
||||||
<span>Search failed. Please try again.</span>
|
<span>Search failed. Please try again.</span>
|
||||||
</div>
|
</div >
|
||||||
`;
|
`;
|
||||||
resultsEl.classList.add('active');
|
resultsEl.classList.add('active');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,4 @@
|
||||||
/**
|
import { api } from '../api.js';
|
||||||
* PhimMoi UI - Video Card Component
|
|
||||||
* Poster-style cards with episode badges and hover effects
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { imageCache } from '../services/imageCache.js';
|
import { imageCache } from '../services/imageCache.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -85,7 +81,9 @@ export function createVideoCard(video, onPlay, onInfo) {
|
||||||
card.className = 'video-card';
|
card.className = 'video-card';
|
||||||
card.dataset.videoId = video.id;
|
card.dataset.videoId = video.id;
|
||||||
|
|
||||||
const thumbnail = video.thumbnail || '';
|
// PERFORMANCE: Use backend image proxy for faster loading (WebP + Resized)
|
||||||
|
const originalThumbnail = video.thumbnail || '';
|
||||||
|
const thumbnail = api.getProxyUrl(originalThumbnail, 200);
|
||||||
const year = video.year || new Date().getFullYear();
|
const year = video.year || new Date().getFullYear();
|
||||||
|
|
||||||
// Smart badge detection
|
// Smart badge detection
|
||||||
|
|
|
||||||
34
frontend/scripts/haptics.js
Normal file
34
frontend/scripts/haptics.js
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { Haptics, ImpactStyle } from '@capacitor/haptics';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger a light haptic feedback for small interactions
|
||||||
|
*/
|
||||||
|
export const hapticLight = async () => {
|
||||||
|
try {
|
||||||
|
await Haptics.impact({ style: ImpactStyle.Light });
|
||||||
|
} catch (e) {
|
||||||
|
// Fail silently if not on native
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger a medium haptic feedback for major interactions
|
||||||
|
*/
|
||||||
|
export const hapticMedium = async () => {
|
||||||
|
try {
|
||||||
|
await Haptics.impact({ style: ImpactStyle.Medium });
|
||||||
|
} catch (e) {
|
||||||
|
// Fail silently
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger a success haptic feedback
|
||||||
|
*/
|
||||||
|
export const hapticSuccess = async () => {
|
||||||
|
try {
|
||||||
|
await Haptics.notification({ type: 'SUCCESS' });
|
||||||
|
} catch (e) {
|
||||||
|
// Fail silently
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -13,7 +13,12 @@ export class KeyboardNavigation {
|
||||||
'.video-card',
|
'.video-card',
|
||||||
'.hero__btn',
|
'.hero__btn',
|
||||||
'.slider-btn',
|
'.slider-btn',
|
||||||
'#topSearchBtn'
|
'#topSearchBtn',
|
||||||
|
'.nav-item',
|
||||||
|
'.category-card',
|
||||||
|
'.tab-btn',
|
||||||
|
'.episode-row',
|
||||||
|
'.recommendation-card'
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,45 @@ import { showToast } from './components/Toast.js';
|
||||||
import { createInfoModal } from './components/InfoModal.js';
|
import { createInfoModal } from './components/InfoModal.js';
|
||||||
import { renderNewAndHotView } from './components/NewAndHot.js';
|
import { renderNewAndHotView } from './components/NewAndHot.js';
|
||||||
import { KeyboardNavigation } from './keyboard-nav.js';
|
import { KeyboardNavigation } from './keyboard-nav.js';
|
||||||
|
import { hapticLight, hapticMedium, hapticSuccess } from './haptics.js';
|
||||||
|
import { StatusBar, Style } from '@capacitor/status-bar';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SplashScreen Controller
|
||||||
|
* Manages loading progress and cinematic transition
|
||||||
|
*/
|
||||||
|
const SplashScreen = {
|
||||||
|
elements: {
|
||||||
|
overlay: document.getElementById('splash-screen'),
|
||||||
|
bar: document.getElementById('loading-bar'),
|
||||||
|
text: document.getElementById('loading-text')
|
||||||
|
},
|
||||||
|
progress: 0,
|
||||||
|
isFinished: false,
|
||||||
|
|
||||||
|
update(percent, message) {
|
||||||
|
if (this.isFinished) return;
|
||||||
|
this.progress = Math.min(percent, 100);
|
||||||
|
if (this.elements.bar) this.elements.bar.style.width = `${this.progress}%`;
|
||||||
|
if (this.elements.text && message) this.elements.text.textContent = message;
|
||||||
|
|
||||||
|
if (this.progress >= 100) {
|
||||||
|
this.finish();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
finish() {
|
||||||
|
if (this.isFinished) return;
|
||||||
|
this.isFinished = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.elements.overlay) {
|
||||||
|
this.elements.overlay.classList.add('fade-out');
|
||||||
|
// Remove from DOM after transition to free up resources
|
||||||
|
setTimeout(() => this.elements.overlay.remove(), 1000);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
// Drag scroll removed per user request
|
// Drag scroll removed per user request
|
||||||
// Application state
|
// Application state
|
||||||
const state = {
|
const state = {
|
||||||
|
|
@ -72,16 +111,20 @@ function setMobileNavActive(viewName) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the application
|
* Initialize the application
|
||||||
*/
|
*/
|
||||||
async function init() {
|
async function init() {
|
||||||
|
SplashScreen.update(10, 'Initializing services...');
|
||||||
|
|
||||||
// Initialize search
|
// Initialize search
|
||||||
initSearch(elements.searchInput, elements.searchResults, handleVideoPlay);
|
initSearch(elements.searchInput, elements.searchResults, handleVideoPlay);
|
||||||
|
SplashScreen.update(20, 'Setting up navigation...');
|
||||||
|
|
||||||
// Initialize Mobile Bottom Nav
|
// Initialize Mobile Bottom Nav
|
||||||
if (elements.mobileBottomNavButtons) {
|
if (elements.mobileBottomNavButtons) {
|
||||||
|
// ... (existing button logic)
|
||||||
elements.mobileBottomNavButtons.forEach(btn => {
|
elements.mobileBottomNavButtons.forEach(btn => {
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -92,6 +135,9 @@ async function init() {
|
||||||
elements.mobileBottomNavButtons.forEach(b => b.classList.remove('active'));
|
elements.mobileBottomNavButtons.forEach(b => b.classList.remove('active'));
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
|
|
||||||
|
// Native Haptic
|
||||||
|
hapticLight();
|
||||||
|
|
||||||
// Handle routing
|
// Handle routing
|
||||||
if (view === 'home') {
|
if (view === 'home') {
|
||||||
renderHome();
|
renderHome();
|
||||||
|
|
@ -132,12 +178,23 @@ async function init() {
|
||||||
|
|
||||||
// Set up event listeners
|
// Set up event listeners
|
||||||
setupEventListeners();
|
setupEventListeners();
|
||||||
|
SplashScreen.update(40, 'Fetching movie catalog...');
|
||||||
|
|
||||||
// Load home view with organized sections
|
// Load home view with organized sections
|
||||||
await renderCategoryView('home');
|
try {
|
||||||
|
await renderCategoryView('home');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Home render failed', e);
|
||||||
|
}
|
||||||
|
SplashScreen.update(70, 'Preparing featured content...');
|
||||||
|
|
||||||
// Render hero with featured content
|
// Render hero with featured content
|
||||||
await renderHero();
|
try {
|
||||||
|
await renderHero();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Hero render failed', e);
|
||||||
|
}
|
||||||
|
SplashScreen.update(90, 'Applying final touches...');
|
||||||
|
|
||||||
// Handle view parameter from URL (e.g. for redirects from watch page)
|
// Handle view parameter from URL (e.g. for redirects from watch page)
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
|
@ -158,6 +215,16 @@ async function init() {
|
||||||
navigator.serviceWorker.register('/sw.js')
|
navigator.serviceWorker.register('/sw.js')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SplashScreen.update(100, 'Welcome to StreamFlix');
|
||||||
|
|
||||||
|
// Initialize Native Status Bar
|
||||||
|
try {
|
||||||
|
await StatusBar.setStyle({ style: Style.Dark });
|
||||||
|
await StatusBar.setBackgroundColor({ color: '#141414' });
|
||||||
|
} catch (e) {
|
||||||
|
// Fail silently
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -217,7 +284,10 @@ function renderHero(video = null) {
|
||||||
if (heroPlayBtn) {
|
if (heroPlayBtn) {
|
||||||
const newPlayBtn = heroPlayBtn.cloneNode(true);
|
const newPlayBtn = heroPlayBtn.cloneNode(true);
|
||||||
heroPlayBtn.parentNode.replaceChild(newPlayBtn, heroPlayBtn);
|
heroPlayBtn.parentNode.replaceChild(newPlayBtn, heroPlayBtn);
|
||||||
newPlayBtn.addEventListener('click', () => handleVideoPlay(featured));
|
newPlayBtn.addEventListener('click', () => {
|
||||||
|
hapticMedium();
|
||||||
|
handleVideoPlay(featured);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Info button
|
// Info button
|
||||||
|
|
@ -431,6 +501,7 @@ function setupEventListeners() {
|
||||||
const searchModal = document.getElementById('searchModal');
|
const searchModal = document.getElementById('searchModal');
|
||||||
const searchInput = document.getElementById('searchInput');
|
const searchInput = document.getElementById('searchInput');
|
||||||
if (searchModal) {
|
if (searchModal) {
|
||||||
|
hapticLight();
|
||||||
searchModal.classList.add('active');
|
searchModal.classList.add('active');
|
||||||
if (searchInput) setTimeout(() => searchInput.focus(), 100);
|
if (searchInput) setTimeout(() => searchInput.focus(), 100);
|
||||||
}
|
}
|
||||||
|
|
@ -446,6 +517,26 @@ function setupEventListeners() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Modal Player Back Button
|
||||||
|
const modalPlayerBackButton = document.getElementById('modalPlayerBackButton');
|
||||||
|
if (modalPlayerBackButton) {
|
||||||
|
modalPlayerBackButton.addEventListener('click', () => {
|
||||||
|
hapticLight();
|
||||||
|
if (window.history.state?.playerOpen) {
|
||||||
|
window.history.back();
|
||||||
|
} else {
|
||||||
|
closePlayerModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global Popstate for Modal Player
|
||||||
|
window.addEventListener('popstate', (event) => {
|
||||||
|
if (elements.playerModal?.classList.contains('active') && !event.state?.playerOpen) {
|
||||||
|
closePlayerModal(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// StreamFlix Nav Links (Tailwind design)
|
// StreamFlix Nav Links (Tailwind design)
|
||||||
const streamflixNavLinks = document.querySelectorAll('.nav-link');
|
const streamflixNavLinks = document.querySelectorAll('.nav-link');
|
||||||
streamflixNavLinks.forEach(link => {
|
streamflixNavLinks.forEach(link => {
|
||||||
|
|
@ -492,7 +583,11 @@ function setupEventListeners() {
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
if (elements.playerModal?.classList.contains('active')) {
|
if (elements.playerModal?.classList.contains('active')) {
|
||||||
closePlayerModal();
|
if (window.history.state?.playerOpen) {
|
||||||
|
window.history.back();
|
||||||
|
} else {
|
||||||
|
closePlayerModal();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (elements.searchWrapper?.classList.contains('active')) {
|
if (elements.searchWrapper?.classList.contains('active')) {
|
||||||
elements.searchWrapper.classList.remove('active');
|
elements.searchWrapper.classList.remove('active');
|
||||||
|
|
@ -1625,7 +1720,7 @@ function renderDemoContent() {
|
||||||
resolution: '4K',
|
resolution: '4K',
|
||||||
category: 'movies',
|
category: 'movies',
|
||||||
year: 2024,
|
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...',
|
description: 'Eddie và Venom đang chạy trốn. Bị cả hai hai thế giới truy đuổi, họ buộc phải đưa ra quyết định khốc liệt...',
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -1813,7 +1908,13 @@ function handleVideoPlay(video) {
|
||||||
// Store all videos for recommendations
|
// Store all videos for recommendations
|
||||||
sessionStorage.setItem('allVideos', JSON.stringify(state.videos));
|
sessionStorage.setItem('allVideos', JSON.stringify(state.videos));
|
||||||
|
|
||||||
// Navigation to Watch => NOW INFO PAGE
|
// Handle back button gesture support via pushState if opening player in same page
|
||||||
|
// (Though here we navigate, it's good practice tracker)
|
||||||
|
if (!window.history.state?.playerOpen) {
|
||||||
|
window.history.pushState({ playerOpen: true }, '', window.location.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation to Watch
|
||||||
navigateToWatch(video);
|
navigateToWatch(video);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1896,6 +1997,11 @@ async function loadEpisode(video, episode, server) {
|
||||||
autoplay: true
|
autoplay: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Push state for back navigation
|
||||||
|
if (!window.history.state?.playerOpen) {
|
||||||
|
window.history.pushState({ playerOpen: true }, '', window.location.href);
|
||||||
|
}
|
||||||
|
|
||||||
if (art && window.historyService) {
|
if (art && window.historyService) {
|
||||||
art.on('video:timeupdate', () => {
|
art.on('video:timeupdate', () => {
|
||||||
const currentTime = art.currentTime;
|
const currentTime = art.currentTime;
|
||||||
|
|
@ -1935,14 +2041,25 @@ async function loadEpisode(video, episode, server) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Close player modal
|
* Close player modal
|
||||||
|
* @param {boolean} shouldUpdateHistory - Whether to update history (defaults to true)
|
||||||
*/
|
*/
|
||||||
function closePlayerModal() {
|
function closePlayerModal(shouldUpdateHistory = true) {
|
||||||
elements.playerModal.classList.remove('active');
|
if (elements.playerModal) {
|
||||||
destroyPlayer();
|
elements.playerModal.classList.add('hidden');
|
||||||
|
elements.playerModal.classList.remove('active');
|
||||||
|
elements.playerModal.style.display = 'none';
|
||||||
|
|
||||||
|
// Destroy player
|
||||||
|
destroyPlayer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're closing and the state still thinks it's open, and we didn't come from popstate
|
||||||
|
if (shouldUpdateHistory && window.history.state?.playerOpen) {
|
||||||
|
// Handled via history.back() usually
|
||||||
|
}
|
||||||
elements.playerContainer.innerHTML = '';
|
elements.playerContainer.innerHTML = '';
|
||||||
state.currentVideo = null;
|
state.currentVideo = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Close add video modal
|
* Close add video modal
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@
|
||||||
import { api } from './api.js';
|
import { api } from './api.js';
|
||||||
import { showToast } from './components/Toast.js';
|
import { showToast } from './components/Toast.js';
|
||||||
import { initPlayer, destroyPlayer } from './components/VideoPlayer.js';
|
import { initPlayer, destroyPlayer } from './components/VideoPlayer.js';
|
||||||
|
import { hapticLight, hapticMedium, hapticSuccess } from './haptics.js';
|
||||||
|
import { KeyboardNavigation } from './keyboard-nav.js';
|
||||||
|
|
||||||
// Page State
|
// Page State
|
||||||
const state = {
|
const state = {
|
||||||
|
|
@ -93,6 +95,13 @@ function initElements() {
|
||||||
* Initialize watch page
|
* Initialize watch page
|
||||||
*/
|
*/
|
||||||
async function init() {
|
async function init() {
|
||||||
|
// Initialize UI elements
|
||||||
|
initElements();
|
||||||
|
|
||||||
|
// Initialize TV Navigation
|
||||||
|
const nav = new KeyboardNavigation();
|
||||||
|
nav.init();
|
||||||
|
|
||||||
// Parse URL parameters
|
// Parse URL parameters
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const videoId = params.get('id');
|
const videoId = params.get('id');
|
||||||
|
|
@ -138,9 +147,18 @@ function setupEventListeners() {
|
||||||
if (elements.watchBackBtn) {
|
if (elements.watchBackBtn) {
|
||||||
elements.watchBackBtn.addEventListener('click', (e) => {
|
elements.watchBackBtn.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (elements.videoPlayerContainer && (elements.videoPlayerContainer.style.display !== 'none' || !elements.videoPlayerContainer.classList.contains('hidden'))) {
|
const playerVisible = elements.videoPlayerContainer && (elements.videoPlayerContainer.style.display !== 'none' || !elements.videoPlayerContainer.classList.contains('hidden'));
|
||||||
closeVideoPlayer();
|
|
||||||
|
if (playerVisible) {
|
||||||
|
hapticLight();
|
||||||
|
// Close player via history if possible
|
||||||
|
if (window.history.state?.playerOpen) {
|
||||||
|
window.history.back();
|
||||||
|
} else {
|
||||||
|
closeVideoPlayer();
|
||||||
|
}
|
||||||
} else if (document.referrer && document.referrer.includes(window.location.host)) {
|
} else if (document.referrer && document.referrer.includes(window.location.host)) {
|
||||||
|
hapticLight();
|
||||||
window.history.back();
|
window.history.back();
|
||||||
} else {
|
} else {
|
||||||
window.location.href = '/index.html';
|
window.location.href = '/index.html';
|
||||||
|
|
@ -148,6 +166,30 @@ function setupEventListeners() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// New Dedicated Player Back Button
|
||||||
|
const playerBackButton = document.getElementById('playerBackButton');
|
||||||
|
if (playerBackButton) {
|
||||||
|
playerBackButton.addEventListener('click', () => {
|
||||||
|
hapticLight();
|
||||||
|
if (window.history.state?.playerOpen) {
|
||||||
|
window.history.back();
|
||||||
|
} else {
|
||||||
|
closeVideoPlayer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// History API for Hardware Back Button / Gestures
|
||||||
|
window.addEventListener('popstate', (event) => {
|
||||||
|
// If the player was open but the state changed (back button pressed)
|
||||||
|
const container = elements.videoPlayerContainer || document.getElementById('videoPlayerContainer');
|
||||||
|
const isPlayerOpen = container && !container.classList.contains('hidden');
|
||||||
|
|
||||||
|
if (isPlayerOpen && !event.state?.playerOpen) {
|
||||||
|
closeVideoPlayer(false); // Close without pushing state again
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
|
|
@ -165,6 +207,9 @@ function setupEventListeners() {
|
||||||
[elements.playBtn, elements.playBtnMobile, elements.mobilePlayBtn].forEach(btn => {
|
[elements.playBtn, elements.playBtnMobile, elements.mobilePlayBtn].forEach(btn => {
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
|
if (btn) {
|
||||||
|
hapticMedium();
|
||||||
|
}
|
||||||
if (elements.videoPlayerContainer) {
|
if (elements.videoPlayerContainer) {
|
||||||
elements.videoPlayerContainer.classList.remove('hidden');
|
elements.videoPlayerContainer.classList.remove('hidden');
|
||||||
elements.videoPlayerContainer.style.display = 'block'; // Ensure visible
|
elements.videoPlayerContainer.style.display = 'block'; // Ensure visible
|
||||||
|
|
@ -180,7 +225,11 @@ function setupEventListeners() {
|
||||||
// Close player button
|
// Close player button
|
||||||
if (elements.closePlayer) {
|
if (elements.closePlayer) {
|
||||||
elements.closePlayer.addEventListener('click', () => {
|
elements.closePlayer.addEventListener('click', () => {
|
||||||
closeVideoPlayer();
|
if (window.history.state?.playerOpen) {
|
||||||
|
window.history.back();
|
||||||
|
} else {
|
||||||
|
closeVideoPlayer();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -212,6 +261,7 @@ function setupEventListeners() {
|
||||||
const added = window.historyService?.toggleFavorite(state.video);
|
const added = window.historyService?.toggleFavorite(state.video);
|
||||||
updateAddListUI(added);
|
updateAddListUI(added);
|
||||||
|
|
||||||
|
hapticLight();
|
||||||
if (added) {
|
if (added) {
|
||||||
showToast('Added to My List', 'success');
|
showToast('Added to My List', 'success');
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -225,11 +275,13 @@ function setupEventListeners() {
|
||||||
if (elements.shareBtnMobile) {
|
if (elements.shareBtnMobile) {
|
||||||
elements.shareBtnMobile.addEventListener('click', () => {
|
elements.shareBtnMobile.addEventListener('click', () => {
|
||||||
if (navigator.share) {
|
if (navigator.share) {
|
||||||
|
hapticLight();
|
||||||
navigator.share({
|
navigator.share({
|
||||||
title: state.video?.title || 'StreamFlix',
|
title: state.video?.title || 'StreamFlix',
|
||||||
url: window.location.href
|
url: window.location.href
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
hapticLight();
|
||||||
// Fallback: Copy to clipboard
|
// Fallback: Copy to clipboard
|
||||||
navigator.clipboard.writeText(window.location.href);
|
navigator.clipboard.writeText(window.location.href);
|
||||||
showToast('Link copied to clipboard', 'success');
|
showToast('Link copied to clipboard', 'success');
|
||||||
|
|
@ -247,6 +299,7 @@ function setupEventListeners() {
|
||||||
|
|
||||||
tabs.forEach(tab => {
|
tabs.forEach(tab => {
|
||||||
tab.addEventListener('click', () => {
|
tab.addEventListener('click', () => {
|
||||||
|
hapticLight();
|
||||||
const targetPanel = tab.dataset.tab;
|
const targetPanel = tab.dataset.tab;
|
||||||
|
|
||||||
// Update active tab styling
|
// Update active tab styling
|
||||||
|
|
@ -277,6 +330,7 @@ function setupEventListeners() {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const view = btn.dataset.view;
|
const view = btn.dataset.view;
|
||||||
if (view) {
|
if (view) {
|
||||||
|
hapticLight();
|
||||||
// Redirect to home with view parameter
|
// Redirect to home with view parameter
|
||||||
window.location.href = `/index.html?view=${view}`;
|
window.location.href = `/index.html?view=${view}`;
|
||||||
}
|
}
|
||||||
|
|
@ -287,8 +341,9 @@ function setupEventListeners() {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Close Video Player (Robust Cleanup)
|
* Close Video Player (Robust Cleanup)
|
||||||
|
* @param {boolean} shouldUpdateHistory - Whether to update history (defaults to true)
|
||||||
*/
|
*/
|
||||||
function closeVideoPlayer() {
|
function closeVideoPlayer(shouldUpdateHistory = true) {
|
||||||
// Re-resolve just in case
|
// Re-resolve just in case
|
||||||
const container = elements.videoPlayerContainer || document.getElementById('videoPlayerContainer');
|
const container = elements.videoPlayerContainer || document.getElementById('videoPlayerContainer');
|
||||||
const player = elements.videoPlayer || document.getElementById('videoPlayer');
|
const player = elements.videoPlayer || document.getElementById('videoPlayer');
|
||||||
|
|
@ -310,6 +365,11 @@ function closeVideoPlayer() {
|
||||||
if (loader) {
|
if (loader) {
|
||||||
loader.style.display = 'none';
|
loader.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we're closing and the state still thinks it's open, and we didn't come from popstate
|
||||||
|
if (shouldUpdateHistory && window.history.state?.playerOpen) {
|
||||||
|
// We handle this via history.back() usually, but if called directly:
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -786,41 +846,54 @@ function renderPlayer(streamUrl, poster, title) {
|
||||||
<iframe src="${streamUrl}" allowfullscreen allow="autoplay; encrypted-media"></iframe>
|
<iframe src="${streamUrl}" allowfullscreen allow="autoplay; encrypted-media"></iframe>
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
const art = initPlayer(elements.videoPlayer, {
|
// Initialize ArtPlayer
|
||||||
url: streamUrl,
|
const art = renderArtPlayer(streamUrl, poster, title);
|
||||||
poster: poster,
|
|
||||||
title: title + ` - Ep ${state.currentEpisode}`,
|
|
||||||
autoplay: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track progress
|
// Push state to history for back navigation
|
||||||
if (art && window.historyService) {
|
if (!window.history.state?.playerOpen) {
|
||||||
art.on('video:timeupdate', () => {
|
window.history.pushState({ playerOpen: true }, '', window.location.href);
|
||||||
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();
|
* Render ArtPlayer instance
|
||||||
const entry = history.find(item => item.slug === state.video.slug);
|
*/
|
||||||
if (entry && entry.progress && entry.progress.episode === state.currentEpisode) {
|
function renderArtPlayer(streamUrl, poster, title) {
|
||||||
if (entry.progress.currentTime > 0 && entry.progress.percentage < 95) {
|
const art = initPlayer(elements.videoPlayer, {
|
||||||
art.once('video:canplay', () => {
|
url: streamUrl,
|
||||||
art.currentTime = entry.progress.currentTime;
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
|
<meta content="width=device-width, initial-scale=1.0, viewport-fit=cover" name="viewport" />
|
||||||
<title>StreamFlix - Movie Details</title>
|
<title>StreamFlix - Movie Details</title>
|
||||||
<meta name="description" content="StreamFlix - Watch Movies Online">
|
<meta name="description" content="StreamFlix - Watch Movies Online">
|
||||||
<meta name="theme-color" content="#141414">
|
<meta name="theme-color" content="#141414">
|
||||||
|
|
@ -42,6 +42,21 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
:root {
|
||||||
|
--safe-top: env(safe-area-inset-top, 0px);
|
||||||
|
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
#watchHeader>div {
|
||||||
|
padding-top: calc(1.5rem + var(--safe-top)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mobileBottomNav {
|
||||||
|
padding-bottom: calc(1.25rem + var(--safe-bottom)) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Custom scrollbar */
|
/* Custom scrollbar */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
|
|
@ -85,6 +100,16 @@
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* TV Focus Ring */
|
||||||
|
.keyboard-focused {
|
||||||
|
outline: 3px solid #ea2a33 !important;
|
||||||
|
outline-offset: 2px !important;
|
||||||
|
transform: scale(1.05) !important;
|
||||||
|
z-index: 50 !important;
|
||||||
|
transition: transform 0.2s ease, outline 0.2s ease !important;
|
||||||
|
box-shadow: 0 0 20px rgba(234, 42, 51, 0.4) !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<!-- PWA Meta Tags -->
|
<!-- PWA Meta Tags -->
|
||||||
|
|
@ -247,8 +272,18 @@
|
||||||
|
|
||||||
<!-- Video Player (Hidden by default, shown when playing) -->
|
<!-- Video Player (Hidden by default, shown when playing) -->
|
||||||
<div class="fixed inset-0 z-[100] bg-black hidden" id="videoPlayerContainer">
|
<div class="fixed inset-0 z-[100] bg-black hidden" id="videoPlayerContainer">
|
||||||
<button class="absolute top-4 right-4 z-10 text-white hover:text-gray-300" id="closePlayer">
|
<!-- Prominent Back Button -->
|
||||||
<span class="material-symbols-outlined text-3xl">close</span>
|
<button
|
||||||
|
class="absolute top-6 left-6 z-[110] flex items-center gap-2 text-white bg-black/40 hover:bg-black/60 backdrop-blur-md px-4 py-2 rounded-full transition-all active:scale-95 group"
|
||||||
|
id="playerBackButton">
|
||||||
|
<span
|
||||||
|
class="material-symbols-outlined text-3xl group-hover:-translate-x-1 transition-transform">arrow_back</span>
|
||||||
|
<span class="font-bold text-lg hidden sm:block">Go Back</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="absolute top-6 right-6 z-[110] text-white/70 hover:text-white transition-colors"
|
||||||
|
id="closePlayer">
|
||||||
|
<span class="material-symbols-outlined text-4xl">close</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="w-full h-full" id="videoPlayer">
|
<div class="w-full h-full" id="videoPlayer">
|
||||||
<div class="w-full h-full flex items-center justify-center" id="playerLoading">
|
<div class="w-full h-full flex items-center justify-center" id="playerLoading">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue