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 video_extractor import extractor, VideoInfo
|
||||
from database import init_db, get_db, VideoRepository, Video
|
||||
from security import verify_hmac
|
||||
from image_service import get_proxied_image
|
||||
|
||||
# Initialize FastAPI app
|
||||
app = FastAPI(
|
||||
|
|
@ -25,7 +27,13 @@ app = FastAPI(
|
|||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_origins=[
|
||||
"https://nf.khoavo.myds.me",
|
||||
"http://localhost:5173",
|
||||
"http://localhost:3000",
|
||||
"capacitor://localhost",
|
||||
"http://localhost"
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
|
|
@ -80,6 +88,17 @@ async def startup():
|
|||
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
|
||||
@app.get("/api/health")
|
||||
async def health_check():
|
||||
|
|
@ -95,7 +114,7 @@ async def health_check():
|
|||
# ============================================
|
||||
|
||||
@app.get("/api/admin/version")
|
||||
async def get_versions():
|
||||
async def get_versions(authorized: bool = Depends(verify_hmac)):
|
||||
"""Get versions of all managed dependencies"""
|
||||
from auto_updater import get_all_versions
|
||||
import asyncio
|
||||
|
|
@ -110,7 +129,7 @@ async def get_versions():
|
|||
|
||||
|
||||
@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
|
||||
|
||||
Args:
|
||||
|
|
@ -141,7 +160,7 @@ async def trigger_update(package: str = None):
|
|||
|
||||
# Video extraction endpoint
|
||||
@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.
|
||||
Uses cache-aside pattern with 3-hour TTL.
|
||||
|
|
@ -193,7 +212,7 @@ async def extract_video(request: ExtractRequest):
|
|||
|
||||
# Get available 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"""
|
||||
try:
|
||||
qualities = await extractor.get_available_qualities(url)
|
||||
|
|
@ -204,7 +223,7 @@ async def get_qualities(url: str):
|
|||
|
||||
# Video CRUD endpoints
|
||||
@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"""
|
||||
repo = VideoRepository(db)
|
||||
|
||||
|
|
@ -222,7 +241,8 @@ async def list_videos(
|
|||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
category: Optional[str] = None,
|
||||
db=Depends(get_db)
|
||||
db=Depends(get_db),
|
||||
authorized: bool = Depends(verify_hmac)
|
||||
):
|
||||
"""List all videos with pagination"""
|
||||
repo = VideoRepository(db)
|
||||
|
|
@ -232,7 +252,7 @@ async def list_videos(
|
|||
|
||||
|
||||
@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"""
|
||||
repo = VideoRepository(db)
|
||||
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}")
|
||||
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"""
|
||||
repo = VideoRepository(db)
|
||||
if repo.delete(video_id):
|
||||
|
|
@ -255,7 +275,8 @@ async def delete_video(video_id: int, db=Depends(get_db)):
|
|||
async def search_videos(
|
||||
q: str = Query(..., min_length=1),
|
||||
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"""
|
||||
repo = VideoRepository(db)
|
||||
|
|
@ -271,7 +292,8 @@ async def get_phimmoichill_catalog(
|
|||
category: Optional[str] = None,
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(24, ge=1, le=50),
|
||||
sort: str = Query("modified", description="Sort by: modified, year, rating")
|
||||
sort: str = Query("modified", description="Sort by: modified, year, rating"),
|
||||
authorized: bool = Depends(verify_hmac)
|
||||
):
|
||||
"""
|
||||
Get movie catalog from ophim API with sorting support.
|
||||
|
|
@ -440,7 +462,8 @@ async def get_phimmoichill_catalog(
|
|||
@app.get("/api/rophim/search")
|
||||
async def search_phimmoichill(
|
||||
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"""
|
||||
import aiohttp
|
||||
|
|
@ -527,7 +550,7 @@ async def search_phimmoichill(
|
|||
|
||||
|
||||
@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
|
||||
Returns types, genres, countries, and years
|
||||
|
|
@ -556,7 +579,8 @@ async def discover_categories():
|
|||
async def get_movies_by_category(
|
||||
slug: str = Query(..., description="Category slug (e.g., 'the-loai/hanh-dong', 'danh-sach/phim-le')"),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(24, ge=1, le=50)
|
||||
limit: int = Query(24, ge=1, le=50),
|
||||
authorized: bool = Depends(verify_hmac)
|
||||
):
|
||||
"""
|
||||
Get movies for a specific category
|
||||
|
|
@ -587,7 +611,7 @@ async def get_movies_by_category(
|
|||
|
||||
|
||||
@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.
|
||||
This provides a Rotten Tomatoes / Moviewiser style layout.
|
||||
|
|
@ -693,7 +717,7 @@ async def get_curated_homepage_sections():
|
|||
|
||||
|
||||
@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.
|
||||
"""
|
||||
|
|
@ -715,7 +739,7 @@ async def get_rophim_stream(slug: str, episode: int = 1):
|
|||
return JSONResponse(status_code=500, content={"detail": str(e)})
|
||||
|
||||
@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
|
||||
"""
|
||||
|
|
@ -748,7 +772,7 @@ async def get_rophim_stream_post(data: dict):
|
|||
|
||||
|
||||
@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).
|
||||
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}")
|
||||
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"""
|
||||
import asyncio
|
||||
from rophim_scraper import get_movie_details
|
||||
|
|
@ -825,7 +849,8 @@ async def get_phimmoichill_movie(slug: str):
|
|||
async def get_phimmoichill_stream(
|
||||
slug: str,
|
||||
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"""
|
||||
import asyncio
|
||||
|
|
@ -861,7 +886,7 @@ class PhimMoiChillStreamRequest(BaseModel):
|
|||
|
||||
|
||||
@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"""
|
||||
import asyncio
|
||||
import re
|
||||
|
|
@ -906,7 +931,8 @@ async def get_phimmoichill_stream_by_url(request: PhimMoiChillStreamRequest):
|
|||
@app.post("/api/crawl/trigger")
|
||||
async def trigger_crawl(
|
||||
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.
|
||||
|
|
@ -949,7 +975,7 @@ async def crawl_status():
|
|||
# ============================================
|
||||
|
||||
@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"""
|
||||
import asyncio
|
||||
from category_scraper import get_categories_sync
|
||||
|
|
@ -967,7 +993,7 @@ async def get_all_categories():
|
|||
|
||||
|
||||
@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"""
|
||||
import asyncio
|
||||
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")
|
||||
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"""
|
||||
import asyncio
|
||||
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")
|
||||
async def get_top10_category():
|
||||
async def get_top10_category(authorized: bool = Depends(verify_hmac)):
|
||||
"""Get Top 10 Most Watched"""
|
||||
import asyncio
|
||||
from category_scraper import PhimMoiChillCategoryScraper
|
||||
|
|
@ -1042,7 +1068,7 @@ async def get_top10_category():
|
|||
|
||||
|
||||
@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"""
|
||||
import asyncio
|
||||
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):
|
||||
print(f"✓ Serving frontend from {frontend_path}")
|
||||
|
||||
# Mount directories only if they exist (Vite production builds often flatten these)
|
||||
for folder in ["assets", "icons", "scripts", "styles", "js"]:
|
||||
# Mount main directories
|
||||
for folder in ["assets", "icons", "scripts", "styles", "js", "public"]:
|
||||
folder_path = os.path.join(frontend_path, folder)
|
||||
if os.path.exists(folder_path):
|
||||
app.mount(f"/{folder}", StaticFiles(directory=folder_path), name=folder)
|
||||
print(f" - Mounted /{folder}")
|
||||
|
||||
@app.get("/")
|
||||
async def serve_index():
|
||||
return FileResponse(os.path.join(frontend_path, "index.html"))
|
||||
|
||||
@app.get("/watch")
|
||||
@app.get("/watch.html")
|
||||
async def serve_watch():
|
||||
return FileResponse(os.path.join(frontend_path, "watch.html"))
|
||||
|
||||
# Direct file responses for root files
|
||||
@app.get("/manifest.json")
|
||||
async def serve_manifest():
|
||||
return FileResponse(os.path.join(frontend_path, "manifest.json"))
|
||||
|
|
@ -1103,13 +1121,38 @@ if os.path.exists(frontend_path):
|
|||
async def serve_sw():
|
||||
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)
|
||||
async def custom_404_handler(request, exc):
|
||||
if not request.url.path.startswith("/api"):
|
||||
async def custom_404_handler(request: Request, exc):
|
||||
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")):
|
||||
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__":
|
||||
|
|
|
|||
|
|
@ -28,3 +28,6 @@ httpx>=0.25.0
|
|||
|
||||
# Multipart uploads
|
||||
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>
|
||||
<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>
|
||||
<meta name="description" content="StreamFlix - Premium Movie Streaming">
|
||||
<meta name="theme-color" content="#141414">
|
||||
|
|
@ -42,6 +42,21 @@
|
|||
</script>
|
||||
|
||||
<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 */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
|
|
@ -97,6 +112,81 @@
|
|||
.search-modal.active {
|
||||
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>
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
|
|
@ -107,11 +197,21 @@
|
|||
<meta name="apple-mobile-web-app-title" content="StreamFlix">
|
||||
<link rel="icon" type="image/png" href="/icons/icon-512.png">
|
||||
<link rel="apple-touch-icon" href="/icons/icon-512.png">
|
||||
<script type="module" crossorigin src="/assets/main-B16Z87Li.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/Toast-BwR22KmJ.js">
|
||||
<script type="module" crossorigin src="/assets/main-xeQDVOBN.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/keyboard-nav-CZ5sEhKF.js">
|
||||
</head>
|
||||
|
||||
<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">
|
||||
|
||||
<!-- Navigation -->
|
||||
|
|
@ -121,10 +221,8 @@
|
|||
<div class="px-4 md:px-12 py-2 md:py-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-8">
|
||||
<!-- Logo -->
|
||||
<a class="flex items-center gap-2 text-primary hover:opacity-90 transition-opacity" href="/">
|
||||
<span class="material-symbols-outlined text-4xl text-primary"
|
||||
style="font-variation-settings: 'FILL' 1;">movie</span>
|
||||
<span class="text-2xl font-bold tracking-tighter text-white hidden sm:block">StreamFlix</span>
|
||||
<a class="flex items-center gap-2 hover:opacity-90 transition-opacity" href="/">
|
||||
<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">
|
||||
</a>
|
||||
<!-- Desktop Nav Links -->
|
||||
<nav class="hidden md:flex items-center gap-6" id="mainNav">
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
<head>
|
||||
<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>
|
||||
<meta name="description" content="StreamFlix - Watch Movies Online">
|
||||
<meta name="theme-color" content="#141414">
|
||||
|
|
@ -42,6 +42,21 @@
|
|||
</script>
|
||||
|
||||
<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 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
|
|
@ -85,6 +100,16 @@
|
|||
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>
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
|
|
@ -95,8 +120,8 @@
|
|||
<meta name="apple-mobile-web-app-title" content="StreamFlix">
|
||||
<link rel="icon" type="image/png" href="/icons/icon-512.png">
|
||||
<link rel="apple-touch-icon" href="/icons/icon-512.png">
|
||||
<script type="module" crossorigin src="/assets/watch-Baf19X1S.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/Toast-BwR22KmJ.js">
|
||||
<script type="module" crossorigin src="/assets/watch-CsORLc4P.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/keyboard-nav-CZ5sEhKF.js">
|
||||
</head>
|
||||
|
||||
<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) -->
|
||||
<div class="fixed inset-0 z-[100] bg-black hidden" id="videoPlayerContainer">
|
||||
<button class="absolute top-4 right-4 z-10 text-white hover:text-gray-300" id="closePlayer">
|
||||
<span class="material-symbols-outlined text-3xl">close</span>
|
||||
<!-- Prominent Back Button -->
|
||||
<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>
|
||||
<div class="w-full h-full" id="videoPlayer">
|
||||
<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>
|
||||
<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>
|
||||
<meta name="description" content="StreamFlix - Premium Movie Streaming">
|
||||
<meta name="theme-color" content="#141414">
|
||||
|
|
@ -42,6 +42,21 @@
|
|||
</script>
|
||||
|
||||
<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 */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
|
|
@ -97,6 +112,81 @@
|
|||
.search-modal.active {
|
||||
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>
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
|
|
@ -110,6 +200,16 @@
|
|||
</head>
|
||||
|
||||
<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">
|
||||
|
||||
<!-- Navigation -->
|
||||
|
|
@ -119,10 +219,8 @@
|
|||
<div class="px-4 md:px-12 py-2 md:py-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-8">
|
||||
<!-- Logo -->
|
||||
<a class="flex items-center gap-2 text-primary hover:opacity-90 transition-opacity" href="/">
|
||||
<span class="material-symbols-outlined text-4xl text-primary"
|
||||
style="font-variation-settings: 'FILL' 1;">movie</span>
|
||||
<span class="text-2xl font-bold tracking-tighter text-white hidden sm:block">StreamFlix</span>
|
||||
<a class="flex items-center gap-2 hover:opacity-90 transition-opacity" href="/">
|
||||
<img src="/assets/logo.svg" alt="StreamFlix" class="h-8 md:h-10">
|
||||
</a>
|
||||
<!-- Desktop Nav Links -->
|
||||
<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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^8.0.0",
|
||||
"@capacitor/haptics": "^8.0.0",
|
||||
"@capacitor/status-bar": "^8.0.0",
|
||||
"artplayer": "^5.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "^6.2.1",
|
||||
"@capacitor/core": "^8.0.0",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,66 @@
|
|||
* 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 {
|
||||
/**
|
||||
* 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
|
||||
* @param {string} url - Source video URL
|
||||
|
|
@ -13,9 +70,15 @@ class ApiClient {
|
|||
* @returns {Promise<Object>} Extraction result with stream URL
|
||||
*/
|
||||
async extractVideo(url, quality = null) {
|
||||
const path = '/api/extract';
|
||||
const authHeaders = await this.signRequest(path, 'POST');
|
||||
|
||||
const response = await fetch(`${API_BASE}/extract`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeaders
|
||||
},
|
||||
body: JSON.stringify({ url, quality })
|
||||
});
|
||||
|
||||
|
|
@ -27,13 +90,28 @@ class ApiClient {
|
|||
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
|
||||
* @param {string} url - Source video URL
|
||||
* @returns {Promise<string[]>} List of available qualities
|
||||
*/
|
||||
async getQualities(url) {
|
||||
const response = await fetch(`${API_BASE}/qualities?url=${encodeURIComponent(url)}`);
|
||||
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) {
|
||||
throw new Error('Failed to get qualities');
|
||||
|
|
@ -54,7 +132,9 @@ class ApiClient {
|
|||
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) {
|
||||
throw new Error('Failed to fetch videos');
|
||||
|
|
@ -69,9 +149,15 @@ class ApiClient {
|
|||
* @returns {Promise<Object>} Created video
|
||||
*/
|
||||
async addVideo(video) {
|
||||
const path = '/api/videos';
|
||||
const authHeaders = await this.signRequest(path, 'POST');
|
||||
|
||||
const response = await fetch(`${API_BASE}/videos`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeaders
|
||||
},
|
||||
body: JSON.stringify(video)
|
||||
});
|
||||
|
||||
|
|
@ -88,8 +174,12 @@ class ApiClient {
|
|||
* @param {number} id - Video ID
|
||||
*/
|
||||
async deleteVideo(id) {
|
||||
const path = `/api/videos/${id}`;
|
||||
const authHeaders = await this.signRequest(path, 'DELETE');
|
||||
|
||||
const response = await fetch(`${API_BASE}/videos/${id}`, {
|
||||
method: 'DELETE'
|
||||
method: 'DELETE',
|
||||
headers: authHeaders
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -104,9 +194,10 @@ class ApiClient {
|
|||
* @returns {Promise<Array>} Search results
|
||||
*/
|
||||
async searchVideos(query, limit = 20) {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/search?q=${encodeURIComponent(query)}&limit=${limit}`
|
||||
);
|
||||
const url = `${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) {
|
||||
throw new Error('Search failed');
|
||||
|
|
@ -139,7 +230,9 @@ class ApiClient {
|
|||
if (country) url += `&country=${encodeURIComponent(country)}`;
|
||||
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) {
|
||||
throw new Error('Failed to fetch RoPhim catalog');
|
||||
|
|
@ -153,7 +246,11 @@ class ApiClient {
|
|||
* @returns {Promise<Object>} Sections with movies sorted by rating
|
||||
*/
|
||||
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) {
|
||||
throw new Error('Failed to fetch curated sections');
|
||||
|
|
@ -169,9 +266,10 @@ class ApiClient {
|
|||
* @returns {Promise<Object>} Search results
|
||||
*/
|
||||
async searchRophim(query, limit = 20) {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/rophim/search?q=${encodeURIComponent(query)}&limit=${limit}`
|
||||
);
|
||||
const url = `${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) {
|
||||
throw new Error('RoPhim search failed');
|
||||
|
|
@ -186,7 +284,11 @@ class ApiClient {
|
|||
* @returns {Promise<Object>} Sections
|
||||
*/
|
||||
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');
|
||||
return response.json();
|
||||
}
|
||||
|
|
@ -197,7 +299,11 @@ class ApiClient {
|
|||
* @returns {Promise<Object>} Movie details
|
||||
*/
|
||||
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) {
|
||||
throw new Error('Failed to fetch movie details');
|
||||
|
|
@ -213,8 +319,12 @@ class ApiClient {
|
|||
* @returns {Promise<Object>} Stream URL
|
||||
*/
|
||||
async getRophimStream(slug, episode = 1) {
|
||||
const path = `/api/rophim/stream/${encodeURIComponent(slug)}`;
|
||||
const authHeaders = await this.signRequest(path, 'GET');
|
||||
|
||||
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) {
|
||||
|
|
@ -234,9 +344,15 @@ class ApiClient {
|
|||
* @returns {Promise<Object>} Stream URL
|
||||
*/
|
||||
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`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeaders
|
||||
},
|
||||
body: JSON.stringify({ source_url: sourceUrl, slug: slug || '', episode, server })
|
||||
});
|
||||
|
||||
|
|
@ -247,6 +363,70 @@ class ApiClient {
|
|||
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
* Netflix 2025 Info Modal Component
|
||||
* Premium, cinematic modal with video preview and rich metadata
|
||||
*/
|
||||
import { hapticLight, hapticMedium } from '../haptics.js';
|
||||
|
||||
export function createInfoModal(video, onClose, onPlay, recommendations = []) {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal modal--info active';
|
||||
|
|
@ -145,9 +147,17 @@ export function createInfoModal(video, onClose, onPlay, recommendations = []) {
|
|||
`;
|
||||
|
||||
// Event Listeners
|
||||
modal.querySelector('.modal__close').addEventListener('click', () => onClose(modal));
|
||||
modal.querySelector('.modal__backdrop').addEventListener('click', () => onClose(modal));
|
||||
modal.querySelector('[data-action="play"]').addEventListener('click', () => onPlay(video));
|
||||
modal.querySelector('.modal__close').addEventListener('click', () => {
|
||||
hapticLight();
|
||||
onClose(modal);
|
||||
});
|
||||
modal.querySelector('.modal__backdrop').addEventListener('click', () => {
|
||||
onClose(modal);
|
||||
});
|
||||
modal.querySelector('[data-action="play"]').addEventListener('click', () => {
|
||||
hapticMedium();
|
||||
onPlay(video);
|
||||
});
|
||||
|
||||
// Autoplay header video
|
||||
const headerVideo = modal.querySelector('.modal__header-preview');
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { api } from '../api.js';
|
||||
|
||||
/**
|
||||
* Netflix 2025 "New & Hot" Feed Component
|
||||
* Optimized for mobile vertical scrolling
|
||||
|
|
@ -11,6 +13,9 @@ export function createNewAndHotItem(video) {
|
|||
const month = months[Math.floor(Math.random() * 12)];
|
||||
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 = `
|
||||
<div class="new-hot-item__sidebar">
|
||||
<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__card">
|
||||
<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">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="40" height="40"><path d="M8 5v14l11-7z"/></svg>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -63,23 +63,26 @@ export function initSearch(inputEl, resultsEl, onSelect) {
|
|||
</div>
|
||||
`;
|
||||
} else {
|
||||
resultsEl.innerHTML = results.map(video => `
|
||||
<div class="search__result" data-video-slug="${video.slug}">
|
||||
<img
|
||||
src="${video.poster_url || video.thumb_url || video.thumbnail || 'data:image/svg+xml,%3Csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 80 45\" fill=\"%231a1a1a\"%3E%3Crect width=\"80\" height=\"45\"/%3E%3C/svg%3E'}"
|
||||
alt="${escapeHtml(video.name || video.title)}"
|
||||
class="search__result-thumb"
|
||||
loading="lazy"
|
||||
>
|
||||
<div class="search__result-info">
|
||||
<div class="search__result-title">${escapeHtml(video.name || video.title)}</div>
|
||||
<div class="search__result-meta">
|
||||
${video.quality ? `${video.quality} • ` : ''}
|
||||
${video.year || ''}
|
||||
resultsEl.innerHTML = results.map(video => {
|
||||
const thumbUrl = api.getProxyUrl(video.poster_url || video.thumb_url || video.thumbnail, 80);
|
||||
return `
|
||||
<div class="search__result" data-video-slug="${video.slug}">
|
||||
<img
|
||||
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'}"
|
||||
alt="${escapeHtml(video.name || video.title)}"
|
||||
class="search__result-thumb"
|
||||
loading="lazy"
|
||||
>
|
||||
<div class="search__result-info">
|
||||
<div class="search__result-title">${escapeHtml(video.name || video.title)}</div>
|
||||
<div class="search__result-meta">
|
||||
${video.quality ? `${video.quality} • ` : ''}
|
||||
${video.year || ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Add click handlers - navigate to watch page
|
||||
resultsEl.querySelectorAll('.search__result[data-video-slug]').forEach(el => {
|
||||
|
|
@ -94,10 +97,10 @@ export function initSearch(inputEl, resultsEl, onSelect) {
|
|||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
resultsEl.innerHTML = `
|
||||
<div class="search__result" style="color: var(--color-error);">
|
||||
<span>Search failed. Please try again.</span>
|
||||
</div>
|
||||
`;
|
||||
< div class="search__result" style = "color: var(--color-error);" >
|
||||
<span>Search failed. Please try again.</span>
|
||||
</div >
|
||||
`;
|
||||
resultsEl.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
/**
|
||||
* PhimMoi UI - Video Card Component
|
||||
* Poster-style cards with episode badges and hover effects
|
||||
*/
|
||||
|
||||
import { api } from '../api.js';
|
||||
import { imageCache } from '../services/imageCache.js';
|
||||
|
||||
/**
|
||||
|
|
@ -85,7 +81,9 @@ export function createVideoCard(video, onPlay, onInfo) {
|
|||
card.className = 'video-card';
|
||||
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();
|
||||
|
||||
// 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',
|
||||
'.hero__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 { renderNewAndHotView } from './components/NewAndHot.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
|
||||
// Application state
|
||||
const state = {
|
||||
|
|
@ -72,16 +111,20 @@ function setMobileNavActive(viewName) {
|
|||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Initialize the application
|
||||
*/
|
||||
async function init() {
|
||||
SplashScreen.update(10, 'Initializing services...');
|
||||
|
||||
// Initialize search
|
||||
initSearch(elements.searchInput, elements.searchResults, handleVideoPlay);
|
||||
SplashScreen.update(20, 'Setting up navigation...');
|
||||
|
||||
// Initialize Mobile Bottom Nav
|
||||
if (elements.mobileBottomNavButtons) {
|
||||
// ... (existing button logic)
|
||||
elements.mobileBottomNavButtons.forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -92,6 +135,9 @@ async function init() {
|
|||
elements.mobileBottomNavButtons.forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
|
||||
// Native Haptic
|
||||
hapticLight();
|
||||
|
||||
// Handle routing
|
||||
if (view === 'home') {
|
||||
renderHome();
|
||||
|
|
@ -132,12 +178,23 @@ async function init() {
|
|||
|
||||
// Set up event listeners
|
||||
setupEventListeners();
|
||||
SplashScreen.update(40, 'Fetching movie catalog...');
|
||||
|
||||
// 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
|
||||
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)
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
|
@ -158,6 +215,16 @@ async function init() {
|
|||
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) {
|
||||
const newPlayBtn = heroPlayBtn.cloneNode(true);
|
||||
heroPlayBtn.parentNode.replaceChild(newPlayBtn, heroPlayBtn);
|
||||
newPlayBtn.addEventListener('click', () => handleVideoPlay(featured));
|
||||
newPlayBtn.addEventListener('click', () => {
|
||||
hapticMedium();
|
||||
handleVideoPlay(featured);
|
||||
});
|
||||
}
|
||||
|
||||
// Info button
|
||||
|
|
@ -431,6 +501,7 @@ function setupEventListeners() {
|
|||
const searchModal = document.getElementById('searchModal');
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
if (searchModal) {
|
||||
hapticLight();
|
||||
searchModal.classList.add('active');
|
||||
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)
|
||||
const streamflixNavLinks = document.querySelectorAll('.nav-link');
|
||||
streamflixNavLinks.forEach(link => {
|
||||
|
|
@ -492,7 +583,11 @@ function setupEventListeners() {
|
|||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (elements.playerModal?.classList.contains('active')) {
|
||||
closePlayerModal();
|
||||
if (window.history.state?.playerOpen) {
|
||||
window.history.back();
|
||||
} else {
|
||||
closePlayerModal();
|
||||
}
|
||||
}
|
||||
if (elements.searchWrapper?.classList.contains('active')) {
|
||||
elements.searchWrapper.classList.remove('active');
|
||||
|
|
@ -1625,7 +1720,7 @@ function renderDemoContent() {
|
|||
resolution: '4K',
|
||||
category: 'movies',
|
||||
year: 2024,
|
||||
description: 'Eddie và Venom đang chạy trốn. Bị cả hai thế giới truy đuổi, họ buộc phải đưa ra quyết định khốc liệt...',
|
||||
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
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -1896,6 +1997,11 @@ async function loadEpisode(video, episode, server) {
|
|||
autoplay: true
|
||||
});
|
||||
|
||||
// Push state for back navigation
|
||||
if (!window.history.state?.playerOpen) {
|
||||
window.history.pushState({ playerOpen: true }, '', window.location.href);
|
||||
}
|
||||
|
||||
if (art && window.historyService) {
|
||||
art.on('video:timeupdate', () => {
|
||||
const currentTime = art.currentTime;
|
||||
|
|
@ -1935,14 +2041,25 @@ async function loadEpisode(video, episode, server) {
|
|||
|
||||
/**
|
||||
* Close player modal
|
||||
* @param {boolean} shouldUpdateHistory - Whether to update history (defaults to true)
|
||||
*/
|
||||
function closePlayerModal() {
|
||||
elements.playerModal.classList.remove('active');
|
||||
destroyPlayer();
|
||||
function closePlayerModal(shouldUpdateHistory = true) {
|
||||
if (elements.playerModal) {
|
||||
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 = '';
|
||||
state.currentVideo = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close add video modal
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
import { api } from './api.js';
|
||||
import { showToast } from './components/Toast.js';
|
||||
import { initPlayer, destroyPlayer } from './components/VideoPlayer.js';
|
||||
import { hapticLight, hapticMedium, hapticSuccess } from './haptics.js';
|
||||
import { KeyboardNavigation } from './keyboard-nav.js';
|
||||
|
||||
// Page State
|
||||
const state = {
|
||||
|
|
@ -93,6 +95,13 @@ function initElements() {
|
|||
* Initialize watch page
|
||||
*/
|
||||
async function init() {
|
||||
// Initialize UI elements
|
||||
initElements();
|
||||
|
||||
// Initialize TV Navigation
|
||||
const nav = new KeyboardNavigation();
|
||||
nav.init();
|
||||
|
||||
// Parse URL parameters
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const videoId = params.get('id');
|
||||
|
|
@ -138,9 +147,18 @@ function setupEventListeners() {
|
|||
if (elements.watchBackBtn) {
|
||||
elements.watchBackBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
if (elements.videoPlayerContainer && (elements.videoPlayerContainer.style.display !== 'none' || !elements.videoPlayerContainer.classList.contains('hidden'))) {
|
||||
closeVideoPlayer();
|
||||
const playerVisible = elements.videoPlayerContainer && (elements.videoPlayerContainer.style.display !== 'none' || !elements.videoPlayerContainer.classList.contains('hidden'));
|
||||
|
||||
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)) {
|
||||
hapticLight();
|
||||
window.history.back();
|
||||
} else {
|
||||
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
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
|
|
@ -165,6 +207,9 @@ function setupEventListeners() {
|
|||
[elements.playBtn, elements.playBtnMobile, elements.mobilePlayBtn].forEach(btn => {
|
||||
if (btn) {
|
||||
btn.addEventListener('click', () => {
|
||||
if (btn) {
|
||||
hapticMedium();
|
||||
}
|
||||
if (elements.videoPlayerContainer) {
|
||||
elements.videoPlayerContainer.classList.remove('hidden');
|
||||
elements.videoPlayerContainer.style.display = 'block'; // Ensure visible
|
||||
|
|
@ -180,7 +225,11 @@ function setupEventListeners() {
|
|||
// Close player button
|
||||
if (elements.closePlayer) {
|
||||
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);
|
||||
updateAddListUI(added);
|
||||
|
||||
hapticLight();
|
||||
if (added) {
|
||||
showToast('Added to My List', 'success');
|
||||
} else {
|
||||
|
|
@ -225,11 +275,13 @@ function setupEventListeners() {
|
|||
if (elements.shareBtnMobile) {
|
||||
elements.shareBtnMobile.addEventListener('click', () => {
|
||||
if (navigator.share) {
|
||||
hapticLight();
|
||||
navigator.share({
|
||||
title: state.video?.title || 'StreamFlix',
|
||||
url: window.location.href
|
||||
});
|
||||
} else {
|
||||
hapticLight();
|
||||
// Fallback: Copy to clipboard
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
showToast('Link copied to clipboard', 'success');
|
||||
|
|
@ -247,6 +299,7 @@ function setupEventListeners() {
|
|||
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
hapticLight();
|
||||
const targetPanel = tab.dataset.tab;
|
||||
|
||||
// Update active tab styling
|
||||
|
|
@ -277,6 +330,7 @@ function setupEventListeners() {
|
|||
e.preventDefault();
|
||||
const view = btn.dataset.view;
|
||||
if (view) {
|
||||
hapticLight();
|
||||
// Redirect to home with view parameter
|
||||
window.location.href = `/index.html?view=${view}`;
|
||||
}
|
||||
|
|
@ -287,8 +341,9 @@ function setupEventListeners() {
|
|||
|
||||
/**
|
||||
* 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
|
||||
const container = elements.videoPlayerContainer || document.getElementById('videoPlayerContainer');
|
||||
const player = elements.videoPlayer || document.getElementById('videoPlayer');
|
||||
|
|
@ -310,6 +365,11 @@ function closeVideoPlayer() {
|
|||
if (loader) {
|
||||
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>
|
||||
`;
|
||||
} else {
|
||||
const art = initPlayer(elements.videoPlayer, {
|
||||
url: streamUrl,
|
||||
poster: poster,
|
||||
title: title + ` - Ep ${state.currentEpisode}`,
|
||||
autoplay: true
|
||||
});
|
||||
// Initialize ArtPlayer
|
||||
const art = renderArtPlayer(streamUrl, poster, title);
|
||||
|
||||
// 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
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
// Push state to history for back navigation
|
||||
if (!window.history.state?.playerOpen) {
|
||||
window.history.pushState({ playerOpen: true }, '', window.location.href);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
/**
|
||||
* Render ArtPlayer instance
|
||||
*/
|
||||
function renderArtPlayer(streamUrl, poster, title) {
|
||||
const art = initPlayer(elements.videoPlayer, {
|
||||
url: streamUrl,
|
||||
poster: poster,
|
||||
title: title + ` - Ep ${state.currentEpisode}`,
|
||||
autoplay: true
|
||||
});
|
||||
|
||||
// Track progress
|
||||
if (art && window.historyService) {
|
||||
art.on('video:timeupdate', () => {
|
||||
const currentTime = art.currentTime;
|
||||
const duration = art.duration;
|
||||
if (currentTime > 0 && duration > 0) {
|
||||
// Save every 5 seconds to avoid excessive writes
|
||||
if (Math.floor(currentTime) % 5 === 0) {
|
||||
window.historyService.addToHistory(state.video, {
|
||||
currentTime,
|
||||
duration,
|
||||
percentage: (currentTime / duration) * 100,
|
||||
episode: state.currentEpisode
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Resume from last position if available
|
||||
const history = window.historyService.getHistory();
|
||||
const entry = history.find(item => item.slug === state.video.slug);
|
||||
if (entry && entry.progress && entry.progress.episode === state.currentEpisode) {
|
||||
if (entry.progress.currentTime > 0 && entry.progress.percentage < 95) {
|
||||
art.once('video:canplay', () => {
|
||||
art.currentTime = entry.progress.currentTime;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
<head>
|
||||
<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>
|
||||
<meta name="description" content="StreamFlix - Watch Movies Online">
|
||||
<meta name="theme-color" content="#141414">
|
||||
|
|
@ -42,6 +42,21 @@
|
|||
</script>
|
||||
|
||||
<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 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
|
|
@ -85,6 +100,16 @@
|
|||
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>
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
|
|
@ -247,8 +272,18 @@
|
|||
|
||||
<!-- Video Player (Hidden by default, shown when playing) -->
|
||||
<div class="fixed inset-0 z-[100] bg-black hidden" id="videoPlayerContainer">
|
||||
<button class="absolute top-4 right-4 z-10 text-white hover:text-gray-300" id="closePlayer">
|
||||
<span class="material-symbols-outlined text-3xl">close</span>
|
||||
<!-- Prominent Back Button -->
|
||||
<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>
|
||||
<div class="w-full h-full" id="videoPlayer">
|
||||
<div class="w-full h-full flex items-center justify-center" id="playerLoading">
|
||||
|
|
|
|||
Loading…
Reference in a new issue